diff --git a/.forgejo/workflows/dev-bakery.yml b/.forgejo/workflows/dev-bakery.yml new file mode 100644 index 0000000..a7c4bde --- /dev/null +++ b/.forgejo/workflows/dev-bakery.yml @@ -0,0 +1,99 @@ +name: dev bakery + +# Publishes a dev-track build on every push to `main` (the trunk branch — +# there is no separate `dev` branch). See docs/release-channels.md for the +# release-track policy this is part of. +on: + push: + branches: ['main'] + paths: + - 'bakery/**' + - 'bread-utils/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.forgejo/workflows/dev-bakery.yml' + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch main --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked -p bakery + + - name: test + run: cd src && cargo test --release --locked -p bakery + + # Auto-bumps the patch version from Cargo.toml and appends a + # timestamp+sha dev suffix — no developer discipline required, and the + # result is visibly "ahead of" the last stable patch release while + # staying valid semver (comparable within the dev track by bakery's + # `is_newer`). + - name: compute dev version + run: | + set -euo pipefail + cd src + # Base the dev version off the latest published stable tag, + # not Cargo.toml — Cargo.toml can go stale relative to the last + # real release (seen in practice: breadbox/breadpad/breadcrumbs/ + # breadpaper), which would make a dev build sort as OLDER than + # what's already installed and bakery would correctly refuse it. + LATEST_TAG="$(git ls-remote --tags --refs \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" + if [ -n "${LATEST_TAG}" ]; then + CUR="${LATEST_TAG}" + else + CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + fi + IFS='.' read -r MA MI PA <<< "${CUR}" + SHA="$(git rev-parse --short HEAD)" + TS="$(date -u +%Y%m%d%H%M%S)" + echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV" + + - name: prepare artifacts + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/bakery/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" + strip "${PKG_DIR}/bakery-x86_64" + sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/bakery-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/bakery/latest" + + - name: sign dev binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/bakery/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \ + -x "${PKG_DIR}/bakery-x86_64.minisig" > "$GITHUB_ENV" + + - name: prepare artifacts + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/bread-theme/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64" + strip "${PKG_DIR}/bread-theme-x86_64" + sha256sum "${PKG_DIR}/bread-theme-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/bread-theme-x86_64.sha256" + cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/dev/bread-theme/latest" + + - name: sign dev binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/dev/bread-theme/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \ + -x "${PKG_DIR}/bread-theme-x86_64.minisig" > "$GITHUB_ENV" + PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" + strip "${PKG_DIR}/bakery-x86_64" + sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/bakery-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bakery/latest" + + - name: sign beta binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \ + -x "${PKG_DIR}/bakery-x86_64.minisig" > "$GITHUB_ENV" + PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}" + mkdir -p "${PKG_DIR}" + cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64" + strip "${PKG_DIR}/bread-theme-x86_64" + sha256sum "${PKG_DIR}/bread-theme-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/bread-theme-x86_64.sha256" + cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bread-theme/latest" + + - name: sign beta binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}" + if [ -n "${MINISIGN_SEC_KEY:-}" ]; then + minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \ + -x "${PKG_DIR}/bread-theme-x86_64.minisig" +fix/ +``` + +Branch off `main`, open a PR/push back into `main` when ready. Short-lived +branches get deleted on merge — they never accumulate the kind of drift a +second long-lived branch does. + +## The release cycle + +There's no separate `beta` or release branch — "stable" and "beta" are both +just **tags** on `main`, not branches that need to be kept in sync: + +1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push + auto-publishes a dev build for both `bakery` and `bread-theme` — install + with `bakery track set dev` and `bakery update --all`, then fix anything + broken with another push. +2. When you want to stabilize before a real release, tag a release + candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to + both remotes). That tag alone triggers a beta-track build — + "freezing" is just pausing pushes to `main` while you test it, not a + branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes. +3. Once an RC has gone without issues, tag the real release: + `git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the + signed stable release build. + +**Version honesty**: bakery's compiled `--version` is +`[workspace.package] version` in the root `Cargo.toml`. The bakery +package version in the index (what `bakery list` shows) is the git tag. +Those must match at tag time — bump `workspace.package.version` to +`X.Y.Z` *before* pushing `vX.Y.Z` or `vX.Y.Z-rc.N`. Never jump a tag +(e.g. `v0.3.1` → `v0.7.1`) without that Cargo.toml bump; the resulting +binary will report the old workspace version while the index claims the +new tag. + +**Note**: `bakery` and `bread-theme` share the same `v*` tag pattern +(both `release-bakery.yml` and `release-bread-theme.yml` trigger on +`tags: ['v*']`, pre-existing behavior this doc isn't changing) — a single +tag push builds and publishes a release for *both* products at once. If +you ever need to release one independently of the other, that's a real gap +worth fixing in the workflow files themselves, not something to work around +by hand. + +## Tracks, from a user's perspective + +``` +bakery track show # what you're currently on (defaults to stable) +bakery track set dev # or beta, or stable +bakery update --all # pull the latest build on your current track +``` + +| Track | What it is | Published from | +|--------|-----------|-----------------| +| `stable` | The last tagged release | a `vX.Y.Z` tag | +| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag | +| `dev` | Bleeding edge | `main`, on every push | + +Dev versions are auto-computed (`X.Y.Z-dev.+`) from the +latest published stable tag, so they always sort as newer than what you +have installed — no manual version bumping needed. Beta versions are just +the RC tag itself (already valid semver, already sorts below the real +release it's a candidate for). + +## Local development + +```sh +cargo build --release -p bakery +cargo test --release -p bakery +``` + +`bakery`, `bread-theme`, `bread-utils`, `bread-app`, `bread-polkit`, +`bread-onnx`, `bread-screenshots`, and `bread-capture` are all workspace +members. Run the same commands with `-p bread-theme --bin bread-theme` +for that crate, `-p bread-utils --features bread-client` for the IPC +client, or `-p bread-app --features bread-client` for the GTK bootstrap +helpers. + +## CI + +- `dev-bakery.yml` / `dev-bread-theme.yml` — triggered on push to `main`. +- `rc-bakery.yml` / `rc-bread-theme.yml` — triggered on any `vX.Y.Z-rc.N` + tag push. +- `release-bakery.yml` / `release-bread-theme.yml` — triggered on any other + `v*` tag push, cuts the actual stable release. +- `package.yml` — publishes `bakery` to the `[breadway]` pacman repo, also + tag-triggered. + +All CI runs on a self-hosted runner; nothing runs automatically on plain +commits or PRs beyond the track builds above. See +[`docs/release-channels.md`](docs/release-channels.md) for the full policy, +including how a new product gets wired onto these tracks. + +## Questions + +Open an issue on this repo's Forgejo tracker. diff --git a/Cargo.lock b/Cargo.lock index 92d225b..a9d191d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,6 +96,40 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -104,14 +138,18 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bakery" -version = "0.3.0" +version = "0.7.5" dependencies = [ "anyhow", + "bread-utils", "chrono", "clap", - "dirs", + "clap_complete", + "dirs 5.0.1", + "fs4", "hex", "minisign-verify", + "semver", "serde", "serde_json", "sha2", @@ -147,9 +185,35 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bread-app" +version = "0.7.5" +dependencies = [ + "bread-utils", +] + +[[package]] +name = "bread-capture" +version = "0.7.5" +dependencies = [ + "anyhow", + "bread-utils", + "clap", + "image", +] + +[[package]] +name = "bread-launcher" +version = "0.7.5" +dependencies = [ + "bread-utils", + "gtk4", + "serde_json", +] + [[package]] name = "bread-onnx" -version = "0.3.0" +version = "0.7.5" dependencies = [ "anyhow", "bread-utils", @@ -163,32 +227,61 @@ dependencies = [ ] [[package]] -name = "bread-shared" -version = "0.7.0" -source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +name = "bread-polkit" +version = "0.7.5" dependencies = [ - "dirs", + "anyhow", + "bread-app", + "bread-theme", + "gtk4", + "serde", + "tokio", + "tracing", + "tracing-subscriber", + "zbus", +] + +[[package]] +name = "bread-screenshots" +version = "0.7.5" +dependencies = [ + "anyhow", + "bread-utils", + "tracing", +] + +[[package]] +name = "bread-shared" +version = "0.8.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0#cdd5de8f58e437b3fc6d9b9087eb7b3d0fd09704" +dependencies = [ + "dirs 6.0.0", "serde", "serde_json", "toml 0.8.23", + "uuid", ] [[package]] name = "bread-theme" -version = "0.3.0" +version = "0.7.5" dependencies = [ - "dirs", + "anyhow", + "dirs 5.0.1", "gtk4", + "libadwaita", "serde", "serde_json", + "toml 0.8.23", + "tracing", ] [[package]] name = "bread-utils" -version = "0.3.0" +version = "0.7.5" dependencies = [ "bread-shared", - "dirs", + "dirs 5.0.1", "gtk4", "gtk4-layer-shell", "serde", @@ -203,6 +296,24 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cairo-rs" version = "0.22.0" @@ -296,6 +407,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f84a88507dbd05c695f2cb5e8558e747179134005e9893882dec964190ed89" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.1" @@ -305,7 +425,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -433,7 +553,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.119", ] [[package]] @@ -444,7 +564,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -474,7 +594,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -484,7 +604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.119", ] [[package]] @@ -503,7 +623,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -514,10 +643,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -526,7 +667,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -541,6 +682,33 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -566,12 +734,41 @@ dependencies = [ "cc", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -613,6 +810,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" +dependencies = [ + "rustix 0.38.44", + "windows-sys 0.52.0", +] + [[package]] name = "futures-channel" version = "0.3.33" @@ -645,6 +852,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.33" @@ -653,7 +873,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -857,7 +1077,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -991,7 +1211,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1164,6 +1384,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1225,12 +1458,58 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libadwaita" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b9900e67182a4b5b1f157b448d94f0715c8b9770cce21cf000801917f53bfa" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3c27642b389852aa99341bd4a4c19ec6f8a2b63ebdd7f5ba1952198079ccd" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.18" @@ -1240,6 +1519,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1274,6 +1559,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matrixmultiply" version = "0.3.11" @@ -1321,6 +1615,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "monostate" version = "0.1.18" @@ -1340,7 +1645,17 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", ] [[package]] @@ -1435,12 +1750,23 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "ort" version = "2.0.0-rc.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" dependencies = [ + "libloading", "ndarray", "ort-sys", "smallvec", @@ -1476,6 +1802,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "paste" version = "1.0.15" @@ -1500,6 +1832,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.14.0" @@ -1551,6 +1896,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quote" version = "1.0.46" @@ -1649,6 +2000,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.13.1" @@ -1701,6 +2063,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1710,7 +2085,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1794,7 +2169,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1810,6 +2185,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1839,12 +2225,31 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -1863,6 +2268,16 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spm_precompiled" version = "0.1.4" @@ -1910,6 +2325,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1918,7 +2344,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1949,7 +2375,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1979,7 +2405,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1990,7 +2416,16 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", ] [[package]] @@ -2037,6 +2472,34 @@ dependencies = [ "unicode_categories", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "toml" version = "0.8.23" @@ -2148,7 +2611,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2160,12 +2623,38 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "once_cell", + "regex-automata", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2253,6 +2742,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -2312,7 +2813,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -2374,7 +2875,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2385,7 +2886,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2615,10 +3116,75 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.54" @@ -2636,7 +3202,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2656,7 +3222,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -2696,7 +3262,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2704,3 +3270,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index 6675555..183d182 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] -members = ["bakery", "bread-theme", "bread-utils", "bread-onnx"] +members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit", "bread-launcher"] resolver = "2" [workspace.package] -version = "0.3.0" +version = "0.7.5" edition = "2021" license = "MIT" authors = ["Breadway "] @@ -21,6 +21,7 @@ clap = { version = "4", features = ["derive", "env"] } chrono = "0.4" minisign-verify = "0.2" tracing = "0.1" +semver = "1" [profile.release] lto = "thin" diff --git a/README.md b/README.md index fff6340..927c629 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,36 @@ A collection of Rust tools for the Linux desktop (Hyprland / Wayland / Arch). Install any product with a single command — no Rust toolchain required. ```sh -curl https://breadway.dev/get | sh +curl -fsSL https://get.breadway.dev | sh bakery install breadbar ``` ## Products +The table below is generated from [`registry/bread-ecosystem.toml`](registry/bread-ecosystem.toml). Regenerate with `scripts/gen-readme-products.sh`. + + + | Package | Description | |---------|-------------| -| `bread` | Reactive automation daemon (`breadd`) + CLI — Lua scripting over Hyprland, udev, power, network, and Bluetooth events | -| `breadbar` | GTK4 status bar (workspaces, clock, CPU/RAM/battery/WiFi/Bluetooth) and D-Bus notification daemon for Hyprland | -| `breadbox` | GTK4 fuzzy app launcher for Hyprland with context-aware sorting; ships an icon-sync daemon (`breadbox-sync`) | -| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale exit-node management and a self-healing watch daemon | -| `breadpad` | Quick-capture scratchpad popup with AI-powered note classification, reminders, recurrence, and a full note viewer (`breadman`) | +| `bakery` | Bread ecosystem package manager | +| `bread-theme` | Shared pywal-accented, fixed-dark-base theming CLI for the bread ecosystem | +| `bread` | Reactive automation daemon and CLI for Linux desktops | +| `breadbar` | Minimal status bar and notification daemon for Hyprland | +| `breadbox` | App launcher for Hyprland / Wayland | +| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale integration | +| `breadpad` | Quick-capture scratchpad and note viewer with AI classification | | `breadpaper` | Wallpaper manager for the bread desktop | +| `breadmon` | Terminal UI monitor manager for Hyprland | +| `breadsearch` | Semantic system-wide search for BOS | +| `breadclip` | Wayland clipboard history manager for Hyprland | +| `breadshot` | Screenshot utility for the bread ecosystem | +| `bos-settings` | System settings app for Bread OS | +| `breadhelp` | Onboarding and help center for Bread OS | +| `breadcast` | Cast your screen to any Chromecast/Google TV or DLNA renderer — daemon + GTK4 popup — Bakery product; not included in the BOS ISO | +| `breadarr` | Single-daemon Sonarr+Radarr+Prowlarr replacement — release watching, matching, grabbing, importing, and a terminal UI, no web UI — Homelab, not shipped on BOS | + + ## Recommended keybinds @@ -68,9 +84,7 @@ spacing, radii, colour roles) the stylesheet is built from. `bakery` is the package manager for the ecosystem. Install it with the bootstrap script: ```sh -curl https://breadway.dev/get | sh -# or -curl -sSfL https://get.breadway.dev | sh +curl -fsSL https://get.breadway.dev | sh ``` The script downloads the prebuilt `bakery` binary to `~/.local/bin/bakery` and prints a note if that directory isn't on your `PATH` yet. @@ -92,6 +106,26 @@ bakery remove # remove a package (data files are never deleted) `bakery install` runs `doctor` first and bails with a clear message if any system dependency is missing. Binaries land in `~/.local/bin` (override with `BAKERY_BIN_DIR`). +## System prefix (BOS) + +Default install root is `~/.local`. BOS sets a system prefix so bakery-managed +desktop apps live on the `@` root subvolume and ride along with +snapper/grub-btrfs snapshots: + +```toml +# /etc/bakery/config.toml +prefix = "/usr/local" +``` + +`BAKERY_PREFIX` overrides the config file. A non-home prefix installs bins to +`$prefix/bin`, share/data/desktop/licenses to `$prefix/share/...`, and systemd +user units to `/usr/lib/systemd/user`. Per-user state (`installed.json`, +update backups) stays in `~/.local/state/bakery`. Writes that need root use +`sudo -n`, then `pkexec`. `bakery doctor` prints the active prefix. + +Hermes and `get.sh` are unchanged — they keep the user-local default. See +[`bakery/README.md`](bakery/README.md). + ## System dependencies by product `bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block. @@ -109,22 +143,66 @@ Install all required deps with `sudo pacman -S `. Use `pacman -Q ## Workspace -This repo is a Cargo workspace: +This repo is a Cargo workspace. Bakery-channel products shipped from here +are `bakery` and `bread-theme`; the other members are shared crates sibling +apps pin, or in-tree tools that are not bakery packages of their own. ``` bread-ecosystem/ -├── bakery/ # package manager binary -├── bread-theme/ # shared pywal + fixed-dark-base theming crate -├── registry/ # bread-ecosystem.toml — product registry +├── bakery/ # package manager binary +├── bread-theme/ # shared pywal + fixed-dark-base theming crate +├── bread-utils/ # shared plumbing (Hyprland IPC, singleton, XDG, BreadClient, …) +├── bread-app/ # GTK bootstrap new tools should use (app id, singleton, overlay, command listen) +├── bread-polkit/ # themed PolicyKit agent (bakery.toml present; unpublished) +├── bread-onnx/ # shared ONNX runtime helpers +├── bread-screenshots/ # grim capture primitive used by app `--screenshot` modes +├── bread-capture/ # orchestrator that drives those `--screenshot` modes +├── registry/ # bread-ecosystem.toml — product registry └── scripts/ - ├── get.sh # curl | sh bootstrap - └── gen-index.sh # generates dl.breadway.dev/index.json from release artifacts + ├── get.sh # curl | sh bootstrap + ├── gen-index.sh # generates dl.breadway.dev/index.json from release artifacts + └── gen-readme-products.sh # rewrites the Products table from the registry +``` + +### New GTK tools + +Do not copy another app's `main.rs`. Depend on `bread-app`: + +- `bread_app::application_id` / `try_acquire` / `toggle_or_kill` for the + `com.breadway.*` application id and single-instance lock +- feature `gtk` re-exports `bread_utils::gtk_popup` (layer-shell overlay) +- feature `bread-client` for `listen_commands` on `bread.command..**` + +See the `bread-app` crate docs. Existing apps are not migrated in this +tree; `bread-polkit` is the first in-tree consumer. + +### bread-polkit + +A session PolicyKit authentication agent (password prompt, cancel, +identity). Not a wrapper around `polkit-gnome`. `bread-polkit/bakery.toml` +exists so it can be published via bakery; it is not in +`registry/bread-ecosystem.toml` and is therefore unpublished — not on the +bakery index and not on the BOS ISO lockfile. + +```sh +cargo run -p bread-polkit +``` + +Autostart — pick one: + +```sh +cp bread-polkit/contrib/bread-polkit.desktop ~/.config/autostart/ +``` + +``` +# hyprland.conf +exec-once = bread-polkit ``` ## Release pipeline -Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has a -`.github/workflows/release.yml` that triggers on `v*` tags. The workflow +Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has +`.forgejo/workflows/release-*.yml` that triggers on `v*` tags. The workflow runs on a self-hosted runner on hestia, builds a stripped x86_64 binary, deposits it at `dl.breadway.dev///`, updates `index.json`, and mirrors the binary to GitHub Releases as a fallback. @@ -132,6 +210,12 @@ and mirrors the binary to GitHub Releases as a fallback. `bakery` always tries `dl.breadway.dev` first and transparently falls back to the GitHub Release URL recorded in the manifest. +Beyond stable releases, most products also publish **dev** and **beta** +tracks — continuous builds off `main` (dev) and `vX.Y.Z-rc.N` tags (beta). +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the branch/release workflow and +[`docs/release-channels.md`](docs/release-channels.md) for the full track +policy. Switch tracks with `bakery track set `. + ### Release artifact contract Each product's `release.yml` **must** upload the following files alongside diff --git a/bakery/Cargo.toml b/bakery/Cargo.toml index a344e87..06ca297 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -17,8 +17,10 @@ ureq = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } clap = { workspace = true } +clap_complete = "4" chrono = { workspace = true } minisign-verify = { workspace = true } - -[dev-dependencies] +semver = { workspace = true } +bread-utils = { path = "../bread-utils" } +fs4 = { version = "0.8", features = ["sync"] } tempfile = "3" diff --git a/bakery/README.md b/bakery/README.md new file mode 100644 index 0000000..21cb2cd --- /dev/null +++ b/bakery/README.md @@ -0,0 +1,30 @@ +# bakery + +Package manager for the bread ecosystem. Usage lives in the +[repo README](../README.md). + +## Install prefix + +Default root is `~/.local` (bins in `~/.local/bin`, data in +`~/.local/share`). That is the hermes / `get.sh` path and must stay the +default. + +BOS sets a system prefix so bakery-managed desktop apps live on the `@` +root subvolume and are included in snapper/grub-btrfs snapshots: + +```toml +# /etc/bakery/config.toml +prefix = "/usr/local" +``` + +`BAKERY_PREFIX` overrides the config file. A non-home prefix installs: + +| Thing | Path | +|-------|------| +| bins | `$prefix/bin` | +| share / desktop / licenses / data | `$prefix/share/...` | +| systemd user units | `/usr/lib/systemd/user` | + +Per-user state (`installed.json` and pre-update backups) stays in +`~/.local/state/bakery`. Writes that need root use `sudo -n`, then +`pkexec`. `bakery doctor` prints the active prefix. diff --git a/bakery/src/doctor.rs b/bakery/src/doctor.rs index 0a7194b..f4ef71b 100644 --- a/bakery/src/doctor.rs +++ b/bakery/src/doctor.rs @@ -1,3 +1,4 @@ +use crate::ui; use anyhow::Result; use std::process::Command; @@ -10,18 +11,48 @@ pub struct DepReport { pub fn check_deps(required: &[String], optional: &[String]) -> Result { Ok(DepReport { - missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(), - warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(), + missing: required + .iter() + .filter(|d| !dep_present(d)) + .cloned() + .collect(), + warnings: optional + .iter() + .filter(|d| !dep_present(d)) + .cloned() + .collect(), }) } +/// Arch package name -> Debian/Ubuntu package name, for the few cases where +/// they differ *and* the Debian package's own binaries don't share a name +/// with either package (so `path_has` can't bridge the gap the way it +/// already does for e.g. `ffmpeg`/`openssl`, whose package name matches +/// their own binary name on both distros). `system_deps` in `bakery.toml` +/// is always written as the Arch name — this is what makes that same +/// declaration also resolve correctly on a Debian-family bakery host like +/// hestia. +const ARCH_TO_DEBIAN_PKG: &[(&str, &str)] = &[("mkvtoolnix-cli", "mkvtoolnix")]; + +fn debian_name(pkg: &str) -> &str { + ARCH_TO_DEBIAN_PKG + .iter() + .find(|(arch, _)| *arch == pkg) + .map(|(_, debian)| *debian) + .unwrap_or(pkg) +} + fn dep_present(pkg: &str) -> bool { // Primary: `pacman -Q` uses the exact Arch package name — no name mapping needed. if pacman_installed(pkg) { return true; } // Fallback for environments without pacman: native PATH search then pkg-config. - path_has(pkg) || pkg_config_exists(pkg) + if path_has(pkg) || pkg_config_exists(pkg) { + return true; + } + // Further fallback for Debian/Ubuntu hosts: dpkg, via the name map above. + dpkg_installed(debian_name(pkg)) } fn pacman_installed(pkg: &str) -> bool { @@ -32,6 +63,17 @@ fn pacman_installed(pkg: &str) -> bool { .unwrap_or(false) } +fn dpkg_installed(pkg: &str) -> bool { + Command::new("dpkg-query") + .args(["-W", "-f=${Status}", pkg]) + .output() + .map(|o| { + o.status.success() + && String::from_utf8_lossy(&o.stdout).contains("install ok installed") + }) + .unwrap_or(false) +} + /// Check PATH without shelling out to `which` (avoids the external dependency). fn path_has(bin: &str) -> bool { std::env::var_os("PATH") @@ -48,34 +90,75 @@ fn pkg_config_exists(lib: &str) -> bool { .unwrap_or(false) } +/// Builds the "install with: ..." hint for a list of missing Arch package +/// names, picking the command for whichever package manager is actually on +/// this host — `sudo pacman -S ...` is meaningless advice on a Debian-family +/// bakery host like hestia, which has neither `pacman` nor the Arch names. +pub fn install_hint(missing: &[String]) -> String { + if path_has("pacman") { + format!("sudo pacman -S {}", missing.join(" ")) + } else if path_has("apt") { + let names: Vec<&str> = missing.iter().map(|p| debian_name(p)).collect(); + format!("sudo apt install {}", names.join(" ")) + } else { + format!("install: {}", missing.join(", ")) + } +} + /// Print a formatted doctor report for a package's system deps. /// Returns true if all *required* deps are satisfied. -pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool { +pub fn report( + package_name: &str, + required: &[String], + optional: &[String], + name_width: usize, +) -> bool { if required.is_empty() && optional.is_empty() { - println!(" {package_name}: no system deps required"); + ui::check_row(true, package_name, name_width, "no system deps required"); return true; } match check_deps(required, optional) { Err(e) => { - eprintln!(" error running doctor for {package_name}: {e}"); + ui::check_row( + false, + package_name, + name_width, + &format!("error running doctor: {e}"), + ); false } Ok(rep) => { for warn in &rep.warnings { eprintln!( - " {package_name}: optional dep not found: {warn} \ - (install for full functionality)" + " {}", + ui::style( + &format!( + "{package_name}: optional dep not found: {warn} \ + (install for full functionality)" + ), + ui::YELLOW + ) ); } if rep.missing.is_empty() { - println!(" {package_name}: all required system deps satisfied"); + ui::check_row( + true, + package_name, + name_width, + "all required system deps satisfied", + ); true } else { - eprintln!( - " {package_name}: missing system deps: {}", - rep.missing.join(", ") + ui::check_row( + false, + package_name, + name_width, + &format!("missing: {}", rep.missing.join(", ")), + ); + eprintln!( + " {}", + ui::dim(&format!("install with: {}", install_hint(&rep.missing))) ); - eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" ")); false } } @@ -105,24 +188,38 @@ mod tests { assert!(path_has("sh")); } + #[test] + fn debian_name_maps_known_alias() { + assert_eq!(debian_name("mkvtoolnix-cli"), "mkvtoolnix"); + } + + #[test] + fn debian_name_passes_through_unmapped() { + assert_eq!(debian_name("ffmpeg"), "ffmpeg"); + } + + // This test only runs on systems with dpkg (Debian/Ubuntu). + #[test] + #[ignore] + fn dpkg_finds_dpkg_itself() { + assert!(dpkg_installed("dpkg")); + } + + #[test] + fn dpkg_missing_package_not_present() { + assert!(!dpkg_installed("this-package-does-not-exist-xyzzy42")); + } + #[test] fn missing_required_dep_detected() { - let rep = check_deps( - &["this-package-does-not-exist-xyzzy42".to_string()], - &[], - ) - .unwrap(); + let rep = check_deps(&["this-package-does-not-exist-xyzzy42".to_string()], &[]).unwrap(); assert_eq!(rep.missing.len(), 1); assert!(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(); + let rep = check_deps(&[], &["this-package-does-not-exist-xyzzy42".to_string()]).unwrap(); assert!(rep.missing.is_empty()); assert_eq!(rep.warnings.len(), 1); } diff --git a/bakery/src/download.rs b/bakery/src/download.rs index 3362bd0..4d1c1c7 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -3,33 +3,27 @@ use sha2::{Digest, Sha256}; use std::path::Path; use crate::manifest::{fetch_binary, Binary}; +use crate::ui; -/// Download a binary to a temp path, verify its SHA-256, then atomically move -/// it into place. Bails before touching `dest` if the checksum fails. -pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> { - println!(" downloading {}…", binary.name); +/// Download a binary, verify its SHA-256, then atomically write it into +/// place (fsynced, temp-in-same-dir-with-unique-name then rename — see +/// `bread_utils::atomic`). Bails before touching `dest` if the checksum +/// fails. Returns the verified hex sha256 so callers (`install:: +/// install_package`) can record it for `bakery verify` without hashing the +/// bytes a second time — `verify_sha256` already confirmed `bytes` matches +/// `binary.sha256`, so that's the value to return. +pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result { + ui::step("downloading", &binary.name); let bytes = fetch_binary(&binary.dl_url, &binary.github_url) .with_context(|| format!("downloading {}", binary.name))?; verify_sha256(&bytes, &binary.sha256) .with_context(|| format!("checksum mismatch for {}", binary.name))?; - if let Some(dir) = dest.parent() { - std::fs::create_dir_all(dir)?; - } - - let tmp = dest.with_extension("tmp"); - std::fs::write(&tmp, &bytes).context("writing binary to tmp")?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?; - } - - std::fs::rename(&tmp, dest).context("placing binary")?; - println!(" installed {}", dest.display()); - Ok(()) + crate::prefix::write_bytes(dest, &bytes, 0o755) + .with_context(|| format!("placing binary at {}", dest.display()))?; + ui::step("placed", &dest.display().to_string()); + Ok(binary.sha256.clone()) } /// Verify that `bytes` hashes to `expected_hex` under SHA-256. @@ -38,6 +32,9 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> { /// [`fetch_and_place`]), and config-example / systemd-unit downloads in /// `install.rs` — so all downloaded artifacts get the same integrity check. pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> { + if expected_hex.is_empty() { + bail!("index entry has no sha256 recorded — refusing to trust an unverifiable download"); + } let mut hasher = Sha256::new(); hasher.update(bytes); let actual = hex::encode(hasher.finalize()); @@ -80,4 +77,14 @@ mod tests { let hash = sha256_hex(bytes); assert!(verify_sha256(bytes, &hash).is_ok()); } + + #[test] + fn verify_missing_sha256_gives_a_clear_error() { + // gen-index.sh emits an empty sha256 string when a .sha256 sidecar + // is missing — must not fall through to the generic mismatch + // message ("expected: \n actual: "), which is confusing about + // what actually went wrong. + let err = verify_sha256(b"anything", "").unwrap_err(); + assert!(err.to_string().contains("no sha256 recorded")); + } } diff --git a/bakery/src/install.rs b/bakery/src/install.rs index 3b4c925..dd31145 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -1,120 +1,363 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; +use std::collections::HashMap; +#[cfg(not(test))] +use std::io::IsTerminal; use std::path::{Path, PathBuf}; use std::process::Command; use crate::download::{fetch_and_place, verify_sha256}; use crate::manifest::{fetch_binary, Package, Service}; +use crate::prefix::{self, Layout}; use crate::state::{InstalledPackage, State}; +use crate::track::Track; +use crate::ui; -pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> { - println!("installing {}@{}…", pkg.name, pkg.version); +/// Rejects a filename that isn't a safe single path component — no `/`, +/// `\`, empty, `.`, or `..`. `bin.name`/`svc.unit`/`cfg.example`/`pkg.name`/ +/// `license_file`/`desktop_file`/`data_archive` all come from the +/// minisign-verified index, so this is defense in depth (not exploitable +/// without a compromised signing key) rather than the primary guard — but +/// closing the path-traversal class is cheap enough to do anyway before any +/// of these are joined onto a fixed base directory. +fn ensure_safe_component(name: &str, what: &str) -> Result<()> { + if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\\') { + bail!("refusing to install: {what} '{name}' is not a safe filename"); + } + Ok(()) +} - // 1. Download and verify all binaries. +/// Whether stdin should be treated as an interactive terminal. Always +/// `false` in test builds regardless of the real process stdin — running +/// `cargo test` from an actual interactive shell (not CI, not piped) gives +/// the test binary a real tty, which previously made `confirm` block on a +/// `read_line` nobody was there to answer. +fn stdin_is_terminal() -> bool { + #[cfg(test)] + { + false + } + #[cfg(not(test))] + { + std::io::stdin().is_terminal() + } +} + +/// Prompts `prompt [y/N] ` and returns the answer. `assume_yes` (the global +/// `--yes` flag) skips the prompt entirely; otherwise, a non-tty stdin +/// (CI, piped input) answers "no" rather than blocking on a read that will +/// never resolve. +fn confirm(prompt: &str, assume_yes: bool) -> bool { + if assume_yes { + return true; + } + if !stdin_is_terminal() { + return false; + } + use std::io::Write; + print!("{prompt} {} ", ui::dim("[y/N]")); + std::io::stdout().flush().ok(); + let mut buf = String::new(); + std::io::stdin().read_line(&mut buf).ok(); + matches!(buf.trim().to_lowercase().as_str(), "y" | "yes") +} + +/// Installs `pkg`. `previous` is the package's current `InstalledPackage` +/// record when this is an update (looked up by the caller before starting, +/// since it's already loaded elsewhere in the call chain) — `None` for a +/// fresh first-time install. Threading it in rather than reloading `State` +/// here avoids a second load, and lets step 1 tell "update" from "fresh +/// install" for the pre-overwrite backup below. +pub fn install_package( + pkg: &Package, + layout: &Layout, + track: Track, + previous: Option<&InstalledPackage>, + no_hooks: bool, + assume_yes: bool, +) -> Result<()> { + ensure_safe_component(&pkg.name, "package name")?; + + // 1. Download and verify all binaries. On an update (not a fresh + // install), back up the current binary first — best-effort, feeding + // `bakery rollback` — before it's overwritten below. Backups stay + // per-user under ~/.local/state even when the live binary is in + // /usr/local/bin, so a snapper snapshot of `@` plus this local copy + // is enough to roll back; no second snapshot system. + let backup_dir = previous.map(|prev| crate::state::backup_dir(&pkg.name, &prev.version)); let mut binary_names = Vec::new(); + let mut binary_sha256 = HashMap::new(); for bin in &pkg.binaries { + ensure_safe_component(&bin.name, "binary name")?; let install_name = strip_arch_suffix(&bin.name); - let dest = bin_dir.join(&install_name); - fetch_and_place(bin, &dest)?; + let dest = layout.bin_dir.join(install_name); + if let Some(dir) = &backup_dir { + backup_current_binary(dir, install_name, &dest); + } + let sha256 = fetch_and_place(bin, &dest)?; binary_names.push(install_name.to_string()); + binary_sha256.insert(install_name.to_string(), sha256); } - // 2. Scaffold config dir + download example file. + // 2. Scaffold config dir + download example file. Config stays + // per-user (~/.config) regardless of prefix — it's authored content, + // not bakery-placed bits. if let Some(cfg) = &pkg.config { scaffold_config(cfg, pkg)?; } - // 3. Install systemd user units. + // 3. Install license file, if declared. + if let Some(license) = &pkg.license_file { + install_license(pkg, license, layout)?; + } + + // 4. Install desktop entry, if declared. + if let Some(desktop) = &pkg.desktop_file { + install_desktop_file(pkg, desktop, layout)?; + } + + // 5. Download + extract data archive, if declared. + if let Some(archive) = &pkg.data_archive { + install_data_archive(pkg, archive, layout)?; + } + + // 6. Install systemd user units. let mut service_names = Vec::new(); for svc in &pkg.services { - install_service(svc, bin_dir, pkg)?; + install_service(svc, layout, pkg)?; service_names.push(svc.unit.clone()); } - // 4. Run post_install hooks. - for cmd in &pkg.post_install { - run_hook(cmd, &pkg.name)?; + // 7. Run post_install hooks — arbitrary `sh -c` on index-controlled + // strings, so this is gated behind --no-hooks and an interactive + // confirmation rather than running unconditionally. + if !pkg.post_install.is_empty() { + if no_hooks { + eprintln!( + " {}", + ui::note(&format!( + "skipped {} post_install hook(s) for {} (--no-hooks)", + pkg.post_install.len(), + pkg.name + )) + ); + } else if confirm( + &format!( + " run {} post_install hook(s) for {}?", + pkg.post_install.len(), + pkg.name + ), + assume_yes, + ) { + for cmd in &pkg.post_install { + run_hook(cmd, &pkg.name)?; + } + } else { + eprintln!( + " {}", + ui::note(&format!( + "skipped post_install hooks for {} (declined)", + pkg.name + )) + ); + } } - // 5. Record in state. - let mut state = State::load()?; - state.record(InstalledPackage { - name: pkg.name.clone(), - version: pkg.version.clone(), - binaries: binary_names, - services: service_names, - installed_at: chrono::Utc::now().to_rfc3339(), - }); - state.save()?; + // 8. Record in state, under an exclusive lock so a concurrent `bakery` + // invocation can't clobber this install's record with its own. + State::with_lock(|state| { + state.record(InstalledPackage { + name: pkg.name.clone(), + version: pkg.version.clone(), + binaries: binary_names, + services: service_names, + installed_at: chrono::Utc::now().to_rfc3339(), + track, + previous_version: previous.map(|p| p.version.clone()), + binary_sha256, + }); + Ok(()) + })?; - println!(" {} installed successfully", pkg.name); - warn_path_if_needed(bin_dir); + println!(" {}", ui::ok(&format!("{} installed", pkg.name))); + warn_path_if_needed(&layout.bin_dir); Ok(()) } -pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> { - let mut state = State::load()?; - let installed = match state.remove(pkg_name) { +/// Best-effort copy of the on-disk binary into `backup_dir` before it's +/// overwritten by an update — feeds `bakery rollback`. `backup_dir` is +/// deliberately a local path (ultimately under `~/.local/state/bakery/ +/// backups///`, see `state::backup_dir`) rather than +/// `bakery rollback` re-fetching the old version from `dl.breadway.dev`: +/// `index.json`'s minisign signature only covers the *current* published +/// version's checksums, so verifying an old version pulled fresh from the +/// server would only be checkable against its unsigned per-version +/// `.sha256` sidecar — a materially weaker guarantee than bakery's normal +/// trust model. A local pre-update snapshot sidesteps that gap entirely. +fn backup_current_binary(backup_dir: &Path, binary_name: &str, current_path: &Path) { + if !current_path.exists() { + return; + } + if let Err(e) = std::fs::create_dir_all(backup_dir) { + eprintln!( + " {}", + ui::warn(&format!( + "could not create backup dir {} ({e}) — rollback won't be available for this update", + backup_dir.display() + )) + ); + return; + } + if let Err(e) = std::fs::copy(current_path, backup_dir.join(binary_name)) { + eprintln!( + " {}", + ui::warn(&format!( + "could not back up {binary_name} before update ({e}) — rollback won't be available for this update" + )) + ); + } +} + +pub fn remove_package( + pkg_name: &str, + layout: &Layout, + assume_yes: bool, + purge: bool, +) -> Result<()> { + let installed = State::with_lock(|state| Ok(state.remove(pkg_name)))?; + let installed = match installed { Some(p) => p, None => { - eprintln!("{pkg_name} is not installed"); + eprintln!(" {}", ui::fail(&format!("{pkg_name} is not installed"))); return Ok(()); } }; - // Commit removal immediately — file cleanup below is best-effort. - state.save()?; + ui::action("Removing", pkg_name, Some(&installed.version)); + // State is already committed by with_lock above — everything from here + // is best-effort file cleanup, and must all run even if part of it fails. - // Remove binaries. + // Remove binaries. Collect failures instead of aborting on the first one + // so a stuck/permission-denied binary doesn't skip service removal and + // the config/data-preserved messages below. + let mut failures = Vec::new(); for bin in &installed.binaries { - let path = bin_dir.join(bin); + let path = layout.bin_dir.join(bin); if path.exists() { - std::fs::remove_file(&path) - .with_context(|| format!("removing {}", path.display()))?; - println!(" removed {}", path.display()); + match prefix::remove_file(&path) { + Ok(()) => ui::step("removed", &path.display().to_string()), + Err(e) => failures.push(format!("{}: {e}", path.display())), + } } } // Prompt for unit removal. if !installed.services.is_empty() { - let service_dir = systemd_user_dir(); + let service_dir = &layout.systemd_user_dir; for unit in &installed.services { let unit_path = service_dir.join(unit); - if confirm_remove_unit(unit) { + if confirm_remove_unit(unit, assume_yes) { let _ = Command::new("systemctl") .args(["--user", "disable", "--now", unit]) .status(); if unit_path.exists() { - std::fs::remove_file(&unit_path).ok(); + let _ = prefix::remove_file(&unit_path); } let _ = Command::new("systemctl") .args(["--user", "daemon-reload"]) .status(); - println!(" removed unit {unit}"); + ui::step("removed", &format!("unit {unit}")); } } } - // Never touch config or data dirs. + // Config is never touched, even with --purge: unlike the license/desktop + // /data paths below (all bakery-downloaded or -extracted content, + // reproducible from source), the config dir holds user-authored/edited + // content bakery never wrote — silently destroying it would be a bad + // surprise no flag should cause. if let Some(cfg_dir) = guess_config_dir(pkg_name) { if cfg_dir.exists() { - println!(" config preserved at {}", cfg_dir.display()); + ui::step("preserved", &format!("config {}", cfg_dir.display())); } } - let data_dir = dirs::data_dir() - .unwrap_or_else(|| PathBuf::from("~/.local/share")) - .join(pkg_name); - if data_dir.exists() { - println!(" data preserved at {}", data_dir.display()); + + let share_dir = &layout.share_dir; + let data_dir = share_dir.join(pkg_name); + + if purge { + let license_dir = share_dir.join("licenses").join(pkg_name); + remove_purged_path(&license_dir, "license dir", true, assume_yes, &mut failures); + + let desktop_file = share_dir + .join("applications") + .join(format!("{pkg_name}.desktop")); + remove_purged_path( + &desktop_file, + "desktop entry", + false, + assume_yes, + &mut failures, + ); + + remove_purged_path(&data_dir, "data dir", true, assume_yes, &mut failures); + } else if data_dir.exists() { + ui::step("preserved", &format!("data {}", data_dir.display())); } - println!(" {pkg_name} removed"); + if !failures.is_empty() { + eprintln!( + " {}", + ui::fail(&format!("failed to remove {} item(s):", failures.len())) + ); + for f in &failures { + eprintln!(" {f}"); + } + bail!("{pkg_name} removed from state, but some files could not be deleted"); + } + + println!(" {}", ui::ok(&format!("{pkg_name} removed"))); Ok(()) } +/// Confirms (via `confirm`, so `--yes` and non-tty stdin behave the same as +/// every other destructive prompt in this file) and removes `path` — a +/// directory when `recursive`, otherwise a single file. Declining leaves it +/// in place and prints the same "preserved at" wording the non-purge path +/// already uses. Shared by `remove_package`'s three `--purge` targets +/// (license dir, desktop entry, data dir). +fn remove_purged_path( + path: &Path, + label: &str, + recursive: bool, + assume_yes: bool, + failures: &mut Vec, +) { + if !path.exists() { + return; + } + if !confirm( + &format!(" remove {label} at {}?", path.display()), + assume_yes, + ) { + ui::step("preserved", &format!("{label} {}", path.display())); + return; + } + let result = if recursive { + prefix::remove_dir_all(path) + } else { + prefix::remove_file(path) + }; + match result { + Ok(()) => ui::step("removed", &path.display().to_string()), + Err(e) => failures.push(format!("{}: {e}", path.display())), + } +} + fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Result<()> { let dir = expand_tilde(&cfg.dir); std::fs::create_dir_all(&dir)?; if let Some(example) = &cfg.example { + ensure_safe_component(example, "config.example")?; let dest = dir.join(example); if !dest.exists() { if let Some((primary, fallback)) = pkg.artifact_urls(example) { @@ -124,81 +367,299 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu Ok(()) => { std::fs::write(&dest, &bytes) .with_context(|| format!("writing {}", dest.display()))?; - println!(" installed example config at {}", dest.display()); + ui::step("config", &dest.display().to_string()); } Err(e) => { eprintln!( - " warning: checksum mismatch for example config {example}: {e} — not installed" + " {}", + ui::warn(&format!( + "checksum mismatch for example config {example}: {e} — not installed" + )) ); - println!(" config dir created at {}", dir.display()); + ui::step("config", &dir.display().to_string()); } }, None => { eprintln!( - " warning: index.json has no sha256 for example config \ - {example} — refusing to install an unverified download" + " {}", + ui::warn(&format!( + "index.json has no sha256 for example config \ + {example} — refusing to install an unverified download" + )) ); - println!(" config dir created at {}", dir.display()); + ui::step("config", &dir.display().to_string()); } }, Err(e) => { - eprintln!(" warning: could not download example config {example}: {e}"); - println!(" config dir created at {}", dir.display()); + eprintln!( + " {}", + ui::warn(&format!("could not download example config {example}: {e}")) + ); + ui::step("config", &dir.display().to_string()); } } } else { - println!(" config dir created at {}", dir.display()); + ui::step("config", &dir.display().to_string()); } } else { - println!(" config at {} already exists, skipping", dest.display()); + ui::step( + "config", + &format!("{} already exists, skipping", dest.display()), + ); } } else { - println!(" config dir created at {}", dir.display()); + ui::step("config", &dir.display().to_string()); } Ok(()) } -fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { - let service_dir = systemd_user_dir(); - std::fs::create_dir_all(&service_dir)?; - - let unit_path = service_dir.join(&svc.unit); - - // Download the unit file if not already present. - if !unit_path.exists() { - if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) { - match fetch_binary(&primary, &fallback) { - Ok(bytes) => match verify_sha256(&bytes, &svc.sha256) { - Ok(()) => { - std::fs::write(&unit_path, &bytes) - .with_context(|| format!("writing {}", unit_path.display()))?; - println!(" downloaded unit {}", unit_path.display()); - } - Err(e) => { - eprintln!( - " warning: checksum mismatch for unit {}: {e} — not installed", - svc.unit - ); - } - }, - Err(e) => { - eprintln!(" warning: could not download {}: {e}", svc.unit); - } - } - } else { - eprintln!(" warning: no artifact URL to download {}", svc.unit); - } - } - - if !unit_path.exists() { +/// Download `filename` from `pkg`'s release dir, verify it against `sha256` +/// (refusing an unverified download the same way `scaffold_config` does), +/// and write it to `dest`. Shared by `install_license`/`install_desktop_file` +/// since both are "fetch one small artifact, verify, place" — unlike a config +/// example, these aren't user-editable, so they're always refreshed rather +/// than skipped when already present. +fn fetch_verify_write( + pkg: &Package, + filename: &str, + sha256: &Option, + dest: &Path, + label: &str, +) -> Result<()> { + let Some((primary, fallback)) = pkg.artifact_urls(filename) else { eprintln!( - " warning: unit file {} not found — skipping service setup", - svc.unit + " {}", + ui::warn(&format!("no artifact URL to download {label} ({filename})")) + ); + return Ok(()); + }; + let bytes = match fetch_binary(&primary, &fallback) { + Ok(b) => b, + Err(e) => { + eprintln!( + " {}", + ui::warn(&format!("could not download {label} {filename}: {e}")) + ); + return Ok(()); + } + }; + let Some(expected) = sha256 else { + eprintln!( + " {}", + ui::warn(&format!( + "index.json has no sha256 for {label} {filename} — \ + refusing to install an unverified download" + )) + ); + return Ok(()); + }; + if let Err(e) = verify_sha256(&bytes, expected) { + eprintln!( + " {}", + ui::warn(&format!( + "checksum mismatch for {label} {filename}: {e} — not installed" + )) ); return Ok(()); } + prefix::write_bytes(dest, &bytes, 0o644) + .with_context(|| format!("writing {}", dest.display()))?; + ui::step("installed", &format!("{label} {}", dest.display())); + Ok(()) +} - patch_exec_start(&unit_path, bin_dir)?; +fn install_license(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> { + ensure_safe_component(filename, "license_file")?; + let dest = layout + .share_dir + .join("licenses") + .join(&pkg.name) + .join("LICENSE"); + fetch_verify_write(pkg, filename, &pkg.license_file_sha256, &dest, "license") +} + +fn install_desktop_file(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> { + ensure_safe_component(filename, "desktop_file")?; + let dest = layout + .share_dir + .join("applications") + .join(format!("{}.desktop", pkg.name)); + fetch_verify_write( + pkg, + filename, + &pkg.desktop_file_sha256, + &dest, + "desktop entry", + ) +} + +fn install_data_archive(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> { + ensure_safe_component(filename, "data_archive")?; + let data_dir = layout.share_dir.join(&pkg.name); + fetch_extract_archive(pkg, filename, &pkg.data_archive_sha256, &data_dir) +} + +/// Downloads + verifies a `.tar.gz` artifact, then extracts it into +/// `dest_dir`. Shells out to `tar` rather than adding an archive-extraction +/// crate dependency — `tar` is universally present on Linux and this file +/// already shells out to `systemctl` for the same "trust the base system +/// has this" reason. Split from `install_data_archive` (which just supplies +/// the real `$prefix/share/` destination) so tests can extract into +/// a tempdir instead. +fn fetch_extract_archive( + pkg: &Package, + filename: &str, + sha256: &Option, + dest_dir: &Path, +) -> Result<()> { + // A securely-named, process-unique temp file — the old + // `std::env::temp_dir().join(format!("bakery-{name}-{filename}"))` was a + // predictable path on a shared /tmp, so another local user could + // pre-plant a symlink there for `fetch_verify_write`'s write to follow. + let tmp_archive = tempfile::Builder::new() + .prefix(&format!("bakery-{}-", pkg.name)) + .tempfile() + .context("creating temp file for archive download")? + .into_temp_path(); + + fetch_verify_write(pkg, filename, sha256, &tmp_archive, "data archive")?; + // `fetch_verify_write` treats a download/checksum/missing-sha failure + // as a soft warning and returns `Ok` *without writing* — but the + // `NamedTempFile` above is already created (0 bytes), so gating on + // mere `exists()` would let an empty file through to `tar tvf`, which + // then bails with a confusing "not in gzip format" error that masks + // the real cause and aborts the whole install. Gate on *non-empty*. + if std::fs::metadata(&tmp_archive) + .map(|m| m.len()) + .unwrap_or(0) + == 0 + { + return Ok(()); + } + + verify_archive_paths(&tmp_archive)?; + + match prefix::extract_tar_gz(&tmp_archive, dest_dir) { + Ok(()) => ui::step( + "extracted", + &format!("{filename} → {}", dest_dir.display()), + ), + Err(e) => { + eprintln!( + " {}", + ui::warn(&format!("could not extract {filename}: {e}")) + ); + } + } + // `tmp_archive` (a `TempPath` guard) deletes the file when it drops here. + Ok(()) +} + +/// Lists `archive_path`'s contents via `tar tvf` and rejects the archive +/// outright (no extraction) if any entry is a symlink or has an unsafe path +/// (`..` component, or absolute). `--no-same-owner --no-same-permissions` on +/// the actual extraction covers ownership/permission escalation, but not a +/// symlink or `../` entry walking the extraction outside `dest_dir` — this +/// closes that gap before `tar` ever touches disk. +fn verify_archive_paths(archive_path: &Path) -> Result<()> { + let output = Command::new("tar") + .arg("tvf") + .arg(archive_path) + .output() + .context("listing archive contents")?; + if !output.status.success() { + bail!( + "tar tvf exited with {} listing archive contents — refusing to extract", + output.status + ); + } + + let listing = String::from_utf8_lossy(&output.stdout); + for line in listing.lines() { + if line.trim().is_empty() { + continue; + } + // tar -tvf: ` /