diff --git a/.forgejo/workflows/dev-bakery.yml b/.forgejo/workflows/dev-bakery.yml index ae2e9ef..a7c4bde 100644 --- a/.forgejo/workflows/dev-bakery.yml +++ b/.forgejo/workflows/dev-bakery.yml @@ -1,13 +1,14 @@ name: dev bakery -# Publishes a dev-track build on every push to `dev` — separate from -# release-bakery.yml's tag-triggered stable releases. See docs/release-channels.md -# for the three-track policy (stable/beta/dev) this is part of. +# 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: ['dev'] + branches: ['main'] paths: - 'bakery/**' + - 'bread-utils/**' - 'Cargo.toml' - 'Cargo.lock' - '.forgejo/workflows/dev-bakery.yml' @@ -20,7 +21,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch dev --depth 1 \ + git clone --branch main --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -45,7 +46,7 @@ jobs: # 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//' | sort -V | tail -1)" + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" if [ -n "${LATEST_TAG}" ]; then CUR="${LATEST_TAG}" else diff --git a/.forgejo/workflows/dev-bread-theme.yml b/.forgejo/workflows/dev-bread-theme.yml index 3d645d2..1cff78e 100644 --- a/.forgejo/workflows/dev-bread-theme.yml +++ b/.forgejo/workflows/dev-bread-theme.yml @@ -1,11 +1,11 @@ name: dev bread-theme -# Publishes a dev-track build on every push to `dev` — separate from -# release-bread-theme.yml's tag-triggered stable releases. See -# docs/release-channels.md for the three-track policy this is part of. +# 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: ['dev'] + branches: ['main'] paths: - 'bread-theme/**' - 'Cargo.toml' @@ -20,7 +20,7 @@ jobs: run: | set -euo pipefail rm -rf src && mkdir src - git clone --branch dev --depth 1 \ + git clone --branch main --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - name: build @@ -37,7 +37,7 @@ jobs: # 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//' | sort -V | tail -1)" + | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" if [ -n "${LATEST_TAG}" ]; then CUR="${LATEST_TAG}" else diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 6725e22..ca941f2 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -6,6 +6,9 @@ on: jobs: package: + # PKGBUILD pkgver cannot contain `-`; skip RC tags the same way + # release-bakery.yml does. + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] container: image: archlinux:latest diff --git a/.forgejo/workflows/beta-bakery.yml b/.forgejo/workflows/rc-bakery.yml similarity index 71% rename from .forgejo/workflows/beta-bakery.yml rename to .forgejo/workflows/rc-bakery.yml index 05c1774..d4e7853 100644 --- a/.forgejo/workflows/beta-bakery.yml +++ b/.forgejo/workflows/rc-bakery.yml @@ -1,14 +1,19 @@ -name: beta bakery +name: beta (rc) bakery -# Publishes a beta-track build when a `beta-v*` tag is pushed — a deliberate -# promotion step (you pick the version string and the commit), distinct from -# dev-bakery.yml's automatic build-on-every-push. See docs/release-channels.md. +# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag pushed +# to `main` — there is no separate `beta` branch; "freezing" is just +# pausing pushes to main while an RC gets tested. See +# docs/release-channels.md for the release-track policy. on: push: - tags: ['beta-v*'] + tags: ['v*'] + # No paths: filter. Tag pushes compare against an unrelated commit and + # would skip the RC publish if bakery/** wasn't in that diff; the job + # `if: contains -rc.` is the real gate. jobs: build: + if: ${{ contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout @@ -27,7 +32,8 @@ jobs: - name: prepare artifacts run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" + VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" @@ -42,7 +48,6 @@ jobs: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" 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" \ @@ -52,8 +57,9 @@ jobs: echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bakery-x86_64 UNSIGNED" fi - # No GitHub Release upload — beta, like dev, is only distributed via - # dl.breadway.dev/beta/. + # No GitHub Release upload step here, unlike release-bakery.yml — beta + # builds happen on every push while the branch is frozen for testing, + # so dl.breadway.dev/beta/ is the only distribution point for this track. - name: regenerate beta index.json env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} diff --git a/.forgejo/workflows/beta-bread-theme.yml b/.forgejo/workflows/rc-bread-theme.yml similarity index 76% rename from .forgejo/workflows/beta-bread-theme.yml rename to .forgejo/workflows/rc-bread-theme.yml index 24bde62..bd53ba1 100644 --- a/.forgejo/workflows/beta-bread-theme.yml +++ b/.forgejo/workflows/rc-bread-theme.yml @@ -1,14 +1,19 @@ -name: beta bread-theme +name: beta (rc) bread-theme -# Publishes a beta-track build when a `beta-v*` tag is pushed — a deliberate -# promotion step, distinct from dev-bread-theme.yml's build-on-every-push. -# See docs/release-channels.md. +# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag pushed +# to `main` — there is no separate `beta` branch; "freezing" is just +# pausing pushes to main while an RC gets tested. See +# docs/release-channels.md for the release-track policy. on: push: - tags: ['beta-v*'] + tags: ['v*'] + # No paths: filter. Tag pushes compare against an unrelated commit and + # would skip the RC publish if bread-theme/** wasn't in that diff; the + # job `if: contains -rc.` is the real gate. jobs: build: + if: ${{ contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout @@ -24,7 +29,8 @@ jobs: - name: prepare artifacts run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" + VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=${VERSION}" >> "$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" @@ -39,7 +45,6 @@ jobs: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#beta-v}" 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" \ diff --git a/.forgejo/workflows/release-bakery.yml b/.forgejo/workflows/release-bakery.yml index 283153b..2ac03bf 100644 --- a/.forgejo/workflows/release-bakery.yml +++ b/.forgejo/workflows/release-bakery.yml @@ -6,6 +6,7 @@ on: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout @@ -58,7 +59,13 @@ jobs: - name: regenerate index.json env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} - run: cd src && bash scripts/gen-index.sh + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate stable index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the stable track)" + exit 1 + fi + cd src && bash scripts/gen-index.sh - name: upload to GitHub Release env: diff --git a/.forgejo/workflows/release-bread-theme.yml b/.forgejo/workflows/release-bread-theme.yml index 335f982..2892e14 100644 --- a/.forgejo/workflows/release-bread-theme.yml +++ b/.forgejo/workflows/release-bread-theme.yml @@ -6,6 +6,7 @@ on: jobs: build: + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] steps: - name: checkout @@ -56,7 +57,13 @@ jobs: - name: regenerate index.json env: MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} - run: cd src && bash scripts/gen-index.sh + run: | + set -euo pipefail + if [ -z "${MINISIGN_SEC_KEY:-}" ]; then + echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate stable index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the stable track)" + exit 1 + fi + cd src && bash scripts/gen-index.sh - name: upload to GitHub Release env: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c8819c5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# AGENTS.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. + +Follow [`CONTRIBUTING.md`](CONTRIBUTING.md) for any git, branch, or release work. Channel/track policy lives in [`docs/release-channels.md`](docs/release-channels.md). The product list is [`registry/bread-ecosystem.toml`](registry/bread-ecosystem.toml) — regenerate the README table with `scripts/gen-readme-products.sh` after editing it. Don't invent a second long-lived branch; there is only `main`. Bakery's package version **must** match `[workspace.package] version` in the root `Cargo.toml` at tag time (`bakery --version` is compiled from that field; `bakery list` reports the git tag) — never push a `v*` tag without bumping Cargo.toml to the same `X.Y.Z`. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push both when publishing. + +## CI +- `.forgejo/workflows/package.yml`, `release-bakery.yml`, `release-bread-theme.yml` all trigger on `push: tags: ['v*']`, gated to skip any tag containing `-rc.` — pushing to `main` doesn't run these. Tag a release to trigger packaging. +- `dev-bakery.yml` / `dev-bread-theme.yml` trigger on `push: branches: ['main']`; `rc-bakery.yml` / `rc-bread-theme.yml` trigger on `push: tags: ['v*']` gated to *only* run for `-rc.` tags — both auto-publish a signed, auto-versioned build to `dl.breadway.dev/{dev,beta}/`. See `docs/release-channels.md` for the full track (stable/beta/dev) policy. +- No build/lint/test CI runs on ordinary commits or PRs to `main` beyond the dev-track workflow above — there's no separate lint/PR-check pipeline. + +## Cleanup +- Delete feature/fix branches (local + remote) once merged. Check with `git branch --merged main`. +- A `fix/audit-findings` branch and a merged `copilot/create-readme-md` branch (both local and on `origin`/`github`) were found stale and fully merged here on 2026-07-21 and removed. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. +- Don't flip bakery's default install prefix. System prefix (`/usr/local` via + `/etc/bakery/config.toml` or `BAKERY_PREFIX`) is for BOS; hermes and + `get.sh` stay on `~/.local`. See [`bakery/README.md`](bakery/README.md). diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 261735d..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,23 +0,0 @@ -# CLAUDE.md — Repo hygiene (local only, not committed) - -Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. - -## Branch model -- `main` — release branch, always tag-ready. Don't commit directly to it. -- `dev` — integration branch. Land day-to-day work here first. -- Feature/fix work goes on short-lived branches off `dev` (`feature/x`, `fix/x`), merged back into `dev`, then `dev` → `main` when ready to release. - -## Remotes -- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. -- `github` — GitHub mirror. Push both when publishing. - -## CI -- `.forgejo/workflows/package.yml`, `release-bakery.yml`, `release-bread-theme.yml` all trigger only on `push: tags: ['v*']` — pushing to `dev` or `main` runs nothing. Tag a release to trigger packaging. -- No build/lint/test CI runs on ordinary commits or PRs — test locally before merging to `dev`/`main`. - -## Cleanup -- Delete feature/fix branches (local + remote) once merged. Check with `git branch --merged dev` / `git branch --merged main`. -- A `fix/audit-findings` branch and a merged `copilot/create-readme-md` branch (both local and on `origin`/`github`) were found stale and fully merged here on 2026-07-21 and removed. - -## Don't -- Don't embed credentials in remote URLs — SSH or a credential helper only. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..349d06b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,120 @@ +# Contributing + +This repo is a Cargo workspace. Bakery-channel products shipped from here +are `bakery` (the ecosystem package manager) and `bread-theme` (the shared +theming crate). Shared crates that sibling apps pin — not bakery packages +of their own — are `bread-utils`, `bread-app`, `bread-onnx`, +`bread-screenshots`, and `bread-capture`. `bread-polkit` is an in-tree +session agent: it has `bread-polkit/bakery.toml` so it *can* be published, +but it is not in `registry/bread-ecosystem.toml` (unpublished — not on +the bakery index, not on the BOS ISO). Other ecosystem products +(`bread`, `breadbar`, `breadbox`, …) live in their own repos under +`Breadway/` but follow the same workflow described here. The product list +is `registry/bread-ecosystem.toml`. New GTK tools should depend on +`bread-app` instead of copying another app's bootstrap. + +## Branches + +There is one long-lived branch: **`main`**. All day-to-day work lands here. +Every push to `main` automatically builds and publishes a **dev-track** +build for both products (see Tracks below) — use this to test your change +in a real install before cutting anything more formal. + +New work — features and bug fixes alike — goes on a short-lived branch: + +``` +feature/ +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 b1812e2..0598260 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,12 +138,15 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bakery" -version = "0.3.1" +version = "0.7.4" dependencies = [ "anyhow", + "bread-utils", "chrono", "clap", - "dirs", + "clap_complete", + "dirs 5.0.1", + "fs4", "hex", "minisign-verify", "semver", @@ -148,9 +185,26 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bread-app" +version = "0.7.4" +dependencies = [ + "bread-utils", +] + +[[package]] +name = "bread-capture" +version = "0.7.4" +dependencies = [ + "anyhow", + "bread-utils", + "clap", + "image", +] + [[package]] name = "bread-onnx" -version = "0.3.1" +version = "0.7.4" dependencies = [ "anyhow", "bread-utils", @@ -164,32 +218,58 @@ 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.4" dependencies = [ - "dirs", + "anyhow", + "bread-app", + "bread-theme", + "gtk4", + "serde", + "tokio", + "tracing", + "tracing-subscriber", + "zbus", +] + +[[package]] +name = "bread-screenshots" +version = "0.7.4" +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.1" +version = "0.7.4" dependencies = [ - "dirs", + "dirs 5.0.1", "gtk4", + "libadwaita", "serde", "serde_json", ] [[package]] name = "bread-utils" -version = "0.3.1" +version = "0.7.4" dependencies = [ "bread-shared", - "dirs", + "dirs 5.0.1", "gtk4", "gtk4-layer-shell", "serde", @@ -204,6 +284,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" @@ -297,6 +395,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" @@ -306,7 +413,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -434,7 +541,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.119", ] [[package]] @@ -445,7 +552,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -475,7 +582,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -485,7 +592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.119", ] [[package]] @@ -504,7 +611,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]] @@ -515,10 +631,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" @@ -527,7 +655,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -542,6 +670,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" @@ -567,12 +722,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" @@ -614,6 +798,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" @@ -646,6 +840,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" @@ -654,7 +861,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -858,7 +1065,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -992,7 +1199,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1165,6 +1372,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" @@ -1226,12 +1446,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" @@ -1241,6 +1507,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" @@ -1275,6 +1547,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" @@ -1322,6 +1603,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" @@ -1341,7 +1633,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]] @@ -1436,12 +1738,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", @@ -1477,6 +1790,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" @@ -1501,6 +1820,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" @@ -1552,6 +1884,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" @@ -1650,6 +1988,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" @@ -1702,6 +2051,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" @@ -1711,7 +2073,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1795,7 +2157,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1811,6 +2173,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" @@ -1840,12 +2213,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" @@ -1864,6 +2256,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" @@ -1911,6 +2313,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" @@ -1919,7 +2332,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1950,7 +2363,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1980,7 +2393,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1991,7 +2404,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]] @@ -2038,6 +2460,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" @@ -2149,7 +2599,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2161,12 +2611,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" @@ -2254,6 +2730,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" @@ -2313,7 +2801,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -2375,7 +2863,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2386,7 +2874,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2616,10 +3104,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" @@ -2637,7 +3190,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2657,7 +3210,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -2697,7 +3250,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2705,3 +3258,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 4957702..10d698a 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"] resolver = "2" [workspace.package] -version = "0.3.1" +version = "0.7.4" edition = "2021" license = "MIT" authors = ["Breadway "] 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 f65aedd..06ca297 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -17,9 +17,10 @@ ureq = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } clap = { workspace = true } +clap_complete = "4" chrono = { workspace = true } minisign-verify = { workspace = true } semver = { workspace = true } - -[dev-dependencies] +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 e261e1d..f4ef71b 100644 --- a/bakery/src/doctor.rs +++ b/bakery/src/doctor.rs @@ -11,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 { @@ -33,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") @@ -49,16 +90,41 @@ 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!(" {}", ui::ok(&format!("{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!(" {}", ui::fail(&format!("error running doctor for {package_name}: {e}"))); + ui::check_row( + false, + package_name, + name_width, + &format!("error running doctor: {e}"), + ); false } Ok(rep) => { @@ -75,17 +141,24 @@ pub fn report(package_name: &str, required: &[String], optional: &[String]) -> b ); } if rep.missing.is_empty() { - println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied"))); + ui::check_row( + true, + package_name, + name_width, + "all required system deps satisfied", + ); true } else { + ui::check_row( + false, + package_name, + name_width, + &format!("missing: {}", rep.missing.join(", ")), + ); eprintln!( " {}", - ui::fail(&format!( - "{package_name}: missing system deps: {}", - rep.missing.join(", ") - )) + ui::dim(&format!("install with: {}", install_hint(&rep.missing))) ); - eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" ")); false } } @@ -115,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..2040e35 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,290 @@ 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")?; + if !tmp_archive.exists() { + // fetch_verify_write already warned (download/checksum failure). + 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: ` /