diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..2128050 --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -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/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..6725e22 --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -0,0 +1,40 @@ +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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72172ba..a977bc2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,11 @@ on: push: tags: ["v*"] +permissions: + contents: write + env: DL_DIR: /srv/breadway-dl - ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem jobs: build: @@ -15,7 +17,10 @@ jobs: - uses: actions/checkout@v4 - name: build - run: cargo build --release --locked + run: cargo build --release --locked -p bakery + + - name: test + run: cargo test --locked --workspace - name: prepare artifacts run: | @@ -28,11 +33,11 @@ jobs: sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/bakery-x86_64.sha256" - # Update the 'latest' symlink. - ln -sfn "${PKG_DIR}" "${DL_DIR}/bakery/latest" + cp bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "${DL_DIR}/bakery/latest" - name: regenerate index.json - run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" + run: bash "${GITHUB_WORKSPACE}/scripts/gen-index.sh" - name: upload to GitHub Release env: @@ -40,6 +45,8 @@ jobs: run: | VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="${DL_DIR}/bakery/${VERSION}" + gh release create "${GITHUB_REF_NAME}" \ + --title "bakery v${VERSION}" --generate-notes 2>/dev/null || true gh release upload "${GITHUB_REF_NAME}" \ "${PKG_DIR}/bakery-x86_64" \ "${PKG_DIR}/bakery-x86_64.sha256" \ diff --git a/BREAD_DESIGN_SYSTEM.md b/BREAD_DESIGN_SYSTEM.md new file mode 100644 index 0000000..942f7a2 --- /dev/null +++ b/BREAD_DESIGN_SYSTEM.md @@ -0,0 +1,118 @@ +# 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): +- color0–color7: 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 diff --git a/Cargo.lock b/Cargo.lock index 78dd279..36707a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,7 +81,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bakery" -version = "0.1.0" +version = "0.2.3" dependencies = [ "anyhow", "chrono", @@ -91,6 +91,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "toml 0.8.23", "ureq", ] @@ -118,7 +119,7 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.1.0" +version = "0.2.3" dependencies = [ "dirs", "gtk4", @@ -322,6 +323,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "field-offset" version = "0.3.6" @@ -348,6 +365,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -497,6 +520,19 @@ dependencies = [ "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]] name = "gio" version = "0.22.6" @@ -687,6 +723,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -811,6 +856,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -839,7 +890,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -866,6 +919,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" @@ -881,6 +940,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -996,6 +1061,16 @@ dependencies = [ "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]] name = "proc-macro-crate" version = "3.5.0" @@ -1023,13 +1098,19 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_users" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", "thiserror", ] @@ -1042,7 +1123,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1057,6 +1138,19 @@ dependencies = [ "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]] name = "rustls" version = "0.23.40" @@ -1259,6 +1353,19 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "thiserror" version = "1.0.69" @@ -1393,6 +1500,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -1459,6 +1572,24 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "wasm-bindgen" version = "0.2.122" @@ -1504,6 +1635,40 @@ dependencies = [ "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]] name = "webpki-roots" version = "0.26.11" @@ -1747,6 +1912,100 @@ dependencies = [ "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]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 9511358..354c58c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["bakery", "bread-theme"] resolver = "2" [workspace.package] -version = "0.1.0" +version = "0.2.3" edition = "2021" license = "MIT" authors = ["Breadway "] diff --git a/README.md b/README.md index 074b6df..1ec4cff 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,146 @@ # Bread Ecosystem -Welcome to the **Bread Ecosystem**! +A collection of Rust tools for the Linux desktop (Hyprland / Wayland / Arch). +Install any product with a single command — no Rust toolchain required. -This repository contains the core components and tools for the Bread Ecosystem, primarily focused on package management and theming. +```sh +curl https://breadway.dev/get | sh +bakery install breadbar +``` -## Workspace Members +## Products -This Rust workspace contains the following crates: +| Package | Description | +|---------|-------------| +| `bread` | Reactive automation daemon (`breadd`) + CLI — Lua scripting over Hyprland, udev, power, network, and Bluetooth events | +| `breadbar` | GTK4 status bar (workspaces, clock, CPU/RAM/battery/WiFi/Bluetooth) and D-Bus notification daemon for Hyprland | +| `breadbox` | GTK4 fuzzy app launcher for Hyprland with context-aware sorting; ships an icon-sync daemon (`breadbox-sync`) | +| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale exit-node management and a self-healing watch daemon | +| `breadpad` | Quick-capture scratchpad popup with AI-powered note classification, reminders, recurrence, and a full note viewer (`breadman`) | -- **[`bakery`](./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. +## Recommended keybinds + +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 # install a package +bakery update # update a package +bakery update --all # update everything +bakery remove # 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 `. Use `pacman -Q ` 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//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///`, 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///`: + +| File | Purpose | +|------|---------| +| `bakery.toml` | Metadata (deps, services, config) read by `gen-index.sh` | +| `-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 -This project is licensed under the MIT License. +MIT diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..c88ee13 --- /dev/null +++ b/bakery.toml @@ -0,0 +1,9 @@ +name = "bakery" +description = "Bread ecosystem package manager" +binaries = ["bakery"] +system_deps = [] +optional_system_deps = [] +bread_deps = [] + +[install] +post_install = [] diff --git a/bakery/Cargo.toml b/bakery/Cargo.toml index b0724b3..df5b7c1 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -18,3 +18,6 @@ sha2 = { workspace = true } hex = { workspace = true } clap = { workspace = true } chrono = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/bakery/src/doctor.rs b/bakery/src/doctor.rs index f36fe45..0a7194b 100644 --- a/bakery/src/doctor.rs +++ b/bakery/src/doctor.rs @@ -1,37 +1,45 @@ use anyhow::Result; use std::process::Command; -/// Check whether a list of system dependencies are present. -/// Returns (missing, warnings) — missing are hard fails, warnings are advisory. -pub fn check_deps(deps: &[String]) -> Result> { - let mut missing = Vec::new(); - for dep in deps { - if !dep_present(dep) { - missing.push(dep.clone()); - } - } - Ok(missing) +pub struct DepReport { + /// Required deps that are not present — blocks install. + pub missing: Vec, + /// Optional deps that are not present — advisory only, never blocks. + pub warnings: Vec, } -fn dep_present(dep: &str) -> bool { - // Try `which` first (covers executables like `iw`, `nmcli`). - if which(dep) { +pub fn check_deps(required: &[String], optional: &[String]) -> Result { + Ok(DepReport { + missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(), + 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; } - // Try `pkg-config --exists` for library packages (gtk4, gtk4-layer-shell, librsvg). - pkg_config_exists(dep) + // Fallback for environments without pacman: native PATH search then pkg-config. + path_has(pkg) || pkg_config_exists(pkg) } -fn which(bin: &str) -> bool { - Command::new("which") - .arg(bin) +fn pacman_installed(pkg: &str) -> bool { + Command::new("pacman") + .args(["-Q", pkg]) .output() .map(|o| o.status.success()) .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 { - // Arch package names map directly to pkg-config names for GTK libs. Command::new("pkg-config") .arg("--exists") .arg(lib) @@ -40,33 +48,90 @@ fn pkg_config_exists(lib: &str) -> bool { .unwrap_or(false) } -/// Print a formatted doctor report for a list of system deps. -/// Returns true if all deps are satisfied. -pub fn report(package_name: &str, deps: &[String]) -> bool { - if deps.is_empty() { +/// Print a formatted doctor report for a package's system deps. +/// Returns true if all *required* deps are satisfied. +pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool { + if required.is_empty() && optional.is_empty() { println!(" {package_name}: no system deps required"); return true; } - match check_deps(deps) { + match check_deps(required, optional) { Err(e) => { - eprintln!(" error running doctor: {e}"); + eprintln!(" error running doctor for {package_name}: {e}"); false } - Ok(missing) => { - if missing.is_empty() { - println!(" {package_name}: all system deps satisfied"); + Ok(rep) => { + for warn in &rep.warnings { + eprintln!( + " {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 } else { eprintln!( " {package_name}: missing system deps: {}", - missing.join(", ") - ); - eprintln!( - " install with: sudo pacman -S {}", - missing.join(" ") + rep.missing.join(", ") ); + eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" ")); 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()); + } +} diff --git a/bakery/src/download.rs b/bakery/src/download.rs index 8701470..c89744c 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -45,3 +45,34 @@ fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> { } 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()); + } +} diff --git a/bakery/src/install.rs b/bakery/src/install.rs index 41383f1..03fb65f 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use crate::download::fetch_and_place; -use crate::manifest::{Package, Service}; +use crate::manifest::{fetch_binary, Package, Service}; use crate::state::{InstalledPackage, State}; pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> { @@ -12,20 +12,21 @@ pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> { // 1. Download and verify all binaries. let mut binary_names = Vec::new(); for bin in &pkg.binaries { - let dest = bin_dir.join(&bin.name); + let install_name = strip_arch_suffix(&bin.name); + let dest = bin_dir.join(&install_name); fetch_and_place(bin, &dest)?; - binary_names.push(bin.name.clone()); + binary_names.push(install_name.to_string()); } - // 2. Scaffold config dir + example file. + // 2. Scaffold config dir + download example file. if let Some(cfg) = &pkg.config { - scaffold_config(cfg)?; + scaffold_config(cfg, pkg)?; } // 3. Install systemd user units. let mut service_names = Vec::new(); for svc in &pkg.services { - install_service(svc, bin_dir)?; + install_service(svc, bin_dir, pkg)?; service_names.push(svc.unit.clone()); } @@ -59,6 +60,8 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> { return Ok(()); } }; + // Commit removal immediately — file cleanup below is best-effort. + state.save()?; // Remove binaries. for bin in &installed.binaries { @@ -103,66 +106,111 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> { println!(" data preserved at {}", data_dir.display()); } - state.save()?; println!(" {pkg_name} removed"); Ok(()) } -fn scaffold_config(cfg: &crate::manifest::ConfigScaffold) -> Result<()> { +fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Result<()> { let dir = expand_tilde(&cfg.dir); std::fs::create_dir_all(&dir)?; + if let Some(example) = &cfg.example { let dest = dir.join(example); if !dest.exists() { - // We don't have the actual example file here at install time — - // the product repo's release bundle should include it. - // For now just note it; release.yml will bundle example configs. - println!(" config dir ready at {}", dir.display()); - println!( - " copy your {example} to {} to configure {}", - dest.display(), - dir.display() - ); + if let Some((primary, fallback)) = pkg.artifact_urls(example) { + match fetch_binary(&primary, &fallback) { + Ok(bytes) => { + std::fs::write(&dest, &bytes) + .with_context(|| format!("writing {}", dest.display()))?; + println!(" installed example config at {}", dest.display()); + } + Err(e) => { + 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 { println!(" config at {} already exists, skipping", dest.display()); } + } else { + println!(" config dir created at {}", dir.display()); } Ok(()) } -fn install_service(svc: &Service, bin_dir: &Path) -> Result<()> { +fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { let service_dir = systemd_user_dir(); std::fs::create_dir_all(&service_dir)?; let unit_path = service_dir.join(&svc.unit); - // The unit file is expected to be bundled alongside the binary in the - // release artifact (or embedded). For now, patch ExecStart if the unit - // already exists (same pattern as bread/scripts/install.sh). - if unit_path.exists() { - patch_exec_start(&unit_path, bin_dir)?; + // Download the unit file if not already present. + if !unit_path.exists() { + if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) { + match fetch_binary(&primary, &fallback) { + Ok(bytes) => { + std::fs::write(&unit_path, &bytes) + .with_context(|| format!("writing {}", unit_path.display()))?; + println!(" downloaded unit {}", unit_path.display()); + } + Err(e) => { + eprintln!(" warning: could not download {}: {e}", svc.unit); + } + } + } else { + eprintln!(" warning: no artifact URL to download {}", svc.unit); + } } - let _ = Command::new("systemctl") + if !unit_path.exists() { + eprintln!( + " warning: unit file {} not found — skipping service setup", + svc.unit + ); + return Ok(()); + } + + patch_exec_start(&unit_path, bin_dir)?; + + if !Command::new("systemctl") .args(["--user", "daemon-reload"]) - .status(); + .status() + .map(|s| s.success()) + .unwrap_or(false) + { + eprintln!(" warning: systemctl daemon-reload failed"); + } if svc.enable { - if Command::new("systemctl") + let already_active = Command::new("systemctl") .args(["--user", "is-active", "--quiet", &svc.unit]) .status() .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) + { + println!(" {} restarted", svc.unit); + } else { + eprintln!(" warning: failed to restart {}", svc.unit); + } + } else if Command::new("systemctl") + .args(["--user", "enable", "--now", &svc.unit]) + .status() + .map(|s| s.success()) .unwrap_or(false) { - let _ = Command::new("systemctl") - .args(["--user", "restart", &svc.unit]) - .status(); - println!(" {} restarted", svc.unit); - } else { - let _ = Command::new("systemctl") - .args(["--user", "enable", "--now", &svc.unit]) - .status(); println!(" {} enabled and started", svc.unit); + } else { + eprintln!(" warning: failed to enable {}", svc.unit); } } @@ -175,7 +223,6 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> { .lines() .map(|line| { if line.trim_start().starts_with("ExecStart=") { - // Replace only the path prefix, keep args. let rest = line.splitn(2, '=').nth(1).unwrap_or(""); let argv: Vec<&str> = rest.split_whitespace().collect(); if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) { @@ -195,7 +242,13 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> { }) .collect::>() .join("\n"); - std::fs::write(unit_path, patched)?; + // Preserve trailing newline if the original had one. + let output = if text.ends_with('\n') { + format!("{patched}\n") + } else { + patched + }; + std::fs::write(unit_path, output)?; Ok(()) } @@ -240,6 +293,16 @@ 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) { let path_var = std::env::var("PATH").unwrap_or_default(); let bin_str = bin_dir.to_string_lossy(); @@ -251,3 +314,53 @@ fn warn_path_if_needed(bin_dir: &Path) { 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")); + } +} diff --git a/bakery/src/main.rs b/bakery/src/main.rs index 1fec0bb..821f55a 100644 --- a/bakery/src/main.rs +++ b/bakery/src/main.rs @@ -6,10 +6,11 @@ mod state; use anyhow::{bail, Result}; use clap::{Parser, Subcommand}; +use std::collections::HashSet; use std::path::PathBuf; #[derive(Parser)] -#[command(name = "bakery", about = "Package manager for the bread ecosystem")] +#[command(name = "bakery", about = "Package manager for the bread ecosystem", version)] struct Cli { #[command(subcommand)] command: Cmd, @@ -20,9 +21,10 @@ struct Cli { #[derive(Subcommand)] enum Cmd { - /// Install a package + /// Install one or more packages Install { - package: String, + #[arg(required = true, num_args = 1..)] + packages: Vec, }, /// Remove an installed package (data files are never deleted) Remove { @@ -30,8 +32,12 @@ enum Cmd { }, /// Update one or all installed packages Update { - /// Package to update; omit to update all installed packages + /// Package to update (omit or use --all to update everything installed) + #[arg(conflicts_with = "all")] package: Option, + /// Update all installed packages + #[arg(long, conflicts_with = "package")] + all: bool, }, /// List packages List { @@ -61,27 +67,59 @@ fn main() -> Result<()> { let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir); match cli.command { - Cmd::Install { package } => cmd_install(&package, &bin_dir), + Cmd::Install { packages } => { + 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::Update { package } => cmd_update(package.as_deref(), &bin_dir), + Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir), Cmd::List { installed } => cmd_list(installed), Cmd::Info { package } => cmd_info(&package), Cmd::Doctor { package } => cmd_doctor(package.as_deref()), } } -fn cmd_install(name: &str, bin_dir: &std::path::Path) -> Result<()> { - let index = manifest::load(false)?; +fn cmd_install(index: &manifest::Index, name: &str, bin_dir: &std::path::Path) -> Result<()> { + let mut visited = HashSet::new(); + install_with_deps(index, name, bin_dir, &mut visited) +} + +/// 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, +) -> Result<()> { + if !visited.insert(name.to_string()) { + return Ok(()); + } + let pkg = index .get(name) .ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?; - // Doctor runs first — bail if system deps are missing. - println!("checking system dependencies…"); - let missing = doctor::check_deps(&pkg.system_deps)?; - if !missing.is_empty() { - eprintln!("missing system dependencies for {name}: {}", missing.join(", ")); - eprintln!("install with: sudo pacman -S {}", missing.join(" ")); + // Install bread_deps first (skip those already recorded in state). + let state = state::State::load()?; + for dep in pkg.bread_deps.clone() { + if !state.is_installed(&dep) { + println!("installing bread dependency: {dep}"); + install_with_deps(index, &dep, bin_dir, visited)?; + } + } + + 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"); } @@ -92,15 +130,22 @@ fn cmd_remove(name: &str, bin_dir: &std::path::Path) -> Result<()> { install::remove_package(name, bin_dir) } -fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> { - let index = manifest::load(true)?; // force refresh on update +fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Result<()> { + let index = manifest::load(true)?; let state = state::State::load()?; - let targets: Vec = match name { - Some(n) => vec![n.to_string()], - None => state.packages.keys().cloned().collect(), + let targets: Vec = if all || name.is_none() { + state.packages.keys().cloned().collect() + } else { + 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 { let installed = match state.packages.get(pkg_name.as_str()) { Some(p) => p, @@ -116,15 +161,45 @@ fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> { continue; } }; + if installed.version == latest.version { println!("{pkg_name} is already at {}", installed.version); - } else { - println!( - "updating {pkg_name} {} → {}", - installed.version, latest.version - ); - install::install_package(latest, bin_dir)?; + continue; } + + println!( + "updating {pkg_name} {} → {}", + installed.version, latest.version + ); + + 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(()) } @@ -173,15 +248,32 @@ fn cmd_info(name: &str) -> Result<()> { println!("{} {}", pkg.name, pkg.version); println!(" {}", pkg.description); println!(" status: {status}"); - println!(" binaries: {}", pkg.binaries.iter().map(|b| b.name.as_str()).collect::>().join(", ")); + println!( + " binaries: {}", + pkg.binaries + .iter() + .map(|b| b.name.as_str()) + .collect::>() + .join(", ") + ); if !pkg.system_deps.is_empty() { 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() { println!(" bread deps: {}", pkg.bread_deps.join(", ")); } if !pkg.services.is_empty() { - println!(" services: {}", pkg.services.iter().map(|s| s.unit.as_str()).collect::>().join(", ")); + println!( + " services: {}", + pkg.services + .iter() + .map(|s| s.unit.as_str()) + .collect::>() + .join(", ") + ); } Ok(()) } @@ -191,7 +283,12 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> { let state = state::State::load()?; let targets: Vec = match name { - Some(n) => vec![n.to_string()], + Some(n) => { + if index.get(n).is_none() { + bail!("unknown package: {n}"); + } + vec![n.to_string()] + } None => state.packages.keys().cloned().collect(), }; @@ -203,9 +300,12 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> { let mut all_ok = true; for pkg_name in &targets { if let Some(pkg) = index.get(pkg_name) { - if !doctor::report(pkg_name, &pkg.system_deps) { + if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) { all_ok = false; } + } else { + eprintln!(" {pkg_name}: not found in index (removed from registry?)"); + all_ok = false; } } diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 5f27249..5106646 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -23,7 +23,7 @@ pub struct Service { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ConfigScaffold { pub dir: String, - /// relative to the product repo root; copied as-is if absent at install time + /// Example config filename, relative to the release artifact directory. pub example: Option, } @@ -36,6 +36,8 @@ pub struct Package { #[serde(default)] pub system_deps: Vec, #[serde(default)] + pub optional_system_deps: Vec, + #[serde(default)] pub bread_deps: Vec, #[serde(default)] pub services: Vec, @@ -44,6 +46,21 @@ pub struct Package { pub post_install: Vec, } +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)] pub struct Index { pub version: String, @@ -67,8 +84,7 @@ pub fn load(force_refresh: bool) -> Result { let cache_path = cache_path(); if !force_refresh && cache_is_fresh(&cache_path) { - let text = std::fs::read_to_string(&cache_path) - .context("reading cached index")?; + let text = std::fs::read_to_string(&cache_path).context("reading cached index")?; return serde_json::from_str(&text).context("parsing cached index"); } @@ -132,6 +148,6 @@ fn fetch_bytes(url: &str) -> Result> { let mut buf = Vec::new(); resp.into_reader() .read_to_end(&mut buf) - .context("reading binary")?; + .context("reading response")?; Ok(buf) } diff --git a/bakery/src/state.rs b/bakery/src/state.rs index adb39a4..92b9aa9 100644 --- a/bakery/src/state.rs +++ b/bakery/src/state.rs @@ -33,7 +33,12 @@ impl State { std::fs::create_dir_all(dir)?; } let text = serde_json::to_string_pretty(self)?; - std::fs::write(&path, text).context("writing installed.json") + // Write to a temp file then rename for atomicity — avoids a torn write + // if the process is killed mid-save. + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, &text).context("writing installed.json.tmp")?; + std::fs::rename(&tmp, &path).context("atomically replacing installed.json")?; + Ok(()) } pub fn is_installed(&self, name: &str) -> bool { @@ -58,3 +63,58 @@ fn state_path() -> PathBuf { }) .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"]); + } +} diff --git a/bread-theme/Cargo.toml b/bread-theme/Cargo.toml index 39930a1..8dc41e7 100644 --- a/bread-theme/Cargo.toml +++ b/bread-theme/Cargo.toml @@ -18,3 +18,9 @@ gtk4 = { version = "0.11", features = ["v4_12"], optional = true } # Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this). # bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature. gtk = ["dep:gtk4"] + +# 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" diff --git a/bread-theme/src/bin/bread-theme.rs b/bread-theme/src/bin/bread-theme.rs new file mode 100644 index 0000000..266ea9c --- /dev/null +++ b/bread-theme/src/bin/bread-theme.rs @@ -0,0 +1,62 @@ +//! `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 + } + } +} diff --git a/bread-theme/src/gtk.rs b/bread-theme/src/gtk.rs index 6e62cb4..aab7d01 100644 --- a/bread-theme/src/gtk.rs +++ b/bread-theme/src/gtk.rs @@ -1,7 +1,100 @@ +use gtk4::gio; +use gtk4::prelude::*; use gtk4::CssProvider; use std::cell::RefCell; use std::path::Path; +thread_local! { + static SHARED_PROVIDER: RefCell> = const { RefCell::new(None) }; + static SHARED_MONITOR: RefCell> = const { RefCell::new(None) }; + static APP_PROVIDER: RefCell> = const { RefCell::new(None) }; + static APP_MONITOR: RefCell> = const { RefCell::new(None) }; + #[allow(clippy::type_complexity)] + static APP_BUILDER: RefCell 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 { + 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 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. /// Re-uses an existing provider if one is passed in (for SIGHUP reloads). pub fn apply_css(css: &str, provider: &RefCell>) { diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index 7d649e8..2ee2307 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -54,6 +54,165 @@ 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 { + 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)`. pub fn hex_to_rgba(hex: &str, alpha: f32) -> String { let h = hex.trim_start_matches('#'); @@ -82,6 +241,66 @@ mod tests { 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] fn hex_to_rgba_known_value() { assert_eq!(hex_to_rgba("#1e1e2e", 1.0), "rgba(30, 30, 46, 1)"); diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..793c187 --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,28 @@ +# Maintainer: Breadway + +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" +} diff --git a/registry/bread-ecosystem.toml b/registry/bread-ecosystem.toml index fb641d8..96887cb 100644 --- a/registry/bread-ecosystem.toml +++ b/registry/bread-ecosystem.toml @@ -7,6 +7,11 @@ description = "Reactive desktop automation ecosystem for Arch Linux / Hyprland" homepage = "https://breadway.dev" dl_base = "https://dl.breadway.dev" +[[products]] +name = "bakery" +repo = "Breadway/bread-ecosystem" +description = "Bread ecosystem package manager" + [[products]] name = "bread" repo = "Breadway/bread" @@ -31,3 +36,8 @@ description = "Profile-aware Wi-Fi state machine with Tailscale integration" name = "breadpad" repo = "Breadway/breadpad" description = "Quick-capture scratchpad and note viewer with AI classification" + +[[products]] +name = "breadpaper" +repo = "Breadway/breadpaper" +description = "Wallpaper manager for the bread desktop" diff --git a/scripts/gen-index.sh b/scripts/gen-index.sh old mode 100644 new mode 100755 index ead3d94..86d3f40 --- a/scripts/gen-index.sh +++ b/scripts/gen-index.sh @@ -1,27 +1,28 @@ #!/usr/bin/env bash # Generate dl.breadway.dev/index.json from: # - registry/bread-ecosystem.toml (product list) -# - /bakery.toml (per-product metadata) -# - /srv/breadway-dl/ (built binaries + sha256 files) +# - //bakery.toml (per-product metadata, uploaded by release.yml) +# - / (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. -# Requires: jq, python3 (for toml parsing via tomllib), sha256sum +# Requires: jq, python3 (tomllib, stdlib since 3.11), sha256sum set -euo pipefail -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_BASE="${DL_BASE:-https://dl.breadway.dev}" -GH_BASE="https://github.com/Breadway" +GH_BASE="https://github.com" OUT="${DL_DIR}/index.json" -# Products are read from the registry. Each line is "name repo". -products=( - "bread Breadway/bread" - "breadbar Breadway/breadbar" - "breadbox Breadway/breadbox" - "breadcrumbs Breadway/breadcrumbs" - "breadpad Breadway/breadpad" -) +# Read the product list from the registry TOML instead of a hardcoded array. +mapfile -t products < <(python3 -c " +import tomllib, sys +with open('${SCRIPT_DIR}/registry/bread-ecosystem.toml', 'rb') as f: + d = tomllib.load(f) +for p in d['products']: + print(p['name'], p['repo']) +") # Build a JSON package entry for one product. # $1 = product name, $2 = github repo slug @@ -33,24 +34,28 @@ build_package_json() { local pkg_dir="${DL_DIR}/${name}" if [[ ! -d "${pkg_dir}" ]]; then echo " warning: no release dir for ${name} at ${pkg_dir}" >&2 - return + return 1 fi # The latest symlink must point to the current version dir. local latest_link="${pkg_dir}/latest" if [[ ! -L "${latest_link}" ]]; then echo " warning: no 'latest' symlink for ${name}" >&2 - return + return 1 fi local version_dir version_dir="$(readlink -f "${latest_link}")" local version version="$(basename "${version_dir}")" - # Collect all binaries in the version dir (files without .sha256 extension). + # Collect all binaries in the version dir (executables only; skip metadata files). local binaries_json="[]" 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 local bin_name bin_name="$(basename "${bin_path}")" @@ -72,42 +77,78 @@ build_package_json() { binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')" done - # Read bakery.toml for this product from a co-located checkout if available, - # else use minimal defaults. - local bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml" - local description="" - local system_deps="[]" - local bread_deps="[]" - local services="[]" - local config="null" - local post_install="[]" + # 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 - if [[ -f "${bakery_toml}" ]]; then - description="$(python3 -c " -import tomllib, sys + local description system_deps optional_system_deps bread_deps services config post_install + + description="$(python3 -c " +import tomllib with open('${bakery_toml}', 'rb') as f: d = tomllib.load(f) print(d.get('description', '')) " 2>/dev/null || true)" - system_deps="$(python3 -c " -import tomllib, json, sys + + system_deps="$(python3 -c " +import tomllib, json with open('${bakery_toml}', 'rb') as f: d = tomllib.load(f) print(json.dumps(d.get('system_deps', []))) " 2>/dev/null || echo "[]")" - bread_deps="$(python3 -c " -import tomllib, json, sys + + 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 " +import tomllib, json with open('${bakery_toml}', 'rb') as f: d = tomllib.load(f) print(json.dumps(d.get('bread_deps', []))) " 2>/dev/null || echo "[]")" - post_install="$(python3 -c " -import tomllib, json, sys + + # [[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 " +import tomllib, json with open('${bakery_toml}', 'rb') as f: d = tomllib.load(f) print(json.dumps(d.get('install', {}).get('post_install', []))) " 2>/dev/null || echo "[]")" - fi jq -n \ --arg name "${name}" \ @@ -115,8 +156,10 @@ print(json.dumps(d.get('install', {}).get('post_install', []))) --arg version "${version}" \ --argjson binaries "${binaries_json}" \ --argjson system_deps "${system_deps}" \ + --argjson optional_system_deps "${optional_system_deps}" \ --argjson bread_deps "${bread_deps}" \ --argjson services "${services}" \ + --argjson config "${config}" \ --argjson post_install "${post_install}" \ '{ name: $name, @@ -124,8 +167,10 @@ print(json.dumps(d.get('install', {}).get('post_install', []))) version: $version, binaries: $binaries, system_deps: $system_deps, + optional_system_deps: $optional_system_deps, bread_deps: $bread_deps, services: $services, + config: $config, post_install: $post_install }' } @@ -136,8 +181,8 @@ for entry in "${products[@]}"; do name="$(echo "${entry}" | awk '{print $1}')" repo="$(echo "${entry}" | awk '{print $2}')" echo "processing ${name}…" - pkg="$(build_package_json "${name}" "${repo}" 2>&1)" || { echo " skipping ${name}: ${pkg}"; continue; } - [[ -z "${pkg}" ]] && continue + pkg="$(build_package_json "${name}" "${repo}")" || { echo " skipping ${name}"; continue; } + [[ -z "${pkg}" ]] && { echo " skipping ${name}: no output"; continue; } packages_json="$(jq -n --argjson m "${packages_json}" --arg k "${name}" --argjson v "${pkg}" '$m + {($k): $v}')" done diff --git a/scripts/get.sh b/scripts/get.sh old mode 100644 new mode 100755 index bdcf743..cc343f0 --- a/scripts/get.sh +++ b/scripts/get.sh @@ -1,12 +1,10 @@ #!/bin/sh -# Bootstrap script: installs the `bakery` binary. +# Bootstrap script: downloads and installs the `bakery` binary. # Usage: curl https://breadway.dev/get | sh # Or: curl -sSfL https://breadway.dev/get | sh set -eu 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}" die() { echo "error: $*" >&2; exit 1; } @@ -15,6 +13,20 @@ die() { echo "error: $*" >&2; exit 1; } uname -m | grep -q x86_64 || die "bakery only supports x86_64 (got $(uname -m))" uname -s | grep -q Linux || die "bakery only supports Linux (got $(uname -s))" +# 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. if command -v curl >/dev/null 2>&1; then fetch() { curl -fsSL "$1" -o "$2"; } @@ -26,13 +38,26 @@ fi mkdir -p "${BIN_DIR}" TMP="$(mktemp)" -trap 'rm -f "${TMP}"' EXIT +trap 'rm -f "${TMP}" "${TMP}.sha256"' EXIT echo "downloading bakery…" if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then echo " from dl.breadway.dev" + # Verify checksum when available from primary. + if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then + expected="$(awk '{print $1}' "${TMP}.sha256")" + actual="$(sha256sum "${TMP}" | awk '{print $1}')" + if [ "${expected}" != "${actual}" ]; then + die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})" + fi + echo " checksum verified" + else + echo " warning: could not fetch checksum — skipping verification" + fi elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then echo " from GitHub (fallback)" + # No .sha256 on the GitHub fallback path; proceed without verification. + echo " warning: checksum not verified for GitHub fallback download" else die "failed to download bakery from both primary and fallback URLs" fi diff --git a/scripts/test-gen-index.sh b/scripts/test-gen-index.sh new file mode 100755 index 0000000..5a2733a --- /dev/null +++ b/scripts/test-gen-index.sh @@ -0,0 +1,113 @@ +#!/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}//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"