Compare commits
16 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10f62fb1a6 | ||
|
|
5e58558dd3 | ||
|
|
0494650805 | ||
|
|
46db2c23cd | ||
|
|
8305b4a58b | ||
|
|
578067183b | ||
|
|
8b659bf83a | ||
|
|
b3a3b0609b | ||
|
|
ddfba38fc5 | ||
|
|
baf145db8a | ||
|
|
8991f45bd6 | ||
|
|
dae5a3381f | ||
|
|
694829c50f | ||
|
|
0b38e8cce3 | ||
|
|
ac7fbf11f6 | ||
|
|
c2828d80d9 |
23 changed files with 1604 additions and 156 deletions
21
.forgejo/workflows/mirror.yml
Normal file
21
.forgejo/workflows/mirror.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
name: Mirror to GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
tags: ['**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: Mirror to GitHub
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
|
||||||
|
cd repo.git
|
||||||
|
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
|
||||||
|
# --prune deletes GitHub refs that no longer exist on Forgejo.
|
||||||
|
git push --prune \
|
||||||
|
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bread-ecosystem.git" \
|
||||||
|
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||||
40
.forgejo/workflows/package.yml
Normal file
40
.forgejo/workflows/package.yml
Normal file
|
|
@ -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"
|
||||||
118
BREAD_DESIGN_SYSTEM.md
Normal file
118
BREAD_DESIGN_SYSTEM.md
Normal file
|
|
@ -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
|
||||||
269
Cargo.lock
generated
269
Cargo.lock
generated
|
|
@ -81,7 +81,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bakery"
|
name = "bakery"
|
||||||
version = "0.2.1"
|
version = "0.2.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -91,6 +91,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"tempfile",
|
||||||
"toml 0.8.23",
|
"toml 0.8.23",
|
||||||
"ureq",
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
@ -118,7 +119,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-theme"
|
name = "bread-theme"
|
||||||
version = "0.2.1"
|
version = "0.2.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
|
|
@ -322,6 +323,22 @@ version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
|
|
@ -348,6 +365,12 @@ dependencies = [
|
||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|
@ -497,6 +520,19 @@ dependencies = [
|
||||||
"wasi",
|
"wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"wasip2",
|
||||||
|
"wasip3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gio"
|
name = "gio"
|
||||||
version = "0.22.6"
|
version = "0.22.6"
|
||||||
|
|
@ -687,6 +723,15 @@ dependencies = [
|
||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
|
|
@ -811,6 +856,12 @@ dependencies = [
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id-arena"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
@ -839,7 +890,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown",
|
"hashbrown 0.17.1",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -866,6 +919,12 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "leb128fmt"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.186"
|
version = "0.2.186"
|
||||||
|
|
@ -881,6 +940,12 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|
@ -996,6 +1061,16 @@ dependencies = [
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prettyplease"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
|
|
@ -1023,13 +1098,19 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.2.17",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
@ -1042,7 +1123,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"getrandom",
|
"getrandom 0.2.17",
|
||||||
"libc",
|
"libc",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
|
|
@ -1057,6 +1138,19 @@ dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.40"
|
version = "0.23.40"
|
||||||
|
|
@ -1259,6 +1353,19 @@ version = "0.13.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
|
|
@ -1393,6 +1500,12 @@ version = "1.0.24"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-xid"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|
@ -1459,6 +1572,24 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip2"
|
||||||
|
version = "1.0.3+wasi-0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen 0.57.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip3"
|
||||||
|
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen 0.51.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.122"
|
version = "0.2.122"
|
||||||
|
|
@ -1504,6 +1635,40 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-encoder"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||||
|
dependencies = [
|
||||||
|
"leb128fmt",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-metadata"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"indexmap",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasmparser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
"indexmap",
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "0.26.11"
|
version = "0.26.11"
|
||||||
|
|
@ -1747,6 +1912,100 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen-rust-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.57.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-core"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"indexmap",
|
||||||
|
"prettyplease",
|
||||||
|
"syn",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-component",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust-macro"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"prettyplease",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-bindgen-rust",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-component"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wasmparser",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-parser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"id-arena",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"unicode-xid",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ members = ["bakery", "bread-theme"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.1"
|
version = "0.2.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Breadway <rileyhorsham@gmail.com>"]
|
authors = ["Breadway <rileyhorsham@gmail.com>"]
|
||||||
|
|
|
||||||
65
README.md
65
README.md
|
|
@ -18,6 +18,36 @@ bakery install breadbar
|
||||||
| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale exit-node management and a self-healing watch daemon |
|
| `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`) |
|
| `breadpad` | Quick-capture scratchpad popup with AI-powered note classification, reminders, recurrence, and a full note viewer (`breadman`) |
|
||||||
|
|
||||||
|
## 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
|
## Installing bakery
|
||||||
|
|
||||||
`bakery` is the package manager for the ecosystem. Install it with the bootstrap script:
|
`bakery` is the package manager for the ecosystem. Install it with the bootstrap script:
|
||||||
|
|
@ -49,13 +79,18 @@ bakery remove <pkg> # remove a package (data files are never deleted)
|
||||||
|
|
||||||
## System dependencies by product
|
## System dependencies by product
|
||||||
|
|
||||||
| Package | Arch packages |
|
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.
|
||||||
|---------|--------------|
|
|
||||||
| `bread` | `libudev` `dbus` |
|
| Package | Required | Optional |
|
||||||
| `breadbar` | `gtk4` `gtk4-layer-shell` `dbus` `iw` |
|
|---------|----------|---------|
|
||||||
| `breadbox` | `gtk4` `gtk4-layer-shell` `librsvg` |
|
| `bakery` | _(statically linked, none)_ | — |
|
||||||
| `breadcrumbs` | `networkmanager` |
|
| `bread` | `systemd-libs` `openssl` `zlib` | `bluez` `hyprland` |
|
||||||
| `breadpad` | `gtk4` `gtk4-layer-shell` `dbus` |
|
| `breadbar` | `gtk4` `gtk4-layer-shell` `iw` `libpulse` | `hyprland` |
|
||||||
|
| `breadbox` | `gtk4` `gtk4-layer-shell` `librsvg` | `hyprland` |
|
||||||
|
| `breadcrumbs` | `networkmanager` | `tailscale` `sudo` `xdg-utils` |
|
||||||
|
| `breadpad` | `gtk4` `gtk4-layer-shell` | `rocm-hip-runtime` `ollama` `hyprland` |
|
||||||
|
|
||||||
|
Install all required deps with `sudo pacman -S <packages>`. Use `pacman -Q <pkg>` to check whether any are already present.
|
||||||
|
|
||||||
## Theming
|
## Theming
|
||||||
|
|
||||||
|
|
@ -90,6 +125,22 @@ and mirrors the binary to GitHub Releases as a fallback.
|
||||||
`bakery` always tries `dl.breadway.dev` first and transparently falls back
|
`bakery` always tries `dl.breadway.dev` first and transparently falls back
|
||||||
to the GitHub Release URL recorded in the manifest.
|
to the GitHub Release URL recorded in the manifest.
|
||||||
|
|
||||||
|
### Release artifact contract
|
||||||
|
|
||||||
|
Each product's `release.yml` **must** upload the following files alongside
|
||||||
|
the binary to `dl.breadway.dev/<name>/<version>/`:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `bakery.toml` | Metadata (deps, services, config) read by `gen-index.sh` |
|
||||||
|
| `<binary>-x86_64.sha256` | Checksum verified by `bakery install` and `get.sh` |
|
||||||
|
| `*.service` | systemd unit files installed by `bakery install` |
|
||||||
|
| `*.example.toml` / `config.example.toml` | Example configs copied on first install |
|
||||||
|
|
||||||
|
`gen-index.sh` **fails loudly** if `bakery.toml` is missing — this is by
|
||||||
|
design to catch omissions in the release workflow before they silently
|
||||||
|
produce empty metadata in production.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ name = "bakery"
|
||||||
description = "Bread ecosystem package manager"
|
description = "Bread ecosystem package manager"
|
||||||
binaries = ["bakery"]
|
binaries = ["bakery"]
|
||||||
system_deps = []
|
system_deps = []
|
||||||
|
optional_system_deps = []
|
||||||
bread_deps = []
|
bread_deps = []
|
||||||
|
|
||||||
[install]
|
[install]
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,6 @@ sha2 = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,45 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
/// Check whether a list of system dependencies are present.
|
pub struct DepReport {
|
||||||
/// Returns (missing, warnings) — missing are hard fails, warnings are advisory.
|
/// Required deps that are not present — blocks install.
|
||||||
pub fn check_deps(deps: &[String]) -> Result<Vec<String>> {
|
pub missing: Vec<String>,
|
||||||
let mut missing = Vec::new();
|
/// Optional deps that are not present — advisory only, never blocks.
|
||||||
for dep in deps {
|
pub warnings: Vec<String>,
|
||||||
if !dep_present(dep) {
|
|
||||||
missing.push(dep.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(missing)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dep_present(dep: &str) -> bool {
|
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> {
|
||||||
// Try `which` first (covers executables like `iw`, `nmcli`).
|
Ok(DepReport {
|
||||||
if which(dep) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
// Try `pkg-config --exists` for library packages (gtk4, gtk4-layer-shell, librsvg).
|
// Fallback for environments without pacman: native PATH search then pkg-config.
|
||||||
pkg_config_exists(dep)
|
path_has(pkg) || pkg_config_exists(pkg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn which(bin: &str) -> bool {
|
fn pacman_installed(pkg: &str) -> bool {
|
||||||
Command::new("which")
|
Command::new("pacman")
|
||||||
.arg(bin)
|
.args(["-Q", pkg])
|
||||||
.output()
|
.output()
|
||||||
.map(|o| o.status.success())
|
.map(|o| o.status.success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check PATH without shelling out to `which` (avoids the external dependency).
|
||||||
|
fn path_has(bin: &str) -> bool {
|
||||||
|
std::env::var_os("PATH")
|
||||||
|
.map(|p| std::env::split_paths(&p).any(|dir| dir.join(bin).is_file()))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn pkg_config_exists(lib: &str) -> bool {
|
fn pkg_config_exists(lib: &str) -> bool {
|
||||||
// Arch package names map directly to pkg-config names for GTK libs.
|
|
||||||
Command::new("pkg-config")
|
Command::new("pkg-config")
|
||||||
.arg("--exists")
|
.arg("--exists")
|
||||||
.arg(lib)
|
.arg(lib)
|
||||||
|
|
@ -40,33 +48,90 @@ fn pkg_config_exists(lib: &str) -> bool {
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print a formatted doctor report for a list of system deps.
|
/// Print a formatted doctor report for a package's system deps.
|
||||||
/// Returns true if all deps are satisfied.
|
/// Returns true if all *required* deps are satisfied.
|
||||||
pub fn report(package_name: &str, deps: &[String]) -> bool {
|
pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool {
|
||||||
if deps.is_empty() {
|
if required.is_empty() && optional.is_empty() {
|
||||||
println!(" {package_name}: no system deps required");
|
println!(" {package_name}: no system deps required");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
match check_deps(deps) {
|
match check_deps(required, optional) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(" error running doctor: {e}");
|
eprintln!(" error running doctor for {package_name}: {e}");
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
Ok(missing) => {
|
Ok(rep) => {
|
||||||
if missing.is_empty() {
|
for warn in &rep.warnings {
|
||||||
println!(" {package_name}: all system deps satisfied");
|
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
|
true
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {package_name}: missing system deps: {}",
|
" {package_name}: missing system deps: {}",
|
||||||
missing.join(", ")
|
rep.missing.join(", ")
|
||||||
);
|
|
||||||
eprintln!(
|
|
||||||
" install with: sudo pacman -S {}",
|
|
||||||
missing.join(" ")
|
|
||||||
);
|
);
|
||||||
|
eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" "));
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_deps_pass() {
|
||||||
|
let rep = check_deps(&[], &[]).unwrap();
|
||||||
|
assert!(rep.missing.is_empty());
|
||||||
|
assert!(rep.warnings.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// This test only runs on systems where pacman is available (Arch Linux).
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn pacman_finds_itself() {
|
||||||
|
assert!(pacman_installed("pacman"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_has_finds_sh() {
|
||||||
|
assert!(path_has("sh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_required_dep_detected() {
|
||||||
|
let rep = check_deps(
|
||||||
|
&["this-package-does-not-exist-xyzzy42".to_string()],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rep.missing.len(), 1);
|
||||||
|
assert!(rep.warnings.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_optional_dep_becomes_warning_not_error() {
|
||||||
|
let rep = check_deps(
|
||||||
|
&[],
|
||||||
|
&["this-package-does-not-exist-xyzzy42".to_string()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(rep.missing.is_empty());
|
||||||
|
assert_eq!(rep.warnings.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This test only runs on systems where pacman is available (Arch Linux).
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn installed_dep_not_missing() {
|
||||||
|
let rep = check_deps(&["pacman".to_string()], &[]).unwrap();
|
||||||
|
assert!(rep.missing.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,3 +45,34 @@ fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
fn sha256_hex(data: &[u8]) -> String {
|
||||||
|
hex::encode(Sha256::digest(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_correct_hash() {
|
||||||
|
let bytes = b"hello bakery";
|
||||||
|
let hash = sha256_hex(bytes);
|
||||||
|
assert!(verify_sha256(bytes, &hash).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_wrong_hash_fails() {
|
||||||
|
let bytes = b"hello bakery";
|
||||||
|
let wrong = "0".repeat(64);
|
||||||
|
assert!(verify_sha256(bytes, &wrong).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_empty_bytes() {
|
||||||
|
let bytes = b"";
|
||||||
|
let hash = sha256_hex(bytes);
|
||||||
|
assert!(verify_sha256(bytes, &hash).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::download::fetch_and_place;
|
use crate::download::fetch_and_place;
|
||||||
use crate::manifest::{Package, Service};
|
use crate::manifest::{fetch_binary, Package, Service};
|
||||||
use crate::state::{InstalledPackage, State};
|
use crate::state::{InstalledPackage, State};
|
||||||
|
|
||||||
pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> {
|
pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> {
|
||||||
|
|
@ -18,15 +18,15 @@ pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> {
|
||||||
binary_names.push(install_name.to_string());
|
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 {
|
if let Some(cfg) = &pkg.config {
|
||||||
scaffold_config(cfg)?;
|
scaffold_config(cfg, pkg)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Install systemd user units.
|
// 3. Install systemd user units.
|
||||||
let mut service_names = Vec::new();
|
let mut service_names = Vec::new();
|
||||||
for svc in &pkg.services {
|
for svc in &pkg.services {
|
||||||
install_service(svc, bin_dir)?;
|
install_service(svc, bin_dir, pkg)?;
|
||||||
service_names.push(svc.unit.clone());
|
service_names.push(svc.unit.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,6 +60,8 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Commit removal immediately — file cleanup below is best-effort.
|
||||||
|
state.save()?;
|
||||||
|
|
||||||
// Remove binaries.
|
// Remove binaries.
|
||||||
for bin in &installed.binaries {
|
for bin in &installed.binaries {
|
||||||
|
|
@ -104,66 +106,111 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
|
||||||
println!(" data preserved at {}", data_dir.display());
|
println!(" data preserved at {}", data_dir.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
state.save()?;
|
|
||||||
println!(" {pkg_name} removed");
|
println!(" {pkg_name} removed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scaffold_config(cfg: &crate::manifest::ConfigScaffold) -> Result<()> {
|
fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Result<()> {
|
||||||
let dir = expand_tilde(&cfg.dir);
|
let dir = expand_tilde(&cfg.dir);
|
||||||
std::fs::create_dir_all(&dir)?;
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
if let Some(example) = &cfg.example {
|
if let Some(example) = &cfg.example {
|
||||||
let dest = dir.join(example);
|
let dest = dir.join(example);
|
||||||
if !dest.exists() {
|
if !dest.exists() {
|
||||||
// We don't have the actual example file here at install time —
|
if let Some((primary, fallback)) = pkg.artifact_urls(example) {
|
||||||
// the product repo's release bundle should include it.
|
match fetch_binary(&primary, &fallback) {
|
||||||
// For now just note it; release.yml will bundle example configs.
|
Ok(bytes) => {
|
||||||
println!(" config dir ready at {}", dir.display());
|
std::fs::write(&dest, &bytes)
|
||||||
println!(
|
.with_context(|| format!("writing {}", dest.display()))?;
|
||||||
" copy your {example} to {} to configure {}",
|
println!(" installed example config at {}", dest.display());
|
||||||
dest.display(),
|
}
|
||||||
dir.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 {
|
} else {
|
||||||
println!(" config at {} already exists, skipping", dest.display());
|
println!(" config at {} already exists, skipping", dest.display());
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
println!(" config dir created at {}", dir.display());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_service(svc: &Service, bin_dir: &Path) -> Result<()> {
|
fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
|
||||||
let service_dir = systemd_user_dir();
|
let service_dir = systemd_user_dir();
|
||||||
std::fs::create_dir_all(&service_dir)?;
|
std::fs::create_dir_all(&service_dir)?;
|
||||||
|
|
||||||
let unit_path = service_dir.join(&svc.unit);
|
let unit_path = service_dir.join(&svc.unit);
|
||||||
|
|
||||||
// The unit file is expected to be bundled alongside the binary in the
|
// Download the unit file if not already present.
|
||||||
// release artifact (or embedded). For now, patch ExecStart if the unit
|
if !unit_path.exists() {
|
||||||
// already exists (same pattern as bread/scripts/install.sh).
|
if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) {
|
||||||
if unit_path.exists() {
|
match fetch_binary(&primary, &fallback) {
|
||||||
patch_exec_start(&unit_path, bin_dir)?;
|
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"])
|
.args(["--user", "daemon-reload"])
|
||||||
.status();
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
eprintln!(" warning: systemctl daemon-reload failed");
|
||||||
|
}
|
||||||
|
|
||||||
if svc.enable {
|
if svc.enable {
|
||||||
if Command::new("systemctl")
|
let already_active = Command::new("systemctl")
|
||||||
.args(["--user", "is-active", "--quiet", &svc.unit])
|
.args(["--user", "is-active", "--quiet", &svc.unit])
|
||||||
.status()
|
.status()
|
||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if already_active {
|
||||||
|
if Command::new("systemctl")
|
||||||
|
.args(["--user", "restart", &svc.unit])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
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)
|
.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);
|
println!(" {} enabled and started", svc.unit);
|
||||||
|
} else {
|
||||||
|
eprintln!(" warning: failed to enable {}", svc.unit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,7 +223,6 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
|
||||||
.lines()
|
.lines()
|
||||||
.map(|line| {
|
.map(|line| {
|
||||||
if line.trim_start().starts_with("ExecStart=") {
|
if line.trim_start().starts_with("ExecStart=") {
|
||||||
// Replace only the path prefix, keep args.
|
|
||||||
let rest = line.splitn(2, '=').nth(1).unwrap_or("");
|
let rest = line.splitn(2, '=').nth(1).unwrap_or("");
|
||||||
let argv: Vec<&str> = rest.split_whitespace().collect();
|
let argv: Vec<&str> = rest.split_whitespace().collect();
|
||||||
if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) {
|
if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) {
|
||||||
|
|
@ -196,7 +242,13 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,7 +293,7 @@ fn expand_tilde(path: &str) -> PathBuf {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn strip_arch_suffix(name: &str) -> &str {
|
pub fn strip_arch_suffix(name: &str) -> &str {
|
||||||
const SUFFIXES: &[&str] = &["-x86_64", "-aarch64", "-arm64", "-armv7"];
|
const SUFFIXES: &[&str] = &["-x86_64", "-aarch64", "-arm64", "-armv7"];
|
||||||
for s in SUFFIXES {
|
for s in SUFFIXES {
|
||||||
if let Some(base) = name.strip_suffix(s) {
|
if let Some(base) = name.strip_suffix(s) {
|
||||||
|
|
@ -262,3 +314,53 @@ fn warn_path_if_needed(bin_dir: &Path) {
|
||||||
println!(" export PATH=\"{}:$PATH\"", bin_str);
|
println!(" export PATH=\"{}:$PATH\"", bin_str);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_known_suffixes() {
|
||||||
|
assert_eq!(strip_arch_suffix("breadd-x86_64"), "breadd");
|
||||||
|
assert_eq!(strip_arch_suffix("breadd-aarch64"), "breadd");
|
||||||
|
assert_eq!(strip_arch_suffix("breadd-arm64"), "breadd");
|
||||||
|
assert_eq!(strip_arch_suffix("breadd-armv7"), "breadd");
|
||||||
|
assert_eq!(strip_arch_suffix("bakery-x86_64"), "bakery");
|
||||||
|
assert_eq!(strip_arch_suffix("breadd"), "breadd");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patch_exec_start_with_args() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("test.service");
|
||||||
|
fs::write(&path, "[Service]\nExecStart=/old/path/bin arg1 arg2\n").unwrap();
|
||||||
|
patch_exec_start(&path, Path::new("/new/bin")).unwrap();
|
||||||
|
let out = fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(out.contains("ExecStart=/new/bin/bin arg1 arg2"));
|
||||||
|
assert!(out.ends_with('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patch_exec_start_no_args() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("test.service");
|
||||||
|
fs::write(&path, "[Service]\nExecStart=/old/path/daemon\n").unwrap();
|
||||||
|
patch_exec_start(&path, Path::new("/usr/local/bin")).unwrap();
|
||||||
|
let out = fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(out.contains("ExecStart=/usr/local/bin/daemon"));
|
||||||
|
assert!(!out.contains("daemon "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patch_exec_start_non_exec_lines_unchanged() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let path = dir.path().join("test.service");
|
||||||
|
fs::write(&path, "[Unit]\nDescription=foo\nExecStart=/bin/foo\n").unwrap();
|
||||||
|
patch_exec_start(&path, Path::new("/usr/bin")).unwrap();
|
||||||
|
let out = fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(out.contains("Description=foo"));
|
||||||
|
assert!(out.contains("ExecStart=/usr/bin/foo"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ mod state;
|
||||||
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "bakery", about = "Package manager for the bread ecosystem")]
|
#[command(name = "bakery", about = "Package manager for the bread ecosystem", version)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Cmd,
|
command: Cmd,
|
||||||
|
|
@ -31,8 +32,12 @@ enum Cmd {
|
||||||
},
|
},
|
||||||
/// Update one or all installed packages
|
/// Update one or all installed packages
|
||||||
Update {
|
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<String>,
|
package: Option<String>,
|
||||||
|
/// Update all installed packages
|
||||||
|
#[arg(long, conflicts_with = "package")]
|
||||||
|
all: bool,
|
||||||
},
|
},
|
||||||
/// List packages
|
/// List packages
|
||||||
List {
|
List {
|
||||||
|
|
@ -63,31 +68,58 @@ fn main() -> Result<()> {
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Cmd::Install { packages } => {
|
Cmd::Install { packages } => {
|
||||||
|
let index = manifest::load(true)?;
|
||||||
for pkg in &packages {
|
for pkg in &packages {
|
||||||
cmd_install(pkg, &bin_dir)?;
|
cmd_install(&index, pkg, &bin_dir)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Cmd::Remove { package } => cmd_remove(&package, &bin_dir),
|
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::List { installed } => cmd_list(installed),
|
||||||
Cmd::Info { package } => cmd_info(&package),
|
Cmd::Info { package } => cmd_info(&package),
|
||||||
Cmd::Doctor { package } => cmd_doctor(package.as_deref()),
|
Cmd::Doctor { package } => cmd_doctor(package.as_deref()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_install(name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
fn cmd_install(index: &manifest::Index, name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
let index = manifest::load(false)?;
|
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<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !visited.insert(name.to_string()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let pkg = index
|
let pkg = index
|
||||||
.get(name)
|
.get(name)
|
||||||
.ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?;
|
.ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?;
|
||||||
|
|
||||||
// Doctor runs first — bail if system deps are missing.
|
// Install bread_deps first (skip those already recorded in state).
|
||||||
println!("checking system dependencies…");
|
let state = state::State::load()?;
|
||||||
let missing = doctor::check_deps(&pkg.system_deps)?;
|
for dep in pkg.bread_deps.clone() {
|
||||||
if !missing.is_empty() {
|
if !state.is_installed(&dep) {
|
||||||
eprintln!("missing system dependencies for {name}: {}", missing.join(", "));
|
println!("installing bread dependency: {dep}");
|
||||||
eprintln!("install with: sudo pacman -S {}", missing.join(" "));
|
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");
|
bail!("system deps not satisfied");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,16 +130,22 @@ fn cmd_remove(name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
install::remove_package(name, bin_dir)
|
install::remove_package(name, bin_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
let index = manifest::load(true)?; // force refresh on update
|
let index = manifest::load(true)?;
|
||||||
let state = state::State::load()?;
|
let state = state::State::load()?;
|
||||||
|
|
||||||
let effective = name.filter(|&n| n != "all");
|
let targets: Vec<String> = if all || name.is_none() {
|
||||||
let targets: Vec<String> = match effective {
|
state.packages.keys().cloned().collect()
|
||||||
Some(n) => vec![n.to_string()],
|
} else {
|
||||||
None => state.packages.keys().cloned().collect(),
|
vec![name.unwrap().to_string()]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if targets.is_empty() {
|
||||||
|
println!("no packages installed");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut any_failed = false;
|
||||||
for pkg_name in &targets {
|
for pkg_name in &targets {
|
||||||
let installed = match state.packages.get(pkg_name.as_str()) {
|
let installed = match state.packages.get(pkg_name.as_str()) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
|
|
@ -123,15 +161,45 @@ fn cmd_update(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if installed.version == latest.version {
|
if installed.version == latest.version {
|
||||||
println!("{pkg_name} is already at {}", installed.version);
|
println!("{pkg_name} is already at {}", installed.version);
|
||||||
} else {
|
continue;
|
||||||
println!(
|
|
||||||
"updating {pkg_name} {} → {}",
|
|
||||||
installed.version, latest.version
|
|
||||||
);
|
|
||||||
install::install_package(latest, bin_dir)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -180,15 +248,32 @@ fn cmd_info(name: &str) -> Result<()> {
|
||||||
println!("{} {}", pkg.name, pkg.version);
|
println!("{} {}", pkg.name, pkg.version);
|
||||||
println!(" {}", pkg.description);
|
println!(" {}", pkg.description);
|
||||||
println!(" status: {status}");
|
println!(" status: {status}");
|
||||||
println!(" binaries: {}", pkg.binaries.iter().map(|b| b.name.as_str()).collect::<Vec<_>>().join(", "));
|
println!(
|
||||||
|
" binaries: {}",
|
||||||
|
pkg.binaries
|
||||||
|
.iter()
|
||||||
|
.map(|b| b.name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
if !pkg.system_deps.is_empty() {
|
if !pkg.system_deps.is_empty() {
|
||||||
println!(" system deps: {}", pkg.system_deps.join(", "));
|
println!(" system deps: {}", pkg.system_deps.join(", "));
|
||||||
}
|
}
|
||||||
|
if !pkg.optional_system_deps.is_empty() {
|
||||||
|
println!(" optional deps: {}", pkg.optional_system_deps.join(", "));
|
||||||
|
}
|
||||||
if !pkg.bread_deps.is_empty() {
|
if !pkg.bread_deps.is_empty() {
|
||||||
println!(" bread deps: {}", pkg.bread_deps.join(", "));
|
println!(" bread deps: {}", pkg.bread_deps.join(", "));
|
||||||
}
|
}
|
||||||
if !pkg.services.is_empty() {
|
if !pkg.services.is_empty() {
|
||||||
println!(" services: {}", pkg.services.iter().map(|s| s.unit.as_str()).collect::<Vec<_>>().join(", "));
|
println!(
|
||||||
|
" services: {}",
|
||||||
|
pkg.services
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.unit.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -198,7 +283,12 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> {
|
||||||
let state = state::State::load()?;
|
let state = state::State::load()?;
|
||||||
|
|
||||||
let targets: Vec<String> = match name {
|
let targets: Vec<String> = match name {
|
||||||
Some(n) => 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(),
|
None => state.packages.keys().cloned().collect(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -210,9 +300,12 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> {
|
||||||
let mut all_ok = true;
|
let mut all_ok = true;
|
||||||
for pkg_name in &targets {
|
for pkg_name in &targets {
|
||||||
if let Some(pkg) = index.get(pkg_name) {
|
if let Some(pkg) = index.get(pkg_name) {
|
||||||
if !doctor::report(pkg_name, &pkg.system_deps) {
|
if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) {
|
||||||
all_ok = false;
|
all_ok = false;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!(" {pkg_name}: not found in index (removed from registry?)");
|
||||||
|
all_ok = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ pub struct Service {
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct ConfigScaffold {
|
pub struct ConfigScaffold {
|
||||||
pub dir: String,
|
pub dir: String,
|
||||||
/// 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<String>,
|
pub example: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +36,8 @@ pub struct Package {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub system_deps: Vec<String>,
|
pub system_deps: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub optional_system_deps: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub bread_deps: Vec<String>,
|
pub bread_deps: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub services: Vec<Service>,
|
pub services: Vec<Service>,
|
||||||
|
|
@ -44,6 +46,21 @@ pub struct Package {
|
||||||
pub post_install: Vec<String>,
|
pub post_install: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Package {
|
||||||
|
/// Returns `(primary_url, github_url)` for any artifact filename in this
|
||||||
|
/// package's release directory. Derived by stripping the filename from the
|
||||||
|
/// first binary's URLs.
|
||||||
|
pub fn artifact_urls(&self, filename: &str) -> Option<(String, String)> {
|
||||||
|
let first = self.binaries.first()?;
|
||||||
|
let dl_base = first.dl_url.rsplit_once('/')?.0;
|
||||||
|
let gh_base = first.github_url.rsplit_once('/')?.0;
|
||||||
|
Some((
|
||||||
|
format!("{dl_base}/{filename}"),
|
||||||
|
format!("{gh_base}/{filename}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct Index {
|
pub struct Index {
|
||||||
pub version: String,
|
pub version: String,
|
||||||
|
|
@ -67,8 +84,7 @@ pub fn load(force_refresh: bool) -> Result<Index> {
|
||||||
let cache_path = cache_path();
|
let cache_path = cache_path();
|
||||||
|
|
||||||
if !force_refresh && cache_is_fresh(&cache_path) {
|
if !force_refresh && cache_is_fresh(&cache_path) {
|
||||||
let text = std::fs::read_to_string(&cache_path)
|
let text = std::fs::read_to_string(&cache_path).context("reading cached index")?;
|
||||||
.context("reading cached index")?;
|
|
||||||
return serde_json::from_str(&text).context("parsing cached index");
|
return serde_json::from_str(&text).context("parsing cached index");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,6 +148,6 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
resp.into_reader()
|
resp.into_reader()
|
||||||
.read_to_end(&mut buf)
|
.read_to_end(&mut buf)
|
||||||
.context("reading binary")?;
|
.context("reading response")?;
|
||||||
Ok(buf)
|
Ok(buf)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,12 @@ impl State {
|
||||||
std::fs::create_dir_all(dir)?;
|
std::fs::create_dir_all(dir)?;
|
||||||
}
|
}
|
||||||
let text = serde_json::to_string_pretty(self)?;
|
let text = serde_json::to_string_pretty(self)?;
|
||||||
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 {
|
pub fn is_installed(&self, name: &str) -> bool {
|
||||||
|
|
@ -58,3 +63,58 @@ fn state_path() -> PathBuf {
|
||||||
})
|
})
|
||||||
.join("bakery/installed.json")
|
.join("bakery/installed.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn pkg(name: &str, version: &str) -> InstalledPackage {
|
||||||
|
InstalledPackage {
|
||||||
|
name: name.to_string(),
|
||||||
|
version: version.to_string(),
|
||||||
|
binaries: vec![],
|
||||||
|
services: vec![],
|
||||||
|
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_and_is_installed() {
|
||||||
|
let mut state = State::default();
|
||||||
|
assert!(!state.is_installed("foo"));
|
||||||
|
state.record(pkg("foo", "1.0.0"));
|
||||||
|
assert!(state.is_installed("foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_installed() {
|
||||||
|
let mut state = State::default();
|
||||||
|
state.record(pkg("foo", "1.0.0"));
|
||||||
|
let removed = state.remove("foo");
|
||||||
|
assert!(removed.is_some());
|
||||||
|
assert!(!state.is_installed("foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_unknown_returns_none() {
|
||||||
|
let mut state = State::default();
|
||||||
|
assert!(state.remove("nope").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_roundtrip() {
|
||||||
|
let mut state = State::default();
|
||||||
|
state.record(InstalledPackage {
|
||||||
|
name: "bar".to_string(),
|
||||||
|
version: "2.0.0".to_string(),
|
||||||
|
binaries: vec!["bar".to_string()],
|
||||||
|
services: vec!["bar.service".to_string()],
|
||||||
|
installed_at: "2026-06-01T00:00:00Z".to_string(),
|
||||||
|
});
|
||||||
|
let json = serde_json::to_string(&state).unwrap();
|
||||||
|
let restored: State = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(restored.is_installed("bar"));
|
||||||
|
assert_eq!(restored.packages["bar"].version, "2.0.0");
|
||||||
|
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,9 @@ gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
|
||||||
# Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this).
|
# Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this).
|
||||||
# bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature.
|
# bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature.
|
||||||
gtk = ["dep:gtk4"]
|
gtk = ["dep:gtk4"]
|
||||||
|
|
||||||
|
# The generator CLI. It only touches the gtk-free lib API (render + write), so
|
||||||
|
# it builds without the gtk feature and stays light.
|
||||||
|
[[bin]]
|
||||||
|
name = "bread-theme"
|
||||||
|
path = "src/bin/bread-theme.rs"
|
||||||
|
|
|
||||||
62
bread-theme/src/bin/bread-theme.rs
Normal file
62
bread-theme/src/bin/bread-theme.rs
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,100 @@
|
||||||
|
use gtk4::gio;
|
||||||
|
use gtk4::prelude::*;
|
||||||
use gtk4::CssProvider;
|
use gtk4::CssProvider;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
||||||
|
static SHARED_MONITOR: RefCell<Option<gio::FileMonitor>> = const { RefCell::new(None) };
|
||||||
|
static APP_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
||||||
|
static APP_MONITOR: RefCell<Option<gio::FileMonitor>> = const { RefCell::new(None) };
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
static APP_BUILDER: RefCell<Option<Box<dyn Fn() -> String>>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reload_shared() {
|
||||||
|
let css = std::fs::read_to_string(crate::shared_css_path())
|
||||||
|
.unwrap_or_else(|_| crate::render());
|
||||||
|
SHARED_PROVIDER.with(|cell| apply_css(&css, cell));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reload_app() {
|
||||||
|
let css = APP_BUILDER.with(|b| b.borrow().as_ref().map(|f| f()));
|
||||||
|
if let Some(css) = css {
|
||||||
|
APP_PROVIDER.with(|cell| apply_css(&css, cell));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch the shared stylesheet for changes and run `reload` when it's rewritten.
|
||||||
|
///
|
||||||
|
/// `bread-theme` writes the file with write-tmp-then-rename (atomic), which
|
||||||
|
/// *replaces the inode*. A monitor on the file itself dies after the first
|
||||||
|
/// replace (inotify reports DELETE_SELF and never re-arms), so we monitor the
|
||||||
|
/// parent *directory* and filter for the stylesheet's filename — that fires
|
||||||
|
/// reliably on every reload. Returns the monitor (keep it alive to stay armed).
|
||||||
|
fn watch_theme_file(reload: fn()) -> Option<gio::FileMonitor> {
|
||||||
|
let target = crate::shared_css_path();
|
||||||
|
let dir = target.parent()?;
|
||||||
|
// The dir must exist to be monitored; `bread-theme generate` makes it at
|
||||||
|
// login, but create it here too so a GUI started first still arms the watch.
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
let monitor = gio::File::for_path(dir)
|
||||||
|
.monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE)
|
||||||
|
.ok()?;
|
||||||
|
monitor.connect_changed(move |_, file, other, _event| {
|
||||||
|
// The rename lands as an event whose file (or move destination) is the
|
||||||
|
// stylesheet. Match either to catch both CREATED/CHANGED and MOVED_IN.
|
||||||
|
let is_target = |f: &gio::File| f.path().as_deref() == Some(target.as_path());
|
||||||
|
if is_target(file) || other.is_some_and(is_target) {
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Some(monitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply an app's *own* stylesheet and keep it live across palette changes.
|
||||||
|
///
|
||||||
|
/// `build` is called now to produce the app-specific CSS, and again every time
|
||||||
|
/// the shared theme file is rewritten — i.e. whenever `bread-theme reload` (or
|
||||||
|
/// `generate`) runs after pywal changes. The app recolours in place, no restart.
|
||||||
|
///
|
||||||
|
/// This is the counterpart to [`apply_shared`]: that hot-reloads the *shared*
|
||||||
|
/// component sheet; this hot-reloads the app's *own* rules (which are built from
|
||||||
|
/// the palette, so they'd otherwise be frozen at startup). Apps that build their
|
||||||
|
/// CSS from [`crate::stylesheet`] themselves can use this alone; apps that layer
|
||||||
|
/// on top of [`apply_shared`] call both.
|
||||||
|
///
|
||||||
|
/// Call once at startup. The closure should read the current palette
|
||||||
|
/// ([`crate::load_palette`]) each time so it picks up the new colours.
|
||||||
|
pub fn apply_app_css<F: Fn() -> String + 'static>(build: F) {
|
||||||
|
APP_BUILDER.with(|b| *b.borrow_mut() = Some(Box::new(build)));
|
||||||
|
reload_app();
|
||||||
|
APP_MONITOR.with(|cell| {
|
||||||
|
if cell.borrow().is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*cell.borrow_mut() = watch_theme_file(reload_app);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the ecosystem's shared stylesheet (the file written by
|
||||||
|
/// `bread-theme generate`, or a freshly rendered fallback if absent) at
|
||||||
|
/// APPLICATION priority, and watch the file so the whole UI recolours live when
|
||||||
|
/// the palette changes — no app rebuild or restart needed.
|
||||||
|
///
|
||||||
|
/// Call once at startup; then add the app's own CSS provider *after* this so
|
||||||
|
/// app-specific rules win on equal specificity.
|
||||||
|
pub fn apply_shared() {
|
||||||
|
reload_shared();
|
||||||
|
SHARED_MONITOR.with(|cell| {
|
||||||
|
if cell.borrow().is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*cell.borrow_mut() = watch_theme_file(reload_shared);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply a CSS string to the default display at APPLICATION priority.
|
/// Apply a CSS string to the default display at APPLICATION priority.
|
||||||
/// Re-uses an existing provider if one is passed in (for SIGHUP reloads).
|
/// Re-uses an existing provider if one is passed in (for SIGHUP reloads).
|
||||||
pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) {
|
pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) {
|
||||||
|
|
|
||||||
|
|
@ -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<std::path::PathBuf> {
|
||||||
|
let path = shared_css_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("css.tmp");
|
||||||
|
std::fs::write(&tmp, render())?;
|
||||||
|
std::fs::rename(&tmp, &path)?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
|
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
|
||||||
pub fn hex_to_rgba(hex: &str, alpha: f32) -> String {
|
pub fn hex_to_rgba(hex: &str, alpha: f32) -> String {
|
||||||
let h = hex.trim_start_matches('#');
|
let h = hex.trim_start_matches('#');
|
||||||
|
|
@ -82,6 +241,66 @@ mod tests {
|
||||||
assert!(css.contains("14px"));
|
assert!(css.contains("14px"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stylesheet_defines_canonical_colors_and_components() {
|
||||||
|
let css = stylesheet(&Palette::default());
|
||||||
|
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
|
||||||
|
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||||
|
}
|
||||||
|
// a representative spread of the shared component selectors
|
||||||
|
for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".title"] {
|
||||||
|
assert!(css.contains(sel), "stylesheet missing selector: {sel}");
|
||||||
|
}
|
||||||
|
assert!(css.contains("Varela Round"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn luminance_black_and_white_are_extremes() {
|
||||||
|
assert!(luminance("#000000") < 0.01);
|
||||||
|
assert!(luminance("#ffffff") > 0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ink_on_picks_dark_text_for_light_backgrounds() {
|
||||||
|
// Light pywal slots (the case that made white text vanish) get dark ink.
|
||||||
|
assert_eq!(ink_on("#ffffff"), "#11111b");
|
||||||
|
assert_eq!(ink_on("#f9e2af"), "#11111b"); // pale yellow
|
||||||
|
assert_eq!(ink_on("#a6e3a1"), "#11111b"); // pale green
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ink_on_picks_light_text_for_dark_backgrounds() {
|
||||||
|
assert_eq!(ink_on("#000000"), "#f5f5f5");
|
||||||
|
assert_eq!(ink_on("#1e1e2e"), "#f5f5f5"); // catppuccin base
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stylesheet_defines_on_colors() {
|
||||||
|
let css = stylesheet(&Palette::default());
|
||||||
|
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
|
||||||
|
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stylesheet_has_no_blanket_label_color_rule() {
|
||||||
|
// A bare `label { color: ... }` would override container colours on child
|
||||||
|
// labels — the bug that made coloured-background text illegible.
|
||||||
|
let css = stylesheet(&Palette::default());
|
||||||
|
assert!(!css.contains("label { color:"), "blanket label colour rule reintroduced");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_css_path_uses_runtime_dir() {
|
||||||
|
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
|
||||||
|
assert_eq!(shared_css_path(), std::path::PathBuf::from("/run/user/1234/bread/theme.css"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_is_nonempty_css() {
|
||||||
|
assert!(render().contains("@define-color bg "));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hex_to_rgba_known_value() {
|
fn hex_to_rgba_known_value() {
|
||||||
assert_eq!(hex_to_rgba("#1e1e2e", 1.0), "rgba(30, 30, 46, 1)");
|
assert_eq!(hex_to_rgba("#1e1e2e", 1.0), "rgba(30, 30, 46, 1)");
|
||||||
|
|
|
||||||
28
packaging/arch/PKGBUILD
Normal file
28
packaging/arch/PKGBUILD
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
||||||
|
|
||||||
|
pkgname=bakery
|
||||||
|
pkgver=0.2.3
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Package manager for the bread ecosystem"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/Breadway/bread-ecosystem"
|
||||||
|
license=('MIT')
|
||||||
|
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
|
||||||
|
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
|
||||||
|
# causing undefined-symbol errors. Disable LTO.
|
||||||
|
options=(!lto !debug)
|
||||||
|
depends=('glibc' 'gcc-libs')
|
||||||
|
makedepends=('rust' 'cargo')
|
||||||
|
source=("${pkgname}-${pkgver}.tar.gz")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||||
|
cargo build --release --locked -p bakery
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||||
|
install -Dm755 target/release/bakery "${pkgdir}/usr/bin/bakery"
|
||||||
|
install -Dm644 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md"
|
||||||
|
}
|
||||||
|
|
@ -36,3 +36,8 @@ description = "Profile-aware Wi-Fi state machine with Tailscale integration"
|
||||||
name = "breadpad"
|
name = "breadpad"
|
||||||
repo = "Breadway/breadpad"
|
repo = "Breadway/breadpad"
|
||||||
description = "Quick-capture scratchpad and note viewer with AI classification"
|
description = "Quick-capture scratchpad and note viewer with AI classification"
|
||||||
|
|
||||||
|
[[products]]
|
||||||
|
name = "breadpaper"
|
||||||
|
repo = "Breadway/breadpaper"
|
||||||
|
description = "Wallpaper manager for the bread desktop"
|
||||||
|
|
|
||||||
113
scripts/gen-index.sh
Normal file → Executable file
113
scripts/gen-index.sh
Normal file → Executable file
|
|
@ -1,28 +1,28 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Generate dl.breadway.dev/index.json from:
|
# Generate dl.breadway.dev/index.json from:
|
||||||
# - registry/bread-ecosystem.toml (product list)
|
# - registry/bread-ecosystem.toml (product list)
|
||||||
# - <repo>/bakery.toml (per-product metadata)
|
# - <DL_DIR>/<name>/bakery.toml (per-product metadata, uploaded by release.yml)
|
||||||
# - /srv/breadway-dl/ (built binaries + sha256 files)
|
# - <DL_DIR>/ (built binaries + sha256 files)
|
||||||
#
|
#
|
||||||
|
# Fallback for local dev: looks for ../name/bakery.toml (sibling repo checkout).
|
||||||
# Run on hestia after each product build, before the dl server is refreshed.
|
# Run on hestia after each product build, before the dl server is refreshed.
|
||||||
# Requires: jq, python3 (for toml parsing via tomllib), sha256sum
|
# Requires: jq, python3 (tomllib, stdlib since 3.11), sha256sum
|
||||||
set -euo pipefail
|
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_DIR="${DL_DIR:-/srv/breadway-dl}"
|
||||||
DL_BASE="${DL_BASE:-https://dl.breadway.dev}"
|
DL_BASE="${DL_BASE:-https://dl.breadway.dev}"
|
||||||
GH_BASE="https://github.com"
|
GH_BASE="https://github.com"
|
||||||
OUT="${DL_DIR}/index.json"
|
OUT="${DL_DIR}/index.json"
|
||||||
|
|
||||||
# Products are read from the registry. Each line is "name repo".
|
# Read the product list from the registry TOML instead of a hardcoded array.
|
||||||
products=(
|
mapfile -t products < <(python3 -c "
|
||||||
"bakery Breadway/bread-ecosystem"
|
import tomllib, sys
|
||||||
"bread Breadway/bread"
|
with open('${SCRIPT_DIR}/registry/bread-ecosystem.toml', 'rb') as f:
|
||||||
"breadbar Breadway/breadbar"
|
d = tomllib.load(f)
|
||||||
"breadbox Breadway/breadbox"
|
for p in d['products']:
|
||||||
"breadcrumbs Breadway/breadcrumbs"
|
print(p['name'], p['repo'])
|
||||||
"breadpad Breadway/breadpad"
|
")
|
||||||
)
|
|
||||||
|
|
||||||
# Build a JSON package entry for one product.
|
# Build a JSON package entry for one product.
|
||||||
# $1 = product name, $2 = github repo slug
|
# $1 = product name, $2 = github repo slug
|
||||||
|
|
@ -34,14 +34,14 @@ build_package_json() {
|
||||||
local pkg_dir="${DL_DIR}/${name}"
|
local pkg_dir="${DL_DIR}/${name}"
|
||||||
if [[ ! -d "${pkg_dir}" ]]; then
|
if [[ ! -d "${pkg_dir}" ]]; then
|
||||||
echo " warning: no release dir for ${name} at ${pkg_dir}" >&2
|
echo " warning: no release dir for ${name} at ${pkg_dir}" >&2
|
||||||
return
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The latest symlink must point to the current version dir.
|
# The latest symlink must point to the current version dir.
|
||||||
local latest_link="${pkg_dir}/latest"
|
local latest_link="${pkg_dir}/latest"
|
||||||
if [[ ! -L "${latest_link}" ]]; then
|
if [[ ! -L "${latest_link}" ]]; then
|
||||||
echo " warning: no 'latest' symlink for ${name}" >&2
|
echo " warning: no 'latest' symlink for ${name}" >&2
|
||||||
return
|
return 1
|
||||||
fi
|
fi
|
||||||
local version_dir
|
local version_dir
|
||||||
version_dir="$(readlink -f "${latest_link}")"
|
version_dir="$(readlink -f "${latest_link}")"
|
||||||
|
|
@ -51,11 +51,11 @@ build_package_json() {
|
||||||
# Collect all binaries in the version dir (executables only; skip metadata files).
|
# Collect all binaries in the version dir (executables only; skip metadata files).
|
||||||
local binaries_json="[]"
|
local binaries_json="[]"
|
||||||
for bin_path in "${version_dir}"/*; do
|
for bin_path in "${version_dir}"/*; do
|
||||||
[[ "${bin_path}" == *.sha256 ]] && continue
|
[[ "${bin_path}" == *.sha256 ]] && continue
|
||||||
[[ "${bin_path}" == *.toml ]] && continue
|
[[ "${bin_path}" == *.toml ]] && continue
|
||||||
[[ "${bin_path}" == *.service ]] && continue
|
[[ "${bin_path}" == *.service ]] && continue
|
||||||
[[ "${bin_path}" == *.css ]] && continue
|
[[ "${bin_path}" == *.css ]] && continue
|
||||||
[[ "${bin_path}" == *.txt ]] && continue
|
[[ "${bin_path}" == *.txt ]] && continue
|
||||||
[[ -f "${bin_path}" ]] || continue
|
[[ -f "${bin_path}" ]] || continue
|
||||||
local bin_name
|
local bin_name
|
||||||
bin_name="$(basename "${bin_path}")"
|
bin_name="$(basename "${bin_path}")"
|
||||||
|
|
@ -77,45 +77,78 @@ build_package_json() {
|
||||||
binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')"
|
binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Read bakery.toml: the release workflow copies it to DL_DIR alongside the
|
# Locate bakery.toml. The release workflow copies it into the version dir
|
||||||
# binaries; fall back to a sibling checkout for local dev use.
|
# alongside the binaries (${version_dir}/bakery.toml). Fall back to a
|
||||||
local bakery_toml="${DL_DIR}/${name}/bakery.toml"
|
# sibling repo checkout for local dev use.
|
||||||
|
local bakery_toml="${version_dir}/bakery.toml"
|
||||||
if [[ ! -f "${bakery_toml}" ]]; then
|
if [[ ! -f "${bakery_toml}" ]]; then
|
||||||
bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml"
|
bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml"
|
||||||
fi
|
fi
|
||||||
local description=""
|
if [[ ! -f "${bakery_toml}" ]]; then
|
||||||
local system_deps="[]"
|
echo "ERROR: bakery.toml not found for ${name} — release.yml must copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2
|
||||||
local bread_deps="[]"
|
return 1
|
||||||
local services="[]"
|
fi
|
||||||
local config="null"
|
|
||||||
local post_install="[]"
|
|
||||||
|
|
||||||
if [[ -f "${bakery_toml}" ]]; then
|
local description system_deps optional_system_deps bread_deps services config post_install
|
||||||
description="$(python3 -c "
|
|
||||||
import tomllib, sys
|
description="$(python3 -c "
|
||||||
|
import tomllib
|
||||||
with open('${bakery_toml}', 'rb') as f:
|
with open('${bakery_toml}', 'rb') as f:
|
||||||
d = tomllib.load(f)
|
d = tomllib.load(f)
|
||||||
print(d.get('description', ''))
|
print(d.get('description', ''))
|
||||||
" 2>/dev/null || true)"
|
" 2>/dev/null || true)"
|
||||||
system_deps="$(python3 -c "
|
|
||||||
import tomllib, json, sys
|
system_deps="$(python3 -c "
|
||||||
|
import tomllib, json
|
||||||
with open('${bakery_toml}', 'rb') as f:
|
with open('${bakery_toml}', 'rb') as f:
|
||||||
d = tomllib.load(f)
|
d = tomllib.load(f)
|
||||||
print(json.dumps(d.get('system_deps', [])))
|
print(json.dumps(d.get('system_deps', [])))
|
||||||
" 2>/dev/null || echo "[]")"
|
" 2>/dev/null || echo "[]")"
|
||||||
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:
|
with open('${bakery_toml}', 'rb') as f:
|
||||||
d = tomllib.load(f)
|
d = tomllib.load(f)
|
||||||
print(json.dumps(d.get('bread_deps', [])))
|
print(json.dumps(d.get('bread_deps', [])))
|
||||||
" 2>/dev/null || echo "[]")"
|
" 2>/dev/null || echo "[]")"
|
||||||
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:
|
with open('${bakery_toml}', 'rb') as f:
|
||||||
d = tomllib.load(f)
|
d = tomllib.load(f)
|
||||||
print(json.dumps(d.get('install', {}).get('post_install', [])))
|
print(json.dumps(d.get('install', {}).get('post_install', [])))
|
||||||
" 2>/dev/null || echo "[]")"
|
" 2>/dev/null || echo "[]")"
|
||||||
fi
|
|
||||||
|
|
||||||
jq -n \
|
jq -n \
|
||||||
--arg name "${name}" \
|
--arg name "${name}" \
|
||||||
|
|
@ -123,8 +156,10 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
|
||||||
--arg version "${version}" \
|
--arg version "${version}" \
|
||||||
--argjson binaries "${binaries_json}" \
|
--argjson binaries "${binaries_json}" \
|
||||||
--argjson system_deps "${system_deps}" \
|
--argjson system_deps "${system_deps}" \
|
||||||
|
--argjson optional_system_deps "${optional_system_deps}" \
|
||||||
--argjson bread_deps "${bread_deps}" \
|
--argjson bread_deps "${bread_deps}" \
|
||||||
--argjson services "${services}" \
|
--argjson services "${services}" \
|
||||||
|
--argjson config "${config}" \
|
||||||
--argjson post_install "${post_install}" \
|
--argjson post_install "${post_install}" \
|
||||||
'{
|
'{
|
||||||
name: $name,
|
name: $name,
|
||||||
|
|
@ -132,8 +167,10 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
|
||||||
version: $version,
|
version: $version,
|
||||||
binaries: $binaries,
|
binaries: $binaries,
|
||||||
system_deps: $system_deps,
|
system_deps: $system_deps,
|
||||||
|
optional_system_deps: $optional_system_deps,
|
||||||
bread_deps: $bread_deps,
|
bread_deps: $bread_deps,
|
||||||
services: $services,
|
services: $services,
|
||||||
|
config: $config,
|
||||||
post_install: $post_install
|
post_install: $post_install
|
||||||
}'
|
}'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
scripts/get.sh
Normal file → Executable file
33
scripts/get.sh
Normal file → Executable file
|
|
@ -1,12 +1,10 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Bootstrap script: installs the `bakery` binary.
|
# Bootstrap script: downloads and installs the `bakery` binary.
|
||||||
# Usage: curl https://breadway.dev/get | sh
|
# Usage: curl https://breadway.dev/get | sh
|
||||||
# Or: curl -sSfL https://breadway.dev/get | sh
|
# Or: curl -sSfL https://breadway.dev/get | sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
BAKERY_VERSION="${BAKERY_VERSION:-latest}"
|
BAKERY_VERSION="${BAKERY_VERSION:-latest}"
|
||||||
DL_PRIMARY="https://dl.breadway.dev/bakery/${BAKERY_VERSION}/bakery-x86_64"
|
|
||||||
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/${BAKERY_VERSION}/bakery-x86_64"
|
|
||||||
BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}"
|
BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}"
|
||||||
|
|
||||||
die() { echo "error: $*" >&2; exit 1; }
|
die() { echo "error: $*" >&2; exit 1; }
|
||||||
|
|
@ -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 -m | grep -q x86_64 || die "bakery only supports x86_64 (got $(uname -m))"
|
||||||
uname -s | grep -q Linux || die "bakery only supports Linux (got $(uname -s))"
|
uname -s | grep -q Linux || die "bakery only supports Linux (got $(uname -s))"
|
||||||
|
|
||||||
|
# Build download URLs. GitHub's "latest" redirect lives at a different path from
|
||||||
|
# versioned releases, so we handle them separately and always prefix tags with 'v'.
|
||||||
|
if [ "${BAKERY_VERSION}" = "latest" ]; then
|
||||||
|
DL_PRIMARY="https://dl.breadway.dev/bakery/latest/bakery-x86_64"
|
||||||
|
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/latest/download/bakery-x86_64"
|
||||||
|
SHA256_URL="https://dl.breadway.dev/bakery/latest/bakery-x86_64.sha256"
|
||||||
|
else
|
||||||
|
# Strip a leading 'v' if the caller included it, then add it back consistently.
|
||||||
|
ver="${BAKERY_VERSION#v}"
|
||||||
|
DL_PRIMARY="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64"
|
||||||
|
DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/v${ver}/bakery-x86_64"
|
||||||
|
SHA256_URL="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64.sha256"
|
||||||
|
fi
|
||||||
|
|
||||||
# Pick a download tool.
|
# Pick a download tool.
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
fetch() { curl -fsSL "$1" -o "$2"; }
|
fetch() { curl -fsSL "$1" -o "$2"; }
|
||||||
|
|
@ -26,13 +38,26 @@ fi
|
||||||
|
|
||||||
mkdir -p "${BIN_DIR}"
|
mkdir -p "${BIN_DIR}"
|
||||||
TMP="$(mktemp)"
|
TMP="$(mktemp)"
|
||||||
trap 'rm -f "${TMP}"' EXIT
|
trap 'rm -f "${TMP}" "${TMP}.sha256"' EXIT
|
||||||
|
|
||||||
echo "downloading bakery…"
|
echo "downloading bakery…"
|
||||||
if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then
|
if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then
|
||||||
echo " from dl.breadway.dev"
|
echo " from dl.breadway.dev"
|
||||||
|
# Verify checksum when available from primary.
|
||||||
|
if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then
|
||||||
|
expected="$(awk '{print $1}' "${TMP}.sha256")"
|
||||||
|
actual="$(sha256sum "${TMP}" | awk '{print $1}')"
|
||||||
|
if [ "${expected}" != "${actual}" ]; then
|
||||||
|
die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})"
|
||||||
|
fi
|
||||||
|
echo " checksum verified"
|
||||||
|
else
|
||||||
|
echo " warning: could not fetch checksum — skipping verification"
|
||||||
|
fi
|
||||||
elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then
|
elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then
|
||||||
echo " from GitHub (fallback)"
|
echo " from GitHub (fallback)"
|
||||||
|
# No .sha256 on the GitHub fallback path; proceed without verification.
|
||||||
|
echo " warning: checksum not verified for GitHub fallback download"
|
||||||
else
|
else
|
||||||
die "failed to download bakery from both primary and fallback URLs"
|
die "failed to download bakery from both primary and fallback URLs"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
113
scripts/test-gen-index.sh
Executable file
113
scripts/test-gen-index.sh
Executable file
|
|
@ -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}/<name>/bakery.toml (no version)
|
||||||
|
cp "${PKG_VER_DIR}/bakery.toml" "${FIXTURE}/fakepkg/bakery.toml"
|
||||||
|
ln -s "${PKG_VER_DIR}" "${FIXTURE}/fakepkg/latest"
|
||||||
|
|
||||||
|
# ── Minimal registry pointing only at fakepkg ────────────────────────────────
|
||||||
|
mkdir -p "${FAKE_REGISTRY}/registry"
|
||||||
|
cat > "${FAKE_REGISTRY}/registry/bread-ecosystem.toml" <<'TOML'
|
||||||
|
[ecosystem]
|
||||||
|
name = "test"
|
||||||
|
|
||||||
|
[[products]]
|
||||||
|
name = "fakepkg"
|
||||||
|
repo = "Test/fakepkg"
|
||||||
|
description = "A fake package"
|
||||||
|
TOML
|
||||||
|
|
||||||
|
# ── Run gen-index with overridden SCRIPT_DIR and DL_DIR ──────────────────────
|
||||||
|
OUT="${FIXTURE}/index.json"
|
||||||
|
SCRIPT_DIR="${FAKE_REGISTRY}" DL_DIR="${FIXTURE}" DL_BASE="https://dl.test" \
|
||||||
|
bash "${REPO_ROOT}/scripts/gen-index.sh" 2>&1 | sed 's/^/ [gen-index] /'
|
||||||
|
|
||||||
|
[[ -f "${OUT}" ]] || fail "index.json was not produced"
|
||||||
|
|
||||||
|
# ── Assertions ────────────────────────────────────────────────────────────────
|
||||||
|
jq -e '.packages.fakepkg' "${OUT}" > /dev/null \
|
||||||
|
|| fail "fakepkg missing from index"
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local label="$1" expected="$2" actual="$3"
|
||||||
|
[[ "${actual}" == "${expected}" ]] \
|
||||||
|
|| fail "${label}: expected '${expected}', got '${actual}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
check "description" \
|
||||||
|
"A fake package for testing" \
|
||||||
|
"$(jq -r '.packages.fakepkg.description' "${OUT}")"
|
||||||
|
|
||||||
|
check "system_deps" \
|
||||||
|
"gtk4" \
|
||||||
|
"$(jq -r '.packages.fakepkg.system_deps | join(",")' "${OUT}")"
|
||||||
|
|
||||||
|
check "optional_system_deps" \
|
||||||
|
"hyprland" \
|
||||||
|
"$(jq -r '.packages.fakepkg.optional_system_deps | join(",")' "${OUT}")"
|
||||||
|
|
||||||
|
check "services[0].unit" \
|
||||||
|
"fakepkg.service" \
|
||||||
|
"$(jq -r '.packages.fakepkg.services[0].unit' "${OUT}")"
|
||||||
|
|
||||||
|
check "services[0].enable" \
|
||||||
|
"true" \
|
||||||
|
"$(jq -r '.packages.fakepkg.services[0].enable' "${OUT}")"
|
||||||
|
|
||||||
|
check "config.dir" \
|
||||||
|
"~/.config/fakepkg" \
|
||||||
|
"$(jq -r '.packages.fakepkg.config.dir' "${OUT}")"
|
||||||
|
|
||||||
|
check "config.example" \
|
||||||
|
"fakepkg.example.toml" \
|
||||||
|
"$(jq -r '.packages.fakepkg.config.example' "${OUT}")"
|
||||||
|
|
||||||
|
check "binaries[0].name" \
|
||||||
|
"fakepkg-x86_64" \
|
||||||
|
"$(jq -r '.packages.fakepkg.binaries[0].name' "${OUT}")"
|
||||||
|
|
||||||
|
check "post_install[0]" \
|
||||||
|
"echo installed" \
|
||||||
|
"$(jq -r '.packages.fakepkg.post_install[0]' "${OUT}")"
|
||||||
|
|
||||||
|
echo "OK: all gen-index assertions passed"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue