diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index 2128050..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -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/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml deleted file mode 100644 index 6725e22..0000000 --- a/.forgejo/workflows/package.yml +++ /dev/null @@ -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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a977bc2..72172ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,11 +4,9 @@ on: push: tags: ["v*"] -permissions: - contents: write - env: DL_DIR: /srv/breadway-dl + ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem jobs: build: @@ -17,10 +15,7 @@ jobs: - uses: actions/checkout@v4 - name: build - run: cargo build --release --locked -p bakery - - - name: test - run: cargo test --locked --workspace + run: cargo build --release --locked - name: prepare artifacts run: | @@ -33,11 +28,11 @@ jobs: sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \ > "${PKG_DIR}/bakery-x86_64.sha256" - cp bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${VERSION}" "${DL_DIR}/bakery/latest" + # Update the 'latest' symlink. + ln -sfn "${PKG_DIR}" "${DL_DIR}/bakery/latest" - 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 env: @@ -45,8 +40,6 @@ 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 deleted file mode 100644 index 942f7a2..0000000 --- a/BREAD_DESIGN_SYSTEM.md +++ /dev/null @@ -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): -- 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 36707a1..78dd279 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,7 +81,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bakery" -version = "0.2.3" +version = "0.1.0" dependencies = [ "anyhow", "chrono", @@ -91,7 +91,6 @@ dependencies = [ "serde", "serde_json", "sha2", - "tempfile", "toml 0.8.23", "ureq", ] @@ -119,7 +118,7 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.2.3" +version = "0.1.0" dependencies = [ "dirs", "gtk4", @@ -323,22 +322,6 @@ 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" @@ -365,12 +348,6 @@ 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" @@ -520,19 +497,6 @@ 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" @@ -723,15 +687,6 @@ 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" @@ -856,12 +811,6 @@ 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" @@ -890,9 +839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "hashbrown", ] [[package]] @@ -919,12 +866,6 @@ 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" @@ -940,12 +881,6 @@ 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" @@ -1061,16 +996,6 @@ 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" @@ -1098,19 +1023,13 @@ 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 0.2.17", + "getrandom", "libredox", "thiserror", ] @@ -1123,7 +1042,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.17", + "getrandom", "libc", "untrusted", "windows-sys 0.52.0", @@ -1138,19 +1057,6 @@ 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" @@ -1353,19 +1259,6 @@ 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" @@ -1500,12 +1393,6 @@ 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" @@ -1572,24 +1459,6 @@ 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" @@ -1635,40 +1504,6 @@ 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" @@ -1912,100 +1747,6 @@ 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 354c58c..9511358 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["bakery", "bread-theme"] resolver = "2" [workspace.package] -version = "0.2.3" +version = "0.1.0" edition = "2021" license = "MIT" authors = ["Breadway "] diff --git a/README.md b/README.md index 1ec4cff..074b6df 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,16 @@ # 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. +Welcome to the **Bread Ecosystem**! -```sh -curl https://breadway.dev/get | sh -bakery install breadbar -``` +This repository contains the core components and tools for the Bread Ecosystem, primarily focused on package management and theming. -## Products +## Workspace Members -| 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`) | +This Rust workspace contains the following crates: -## 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. +- **[`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. ## License -MIT +This project is licensed under the MIT License. diff --git a/bakery.toml b/bakery.toml deleted file mode 100644 index c88ee13..0000000 --- a/bakery.toml +++ /dev/null @@ -1,9 +0,0 @@ -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 df5b7c1..b0724b3 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -18,6 +18,3 @@ 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 0a7194b..f36fe45 100644 --- a/bakery/src/doctor.rs +++ b/bakery/src/doctor.rs @@ -1,45 +1,37 @@ use anyhow::Result; use std::process::Command; -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, +/// 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 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) { +fn dep_present(dep: &str) -> bool { + // Try `which` first (covers executables like `iw`, `nmcli`). + if which(dep) { return true; } - // Fallback for environments without pacman: native PATH search then pkg-config. - path_has(pkg) || pkg_config_exists(pkg) + // Try `pkg-config --exists` for library packages (gtk4, gtk4-layer-shell, librsvg). + pkg_config_exists(dep) } -fn pacman_installed(pkg: &str) -> bool { - Command::new("pacman") - .args(["-Q", pkg]) +fn which(bin: &str) -> bool { + Command::new("which") + .arg(bin) .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) @@ -48,90 +40,33 @@ fn pkg_config_exists(lib: &str) -> bool { .unwrap_or(false) } -/// 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() { +/// 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() { println!(" {package_name}: no system deps required"); return true; } - match check_deps(required, optional) { + match check_deps(deps) { Err(e) => { - eprintln!(" error running doctor for {package_name}: {e}"); + eprintln!(" error running doctor: {e}"); false } - 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"); + Ok(missing) => { + if missing.is_empty() { + println!(" {package_name}: all system deps satisfied"); true } else { eprintln!( " {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 } } } } - -#[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 c89744c..8701470 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -45,34 +45,3 @@ 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 03fb65f..41383f1 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::{fetch_binary, Package, Service}; +use crate::manifest::{Package, Service}; use crate::state::{InstalledPackage, State}; 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. let mut binary_names = Vec::new(); for bin in &pkg.binaries { - let install_name = strip_arch_suffix(&bin.name); - let dest = bin_dir.join(&install_name); + let dest = bin_dir.join(&bin.name); 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 { - scaffold_config(cfg, pkg)?; + scaffold_config(cfg)?; } // 3. Install systemd user units. let mut service_names = Vec::new(); for svc in &pkg.services { - install_service(svc, bin_dir, pkg)?; + install_service(svc, bin_dir)?; service_names.push(svc.unit.clone()); } @@ -60,8 +59,6 @@ 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 { @@ -106,111 +103,66 @@ 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, pkg: &Package) -> Result<()> { +fn scaffold_config(cfg: &crate::manifest::ConfigScaffold) -> 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() { - 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()); - } + // 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() + ); } 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, pkg: &Package) -> Result<()> { +fn install_service(svc: &Service, bin_dir: &Path) -> Result<()> { let service_dir = systemd_user_dir(); std::fs::create_dir_all(&service_dir)?; let unit_path = service_dir.join(&svc.unit); - // 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); - } + // 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)?; } - 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") + let _ = Command::new("systemctl") .args(["--user", "daemon-reload"]) - .status() - .map(|s| s.success()) - .unwrap_or(false) - { - eprintln!(" warning: systemctl daemon-reload failed"); - } + .status(); if svc.enable { - let already_active = Command::new("systemctl") + if 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) { - println!(" {} enabled and started", svc.unit); + let _ = Command::new("systemctl") + .args(["--user", "restart", &svc.unit]) + .status(); + println!(" {} restarted", svc.unit); } else { - eprintln!(" warning: failed to enable {}", svc.unit); + let _ = Command::new("systemctl") + .args(["--user", "enable", "--now", &svc.unit]) + .status(); + println!(" {} enabled and started", svc.unit); } } @@ -223,6 +175,7 @@ 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()) { @@ -242,13 +195,7 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> { }) .collect::>() .join("\n"); - // 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)?; + std::fs::write(unit_path, patched)?; 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) { let path_var = std::env::var("PATH").unwrap_or_default(); 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); } } - -#[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 821f55a..1fec0bb 100644 --- a/bakery/src/main.rs +++ b/bakery/src/main.rs @@ -6,11 +6,10 @@ 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", version)] +#[command(name = "bakery", about = "Package manager for the bread ecosystem")] struct Cli { #[command(subcommand)] command: Cmd, @@ -21,10 +20,9 @@ struct Cli { #[derive(Subcommand)] enum Cmd { - /// Install one or more packages + /// Install a package Install { - #[arg(required = true, num_args = 1..)] - packages: Vec, + package: String, }, /// Remove an installed package (data files are never deleted) Remove { @@ -32,12 +30,8 @@ enum Cmd { }, /// Update one or all installed packages Update { - /// Package to update (omit or use --all to update everything installed) - #[arg(conflicts_with = "all")] + /// Package to update; omit to update all installed packages package: Option, - /// Update all installed packages - #[arg(long, conflicts_with = "package")] - all: bool, }, /// List packages List { @@ -67,59 +61,27 @@ fn main() -> Result<()> { let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir); match cli.command { - Cmd::Install { packages } => { - let index = manifest::load(true)?; - for pkg in &packages { - cmd_install(&index, pkg, &bin_dir)?; - } - Ok(()) - } + Cmd::Install { package } => cmd_install(&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::Info { package } => cmd_info(&package), Cmd::Doctor { package } => cmd_doctor(package.as_deref()), } } -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(()); - } - +fn cmd_install(name: &str, bin_dir: &std::path::Path) -> Result<()> { + let index = manifest::load(false)?; let pkg = index .get(name) .ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?; - // 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(" ")); + // 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(" ")); 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) } -fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Result<()> { - let index = manifest::load(true)?; +fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> { + let index = manifest::load(true)?; // force refresh on update let state = state::State::load()?; - let targets: Vec = if all || name.is_none() { - state.packages.keys().cloned().collect() - } else { - vec![name.unwrap().to_string()] + let targets: Vec = match name { + Some(n) => vec![n.to_string()], + None => state.packages.keys().cloned().collect(), }; - 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, @@ -161,45 +116,15 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Resul continue; } }; - if installed.version == latest.version { println!("{pkg_name} is already at {}", installed.version); - 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(", ") + } else { + println!( + "updating {pkg_name} {} → {}", + installed.version, latest.version ); - any_failed = true; - continue; + install::install_package(latest, bin_dir)?; } - - 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(()) } @@ -248,32 +173,15 @@ 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(()) } @@ -283,12 +191,7 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> { let state = state::State::load()?; let targets: Vec = match name { - Some(n) => { - if index.get(n).is_none() { - bail!("unknown package: {n}"); - } - vec![n.to_string()] - } + Some(n) => vec![n.to_string()], None => state.packages.keys().cloned().collect(), }; @@ -300,12 +203,9 @@ 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, &pkg.optional_system_deps) { + if !doctor::report(pkg_name, &pkg.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 5106646..5f27249 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, - /// 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, } @@ -36,8 +36,6 @@ 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, @@ -46,21 +44,6 @@ 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, @@ -84,7 +67,8 @@ 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"); } @@ -148,6 +132,6 @@ fn fetch_bytes(url: &str) -> Result> { let mut buf = Vec::new(); resp.into_reader() .read_to_end(&mut buf) - .context("reading response")?; + .context("reading binary")?; Ok(buf) } diff --git a/bakery/src/state.rs b/bakery/src/state.rs index 92b9aa9..adb39a4 100644 --- a/bakery/src/state.rs +++ b/bakery/src/state.rs @@ -33,12 +33,7 @@ impl State { std::fs::create_dir_all(dir)?; } let text = serde_json::to_string_pretty(self)?; - // Write to a temp file then rename for atomicity — avoids a torn write - // if the process is killed mid-save. - let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, &text).context("writing installed.json.tmp")?; - std::fs::rename(&tmp, &path).context("atomically replacing installed.json")?; - Ok(()) + std::fs::write(&path, text).context("writing installed.json") } pub fn is_installed(&self, name: &str) -> bool { @@ -63,58 +58,3 @@ 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 8dc41e7..39930a1 100644 --- a/bread-theme/Cargo.toml +++ b/bread-theme/Cargo.toml @@ -18,9 +18,3 @@ 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 deleted file mode 100644 index 266ea9c..0000000 --- a/bread-theme/src/bin/bread-theme.rs +++ /dev/null @@ -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 - } - } -} diff --git a/bread-theme/src/gtk.rs b/bread-theme/src/gtk.rs index aab7d01..6e62cb4 100644 --- a/bread-theme/src/gtk.rs +++ b/bread-theme/src/gtk.rs @@ -1,100 +1,7 @@ -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 2ee2307..7d649e8 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -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 { - 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('#'); @@ -241,66 +82,6 @@ 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 deleted file mode 100644 index 793c187..0000000 --- a/packaging/arch/PKGBUILD +++ /dev/null @@ -1,28 +0,0 @@ -# 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 96887cb..fb641d8 100644 --- a/registry/bread-ecosystem.toml +++ b/registry/bread-ecosystem.toml @@ -7,11 +7,6 @@ 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" @@ -36,8 +31,3 @@ 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 100755 new mode 100644 index 86d3f40..ead3d94 --- a/scripts/gen-index.sh +++ b/scripts/gen-index.sh @@ -1,28 +1,27 @@ #!/usr/bin/env bash # Generate dl.breadway.dev/index.json from: # - registry/bread-ecosystem.toml (product list) -# - //bakery.toml (per-product metadata, uploaded by release.yml) -# - / (built binaries + sha256 files) +# - /bakery.toml (per-product metadata) +# - /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. -# Requires: jq, python3 (tomllib, stdlib since 3.11), sha256sum +# Requires: jq, python3 (for toml parsing via tomllib), sha256sum 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_BASE="${DL_BASE:-https://dl.breadway.dev}" -GH_BASE="https://github.com" +GH_BASE="https://github.com/Breadway" OUT="${DL_DIR}/index.json" -# 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']) -") +# 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" +) # Build a JSON package entry for one product. # $1 = product name, $2 = github repo slug @@ -34,28 +33,24 @@ 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 1 + return 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 1 + return fi local version_dir version_dir="$(readlink -f "${latest_link}")" local version 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="[]" for bin_path in "${version_dir}"/*; do - [[ "${bin_path}" == *.sha256 ]] && continue - [[ "${bin_path}" == *.toml ]] && continue - [[ "${bin_path}" == *.service ]] && continue - [[ "${bin_path}" == *.css ]] && continue - [[ "${bin_path}" == *.txt ]] && continue + [[ "${bin_path}" == *.sha256 ]] && continue [[ -f "${bin_path}" ]] || continue local bin_name 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]')" done - # Locate bakery.toml. The release workflow copies it into the version dir - # alongside the binaries (${version_dir}/bakery.toml). Fall back to a - # sibling repo checkout for local dev use. - local bakery_toml="${version_dir}/bakery.toml" - if [[ ! -f "${bakery_toml}" ]]; then - bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml" - fi - if [[ ! -f "${bakery_toml}" ]]; then - echo "ERROR: bakery.toml not found for ${name} — release.yml must copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2 - return 1 - fi + # 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="[]" - local description system_deps optional_system_deps bread_deps services config post_install - - description="$(python3 -c " -import tomllib + if [[ -f "${bakery_toml}" ]]; then + description="$(python3 -c " +import tomllib, sys 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 + system_deps="$(python3 -c " +import tomllib, json, sys with open('${bakery_toml}', 'rb') as f: d = tomllib.load(f) print(json.dumps(d.get('system_deps', []))) " 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 " -import tomllib, json + bread_deps="$(python3 -c " +import tomllib, json, sys with open('${bakery_toml}', 'rb') as f: d = tomllib.load(f) print(json.dumps(d.get('bread_deps', []))) " 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 " -import tomllib, json + post_install="$(python3 -c " +import tomllib, json, sys 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}" \ @@ -156,10 +115,8 @@ 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, @@ -167,10 +124,8 @@ 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 }' } @@ -181,8 +136,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}")" || { echo " skipping ${name}"; continue; } - [[ -z "${pkg}" ]] && { echo " skipping ${name}: no output"; continue; } + pkg="$(build_package_json "${name}" "${repo}" 2>&1)" || { echo " skipping ${name}: ${pkg}"; continue; } + [[ -z "${pkg}" ]] && 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 100755 new mode 100644 index cc343f0..bdcf743 --- a/scripts/get.sh +++ b/scripts/get.sh @@ -1,10 +1,12 @@ #!/bin/sh -# Bootstrap script: downloads and installs the `bakery` binary. +# Bootstrap script: 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; } @@ -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 -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"; } @@ -38,26 +26,13 @@ fi mkdir -p "${BIN_DIR}" TMP="$(mktemp)" -trap 'rm -f "${TMP}" "${TMP}.sha256"' EXIT +trap 'rm -f "${TMP}"' 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 deleted file mode 100755 index 5a2733a..0000000 --- a/scripts/test-gen-index.sh +++ /dev/null @@ -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}//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"