Compare commits

..

No commits in common. "main" and "copilot/create-readme-md" have entirely different histories.

24 changed files with 162 additions and 1735 deletions

View file

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

View file

@ -1,40 +0,0 @@
name: Build and publish package
on:
push:
tags: ['v*']
jobs:
package:
runs-on: [self-hosted, hestia]
container:
image: archlinux:latest
steps:
# Note: no actions/checkout — the archlinux image has no Node, which JS
# actions require. Everything runs as shell steps and clones manually.
- name: Build and publish
env:
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
pacman -Syu --noconfirm base-devel git rust cargo
useradd -m builder
git config --global --add safe.directory '*'
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
cd /home/builder/src
git archive --format=tar.gz --prefix="bakery-${VERSION}/" HEAD \
> packaging/arch/bakery-${VERSION}.tar.gz
SHA=$(sha256sum packaging/arch/bakery-${VERSION}.tar.gz | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD
chown -R builder:builder /home/builder/src
# --nocheck: packaging builds the artifact; tests belong in a CI job.
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1)
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}" \
"https://git.breadway.dev/api/packages/Breadway/arch/os"

View file

@ -4,11 +4,9 @@ on:
push: push:
tags: ["v*"] tags: ["v*"]
permissions:
contents: write
env: env:
DL_DIR: /srv/breadway-dl DL_DIR: /srv/breadway-dl
ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem
jobs: jobs:
build: build:
@ -17,10 +15,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: build - name: build
run: cargo build --release --locked -p bakery run: cargo build --release --locked
- name: test
run: cargo test --locked --workspace
- name: prepare artifacts - name: prepare artifacts
run: | run: |
@ -33,11 +28,11 @@ jobs:
sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \ sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/bakery-x86_64.sha256" > "${PKG_DIR}/bakery-x86_64.sha256"
cp bakery.toml "${PKG_DIR}/bakery.toml" # Update the 'latest' symlink.
ln -sfn "${VERSION}" "${DL_DIR}/bakery/latest" ln -sfn "${PKG_DIR}" "${DL_DIR}/bakery/latest"
- name: regenerate index.json - name: regenerate index.json
run: bash "${GITHUB_WORKSPACE}/scripts/gen-index.sh" run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh"
- name: upload to GitHub Release - name: upload to GitHub Release
env: env:
@ -45,8 +40,6 @@ jobs:
run: | run: |
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="${DL_DIR}/bakery/${VERSION}" PKG_DIR="${DL_DIR}/bakery/${VERSION}"
gh release create "${GITHUB_REF_NAME}" \
--title "bakery v${VERSION}" --generate-notes 2>/dev/null || true
gh release upload "${GITHUB_REF_NAME}" \ gh release upload "${GITHUB_REF_NAME}" \
"${PKG_DIR}/bakery-x86_64" \ "${PKG_DIR}/bakery-x86_64" \
"${PKG_DIR}/bakery-x86_64.sha256" \ "${PKG_DIR}/bakery-x86_64.sha256" \

View file

@ -1,118 +0,0 @@
# Bread Design System
Unified visual identity for breadbar, breadbox, breadpad/breadman, and
bos-settings.
## Architecture (single source of truth)
The tokens below are implemented once in the **`bread-theme`** crate as
`stylesheet(&Palette)` — the full component stylesheet (buttons, entries,
switches, lists/rows/sidebars, cards, chips, scrollbars, headings) over a
canonical `@define-color` palette (`surface`=color0, `overlay`=color7,
`accent`=color4).
- The `bread-theme` **CLI** renders it from the live pywal palette to
`$XDG_RUNTIME_DIR/bread/theme.css` (run at login and from a pywal hook).
- Every GUI loads that file via `bread_theme::gtk::apply_shared()` and
**live-reloads** it, then layers on only its own app-specific rules.
Result: one definition, no per-app drift, and palette changes recolour the
whole desktop with no rebuilds. Apps reference the shared `@define-color`
names rather than raw palette slots.
## Typography
- **Font Family**: Varela Round, sans-serif
- **Base Size**: 14px
- **Secondary**: 12px (metadata, helper text, secondary labels)
- **Font Weight**: Normal (400) for body, Bold (700) for emphasis
## Spacing Scale (4px units)
Use these values consistently across all projects:
- **xs**: 4px (small gaps, internal padding)
- **sm**: 8px (default spacing between elements)
- **md**: 12px (medium spacing, main padding)
- **lg**: 16px (large padding, major spacing)
- **xl**: 20px (extra large spacing, section breaks)
## Border Radius
Establish a visual hierarchy with consistent rounding:
- **Primary** (buttons, cards, main containers): **8px**
- **Secondary** (input fields, chips, entries): **6px**
- **Tertiary** (small interactive elements): **4px**
- **Pill** (fully rounded buttons, badges): **999px**
## Color System
All projects use **pywal dynamic theming** with **Catppuccin Mocha** as the fallback palette:
- **Background**: `#1e1e2e` (Catppuccin)
- **Foreground**: `#cdd6f4` (Catppuccin)
- **Surface**: `#181825` (Catppuccin)
- **Accent**: Dynamic (from pywal)
Color palette slots (via wal):
- color0color7: ANSI colors
- Semantic: red, green, yellow, blue, pink, teal
## Component Standards
### Buttons
- Border Radius: 8px
- Padding: 8px 16px (primary), 4px 8px (secondary)
- Font Size: 14px
- Background: Theme accent color
### Input Fields
- Border Radius: 6px
- Padding: 12px 16px
- Font Size: 14px
- Border: 1px or 2px solid (blue on focus)
### Cards
- Border Radius: 8px
- Padding: 12px
- Margin: 8px
- Box Shadow: Optional, for depth
### Stat Labels
- Font Size: 14px
- Margin Right (between icon/text): 5px
- Group Margin Right: 12px
### Notification Cards
- Border Radius: 8px
- Padding: 12px
- Margin Bottom: 8px
- Font Size: 14px (summary), 12px (body)
## Current Implementation
All GUI apps load `bread_theme::stylesheet` (via the generated shared file) and
add only app-specific rules:
- **breadbar** — shared base + bar window, workspace buttons, stats, notification
and OSD cards.
- **breadbox** — shared base + launcher panel, search entry, result rows.
- **breadpad / breadman** — shared base + capture popup, type chips, note cards,
reminder window, sidebar rows.
- **bos-settings** — shared base + content padding only (was previously a
hardcoded Nord palette; migrated to the shared stylesheet).
- **breadcrumbs** — CLI tool; ANSI colours only, no GUI styling.
> Palette note: the fallback is Catppuccin Mocha, but installs (e.g. BOS) drive
> the real palette from pywal — BOS ships a black-base palette.
## Future Consistency Checks
When adding new components or updating existing ones:
1. Use Varela Round for all text
2. Set base font size to 14px (12px for secondary)
3. Use spacing scale (4px units: 4, 8, 12, 16, 20)
4. Use border radius from this system (8px default, 6px secondary)
5. Leverage pywal colors for dynamic theming
6. Keep margins/padding consistent across similar components

269
Cargo.lock generated
View file

@ -81,7 +81,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "bakery" name = "bakery"
version = "0.2.3" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -91,7 +91,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"tempfile",
"toml 0.8.23", "toml 0.8.23",
"ureq", "ureq",
] ]
@ -119,7 +118,7 @@ dependencies = [
[[package]] [[package]]
name = "bread-theme" name = "bread-theme"
version = "0.2.3" version = "0.1.0"
dependencies = [ dependencies = [
"dirs", "dirs",
"gtk4", "gtk4",
@ -323,22 +322,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]] [[package]]
name = "field-offset" name = "field-offset"
version = "0.3.6" version = "0.3.6"
@ -365,12 +348,6 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@ -520,19 +497,6 @@ dependencies = [
"wasi", "wasi",
] ]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]] [[package]]
name = "gio" name = "gio"
version = "0.22.6" version = "0.22.6"
@ -723,15 +687,6 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@ -856,12 +811,6 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.1.0" version = "1.1.0"
@ -890,9 +839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.17.1", "hashbrown",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@ -919,12 +866,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@ -940,12 +881,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@ -1061,16 +996,6 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
@ -1098,19 +1023,13 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.4.6" version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [ dependencies = [
"getrandom 0.2.17", "getrandom",
"libredox", "libredox",
"thiserror", "thiserror",
] ]
@ -1123,7 +1042,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [ dependencies = [
"cc", "cc",
"cfg-if", "cfg-if",
"getrandom 0.2.17", "getrandom",
"libc", "libc",
"untrusted", "untrusted",
"windows-sys 0.52.0", "windows-sys 0.52.0",
@ -1138,19 +1057,6 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.40" version = "0.23.40"
@ -1353,19 +1259,6 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@ -1500,12 +1393,6 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@ -1572,24 +1459,6 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.122" version = "0.2.122"
@ -1635,40 +1504,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"
@ -1912,100 +1747,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.3" version = "0.6.3"

View file

@ -3,7 +3,7 @@ members = ["bakery", "bread-theme"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.2.3" version = "0.1.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Breadway <rileyhorsham@gmail.com>"] authors = ["Breadway <rileyhorsham@gmail.com>"]

144
README.md
View file

@ -1,146 +1,16 @@
# Bread Ecosystem # Bread Ecosystem
A collection of Rust tools for the Linux desktop (Hyprland / Wayland / Arch). Welcome to the **Bread Ecosystem**!
Install any product with a single command — no Rust toolchain required.
```sh This repository contains the core components and tools for the Bread Ecosystem, primarily focused on package management and theming.
curl https://breadway.dev/get | sh
bakery install breadbar
```
## Products ## Workspace Members
| Package | Description | This Rust workspace contains the following crates:
|---------|-------------|
| `bread` | Reactive automation daemon (`breadd`) + CLI — Lua scripting over Hyprland, udev, power, network, and Bluetooth events |
| `breadbar` | GTK4 status bar (workspaces, clock, CPU/RAM/battery/WiFi/Bluetooth) and D-Bus notification daemon for Hyprland |
| `breadbox` | GTK4 fuzzy app launcher for Hyprland with context-aware sorting; ships an icon-sync daemon (`breadbox-sync`) |
| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale exit-node management and a self-healing watch daemon |
| `breadpad` | Quick-capture scratchpad popup with AI-powered note classification, reminders, recurrence, and a full note viewer (`breadman`) |
## Recommended keybinds - **[`bakery`](./bakery)**: The package manager for the Bread Ecosystem.
- **[`bread-theme`](./bread-theme)**: A shared theming crate integrating pywal and Catppuccin for the Bread Ecosystem, with optional support for GTK4 and Wayland.
The ecosystem assumes a Hyprland setup with `SUPER` as the modifier. The
conventional bindings (used by BOS and recommended for any install):
| Keys | Action |
|------|--------|
| `SUPER+Space` | `breadbox` — app launcher |
| `SUPER+U` | `breadpad` — quick-capture notes/reminders |
| `SUPER+M` | `breadman` — note viewer / manager |
| `SUPER+,` | settings (`bos-settings`, where installed) |
`breadbar` and `breadd` are services started at login (`exec-once`), not bound
to keys.
## Theming
All GUIs share one look via `bread-theme`. The `bread-theme` CLI renders the
component stylesheet from your pywal palette (Catppuccin Mocha fallback) to
`$XDG_RUNTIME_DIR/bread/theme.css`; every app loads that file and **live-reloads**
it, so changing your wallpaper recolours the whole ecosystem with no rebuilds:
```sh
wal -i ~/Pictures/wall.png # regenerate pywal palette
bread-theme generate # render the shared stylesheet (run from a wal hook)
```
See [`BREAD_DESIGN_SYSTEM.md`](BREAD_DESIGN_SYSTEM.md) for the tokens (fonts,
spacing, radii, colour roles) the stylesheet is built from.
## Installing bakery
`bakery` is the package manager for the ecosystem. Install it with the bootstrap script:
```sh
curl https://breadway.dev/get | sh
# or
curl -sSfL https://get.breadway.dev | sh
```
The script downloads the prebuilt `bakery` binary to `~/.local/bin/bakery` and prints a note if that directory isn't on your `PATH` yet.
## Using bakery
```sh
bakery list # all available packages
bakery list --installed # only installed packages
bakery info breadbar # version, binaries, system deps, services
bakery doctor # check system deps for installed packages
bakery doctor breadbar # check system deps for a specific package
bakery install <pkg> # install a package
bakery update <pkg> # update a package
bakery update --all # update everything
bakery remove <pkg> # remove a package (data files are never deleted)
```
`bakery install` runs `doctor` first and bails with a clear message if any system dependency is missing. Binaries land in `~/.local/bin` (override with `BAKERY_BIN_DIR`).
## System dependencies by product
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.
| Package | Required | Optional |
|---------|----------|---------|
| `bakery` | _(statically linked, none)_ | — |
| `bread` | `systemd-libs` `openssl` `zlib` | `bluez` `hyprland` |
| `breadbar` | `gtk4` `gtk4-layer-shell` `iw` `libpulse` | `hyprland` |
| `breadbox` | `gtk4` `gtk4-layer-shell` `librsvg` | `hyprland` |
| `breadcrumbs` | `networkmanager` | `tailscale` `sudo` `xdg-utils` |
| `breadpad` | `gtk4` `gtk4-layer-shell` | `rocm-hip-runtime` `ollama` `hyprland` |
Install all required deps with `sudo pacman -S <packages>`. Use `pacman -Q <pkg>` to check whether any are already present.
## Theming
All GUI products (breadbar, breadbox, breadpad) read pywal colors from
`~/.cache/wal/colors.json` and fall back to Catppuccin Mocha when that file
is absent. Per-app CSS overrides live at `~/.config/<app>/style.css`.
The shared theming logic lives in the `bread-theme` crate in this repo.
## Workspace
This repo is a Cargo workspace:
```
bread-ecosystem/
├── bakery/ # package manager binary
├── bread-theme/ # shared pywal + Catppuccin theming crate
├── registry/ # bread-ecosystem.toml — product registry
└── scripts/
├── get.sh # curl | sh bootstrap
└── gen-index.sh # generates dl.breadway.dev/index.json from release artifacts
```
## Release pipeline
Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has a
`.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,
deposits it at `dl.breadway.dev/<pkg>/<version>/`, updates `index.json`,
and mirrors the binary to GitHub Releases as a fallback.
`bakery` always tries `dl.breadway.dev` first and transparently falls back
to the GitHub Release URL recorded in the manifest.
### Release artifact contract
Each product's `release.yml` **must** upload the following files alongside
the binary to `dl.breadway.dev/<name>/<version>/`:
| File | Purpose |
|------|---------|
| `bakery.toml` | Metadata (deps, services, config) read by `gen-index.sh` |
| `<binary>-x86_64.sha256` | Checksum verified by `bakery install` and `get.sh` |
| `*.service` | systemd unit files installed by `bakery install` |
| `*.example.toml` / `config.example.toml` | Example configs copied on first install |
`gen-index.sh` **fails loudly** if `bakery.toml` is missing — this is by
design to catch omissions in the release workflow before they silently
produce empty metadata in production.
## License ## License
MIT This project is licensed under the MIT License.

View file

@ -1,9 +0,0 @@
name = "bakery"
description = "Bread ecosystem package manager"
binaries = ["bakery"]
system_deps = []
optional_system_deps = []
bread_deps = []
[install]
post_install = []

View file

@ -18,6 +18,3 @@ sha2 = { workspace = true }
hex = { workspace = true } hex = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
[dev-dependencies]
tempfile = "3"

View file

@ -1,45 +1,37 @@
use anyhow::Result; use anyhow::Result;
use std::process::Command; use std::process::Command;
pub struct DepReport { /// Check whether a list of system dependencies are present.
/// Required deps that are not present — blocks install. /// Returns (missing, warnings) — missing are hard fails, warnings are advisory.
pub missing: Vec<String>, pub fn check_deps(deps: &[String]) -> Result<Vec<String>> {
/// Optional deps that are not present — advisory only, never blocks. let mut missing = Vec::new();
pub warnings: Vec<String>, for dep in deps {
if !dep_present(dep) {
missing.push(dep.clone());
}
}
Ok(missing)
} }
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> { fn dep_present(dep: &str) -> bool {
Ok(DepReport { // Try `which` first (covers executables like `iw`, `nmcli`).
missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(), if which(dep) {
warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(),
})
}
fn dep_present(pkg: &str) -> bool {
// Primary: `pacman -Q` uses the exact Arch package name — no name mapping needed.
if pacman_installed(pkg) {
return true; return true;
} }
// Fallback for environments without pacman: native PATH search then pkg-config. // Try `pkg-config --exists` for library packages (gtk4, gtk4-layer-shell, librsvg).
path_has(pkg) || pkg_config_exists(pkg) pkg_config_exists(dep)
} }
fn pacman_installed(pkg: &str) -> bool { fn which(bin: &str) -> bool {
Command::new("pacman") Command::new("which")
.args(["-Q", pkg]) .arg(bin)
.output() .output()
.map(|o| o.status.success()) .map(|o| o.status.success())
.unwrap_or(false) .unwrap_or(false)
} }
/// Check PATH without shelling out to `which` (avoids the external dependency).
fn path_has(bin: &str) -> bool {
std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).any(|dir| dir.join(bin).is_file()))
.unwrap_or(false)
}
fn pkg_config_exists(lib: &str) -> bool { fn pkg_config_exists(lib: &str) -> bool {
// Arch package names map directly to pkg-config names for GTK libs.
Command::new("pkg-config") Command::new("pkg-config")
.arg("--exists") .arg("--exists")
.arg(lib) .arg(lib)
@ -48,90 +40,33 @@ fn pkg_config_exists(lib: &str) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// Print a formatted doctor report for a package's system deps. /// Print a formatted doctor report for a list of system deps.
/// Returns true if all *required* deps are satisfied. /// Returns true if all deps are satisfied.
pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool { pub fn report(package_name: &str, deps: &[String]) -> bool {
if required.is_empty() && optional.is_empty() { if deps.is_empty() {
println!(" {package_name}: no system deps required"); println!(" {package_name}: no system deps required");
return true; return true;
} }
match check_deps(required, optional) { match check_deps(deps) {
Err(e) => { Err(e) => {
eprintln!(" error running doctor for {package_name}: {e}"); eprintln!(" error running doctor: {e}");
false false
} }
Ok(rep) => { Ok(missing) => {
for warn in &rep.warnings { if missing.is_empty() {
eprintln!( println!(" {package_name}: all system deps satisfied");
" {package_name}: optional dep not found: {warn} \
(install for full functionality)"
);
}
if rep.missing.is_empty() {
println!(" {package_name}: all required system deps satisfied");
true true
} else { } else {
eprintln!( eprintln!(
" {package_name}: missing system deps: {}", " {package_name}: missing system deps: {}",
rep.missing.join(", ") missing.join(", ")
);
eprintln!(
" install with: sudo pacman -S {}",
missing.join(" ")
); );
eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" "));
false false
} }
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_deps_pass() {
let rep = check_deps(&[], &[]).unwrap();
assert!(rep.missing.is_empty());
assert!(rep.warnings.is_empty());
}
// This test only runs on systems where pacman is available (Arch Linux).
#[test]
#[ignore]
fn pacman_finds_itself() {
assert!(pacman_installed("pacman"));
}
#[test]
fn path_has_finds_sh() {
assert!(path_has("sh"));
}
#[test]
fn missing_required_dep_detected() {
let rep = check_deps(
&["this-package-does-not-exist-xyzzy42".to_string()],
&[],
)
.unwrap();
assert_eq!(rep.missing.len(), 1);
assert!(rep.warnings.is_empty());
}
#[test]
fn missing_optional_dep_becomes_warning_not_error() {
let rep = check_deps(
&[],
&["this-package-does-not-exist-xyzzy42".to_string()],
)
.unwrap();
assert!(rep.missing.is_empty());
assert_eq!(rep.warnings.len(), 1);
}
// This test only runs on systems where pacman is available (Arch Linux).
#[test]
#[ignore]
fn installed_dep_not_missing() {
let rep = check_deps(&["pacman".to_string()], &[]).unwrap();
assert!(rep.missing.is_empty());
}
}

View file

@ -45,34 +45,3 @@ fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
use sha2::{Digest, Sha256};
fn sha256_hex(data: &[u8]) -> String {
hex::encode(Sha256::digest(data))
}
#[test]
fn verify_correct_hash() {
let bytes = b"hello bakery";
let hash = sha256_hex(bytes);
assert!(verify_sha256(bytes, &hash).is_ok());
}
#[test]
fn verify_wrong_hash_fails() {
let bytes = b"hello bakery";
let wrong = "0".repeat(64);
assert!(verify_sha256(bytes, &wrong).is_err());
}
#[test]
fn verify_empty_bytes() {
let bytes = b"";
let hash = sha256_hex(bytes);
assert!(verify_sha256(bytes, &hash).is_ok());
}
}

View file

@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use crate::download::fetch_and_place; use crate::download::fetch_and_place;
use crate::manifest::{fetch_binary, Package, Service}; use crate::manifest::{Package, Service};
use crate::state::{InstalledPackage, State}; use crate::state::{InstalledPackage, State};
pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> { pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> {
@ -12,21 +12,20 @@ pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> {
// 1. Download and verify all binaries. // 1. Download and verify all binaries.
let mut binary_names = Vec::new(); let mut binary_names = Vec::new();
for bin in &pkg.binaries { for bin in &pkg.binaries {
let install_name = strip_arch_suffix(&bin.name); let dest = bin_dir.join(&bin.name);
let dest = bin_dir.join(&install_name);
fetch_and_place(bin, &dest)?; fetch_and_place(bin, &dest)?;
binary_names.push(install_name.to_string()); binary_names.push(bin.name.clone());
} }
// 2. Scaffold config dir + download example file. // 2. Scaffold config dir + example file.
if let Some(cfg) = &pkg.config { if let Some(cfg) = &pkg.config {
scaffold_config(cfg, pkg)?; scaffold_config(cfg)?;
} }
// 3. Install systemd user units. // 3. Install systemd user units.
let mut service_names = Vec::new(); let mut service_names = Vec::new();
for svc in &pkg.services { for svc in &pkg.services {
install_service(svc, bin_dir, pkg)?; install_service(svc, bin_dir)?;
service_names.push(svc.unit.clone()); service_names.push(svc.unit.clone());
} }
@ -60,8 +59,6 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
return Ok(()); return Ok(());
} }
}; };
// Commit removal immediately — file cleanup below is best-effort.
state.save()?;
// Remove binaries. // Remove binaries.
for bin in &installed.binaries { for bin in &installed.binaries {
@ -106,111 +103,66 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
println!(" data preserved at {}", data_dir.display()); println!(" data preserved at {}", data_dir.display());
} }
state.save()?;
println!(" {pkg_name} removed"); println!(" {pkg_name} removed");
Ok(()) Ok(())
} }
fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Result<()> { fn scaffold_config(cfg: &crate::manifest::ConfigScaffold) -> Result<()> {
let dir = expand_tilde(&cfg.dir); let dir = expand_tilde(&cfg.dir);
std::fs::create_dir_all(&dir)?; std::fs::create_dir_all(&dir)?;
if let Some(example) = &cfg.example { if let Some(example) = &cfg.example {
let dest = dir.join(example); let dest = dir.join(example);
if !dest.exists() { if !dest.exists() {
if let Some((primary, fallback)) = pkg.artifact_urls(example) { // We don't have the actual example file here at install time —
match fetch_binary(&primary, &fallback) { // the product repo's release bundle should include it.
Ok(bytes) => { // For now just note it; release.yml will bundle example configs.
std::fs::write(&dest, &bytes) println!(" config dir ready at {}", dir.display());
.with_context(|| format!("writing {}", dest.display()))?; println!(
println!(" installed example config at {}", dest.display()); " copy your {example} to {} to configure {}",
} dest.display(),
Err(e) => { dir.display()
eprintln!(" warning: could not download example config {example}: {e}"); );
println!(" config dir created at {}", dir.display());
}
}
} else {
println!(" config dir created at {}", dir.display());
}
} else { } else {
println!(" config at {} already exists, skipping", dest.display()); println!(" config at {} already exists, skipping", dest.display());
} }
} else {
println!(" config dir created at {}", dir.display());
} }
Ok(()) Ok(())
} }
fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { fn install_service(svc: &Service, bin_dir: &Path) -> Result<()> {
let service_dir = systemd_user_dir(); let service_dir = systemd_user_dir();
std::fs::create_dir_all(&service_dir)?; std::fs::create_dir_all(&service_dir)?;
let unit_path = service_dir.join(&svc.unit); let unit_path = service_dir.join(&svc.unit);
// Download the unit file if not already present. // The unit file is expected to be bundled alongside the binary in the
if !unit_path.exists() { // release artifact (or embedded). For now, patch ExecStart if the unit
if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) { // already exists (same pattern as bread/scripts/install.sh).
match fetch_binary(&primary, &fallback) { if unit_path.exists() {
Ok(bytes) => {
std::fs::write(&unit_path, &bytes)
.with_context(|| format!("writing {}", unit_path.display()))?;
println!(" downloaded unit {}", unit_path.display());
}
Err(e) => {
eprintln!(" warning: could not download {}: {e}", svc.unit);
}
}
} else {
eprintln!(" warning: no artifact URL to download {}", svc.unit);
}
}
if !unit_path.exists() {
eprintln!(
" warning: unit file {} not found — skipping service setup",
svc.unit
);
return Ok(());
}
patch_exec_start(&unit_path, bin_dir)?; patch_exec_start(&unit_path, bin_dir)?;
if !Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status()
.map(|s| s.success())
.unwrap_or(false)
{
eprintln!(" warning: systemctl daemon-reload failed");
} }
let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status();
if svc.enable { if svc.enable {
let already_active = Command::new("systemctl") if Command::new("systemctl")
.args(["--user", "is-active", "--quiet", &svc.unit]) .args(["--user", "is-active", "--quiet", &svc.unit])
.status() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false);
if already_active {
if Command::new("systemctl")
.args(["--user", "restart", &svc.unit])
.status()
.map(|s| s.success())
.unwrap_or(false) .unwrap_or(false)
{ {
let _ = Command::new("systemctl")
.args(["--user", "restart", &svc.unit])
.status();
println!(" {} restarted", svc.unit); println!(" {} restarted", svc.unit);
} else { } else {
eprintln!(" warning: failed to restart {}", svc.unit); let _ = Command::new("systemctl")
}
} else if Command::new("systemctl")
.args(["--user", "enable", "--now", &svc.unit]) .args(["--user", "enable", "--now", &svc.unit])
.status() .status();
.map(|s| s.success())
.unwrap_or(false)
{
println!(" {} enabled and started", svc.unit); println!(" {} enabled and started", svc.unit);
} else {
eprintln!(" warning: failed to enable {}", svc.unit);
} }
} }
@ -223,6 +175,7 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
.lines() .lines()
.map(|line| { .map(|line| {
if line.trim_start().starts_with("ExecStart=") { if line.trim_start().starts_with("ExecStart=") {
// Replace only the path prefix, keep args.
let rest = line.splitn(2, '=').nth(1).unwrap_or(""); let rest = line.splitn(2, '=').nth(1).unwrap_or("");
let argv: Vec<&str> = rest.split_whitespace().collect(); let argv: Vec<&str> = rest.split_whitespace().collect();
if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) { if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) {
@ -242,13 +195,7 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
// Preserve trailing newline if the original had one. std::fs::write(unit_path, patched)?;
let output = if text.ends_with('\n') {
format!("{patched}\n")
} else {
patched
};
std::fs::write(unit_path, output)?;
Ok(()) Ok(())
} }
@ -293,16 +240,6 @@ fn expand_tilde(path: &str) -> PathBuf {
} }
} }
pub fn strip_arch_suffix(name: &str) -> &str {
const SUFFIXES: &[&str] = &["-x86_64", "-aarch64", "-arm64", "-armv7"];
for s in SUFFIXES {
if let Some(base) = name.strip_suffix(s) {
return base;
}
}
name
}
fn warn_path_if_needed(bin_dir: &Path) { fn warn_path_if_needed(bin_dir: &Path) {
let path_var = std::env::var("PATH").unwrap_or_default(); let path_var = std::env::var("PATH").unwrap_or_default();
let bin_str = bin_dir.to_string_lossy(); let bin_str = bin_dir.to_string_lossy();
@ -314,53 +251,3 @@ fn warn_path_if_needed(bin_dir: &Path) {
println!(" export PATH=\"{}:$PATH\"", bin_str); println!(" export PATH=\"{}:$PATH\"", bin_str);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn strip_known_suffixes() {
assert_eq!(strip_arch_suffix("breadd-x86_64"), "breadd");
assert_eq!(strip_arch_suffix("breadd-aarch64"), "breadd");
assert_eq!(strip_arch_suffix("breadd-arm64"), "breadd");
assert_eq!(strip_arch_suffix("breadd-armv7"), "breadd");
assert_eq!(strip_arch_suffix("bakery-x86_64"), "bakery");
assert_eq!(strip_arch_suffix("breadd"), "breadd");
}
#[test]
fn patch_exec_start_with_args() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.service");
fs::write(&path, "[Service]\nExecStart=/old/path/bin arg1 arg2\n").unwrap();
patch_exec_start(&path, Path::new("/new/bin")).unwrap();
let out = fs::read_to_string(&path).unwrap();
assert!(out.contains("ExecStart=/new/bin/bin arg1 arg2"));
assert!(out.ends_with('\n'));
}
#[test]
fn patch_exec_start_no_args() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.service");
fs::write(&path, "[Service]\nExecStart=/old/path/daemon\n").unwrap();
patch_exec_start(&path, Path::new("/usr/local/bin")).unwrap();
let out = fs::read_to_string(&path).unwrap();
assert!(out.contains("ExecStart=/usr/local/bin/daemon"));
assert!(!out.contains("daemon "));
}
#[test]
fn patch_exec_start_non_exec_lines_unchanged() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.service");
fs::write(&path, "[Unit]\nDescription=foo\nExecStart=/bin/foo\n").unwrap();
patch_exec_start(&path, Path::new("/usr/bin")).unwrap();
let out = fs::read_to_string(&path).unwrap();
assert!(out.contains("Description=foo"));
assert!(out.contains("ExecStart=/usr/bin/foo"));
}
}

View file

@ -6,11 +6,10 @@ mod state;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "bakery", about = "Package manager for the bread ecosystem", version)] #[command(name = "bakery", about = "Package manager for the bread ecosystem")]
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Cmd, command: Cmd,
@ -21,10 +20,9 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Cmd { enum Cmd {
/// Install one or more packages /// Install a package
Install { Install {
#[arg(required = true, num_args = 1..)] package: String,
packages: Vec<String>,
}, },
/// Remove an installed package (data files are never deleted) /// Remove an installed package (data files are never deleted)
Remove { Remove {
@ -32,12 +30,8 @@ enum Cmd {
}, },
/// Update one or all installed packages /// Update one or all installed packages
Update { Update {
/// Package to update (omit or use --all to update everything installed) /// Package to update; omit to update all installed packages
#[arg(conflicts_with = "all")]
package: Option<String>, package: Option<String>,
/// Update all installed packages
#[arg(long, conflicts_with = "package")]
all: bool,
}, },
/// List packages /// List packages
List { List {
@ -67,59 +61,27 @@ fn main() -> Result<()> {
let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir); let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir);
match cli.command { match cli.command {
Cmd::Install { packages } => { Cmd::Install { package } => cmd_install(&package, &bin_dir),
let index = manifest::load(true)?;
for pkg in &packages {
cmd_install(&index, pkg, &bin_dir)?;
}
Ok(())
}
Cmd::Remove { package } => cmd_remove(&package, &bin_dir), Cmd::Remove { package } => cmd_remove(&package, &bin_dir),
Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir), Cmd::Update { package } => cmd_update(package.as_deref(), &bin_dir),
Cmd::List { installed } => cmd_list(installed), Cmd::List { installed } => cmd_list(installed),
Cmd::Info { package } => cmd_info(&package), Cmd::Info { package } => cmd_info(&package),
Cmd::Doctor { package } => cmd_doctor(package.as_deref()), Cmd::Doctor { package } => cmd_doctor(package.as_deref()),
} }
} }
fn cmd_install(index: &manifest::Index, name: &str, bin_dir: &std::path::Path) -> Result<()> { fn cmd_install(name: &str, bin_dir: &std::path::Path) -> Result<()> {
let mut visited = HashSet::new(); let index = manifest::load(false)?;
install_with_deps(index, name, bin_dir, &mut visited)
}
/// Recursively installs `name` and any bread_deps, skipping already-installed
/// packages. The `visited` set prevents cycles.
fn install_with_deps(
index: &manifest::Index,
name: &str,
bin_dir: &std::path::Path,
visited: &mut HashSet<String>,
) -> Result<()> {
if !visited.insert(name.to_string()) {
return Ok(());
}
let pkg = index let pkg = index
.get(name) .get(name)
.ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?; .ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?;
// Install bread_deps first (skip those already recorded in state). // Doctor runs first — bail if system deps are missing.
let state = state::State::load()?; println!("checking system dependencies…");
for dep in pkg.bread_deps.clone() { let missing = doctor::check_deps(&pkg.system_deps)?;
if !state.is_installed(&dep) { if !missing.is_empty() {
println!("installing bread dependency: {dep}"); eprintln!("missing system dependencies for {name}: {}", missing.join(", "));
install_with_deps(index, &dep, bin_dir, visited)?; eprintln!("install with: sudo pacman -S {}", missing.join(" "));
}
}
println!("checking system dependencies for {name}");
let rep = doctor::check_deps(&pkg.system_deps, &pkg.optional_system_deps)?;
for warn in &rep.warnings {
eprintln!(" note: optional dep not installed: {warn}");
}
if !rep.missing.is_empty() {
eprintln!("missing system deps for {name}: {}", rep.missing.join(", "));
eprintln!("install with: sudo pacman -S {}", rep.missing.join(" "));
bail!("system deps not satisfied"); bail!("system deps not satisfied");
} }
@ -130,22 +92,15 @@ fn cmd_remove(name: &str, bin_dir: &std::path::Path) -> Result<()> {
install::remove_package(name, bin_dir) install::remove_package(name, bin_dir)
} }
fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Result<()> { fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
let index = manifest::load(true)?; let index = manifest::load(true)?; // force refresh on update
let state = state::State::load()?; let state = state::State::load()?;
let targets: Vec<String> = if all || name.is_none() { let targets: Vec<String> = match name {
state.packages.keys().cloned().collect() Some(n) => vec![n.to_string()],
} else { None => state.packages.keys().cloned().collect(),
vec![name.unwrap().to_string()]
}; };
if targets.is_empty() {
println!("no packages installed");
return Ok(());
}
let mut any_failed = false;
for pkg_name in &targets { for pkg_name in &targets {
let installed = match state.packages.get(pkg_name.as_str()) { let installed = match state.packages.get(pkg_name.as_str()) {
Some(p) => p, Some(p) => p,
@ -161,45 +116,15 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Resul
continue; continue;
} }
}; };
if installed.version == latest.version { if installed.version == latest.version {
println!("{pkg_name} is already at {}", installed.version); println!("{pkg_name} is already at {}", installed.version);
continue; } else {
}
println!( println!(
"updating {pkg_name} {} → {}", "updating {pkg_name} {} → {}",
installed.version, latest.version installed.version, latest.version
); );
install::install_package(latest, bin_dir)?;
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
Ok(r) => r,
Err(e) => {
eprintln!(" doctor check failed for {pkg_name}: {e}");
any_failed = true;
continue;
} }
};
for warn in &rep.warnings {
eprintln!(" note: optional dep not installed: {warn}");
}
if !rep.missing.is_empty() {
eprintln!(
" missing deps for {pkg_name}: {} — skipping update",
rep.missing.join(", ")
);
any_failed = true;
continue;
}
if let Err(e) = install::install_package(latest, bin_dir) {
eprintln!(" failed to update {pkg_name}: {e}");
any_failed = true;
}
}
if any_failed {
bail!("one or more packages could not be updated");
} }
Ok(()) Ok(())
} }
@ -248,32 +173,15 @@ fn cmd_info(name: &str) -> Result<()> {
println!("{} {}", pkg.name, pkg.version); println!("{} {}", pkg.name, pkg.version);
println!(" {}", pkg.description); println!(" {}", pkg.description);
println!(" status: {status}"); println!(" status: {status}");
println!( println!(" binaries: {}", pkg.binaries.iter().map(|b| b.name.as_str()).collect::<Vec<_>>().join(", "));
" binaries: {}",
pkg.binaries
.iter()
.map(|b| b.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
if !pkg.system_deps.is_empty() { if !pkg.system_deps.is_empty() {
println!(" system deps: {}", pkg.system_deps.join(", ")); println!(" system deps: {}", pkg.system_deps.join(", "));
} }
if !pkg.optional_system_deps.is_empty() {
println!(" optional deps: {}", pkg.optional_system_deps.join(", "));
}
if !pkg.bread_deps.is_empty() { if !pkg.bread_deps.is_empty() {
println!(" bread deps: {}", pkg.bread_deps.join(", ")); println!(" bread deps: {}", pkg.bread_deps.join(", "));
} }
if !pkg.services.is_empty() { if !pkg.services.is_empty() {
println!( println!(" services: {}", pkg.services.iter().map(|s| s.unit.as_str()).collect::<Vec<_>>().join(", "));
" services: {}",
pkg.services
.iter()
.map(|s| s.unit.as_str())
.collect::<Vec<_>>()
.join(", ")
);
} }
Ok(()) Ok(())
} }
@ -283,12 +191,7 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> {
let state = state::State::load()?; let state = state::State::load()?;
let targets: Vec<String> = match name { let targets: Vec<String> = match name {
Some(n) => { Some(n) => vec![n.to_string()],
if index.get(n).is_none() {
bail!("unknown package: {n}");
}
vec![n.to_string()]
}
None => state.packages.keys().cloned().collect(), None => state.packages.keys().cloned().collect(),
}; };
@ -300,12 +203,9 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> {
let mut all_ok = true; let mut all_ok = true;
for pkg_name in &targets { for pkg_name in &targets {
if let Some(pkg) = index.get(pkg_name) { if let Some(pkg) = index.get(pkg_name) {
if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) { if !doctor::report(pkg_name, &pkg.system_deps) {
all_ok = false; all_ok = false;
} }
} else {
eprintln!(" {pkg_name}: not found in index (removed from registry?)");
all_ok = false;
} }
} }

View file

@ -23,7 +23,7 @@ pub struct Service {
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ConfigScaffold { pub struct ConfigScaffold {
pub dir: String, pub dir: String,
/// Example config filename, relative to the release artifact directory. /// relative to the product repo root; copied as-is if absent at install time
pub example: Option<String>, pub example: Option<String>,
} }
@ -36,8 +36,6 @@ pub struct Package {
#[serde(default)] #[serde(default)]
pub system_deps: Vec<String>, pub system_deps: Vec<String>,
#[serde(default)] #[serde(default)]
pub optional_system_deps: Vec<String>,
#[serde(default)]
pub bread_deps: Vec<String>, pub bread_deps: Vec<String>,
#[serde(default)] #[serde(default)]
pub services: Vec<Service>, pub services: Vec<Service>,
@ -46,21 +44,6 @@ pub struct Package {
pub post_install: Vec<String>, pub post_install: Vec<String>,
} }
impl Package {
/// Returns `(primary_url, github_url)` for any artifact filename in this
/// package's release directory. Derived by stripping the filename from the
/// first binary's URLs.
pub fn artifact_urls(&self, filename: &str) -> Option<(String, String)> {
let first = self.binaries.first()?;
let dl_base = first.dl_url.rsplit_once('/')?.0;
let gh_base = first.github_url.rsplit_once('/')?.0;
Some((
format!("{dl_base}/{filename}"),
format!("{gh_base}/{filename}"),
))
}
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Index { pub struct Index {
pub version: String, pub version: String,
@ -84,7 +67,8 @@ pub fn load(force_refresh: bool) -> Result<Index> {
let cache_path = cache_path(); let cache_path = cache_path();
if !force_refresh && cache_is_fresh(&cache_path) { if !force_refresh && cache_is_fresh(&cache_path) {
let text = std::fs::read_to_string(&cache_path).context("reading cached index")?; let text = std::fs::read_to_string(&cache_path)
.context("reading cached index")?;
return serde_json::from_str(&text).context("parsing cached index"); return serde_json::from_str(&text).context("parsing cached index");
} }
@ -148,6 +132,6 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
let mut buf = Vec::new(); let mut buf = Vec::new();
resp.into_reader() resp.into_reader()
.read_to_end(&mut buf) .read_to_end(&mut buf)
.context("reading response")?; .context("reading binary")?;
Ok(buf) Ok(buf)
} }

View file

@ -33,12 +33,7 @@ impl State {
std::fs::create_dir_all(dir)?; std::fs::create_dir_all(dir)?;
} }
let text = serde_json::to_string_pretty(self)?; let text = serde_json::to_string_pretty(self)?;
// Write to a temp file then rename for atomicity — avoids a torn write std::fs::write(&path, text).context("writing installed.json")
// if the process is killed mid-save.
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &text).context("writing installed.json.tmp")?;
std::fs::rename(&tmp, &path).context("atomically replacing installed.json")?;
Ok(())
} }
pub fn is_installed(&self, name: &str) -> bool { pub fn is_installed(&self, name: &str) -> bool {
@ -63,58 +58,3 @@ fn state_path() -> PathBuf {
}) })
.join("bakery/installed.json") .join("bakery/installed.json")
} }
#[cfg(test)]
mod tests {
use super::*;
fn pkg(name: &str, version: &str) -> InstalledPackage {
InstalledPackage {
name: name.to_string(),
version: version.to_string(),
binaries: vec![],
services: vec![],
installed_at: "2026-01-01T00:00:00Z".to_string(),
}
}
#[test]
fn record_and_is_installed() {
let mut state = State::default();
assert!(!state.is_installed("foo"));
state.record(pkg("foo", "1.0.0"));
assert!(state.is_installed("foo"));
}
#[test]
fn remove_installed() {
let mut state = State::default();
state.record(pkg("foo", "1.0.0"));
let removed = state.remove("foo");
assert!(removed.is_some());
assert!(!state.is_installed("foo"));
}
#[test]
fn remove_unknown_returns_none() {
let mut state = State::default();
assert!(state.remove("nope").is_none());
}
#[test]
fn json_roundtrip() {
let mut state = State::default();
state.record(InstalledPackage {
name: "bar".to_string(),
version: "2.0.0".to_string(),
binaries: vec!["bar".to_string()],
services: vec!["bar.service".to_string()],
installed_at: "2026-06-01T00:00:00Z".to_string(),
});
let json = serde_json::to_string(&state).unwrap();
let restored: State = serde_json::from_str(&json).unwrap();
assert!(restored.is_installed("bar"));
assert_eq!(restored.packages["bar"].version, "2.0.0");
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
}
}

View file

@ -18,9 +18,3 @@ gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
# 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"]
# The generator CLI. It only touches the gtk-free lib API (render + write), so
# it builds without the gtk feature and stays light.
[[bin]]
name = "bread-theme"
path = "src/bin/bread-theme.rs"

View file

@ -1,62 +0,0 @@
//! `bread-theme` — generates the ecosystem's shared GTK stylesheet from the
//! current pywal palette and writes it to the canonical path that every bread
//! GUI loads. Run it at session start, and again after the wallpaper/palette
//! changes (e.g. from a pywal hook); apps watch the file and recolour live.
//!
//! bread-theme # same as `generate`
//! bread-theme generate # render + write the shared stylesheet
//! bread-theme reload # re-render from the current pywal palette and
//! # signal every running bread GUI to recolour
//! bread-theme path # print the stylesheet path
//! bread-theme print # render to stdout (no write)
use std::process::ExitCode;
fn write_and_report(verb: &str) -> ExitCode {
match bread_theme::write_shared_css() {
Ok(path) => {
eprintln!("bread-theme: {verb} {}", path.display());
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("bread-theme: failed to write stylesheet: {e}");
ExitCode::FAILURE
}
}
}
fn main() -> ExitCode {
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
match cmd.as_str() {
"path" => {
println!("{}", bread_theme::shared_css_path().display());
ExitCode::SUCCESS
}
"print" => {
print!("{}", bread_theme::render());
ExitCode::SUCCESS
}
"generate" => write_and_report("wrote"),
// `reload` is `generate` from the caller's view, but it's the verb to use
// after changing pywal colours: rewriting the file (atomic rename) trips
// the file monitor in every running bread GUI, so they all re-read the
// palette and recolour live — shared widgets *and* each app's own rules.
"reload" => write_and_report("reloaded"),
"-h" | "--help" | "help" => {
eprintln!(
"bread-theme — shared stylesheet generator\n\n\
USAGE:\n bread-theme [generate|reload|path|print]\n\n\
generate render the pywal palette to the shared stylesheet (default)\n\
reload re-render and signal running bread GUIs to recolour live\n\
path print the stylesheet path ({})\n\
print render to stdout without writing",
bread_theme::shared_css_path().display()
);
ExitCode::SUCCESS
}
other => {
eprintln!("bread-theme: unknown command '{other}' (try generate|reload|path|print)");
ExitCode::FAILURE
}
}
}

View file

@ -1,100 +1,7 @@
use gtk4::gio;
use gtk4::prelude::*;
use gtk4::CssProvider; use gtk4::CssProvider;
use std::cell::RefCell; use std::cell::RefCell;
use std::path::Path; use std::path::Path;
thread_local! {
static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
static SHARED_MONITOR: RefCell<Option<gio::FileMonitor>> = const { RefCell::new(None) };
static APP_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
static APP_MONITOR: RefCell<Option<gio::FileMonitor>> = const { RefCell::new(None) };
#[allow(clippy::type_complexity)]
static APP_BUILDER: RefCell<Option<Box<dyn Fn() -> String>>> = const { RefCell::new(None) };
}
fn reload_shared() {
let css = std::fs::read_to_string(crate::shared_css_path())
.unwrap_or_else(|_| crate::render());
SHARED_PROVIDER.with(|cell| apply_css(&css, cell));
}
fn reload_app() {
let css = APP_BUILDER.with(|b| b.borrow().as_ref().map(|f| f()));
if let Some(css) = css {
APP_PROVIDER.with(|cell| apply_css(&css, cell));
}
}
/// Watch the shared stylesheet for changes and run `reload` when it's rewritten.
///
/// `bread-theme` writes the file with write-tmp-then-rename (atomic), which
/// *replaces the inode*. A monitor on the file itself dies after the first
/// replace (inotify reports DELETE_SELF and never re-arms), so we monitor the
/// parent *directory* and filter for the stylesheet's filename — that fires
/// reliably on every reload. Returns the monitor (keep it alive to stay armed).
fn watch_theme_file(reload: fn()) -> Option<gio::FileMonitor> {
let target = crate::shared_css_path();
let dir = target.parent()?;
// The dir must exist to be monitored; `bread-theme generate` makes it at
// login, but create it here too so a GUI started first still arms the watch.
let _ = std::fs::create_dir_all(dir);
let monitor = gio::File::for_path(dir)
.monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE)
.ok()?;
monitor.connect_changed(move |_, file, other, _event| {
// The rename lands as an event whose file (or move destination) is the
// stylesheet. Match either to catch both CREATED/CHANGED and MOVED_IN.
let is_target = |f: &gio::File| f.path().as_deref() == Some(target.as_path());
if is_target(file) || other.is_some_and(is_target) {
reload();
}
});
Some(monitor)
}
/// Apply an app's *own* stylesheet and keep it live across palette changes.
///
/// `build` is called now to produce the app-specific CSS, and again every time
/// the shared theme file is rewritten — i.e. whenever `bread-theme reload` (or
/// `generate`) runs after pywal changes. The app recolours in place, no restart.
///
/// This is the counterpart to [`apply_shared`]: that hot-reloads the *shared*
/// component sheet; this hot-reloads the app's *own* rules (which are built from
/// the palette, so they'd otherwise be frozen at startup). Apps that build their
/// CSS from [`crate::stylesheet`] themselves can use this alone; apps that layer
/// on top of [`apply_shared`] call both.
///
/// Call once at startup. The closure should read the current palette
/// ([`crate::load_palette`]) each time so it picks up the new colours.
pub fn apply_app_css<F: Fn() -> String + 'static>(build: F) {
APP_BUILDER.with(|b| *b.borrow_mut() = Some(Box::new(build)));
reload_app();
APP_MONITOR.with(|cell| {
if cell.borrow().is_some() {
return;
}
*cell.borrow_mut() = watch_theme_file(reload_app);
});
}
/// Load the ecosystem's shared stylesheet (the file written by
/// `bread-theme generate`, or a freshly rendered fallback if absent) at
/// APPLICATION priority, and watch the file so the whole UI recolours live when
/// the palette changes — no app rebuild or restart needed.
///
/// Call once at startup; then add the app's own CSS provider *after* this so
/// app-specific rules win on equal specificity.
pub fn apply_shared() {
reload_shared();
SHARED_MONITOR.with(|cell| {
if cell.borrow().is_some() {
return;
}
*cell.borrow_mut() = watch_theme_file(reload_shared);
});
}
/// Apply a CSS string to the default display at APPLICATION priority. /// Apply a CSS string to the default display at APPLICATION priority.
/// Re-uses an existing provider if one is passed in (for SIGHUP reloads). /// Re-uses an existing provider if one is passed in (for SIGHUP reloads).
pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) { pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) {

View file

@ -54,165 +54,6 @@ pub fn css_vars(p: &Palette) -> String {
) )
} }
/// Relative luminance (WCAG, sRGB) of a `#rrggbb` colour, 0.0 (black) 1.0 (white).
pub fn luminance(hex: &str) -> f32 {
let h = hex.trim_start_matches('#');
let lin = |i: usize| -> f32 {
let c = u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f32 / 255.0;
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
};
0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4)
}
/// Pick a legible ink (near-black or near-white) for text drawn on `hex`.
/// 0.179 is the WCAG crossover where contrast against black equals contrast
/// against white — so whichever side we pick always wins. This is what keeps
/// text readable no matter how light or dark pywal makes a given palette slot,
/// without altering the palette colours themselves.
pub fn ink_on(hex: &str) -> &'static str {
if luminance(hex) > 0.179 { "#11111b" } else { "#f5f5f5" }
}
/// Canonical `@define-color` block: the single naming all bread apps share.
/// `surface` = color0 (darkest surface), `overlay` = color7 (muted), and
/// `accent` = color4. Apps must use these names, not raw palette slots, so the
/// whole ecosystem recolours together.
///
/// The `on-*` colours are computed ink (black/white) guaranteed to be legible on
/// the matching background — use `@on-surface` for text on a `@surface` panel,
/// `@on-accent` on an `@accent` button, etc. They exist because pywal can emit a
/// light value in any slot, and white text on a light surface disappears.
fn define_colors(p: &Palette) -> String {
format!(
"@define-color bg {bg};\n\
@define-color fg {fg};\n\
@define-color surface {c0};\n\
@define-color overlay {c7};\n\
@define-color accent {c4};\n\
@define-color red {c1};\n\
@define-color green {c2};\n\
@define-color yellow {c3};\n\
@define-color blue {c4};\n\
@define-color pink {c5};\n\
@define-color teal {c6};\n\
@define-color on-bg {on_bg};\n\
@define-color on-surface {on_surface};\n\
@define-color on-accent {on_accent};\n\
@define-color on-red {on_red};\n\
@define-color on-overlay {on_overlay};\n",
bg = p.background, fg = p.foreground,
c0 = p.color0, c1 = p.color1, c2 = p.color2, c3 = p.color3,
c4 = p.color4, c5 = p.color5, c6 = p.color6, c7 = p.color7,
on_bg = ink_on(&p.background),
on_surface = ink_on(&p.color0),
on_accent = ink_on(&p.color4),
on_red = ink_on(&p.color1),
on_overlay = ink_on(&p.color7),
)
}
/// The full shared component stylesheet — the single source of truth for how
/// every bread GUI (bos-settings, breadbar, breadbox, breadpad, breadman) styles
/// common widgets. Apps load this, then append only their own *layout* rules.
///
/// Built entirely from the design tokens (font, spacing, radii) and the
/// `@define-color` palette, so changing the palette recolours every app.
pub fn stylesheet(p: &Palette) -> String {
use tokens::*;
format!(
"{vars}\
* {{ font-family: '{font}'; font-size: {base}px; }}\n\
/* Colour is set on containers; labels inherit it, so text on any panel,\
button, or accent is always the legible ink for that background. Bare\
`label {{ color }}` is deliberately avoided as a type selector it\
would override a container's colour on its own child labels. */\n\
window {{ background-color: @bg; color: @on-bg; }}\n\
.dim-label, .dim {{ opacity: 0.6; font-size: {sec}px; }}\n\
.title {{ font-size: 1.4em; font-weight: bold; }}\n\
.heading {{ font-weight: bold; opacity: 0.85; }}\n\
.subtitle {{ opacity: 0.7; font-size: {sec}px; }}\n\
button {{ background-color: @surface; color: @on-surface; border: none;\
border-radius: {r1}px; padding: {sm}px {lg}px; }}\n\
button:hover {{ background-color: alpha(@on-surface, 0.14); }}\n\
button:active {{ background-color: alpha(@on-surface, 0.20); }}\n\
button:disabled {{ opacity: 0.5; }}\n\
button.flat {{ background-color: transparent; color: @on-bg; }}\n\
button.suggested-action {{ background-color: @accent; color: @on-accent; }}\n\
button.suggested-action:hover {{ background-color: alpha(@accent, 0.85); }}\n\
button.destructive-action {{ background-color: @red; color: @on-red; }}\n\
button.destructive-action:hover {{ background-color: alpha(@red, 0.85); }}\n\
entry, spinbutton {{ background-color: @surface; color: @on-surface;\
border: 1px solid @overlay; border-radius: {r2}px;\
padding: {xs}px {sm}px; caret-color: @on-surface; }}\n\
entry:focus-within, spinbutton:focus-within {{ border-color: @accent; outline: none; }}\n\
entry image, spinbutton button {{ color: @on-surface; }}\n\
dropdown > button {{ background-color: @surface; color: @on-surface; border-radius: {r2}px; }}\n\
popover > contents {{ background-color: @surface; color: @on-surface; border-radius: {r1}px; }}\n\
switch {{ background-color: @overlay; border-radius: {pill}px; }}\n\
switch:checked {{ background-color: @accent; }}\n\
switch slider {{ background-color: @on-surface; border-radius: {pill}px; }}\n\
list, listbox {{ background-color: transparent; }}\n\
row {{ border-radius: {r2}px; }}\n\
row:selected, list row:selected {{ background-color: @accent; color: @on-accent; }}\n\
.sidebar {{ background-color: @surface; color: @on-surface; }}\n\
.sidebar row {{ padding: {sm}px {md}px; }}\n\
.sidebar row:selected {{ background-color: @accent; color: @on-accent; }}\n\
.sidebar .section-header {{ padding: {md}px {md}px {xs}px {md}px;\
font-size: {sec}px; font-weight: bold; opacity: 0.55; }}\n\
.card {{ background-color: @surface; color: @on-surface; border-radius: {r1}px; padding: {md}px; }}\n\
.chip, .pill {{ background-color: @overlay; color: @on-overlay; border-radius: {pill}px;\
padding: {xs}px {md}px; font-size: {sec}px; }}\n\
.chip.active, .pill.active {{ background-color: @accent; color: @on-accent; }}\n\
scrollbar {{ background-color: transparent; }}\n\
scrollbar slider {{ background-color: alpha(@on-bg, 0.25); border-radius: {pill}px;\
min-width: 6px; min-height: 6px; }}\n\
scrollbar slider:hover {{ background-color: alpha(@on-bg, 0.45); }}\n\
textview, .mono {{ font-family: monospace; }}\n\
textview text {{ background-color: @surface; color: @on-surface; }}\n",
vars = define_colors(p),
font = FONT_FAMILY,
base = FONT_SIZE_BASE,
sec = FONT_SIZE_SECONDARY,
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG,
r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, pill = RADIUS_PILL,
)
}
/// Render the shared stylesheet for the current (pywal) palette. Used by the
/// `bread-theme` generator and as the in-app fallback when the generated file
/// isn't present yet.
pub fn render() -> String {
stylesheet(&load_palette())
}
/// Canonical path of the generated shared stylesheet. Apps load it; the
/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`,
/// falling back to the cache dir.
pub fn shared_css_path() -> std::path::PathBuf {
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
if !rt.is_empty() {
return std::path::PathBuf::from(rt).join("bread").join("theme.css");
}
}
dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
.join("bread")
.join("theme.css")
}
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
/// the path written. Used by the `bread-theme` CLI.
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
let path = shared_css_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
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)`.
pub fn hex_to_rgba(hex: &str, alpha: f32) -> String { pub fn hex_to_rgba(hex: &str, alpha: f32) -> String {
let h = hex.trim_start_matches('#'); let h = hex.trim_start_matches('#');
@ -241,66 +82,6 @@ mod tests {
assert!(css.contains("14px")); assert!(css.contains("14px"));
} }
#[test]
fn stylesheet_defines_canonical_colors_and_components() {
let css = stylesheet(&Palette::default());
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
}
// a representative spread of the shared component selectors
for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".title"] {
assert!(css.contains(sel), "stylesheet missing selector: {sel}");
}
assert!(css.contains("Varela Round"));
}
#[test]
fn luminance_black_and_white_are_extremes() {
assert!(luminance("#000000") < 0.01);
assert!(luminance("#ffffff") > 0.99);
}
#[test]
fn ink_on_picks_dark_text_for_light_backgrounds() {
// Light pywal slots (the case that made white text vanish) get dark ink.
assert_eq!(ink_on("#ffffff"), "#11111b");
assert_eq!(ink_on("#f9e2af"), "#11111b"); // pale yellow
assert_eq!(ink_on("#a6e3a1"), "#11111b"); // pale green
}
#[test]
fn ink_on_picks_light_text_for_dark_backgrounds() {
assert_eq!(ink_on("#000000"), "#f5f5f5");
assert_eq!(ink_on("#1e1e2e"), "#f5f5f5"); // catppuccin base
}
#[test]
fn stylesheet_defines_on_colors() {
let css = stylesheet(&Palette::default());
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
}
}
#[test]
fn stylesheet_has_no_blanket_label_color_rule() {
// A bare `label { color: ... }` would override container colours on child
// labels — the bug that made coloured-background text illegible.
let css = stylesheet(&Palette::default());
assert!(!css.contains("label { color:"), "blanket label colour rule reintroduced");
}
#[test]
fn shared_css_path_uses_runtime_dir() {
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
assert_eq!(shared_css_path(), std::path::PathBuf::from("/run/user/1234/bread/theme.css"));
}
#[test]
fn render_is_nonempty_css() {
assert!(render().contains("@define-color bg "));
}
#[test] #[test]
fn hex_to_rgba_known_value() { fn hex_to_rgba_known_value() {
assert_eq!(hex_to_rgba("#1e1e2e", 1.0), "rgba(30, 30, 46, 1)"); assert_eq!(hex_to_rgba("#1e1e2e", 1.0), "rgba(30, 30, 46, 1)");

View file

@ -1,28 +0,0 @@
# Maintainer: Breadway <rileyhorsham@gmail.com>
pkgname=bakery
pkgver=0.2.3
pkgrel=1
pkgdesc="Package manager for the bread ecosystem"
arch=('x86_64')
url="https://github.com/Breadway/bread-ecosystem"
license=('MIT')
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
# causing undefined-symbol errors. Disable LTO.
options=(!lto !debug)
depends=('glibc' 'gcc-libs')
makedepends=('rust' 'cargo')
source=("${pkgname}-${pkgver}.tar.gz")
sha256sums=('SKIP')
build() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo build --release --locked -p bakery
}
package() {
cd "${srcdir}/${pkgname}-${pkgver}"
install -Dm755 target/release/bakery "${pkgdir}/usr/bin/bakery"
install -Dm644 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md"
}

View file

@ -7,11 +7,6 @@ description = "Reactive desktop automation ecosystem for Arch Linux / Hyprland"
homepage = "https://breadway.dev" homepage = "https://breadway.dev"
dl_base = "https://dl.breadway.dev" dl_base = "https://dl.breadway.dev"
[[products]]
name = "bakery"
repo = "Breadway/bread-ecosystem"
description = "Bread ecosystem package manager"
[[products]] [[products]]
name = "bread" name = "bread"
repo = "Breadway/bread" repo = "Breadway/bread"
@ -36,8 +31,3 @@ description = "Profile-aware Wi-Fi state machine with Tailscale integration"
name = "breadpad" name = "breadpad"
repo = "Breadway/breadpad" repo = "Breadway/breadpad"
description = "Quick-capture scratchpad and note viewer with AI classification" description = "Quick-capture scratchpad and note viewer with AI classification"
[[products]]
name = "breadpaper"
repo = "Breadway/breadpaper"
description = "Wallpaper manager for the bread desktop"

111
scripts/gen-index.sh Executable file → Normal file
View file

@ -1,28 +1,27 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Generate dl.breadway.dev/index.json from: # Generate dl.breadway.dev/index.json 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) # - <repo>/bakery.toml (per-product metadata)
# - <DL_DIR>/ (built binaries + sha256 files) # - /srv/breadway-dl/ (built binaries + sha256 files)
# #
# Fallback for local dev: looks for ../name/bakery.toml (sibling repo checkout).
# Run on hestia after each product build, before the dl server is refreshed. # Run on hestia after each product build, before the dl server is refreshed.
# Requires: jq, python3 (tomllib, stdlib since 3.11), sha256sum # Requires: jq, python3 (for toml parsing via tomllib), sha256sum
set -euo pipefail set -euo pipefail
SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" 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}"
GH_BASE="https://github.com" GH_BASE="https://github.com/Breadway"
OUT="${DL_DIR}/index.json" OUT="${DL_DIR}/index.json"
# Read the product list from the registry TOML instead of a hardcoded array. # Products are read from the registry. Each line is "name repo".
mapfile -t products < <(python3 -c " products=(
import tomllib, sys "bread Breadway/bread"
with open('${SCRIPT_DIR}/registry/bread-ecosystem.toml', 'rb') as f: "breadbar Breadway/breadbar"
d = tomllib.load(f) "breadbox Breadway/breadbox"
for p in d['products']: "breadcrumbs Breadway/breadcrumbs"
print(p['name'], p['repo']) "breadpad Breadway/breadpad"
") )
# Build a JSON package entry for one product. # Build a JSON package entry for one product.
# $1 = product name, $2 = github repo slug # $1 = product name, $2 = github repo slug
@ -34,28 +33,24 @@ build_package_json() {
local pkg_dir="${DL_DIR}/${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
fi fi
# The latest symlink must point to the current version dir. # The latest symlink must point to the current version dir.
local latest_link="${pkg_dir}/latest" local latest_link="${pkg_dir}/latest"
if [[ ! -L "${latest_link}" ]]; then if [[ ! -L "${latest_link}" ]]; then
echo " warning: no 'latest' symlink for ${name}" >&2 echo " warning: no 'latest' symlink for ${name}" >&2
return 1 return
fi fi
local version_dir local version_dir
version_dir="$(readlink -f "${latest_link}")" version_dir="$(readlink -f "${latest_link}")"
local version local version
version="$(basename "${version_dir}")" version="$(basename "${version_dir}")"
# Collect all binaries in the version dir (executables only; skip metadata files). # Collect all binaries in the version dir (files without .sha256 extension).
local binaries_json="[]" local binaries_json="[]"
for bin_path in "${version_dir}"/*; do for bin_path in "${version_dir}"/*; do
[[ "${bin_path}" == *.sha256 ]] && continue [[ "${bin_path}" == *.sha256 ]] && continue
[[ "${bin_path}" == *.toml ]] && continue
[[ "${bin_path}" == *.service ]] && continue
[[ "${bin_path}" == *.css ]] && continue
[[ "${bin_path}" == *.txt ]] && 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}")"
@ -77,78 +72,42 @@ build_package_json() {
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 # Read bakery.toml for this product from a co-located checkout if available,
# alongside the binaries (${version_dir}/bakery.toml). Fall back to a # else use minimal defaults.
# sibling repo checkout for local dev use. local bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml"
local bakery_toml="${version_dir}/bakery.toml" local description=""
if [[ ! -f "${bakery_toml}" ]]; then local system_deps="[]"
bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml" local bread_deps="[]"
fi local services="[]"
if [[ ! -f "${bakery_toml}" ]]; then local config="null"
echo "ERROR: bakery.toml not found for ${name} — release.yml must copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2 local post_install="[]"
return 1
fi
local description system_deps optional_system_deps bread_deps services config post_install
if [[ -f "${bakery_toml}" ]]; then
description="$(python3 -c " description="$(python3 -c "
import tomllib import tomllib, sys
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f) d = tomllib.load(f)
print(d.get('description', '')) print(d.get('description', ''))
" 2>/dev/null || true)" " 2>/dev/null || true)"
system_deps="$(python3 -c " system_deps="$(python3 -c "
import tomllib, json import tomllib, json, sys
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f) d = tomllib.load(f)
print(json.dumps(d.get('system_deps', []))) print(json.dumps(d.get('system_deps', [])))
" 2>/dev/null || echo "[]")" " 2>/dev/null || echo "[]")"
optional_system_deps="$(python3 -c "
import tomllib, json
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
print(json.dumps(d.get('optional_system_deps', [])))
" 2>/dev/null || echo "[]")"
bread_deps="$(python3 -c " bread_deps="$(python3 -c "
import tomllib, json import tomllib, json, sys
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f) d = tomllib.load(f)
print(json.dumps(d.get('bread_deps', []))) print(json.dumps(d.get('bread_deps', [])))
" 2>/dev/null || echo "[]")" " 2>/dev/null || echo "[]")"
# [[service]] entries → [{unit, enable}]
services="$(python3 -c "
import tomllib, json
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
svcs = d.get('service', [])
print(json.dumps([{'unit': s['unit'], 'enable': s.get('enable', False)} for s in svcs]))
" 2>/dev/null || echo "[]")"
# [config] → {dir, example?} or null
config="$(python3 -c "
import tomllib, json
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
cfg = d.get('config')
if cfg:
obj = {'dir': cfg['dir']}
if 'example' in cfg:
obj['example'] = cfg['example']
print(json.dumps(obj))
else:
print('null')
" 2>/dev/null || echo "null")"
post_install="$(python3 -c " post_install="$(python3 -c "
import tomllib, json import tomllib, json, sys
with open('${bakery_toml}', 'rb') as f: with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f) d = tomllib.load(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 "[]")"
fi
jq -n \ jq -n \
--arg name "${name}" \ --arg name "${name}" \
@ -156,10 +115,8 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
--arg version "${version}" \ --arg version "${version}" \
--argjson binaries "${binaries_json}" \ --argjson binaries "${binaries_json}" \
--argjson system_deps "${system_deps}" \ --argjson system_deps "${system_deps}" \
--argjson optional_system_deps "${optional_system_deps}" \
--argjson bread_deps "${bread_deps}" \ --argjson bread_deps "${bread_deps}" \
--argjson services "${services}" \ --argjson services "${services}" \
--argjson config "${config}" \
--argjson post_install "${post_install}" \ --argjson post_install "${post_install}" \
'{ '{
name: $name, name: $name,
@ -167,10 +124,8 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
version: $version, version: $version,
binaries: $binaries, binaries: $binaries,
system_deps: $system_deps, system_deps: $system_deps,
optional_system_deps: $optional_system_deps,
bread_deps: $bread_deps, bread_deps: $bread_deps,
services: $services, services: $services,
config: $config,
post_install: $post_install post_install: $post_install
}' }'
} }
@ -181,8 +136,8 @@ for entry in "${products[@]}"; do
name="$(echo "${entry}" | awk '{print $1}')" name="$(echo "${entry}" | awk '{print $1}')"
repo="$(echo "${entry}" | awk '{print $2}')" repo="$(echo "${entry}" | awk '{print $2}')"
echo "processing ${name}" echo "processing ${name}"
pkg="$(build_package_json "${name}" "${repo}")" || { echo " skipping ${name}"; continue; } pkg="$(build_package_json "${name}" "${repo}" 2>&1)" || { echo " skipping ${name}: ${pkg}"; continue; }
[[ -z "${pkg}" ]] && { echo " skipping ${name}: no output"; continue; } [[ -z "${pkg}" ]] && continue
packages_json="$(jq -n --argjson m "${packages_json}" --arg k "${name}" --argjson v "${pkg}" '$m + {($k): $v}')" packages_json="$(jq -n --argjson m "${packages_json}" --arg k "${name}" --argjson v "${pkg}" '$m + {($k): $v}')"
done done

33
scripts/get.sh Executable file → Normal file
View file

@ -1,10 +1,12 @@
#!/bin/sh #!/bin/sh
# Bootstrap script: downloads and installs the `bakery` binary. # Bootstrap script: installs the `bakery` binary.
# Usage: curl https://breadway.dev/get | sh # Usage: curl https://breadway.dev/get | sh
# Or: curl -sSfL https://breadway.dev/get | sh # Or: curl -sSfL https://breadway.dev/get | sh
set -eu set -eu
BAKERY_VERSION="${BAKERY_VERSION:-latest}" BAKERY_VERSION="${BAKERY_VERSION:-latest}"
DL_PRIMARY="https://dl.breadway.dev/bakery/${BAKERY_VERSION}/bakery-x86_64"
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/${BAKERY_VERSION}/bakery-x86_64"
BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}" BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}"
die() { echo "error: $*" >&2; exit 1; } die() { echo "error: $*" >&2; exit 1; }
@ -13,20 +15,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))"
# Build download URLs. GitHub's "latest" redirect lives at a different path from
# versioned releases, so we handle them separately and always prefix tags with 'v'.
if [ "${BAKERY_VERSION}" = "latest" ]; then
DL_PRIMARY="https://dl.breadway.dev/bakery/latest/bakery-x86_64"
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/latest/download/bakery-x86_64"
SHA256_URL="https://dl.breadway.dev/bakery/latest/bakery-x86_64.sha256"
else
# Strip a leading 'v' if the caller included it, then add it back consistently.
ver="${BAKERY_VERSION#v}"
DL_PRIMARY="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64"
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/v${ver}/bakery-x86_64"
SHA256_URL="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64.sha256"
fi
# Pick a download tool. # Pick a download tool.
if command -v curl >/dev/null 2>&1; then if command -v curl >/dev/null 2>&1; then
fetch() { curl -fsSL "$1" -o "$2"; } fetch() { curl -fsSL "$1" -o "$2"; }
@ -38,26 +26,13 @@ fi
mkdir -p "${BIN_DIR}" mkdir -p "${BIN_DIR}"
TMP="$(mktemp)" TMP="$(mktemp)"
trap 'rm -f "${TMP}" "${TMP}.sha256"' EXIT trap 'rm -f "${TMP}"' EXIT
echo "downloading bakery…" 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"
# Verify checksum when available from primary.
if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then
expected="$(awk '{print $1}' "${TMP}.sha256")"
actual="$(sha256sum "${TMP}" | awk '{print $1}')"
if [ "${expected}" != "${actual}" ]; then
die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})"
fi
echo " checksum verified"
else
echo " warning: could not fetch checksum — skipping verification"
fi
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)"
# No .sha256 on the GitHub fallback path; proceed without verification.
echo " warning: checksum not verified 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

View file

@ -1,113 +0,0 @@
#!/usr/bin/env bash
# Smoke-test gen-index.sh against a minimal fixture DL_DIR tree.
# Verifies that services, config, system_deps, optional_system_deps,
# description, and post_install are all populated correctly.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FIXTURE="$(mktemp -d)"
FAKE_REGISTRY="$(mktemp -d)"
trap 'rm -rf "${FIXTURE}" "${FAKE_REGISTRY}"' EXIT
fail() { echo "FAIL: $*" >&2; exit 1; }
# ── Build a minimal release tree for "fakepkg" ───────────────────────────────
PKG_VER_DIR="${FIXTURE}/fakepkg/0.1.0"
mkdir -p "${PKG_VER_DIR}"
printf 'fake-binary-content' > "${PKG_VER_DIR}/fakepkg-x86_64"
sha256sum "${PKG_VER_DIR}/fakepkg-x86_64" | awk '{print $1}' \
> "${PKG_VER_DIR}/fakepkg-x86_64.sha256"
printf '[Unit]\nDescription=fakepkg\n' > "${PKG_VER_DIR}/fakepkg.service"
printf '# example config\n' > "${PKG_VER_DIR}/fakepkg.example.toml"
cat > "${PKG_VER_DIR}/bakery.toml" <<'TOML'
name = "fakepkg"
description = "A fake package for testing"
binaries = ["fakepkg"]
system_deps = ["gtk4"]
optional_system_deps = ["hyprland"]
bread_deps = []
[[service]]
unit = "fakepkg.service"
enable = true
[config]
dir = "~/.config/fakepkg"
example = "fakepkg.example.toml"
[install]
post_install = ["echo installed"]
TOML
# gen-index looks for bakery.toml at ${DL_DIR}/<name>/bakery.toml (no version)
cp "${PKG_VER_DIR}/bakery.toml" "${FIXTURE}/fakepkg/bakery.toml"
ln -s "${PKG_VER_DIR}" "${FIXTURE}/fakepkg/latest"
# ── Minimal registry pointing only at fakepkg ────────────────────────────────
mkdir -p "${FAKE_REGISTRY}/registry"
cat > "${FAKE_REGISTRY}/registry/bread-ecosystem.toml" <<'TOML'
[ecosystem]
name = "test"
[[products]]
name = "fakepkg"
repo = "Test/fakepkg"
description = "A fake package"
TOML
# ── Run gen-index with overridden SCRIPT_DIR and DL_DIR ──────────────────────
OUT="${FIXTURE}/index.json"
SCRIPT_DIR="${FAKE_REGISTRY}" DL_DIR="${FIXTURE}" DL_BASE="https://dl.test" \
bash "${REPO_ROOT}/scripts/gen-index.sh" 2>&1 | sed 's/^/ [gen-index] /'
[[ -f "${OUT}" ]] || fail "index.json was not produced"
# ── Assertions ────────────────────────────────────────────────────────────────
jq -e '.packages.fakepkg' "${OUT}" > /dev/null \
|| fail "fakepkg missing from index"
check() {
local label="$1" expected="$2" actual="$3"
[[ "${actual}" == "${expected}" ]] \
|| fail "${label}: expected '${expected}', got '${actual}'"
}
check "description" \
"A fake package for testing" \
"$(jq -r '.packages.fakepkg.description' "${OUT}")"
check "system_deps" \
"gtk4" \
"$(jq -r '.packages.fakepkg.system_deps | join(",")' "${OUT}")"
check "optional_system_deps" \
"hyprland" \
"$(jq -r '.packages.fakepkg.optional_system_deps | join(",")' "${OUT}")"
check "services[0].unit" \
"fakepkg.service" \
"$(jq -r '.packages.fakepkg.services[0].unit' "${OUT}")"
check "services[0].enable" \
"true" \
"$(jq -r '.packages.fakepkg.services[0].enable' "${OUT}")"
check "config.dir" \
"~/.config/fakepkg" \
"$(jq -r '.packages.fakepkg.config.dir' "${OUT}")"
check "config.example" \
"fakepkg.example.toml" \
"$(jq -r '.packages.fakepkg.config.example' "${OUT}")"
check "binaries[0].name" \
"fakepkg-x86_64" \
"$(jq -r '.packages.fakepkg.binaries[0].name' "${OUT}")"
check "post_install[0]" \
"echo installed" \
"$(jq -r '.packages.fakepkg.post_install[0]' "${OUT}")"
echo "OK: all gen-index assertions passed"