diff --git a/.forgejo/workflows/beta-bakery.yml b/.forgejo/workflows/beta-bakery.yml new file mode 100644 index 0000000..ca2f49c --- /dev/null +++ b/.forgejo/workflows/beta-bakery.yml @@ -0,0 +1,61 @@ +name: beta 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. +on: + push: + tags: ['beta-v*'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --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 + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#beta-v}" + 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 + 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" \ + -x "${PKG_DIR}/bakery-x86_64.minisig" "${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 + 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" \ + -x "${PKG_DIR}/bread-theme-x86_64.minisig" > "$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" "${PKG_DIR}/bakery-x86_64.sha256" + cp src/bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${VERSION}" "/srv/breadway-dl/bakery/latest" + + # Signs the bakery binary itself with the shared bakery ecosystem signing + # key (same key that signs index.json and bread-theme — see + # release-bread-theme.yml). BAKERY_MINISIGN_SEC_KEY_PATH is a *path on + # this runner's disk* (hestia has persistent storage), not the key + # contents. Dormant (binary ships unsigned, as today) until that secret + # is provisioned. + - name: sign release binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/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" /dev/null || true + ASSETS="${PKG_DIR}/bakery-x86_64 ${PKG_DIR}/bakery-x86_64.sha256" + [ -f "${PKG_DIR}/bakery-x86_64.minisig" ] && ASSETS="${ASSETS} ${PKG_DIR}/bakery-x86_64.minisig" + gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem ${ASSETS} --clobber diff --git a/.forgejo/workflows/release-bread-theme.yml b/.forgejo/workflows/release-bread-theme.yml new file mode 100644 index 0000000..335f982 --- /dev/null +++ b/.forgejo/workflows/release-bread-theme.yml @@ -0,0 +1,72 @@ +name: release bread-theme + +on: + push: + tags: ['v*'] + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - name: checkout + run: | + set -euo pipefail + rm -rf src && mkdir src + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: build + run: cd src && cargo build --release --locked -p bread-theme --bin bread-theme + + - name: prepare artifacts + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/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/bread-theme/latest" + + # Signs the bread-theme binary with the shared bakery ecosystem signing + # key (same key that signs index.json — get.sh / manifest.rs pin the + # matching public key). BAKERY_MINISIGN_SEC_KEY_PATH is a *path on this + # runner's disk* (hestia has persistent storage, unlike a fresh + # GitHub-hosted runner), not the key contents — see the handoff note + # in scripts/gen-index.sh. Dormant (binary ships unsigned, as today) + # until that secret is provisioned. + - name: sign release binary + env: + MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="/srv/breadway-dl/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" /dev/null || true + ASSETS="${PKG_DIR}/bread-theme-x86_64 ${PKG_DIR}/bread-theme-x86_64.sha256" + [ -f "${PKG_DIR}/bread-theme-x86_64.minisig" ] && ASSETS="${ASSETS} ${PKG_DIR}/bread-theme-x86_64.minisig" + gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem ${ASSETS} --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index a977bc2..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: release - -on: - push: - tags: ["v*"] - -permissions: - contents: write - -env: - DL_DIR: /srv/breadway-dl - -jobs: - build: - runs-on: [self-hosted, hestia] - steps: - - uses: actions/checkout@v4 - - - name: build - run: cargo build --release --locked -p bakery - - - name: test - run: cargo test --locked --workspace - - - name: prepare artifacts - run: | - VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="${DL_DIR}/bakery/${VERSION}" - mkdir -p "${PKG_DIR}" - - cp 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 bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${VERSION}" "${DL_DIR}/bakery/latest" - - - name: regenerate index.json - run: bash "${GITHUB_WORKSPACE}/scripts/gen-index.sh" - - - name: upload to GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="${DL_DIR}/bakery/${VERSION}" - gh release create "${GITHUB_REF_NAME}" \ - --title "bakery v${VERSION}" --generate-notes 2>/dev/null || true - gh release upload "${GITHUB_REF_NAME}" \ - "${PKG_DIR}/bakery-x86_64" \ - "${PKG_DIR}/bakery-x86_64.sha256" \ - --clobber diff --git a/.gitignore b/.gitignore index b83d222..4c046ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ /target/ + +# minisign secret keys must never be committed — the bakery/index signing +# key lives outside this repo entirely (see scripts/gen-index.sh / +# scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY). +*.minisign-sec +minisign.key diff --git a/BREAD_DESIGN_SYSTEM.md b/BREAD_DESIGN_SYSTEM.md index 942f7a2..ae57901 100644 --- a/BREAD_DESIGN_SYSTEM.md +++ b/BREAD_DESIGN_SYSTEM.md @@ -48,16 +48,27 @@ Establish a visual hierarchy with consistent rounding: ## Color System -All projects use **pywal dynamic theming** with **Catppuccin Mocha** as the fallback palette: +All projects use **pywal dynamic theming** for accents, layered on a **fixed BOS +dark base** — background, surface, overlay, and foreground never come from +pywal, only the accent slots (color1–6) track the current wallpaper: -- **Background**: `#1e1e2e` (Catppuccin) -- **Foreground**: `#cdd6f4` (Catppuccin) -- **Surface**: `#181825` (Catppuccin) -- **Accent**: Dynamic (from pywal) +- **Background**: `#0c0c0c` (fixed) +- **Foreground**: `#e8e8e8` (fixed) +- **Surface**: `#1a1a1a` (fixed, `color0`) +- **Overlay**: `#d8d8d8` (fixed, `color7`) +- **Accent**: Dynamic (from pywal `color4`), with curated bread-toned defaults + before any wallpaper has been set + +Without pinning bg/surface/overlay, a light or muddy-toned wallpaper makes +pywal hand back a light or off-hue background, and every bread GUI's panels +inherit it — see `bread-theme/src/palette.rs` for the implementation. Color palette slots (via wal): -- color0–color7: ANSI colors +- color0–color7: ANSI colors (0 and 7 fixed, 1–6 pywal-derived) - Semantic: red, green, yellow, blue, pink, teal +- Computed ink: `on-bg`, `on-surface`, `on-accent`, `on-red`, `on-overlay` — + black or white text, whichever is legible against that background (see + `bread_theme::ink_on`) ## Component Standards @@ -104,8 +115,9 @@ add only app-specific rules: hardcoded Nord palette; migrated to the shared stylesheet). - **breadcrumbs** — CLI tool; ANSI colours only, no GUI styling. -> Palette note: the fallback is Catppuccin Mocha, but installs (e.g. BOS) drive -> the real palette from pywal — BOS ships a black-base palette. +> Palette note: background/surface/overlay/foreground are a fixed BOS dark +> base, never pywal-derived; only the accent slots (color1–6) track the +> current wallpaper via pywal. ## Future Consistency Checks diff --git a/Cargo.lock b/Cargo.lock index 36707a1..b1812e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,29 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -69,9 +92,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "autocfg" @@ -81,13 +104,15 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bakery" -version = "0.2.3" +version = "0.3.1" dependencies = [ "anyhow", "chrono", "clap", "dirs", "hex", + "minisign-verify", + "semver", "serde", "serde_json", "sha2", @@ -96,6 +121,12 @@ dependencies = [ "ureq", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -104,9 +135,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -117,9 +148,35 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bread-onnx" +version = "0.3.1" +dependencies = [ + "anyhow", + "bread-utils", + "hex", + "ort", + "sha2", + "tempfile", + "tokenizers", + "tracing", + "ureq", +] + +[[package]] +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +dependencies = [ + "dirs", + "serde", + "serde_json", + "toml 0.8.23", +] + [[package]] name = "bread-theme" -version = "0.2.3" +version = "0.3.1" dependencies = [ "dirs", "gtk4", @@ -127,6 +184,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-utils" +version = "0.3.1" +dependencies = [ + "bread-shared", + "dirs", + "gtk4", + "gtk4-layer-shell", + "serde", + "serde_json", + "tempfile", + "toml_edit 0.22.27", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -157,10 +228,19 @@ dependencies = [ ] [[package]] -name = "cc" -version = "1.2.63" +name = "castaway" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "shlex", @@ -197,9 +277,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", "clap_derive", @@ -207,9 +287,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -241,6 +321,33 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -265,6 +372,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crypto-common" version = "0.1.7" @@ -275,6 +407,87 @@ dependencies = [ "typenum", ] +[[package]] +name = "daachorse" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f55d7153ba3b507595872a3874803f07a8a81d1e888abed8e5db7da0597d6e2" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -317,6 +530,18 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -333,6 +558,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +dependencies = [ + "cc", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -366,10 +600,10 @@ dependencies = [ ] [[package]] -name = "foldhash" -version = "0.1.5" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "form_urlencoded" @@ -382,24 +616,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -408,15 +642,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", @@ -425,15 +659,15 @@ dependencies = [ [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-macro", @@ -469,14 +703,15 @@ dependencies = [ [[package]] name = "gdk4" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" +checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39" dependencies = [ "cairo-rs", "gdk-pixbuf", "gdk4-sys", "gio", + "gl", "glib", "libc", "pango", @@ -484,9 +719,9 @@ dependencies = [ [[package]] name = "gdk4-sys" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" +checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -522,22 +757,32 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", - "wasip3", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", ] [[package]] name = "gio" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" +checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded" dependencies = [ "futures-channel", "futures-core", @@ -552,9 +797,9 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5" dependencies = [ "glib-sys", "gobject-sys", @@ -564,10 +809,30 @@ dependencies = [ ] [[package]] -name = "glib" -version = "0.22.7" +name = "gl" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glib" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" dependencies = [ "bitflags", "futures-channel", @@ -598,9 +863,9 @@ dependencies = [ [[package]] name = "glib-sys" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" +checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", "system-deps", @@ -619,32 +884,30 @@ dependencies = [ [[package]] name = "graphene-rs" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" dependencies = [ "glib", "graphene-sys", - "libc", ] [[package]] name = "graphene-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "pkg-config", "system-deps", ] [[package]] name = "gsk4" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff" dependencies = [ "cairo-rs", "gdk4", @@ -657,9 +920,9 @@ dependencies = [ [[package]] name = "gsk4-sys" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088" dependencies = [ "cairo-sys-rs", "gdk4-sys", @@ -673,9 +936,9 @@ dependencies = [ [[package]] name = "gtk4" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" +checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9" dependencies = [ "cairo-rs", "field-offset", @@ -693,10 +956,38 @@ dependencies = [ ] [[package]] -name = "gtk4-macros" -version = "0.11.0" +name = "gtk4-layer-shell" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +dependencies = [ + "bitflags", + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-layer-shell-sys", + "libc", +] + +[[package]] +name = "gtk4-layer-shell-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gtk4-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -706,9 +997,9 @@ dependencies = [ [[package]] name = "gtk4-sys" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" +checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -723,15 +1014,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -857,10 +1139,10 @@ dependencies = [ ] [[package]] -name = "id-arena" -version = "2.3.0" +name = "ident_case" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" @@ -890,9 +1172,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "hashbrown", +] + +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", ] [[package]] @@ -901,6 +1194,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -909,21 +1211,20 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "khronos_api" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "libc" @@ -933,9 +1234,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -954,15 +1255,41 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -973,6 +1300,18 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -983,6 +1322,71 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1004,6 +1408,28 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -1011,14 +1437,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] -name = "pango" -version = "0.22.6" +name = "ort" +version = "2.0.0-rc.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" + +[[package]] +name = "pango" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c" dependencies = [ "gio", "glib", - "libc", "pango-sys", ] @@ -1034,6 +1477,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1052,6 +1501,21 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1062,13 +1526,12 @@ dependencies = [ ] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "proc-macro2", - "syn", + "zerocopy", ] [[package]] @@ -1077,7 +1540,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -1091,19 +1554,91 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -1112,9 +1647,38 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "ring" version = "0.17.14" @@ -1153,9 +1717,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "log", "once_cell", @@ -1168,9 +1732,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "zeroize", ] @@ -1188,9 +1752,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "semver" @@ -1278,9 +1848,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "slab" @@ -1290,9 +1860,21 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom", + "serde", + "unicode-segmentation", +] [[package]] name = "stable_deref_trait" @@ -1300,6 +1882,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -1314,9 +1902,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -1343,7 +1931,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "version-compare", ] @@ -1360,7 +1948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1372,7 +1960,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1386,6 +1983,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1396,6 +2004,40 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tokenizers" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44e5bea67576e04b6ff8564c5d9e09c2ef0cf476502245f2f120e497769d3112" +dependencies = [ + "ahash", + "compact_str", + "daachorse", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "indicatif", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "toml" version = "0.8.23" @@ -1410,9 +2052,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", @@ -1420,7 +2062,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -1457,14 +2099,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -1473,7 +2115,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -1484,9 +2126,40 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] [[package]] name = "typenum" @@ -1501,10 +2174,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "unicode-normalization-alignments" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" [[package]] name = "untrusted" @@ -1518,7 +2218,7 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ - "base64", + "base64 0.22.1", "flate2", "log", "once_cell", @@ -1574,27 +2274,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -1605,9 +2296,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1615,9 +2306,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -1628,45 +2319,21 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -1675,14 +2342,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -1905,113 +2572,31 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "yoke" version = "0.8.3" @@ -2035,6 +2620,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -2058,9 +2663,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" @@ -2097,6 +2702,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 354c58c..4957702 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [workspace] -members = ["bakery", "bread-theme"] +members = ["bakery", "bread-theme", "bread-utils", "bread-onnx"] resolver = "2" [workspace.package] -version = "0.2.3" +version = "0.3.1" edition = "2021" license = "MIT" -authors = ["Breadway "] +authors = ["Breadway "] [workspace.dependencies] anyhow = "1" @@ -19,6 +19,9 @@ sha2 = "0.10" hex = "0.4" 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/LICENSE b/LICENSE new file mode 100644 index 0000000..373e4ee --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Breadway + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 1ec4cff..fff6340 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ bakery install breadbar | `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`) | +| `breadpaper` | Wallpaper manager for the bread desktop | ## Recommended keybinds @@ -35,17 +36,31 @@ to keys. ## Theming -All GUIs share one look via `bread-theme`. The `bread-theme` CLI renders the -component stylesheet from your pywal palette (Catppuccin Mocha fallback) to -`$XDG_RUNTIME_DIR/bread/theme.css`; every app loads that file and **live-reloads** -it, so changing your wallpaper recolours the whole ecosystem with no rebuilds: +All GUI products (breadbar, breadbox, breadpad) share one stylesheet via +`bread-theme`. Background, surface, overlay, and foreground are always BOS's +fixed dark values; only the accent colors are read from the pywal palette in +`~/.cache/wal/colors.json`. When that file is absent, the accents fall back +to BOS's curated bread-toned defaults (not Catppuccin Mocha). The stylesheet +is written to `$XDG_RUNTIME_DIR/bread/theme.css`; running apps watch that +file and recolour live when it changes. Per-app CSS overrides live at +`~/.config//style.css`. ```sh wal -i ~/Pictures/wall.png # regenerate pywal palette bread-theme generate # render the shared stylesheet (run from a wal hook) ``` -See [`BREAD_DESIGN_SYSTEM.md`](BREAD_DESIGN_SYSTEM.md) for the tokens (fonts, +`bread-theme` subcommands: + +| Subcommand | Description | +|------------|-------------| +| `generate` | Render the current palette and write the shared stylesheet (default) | +| `reload` | Same as `generate`; use after a palette change to trigger live recolour in running apps | +| `path` | Print the stylesheet path | +| `print` | Render the stylesheet to stdout without writing | + +The shared theming logic lives in the `bread-theme` crate in this repo. See +[`BREAD_DESIGN_SYSTEM.md`](BREAD_DESIGN_SYSTEM.md) for the design tokens (fonts, spacing, radii, colour roles) the stylesheet is built from. ## Installing bakery @@ -92,14 +107,6 @@ bakery remove # remove a package (data files are never deleted) Install all required deps with `sudo pacman -S `. Use `pacman -Q ` to check whether any are already present. -## Theming - -All GUI products (breadbar, breadbox, breadpad) read pywal colors from -`~/.cache/wal/colors.json` and fall back to Catppuccin Mocha when that file -is absent. Per-app CSS overrides live at `~/.config//style.css`. - -The shared theming logic lives in the `bread-theme` crate in this repo. - ## Workspace This repo is a Cargo workspace: @@ -107,7 +114,7 @@ This repo is a Cargo workspace: ``` bread-ecosystem/ ├── bakery/ # package manager binary -├── bread-theme/ # shared pywal + Catppuccin theming crate +├── bread-theme/ # shared pywal + fixed-dark-base theming crate ├── registry/ # bread-ecosystem.toml — product registry └── scripts/ ├── get.sh # curl | sh bootstrap diff --git a/bakery/Cargo.toml b/bakery/Cargo.toml index df5b7c1..f65aedd 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true authors.workspace = true description = "Package manager for the bread ecosystem" -repository = "https://github.com/Breadway/bread-ecosystem" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" [dependencies] anyhow = { workspace = true } @@ -18,6 +18,8 @@ sha2 = { workspace = true } hex = { workspace = true } clap = { workspace = true } chrono = { workspace = true } +minisign-verify = { workspace = true } +semver = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/bakery/src/doctor.rs b/bakery/src/doctor.rs index 0a7194b..e261e1d 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; @@ -52,28 +53,37 @@ fn pkg_config_exists(lib: &str) -> bool { /// Returns true if all *required* deps are satisfied. pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool { if required.is_empty() && optional.is_empty() { - println!(" {package_name}: no system deps required"); + println!(" {}", ui::ok(&format!("{package_name}: no system deps required"))); return true; } match check_deps(required, optional) { Err(e) => { - eprintln!(" error running doctor for {package_name}: {e}"); + eprintln!(" {}", ui::fail(&format!("error running doctor for {package_name}: {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"); + println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied"))); true } else { eprintln!( - " {package_name}: missing system deps: {}", - rep.missing.join(", ") + " {}", + ui::fail(&format!( + "{package_name}: missing system deps: {}", + rep.missing.join(", ") + )) ); eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" ")); false diff --git a/bakery/src/download.rs b/bakery/src/download.rs index c89744c..3362bd0 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -32,7 +32,12 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> { Ok(()) } -fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> { +/// Verify that `bytes` hashes to `expected_hex` under SHA-256. +/// +/// Shared by every artifact download path — binaries (via +/// [`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<()> { let mut hasher = Sha256::new(); hasher.update(bytes); let actual = hex::encode(hasher.finalize()); diff --git a/bakery/src/install.rs b/bakery/src/install.rs index 03fb65f..3b4c925 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; use std::process::Command; -use crate::download::fetch_and_place; +use crate::download::{fetch_and_place, verify_sha256}; use crate::manifest::{fetch_binary, Package, Service}; use crate::state::{InstalledPackage, State}; @@ -119,11 +119,28 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu if !dest.exists() { if let Some((primary, fallback)) = pkg.artifact_urls(example) { match fetch_binary(&primary, &fallback) { - Ok(bytes) => { - std::fs::write(&dest, &bytes) - .with_context(|| format!("writing {}", dest.display()))?; - println!(" installed example config at {}", dest.display()); - } + Ok(bytes) => match &cfg.example_sha256 { + Some(expected) => match verify_sha256(&bytes, expected) { + Ok(()) => { + std::fs::write(&dest, &bytes) + .with_context(|| format!("writing {}", dest.display()))?; + println!(" installed example config at {}", dest.display()); + } + Err(e) => { + eprintln!( + " warning: checksum mismatch for example config {example}: {e} — not installed" + ); + println!(" config dir created at {}", dir.display()); + } + }, + None => { + eprintln!( + " warning: index.json has no sha256 for example config \ + {example} — refusing to install an unverified download" + ); + println!(" config dir created at {}", dir.display()); + } + }, Err(e) => { eprintln!(" warning: could not download example config {example}: {e}"); println!(" config dir created at {}", dir.display()); @@ -151,11 +168,19 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { if !unit_path.exists() { if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) { match fetch_binary(&primary, &fallback) { - Ok(bytes) => { - std::fs::write(&unit_path, &bytes) - .with_context(|| format!("writing {}", unit_path.display()))?; - println!(" downloaded unit {}", unit_path.display()); - } + 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); } diff --git a/bakery/src/main.rs b/bakery/src/main.rs index 821f55a..8a16b2c 100644 --- a/bakery/src/main.rs +++ b/bakery/src/main.rs @@ -3,11 +3,14 @@ mod download; mod install; mod manifest; mod state; +mod track; +mod ui; -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use std::collections::HashSet; use std::path::PathBuf; +use track::Track; #[derive(Parser)] #[command(name = "bakery", about = "Package manager for the bread ecosystem", version)] @@ -54,6 +57,20 @@ enum Cmd { /// Package to check; omit to check all installed packages package: Option, }, + /// View or switch which build track bakery follows (stable/beta/dev) + Track { + #[command(subcommand)] + action: TrackCmd, + }, +} + +#[derive(Subcommand)] +enum TrackCmd { + /// Show the currently selected track + Show, + /// Switch tracks. Only changes the preference — run `bakery update --all` + /// afterwards to actually install builds from the new track. + Set { track: Track }, } fn default_bin_dir() -> PathBuf { @@ -65,23 +82,52 @@ fn default_bin_dir() -> PathBuf { fn main() -> Result<()> { let cli = Cli::parse(); let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir); + let track = state::State::load()?.track; match cli.command { Cmd::Install { packages } => { - let index = manifest::load(true)?; + let index = manifest::load(true, track)?; for pkg in &packages { cmd_install(&index, pkg, &bin_dir)?; } Ok(()) } Cmd::Remove { package } => cmd_remove(&package, &bin_dir), - Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir), - Cmd::List { installed } => cmd_list(installed), - Cmd::Info { package } => cmd_info(&package), - Cmd::Doctor { package } => cmd_doctor(package.as_deref()), + Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir, track), + Cmd::List { installed } => cmd_list(installed, track), + Cmd::Info { package } => cmd_info(&package, track), + Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track), + Cmd::Track { action } => cmd_track(action), } } +fn cmd_track(action: TrackCmd) -> Result<()> { + let mut state = state::State::load()?; + match action { + TrackCmd::Show => { + println!("current track: {}", ui::style(state.track.as_str(), ui::CYAN)); + } + TrackCmd::Set { track } => { + if state.track == track { + println!("already on track {track}"); + return Ok(()); + } + // Fail fast on a bad/unreachable track rather than silently + // recording a preference bakery can't actually serve. + manifest::load(true, track) + .with_context(|| format!("could not validate {track} track, not switching"))?; + state.set_track(track); + state.save()?; + println!( + "switched to {} — run 'bakery update --all' to install {} builds", + ui::style(track.as_str(), ui::CYAN), + track + ); + } + } + Ok(()) +} + fn cmd_install(index: &manifest::Index, name: &str, bin_dir: &std::path::Path) -> Result<()> { let mut visited = HashSet::new(); install_with_deps(index, name, bin_dir, &mut visited) @@ -130,8 +176,8 @@ fn cmd_remove(name: &str, bin_dir: &std::path::Path) -> Result<()> { install::remove_package(name, bin_dir) } -fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Result<()> { - let index = manifest::load(true)?; +fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: Track) -> Result<()> { + let index = manifest::load(true, track)?; let state = state::State::load()?; let targets: Vec = if all || name.is_none() { @@ -162,14 +208,16 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Resul } }; - if installed.version == latest.version { - println!("{pkg_name} is already at {}", installed.version); + if !is_newer(&installed.version, &latest.version) { + println!("{}", ui::style(&format!("{pkg_name} is already at {}", installed.version), ui::GREEN)); continue; } println!( - "updating {pkg_name} {} → {}", - installed.version, latest.version + "updating {pkg_name} {} {} {}", + ui::style(&installed.version, ui::DIM), + ui::style("→", ui::CYAN), + ui::style(&latest.version, ui::BOLD) ); let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) { @@ -204,7 +252,28 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Resul Ok(()) } -fn cmd_list(installed_only: bool) -> Result<()> { +/// Is `latest` newer than `installed`? Real semver comparison — the +/// previous plain string-equality check couldn't tell "different" from +/// "actually newer", so it would happily "update" a package to a lexically +/// different but not-newer version. Falls back to a simple inequality check +/// (with a warning) for any version string that isn't valid semver, rather +/// than hard-erroring on packages built before this convention existed. +fn is_newer(installed: &str, latest: &str) -> bool { + match (semver::Version::parse(installed), semver::Version::parse(latest)) { + (Ok(i), Ok(l)) => l > i, + _ => { + if installed != latest { + eprintln!( + " warning: '{installed}' or '{latest}' is not valid semver, \ + falling back to a plain inequality check" + ); + } + installed != latest + } + } +} + +fn cmd_list(installed_only: bool, track: Track) -> Result<()> { let state = state::State::load()?; if installed_only { @@ -217,35 +286,39 @@ fn cmd_list(installed_only: bool) -> Result<()> { return Ok(()); } - let index = manifest::load(false)?; + if !matches!(track, Track::Stable) { + println!("tracking:{}\n", ui::track_badge(track)); + } + + let index = manifest::load(false, track)?; let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect(); names.sort(); for name in names { let pkg = &index.packages[name]; let tag = if state.is_installed(name) { - format!(" [installed {}]", state.packages[name].version) + ui::style(&format!(" [installed {}]", state.packages[name].version), ui::GREEN) } else { String::new() }; - println!(" {} {} — {}{}", pkg.name, pkg.version, pkg.description, tag); + println!(" {:<14} {:<10} — {}{}", pkg.name, pkg.version, pkg.description, tag); } Ok(()) } -fn cmd_info(name: &str) -> Result<()> { - let index = manifest::load(false)?; +fn cmd_info(name: &str, track: Track) -> Result<()> { + let index = manifest::load(false, track)?; let pkg = index .get(name) .ok_or_else(|| anyhow::anyhow!("unknown package: {name}"))?; let state = state::State::load()?; let status = if let Some(inst) = state.packages.get(name) { - format!("installed ({})", inst.version) + ui::style(&format!("installed ({})", inst.version), ui::GREEN) } else { - "not installed".to_string() + ui::style("not installed", ui::DIM) }; - println!("{} {}", pkg.name, pkg.version); + println!("{}{} {}", ui::style(&pkg.name, ui::BOLD), ui::track_badge(track), pkg.version); println!(" {}", pkg.description); println!(" status: {status}"); println!( @@ -278,8 +351,8 @@ fn cmd_info(name: &str) -> Result<()> { Ok(()) } -fn cmd_doctor(name: Option<&str>) -> Result<()> { - let index = manifest::load(false)?; +fn cmd_doctor(name: Option<&str>, track: Track) -> Result<()> { + let index = manifest::load(false, track)?; let state = state::State::load()?; let targets: Vec = match name { @@ -310,7 +383,41 @@ fn cmd_doctor(name: Option<&str>) -> Result<()> { } if all_ok { - println!("all checks passed"); + println!("{}", ui::ok("all checks passed")); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_newer_detects_real_semver_increase() { + assert!(is_newer("0.3.1", "0.3.2")); + assert!(is_newer("0.3.1", "0.4.0")); + assert!(!is_newer("0.3.2", "0.3.1")); + } + + #[test] + fn is_newer_false_when_equal() { + assert!(!is_newer("0.3.1", "0.3.1")); + } + + #[test] + fn is_newer_orders_dev_prereleases_within_a_track() { + // Two dev builds of the same upcoming patch, ordered by their + // timestamp+sha build suffix. + assert!(is_newer( + "0.3.2-dev.20260722120000+aaa1111", + "0.3.2-dev.20260722130000+bbb2222" + )); + } + + #[test] + fn is_newer_falls_back_to_inequality_on_unparseable_versions() { + // Pre-semver version strings should never hard-fail an update check. + assert!(is_newer("weird-version-1", "weird-version-2")); + assert!(!is_newer("weird-version-1", "weird-version-1")); + } +} diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 5106646..248b715 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -1,11 +1,66 @@ +use crate::track::Track; use anyhow::{bail, Context, Result}; +use minisign_verify::{PublicKey, Signature}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; -const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json"; +const DEFAULT_BASE_URL: &str = "https://dl.breadway.dev"; const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600); +/// The `https://dl.breadway.dev` base can be overridden for local/staging +/// testing (e.g. serving a fake index from `python3 -m http.server`) without +/// rebuilding bakery — same pattern as `main.rs`'s `BAKERY_BIN_DIR` override. +fn base_url() -> String { + std::env::var("BAKERY_INDEX_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()) +} + +/// Index URL for `track`. `Stable` keeps the exact pre-track path +/// (`{base}/index.json`) so existing infra and warm caches are unaffected; +/// `Beta`/`Dev` live under a track-prefixed subpath. +fn primary_url(track: Track) -> String { + match track { + Track::Stable => format!("{}/index.json", base_url()), + Track::Beta | Track::Dev => format!("{}/{}/index.json", base_url(), track.as_str()), + } +} + +fn sig_url(track: Track) -> String { + format!("{}.minisig", primary_url(track)) +} + +/// The bakery index-signing public key. +/// +/// The matching secret key is used offline (never on this machine, never in +/// this repo) to sign `index.json` with `minisign` as part of publishing a +/// new index — see `scripts/gen-index.sh`. Every fetch of `index.json`, and +/// every load of the on-disk cache, must verify against this key before the +/// bytes are trusted or parsed. This is the single control point: the +/// per-artifact `sha256` fields and `post_install` hook strings all live +/// inside `index.json` itself, so a valid signature transitively covers them. +const PUBKEY: &str = "RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8"; + +/// Verify `bytes` against `sig_text` (the contents of an `index.json.minisig` +/// file) using the pinned [`PUBKEY`]. Returns an error on any failure — +/// missing/malformed signature, wrong key, or a hash mismatch. +fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> { + verify_against_key(bytes, sig_text, PUBKEY) +} + +/// Verify `bytes` against a minisign `sig_text` using an arbitrary base64 +/// public key. Split out from [`verify_index_signature`] purely so tests can +/// exercise the verification logic with a throwaway keypair instead of the +/// real production key. +fn verify_against_key(bytes: &[u8], sig_text: &str, pubkey_b64: &str) -> Result<()> { + let public_key = + PublicKey::from_base64(pubkey_b64).context("public key is malformed")?; + let signature = + Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?; + public_key + .verify(bytes, &signature, false) + .context("index.json failed signature verification against the pinned bakery key") +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Binary { pub name: String, @@ -18,6 +73,10 @@ pub struct Binary { pub struct Service { pub unit: String, pub enable: bool, + /// SHA-256 of the unit file artifact. Required to verify the download in + /// `install::install_service`, same as binaries; `index.json` carries it + /// (and is itself minisign-signed, which is what makes it trustworthy). + pub sha256: String, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -25,6 +84,10 @@ pub struct ConfigScaffold { pub dir: String, /// Example config filename, relative to the release artifact directory. pub example: Option, + /// SHA-256 of the example config artifact, when `example` is set. + /// Verified in `install::scaffold_config` the same way binaries are. + #[serde(default)] + pub example_sha256: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -78,17 +141,46 @@ impl Index { } } -/// Load the manifest, using the on-disk cache when it is fresh enough. -/// Always fetches if `force_refresh` is true. -pub fn load(force_refresh: bool) -> Result { - let cache_path = cache_path(); +/// Load the manifest for `track`, using the on-disk cache when it is fresh +/// enough. Always fetches if `force_refresh` is true. +/// +/// Every path — fresh fetch or cached read — verifies the minisign +/// signature over the raw `index.json` bytes before the JSON is parsed or +/// trusted. A signature failure on a freshly fetched index is always a hard +/// error. A signature failure on the *cached* copy is treated as a +/// (possibly tampered, possibly just stale-format) cache and triggers one +/// re-fetch from the network rather than bricking the CLI outright; if the +/// freshly fetched copy also fails to verify, that's a hard error. +pub fn load(force_refresh: bool, track: Track) -> Result { + let cache_path = cache_path(track); + let sig_cache_path = sig_cache_path(&cache_path); if !force_refresh && cache_is_fresh(&cache_path) { - let text = std::fs::read_to_string(&cache_path).context("reading cached index")?; - return serde_json::from_str(&text).context("parsing cached index"); + match read_and_verify_cache(&cache_path, &sig_cache_path, track) { + Ok(index) => return Ok(index), + Err(err) => { + eprintln!( + " warning: cached index.json failed verification ({err}), re-fetching…" + ); + } + } } - fetch_and_cache(&cache_path) + fetch_and_cache(&cache_path, &sig_cache_path, track) +} + +fn read_and_verify_cache( + cache_path: &PathBuf, + sig_cache_path: &PathBuf, + track: Track, +) -> Result { + let bytes = std::fs::read(cache_path).context("reading cached index")?; + let sig_text = std::fs::read_to_string(sig_cache_path) + .context("reading cached index.json.minisig (cache predates signing support)")?; + verify_index_signature(&bytes, &sig_text).with_context(|| { + format!("cached {track} index failed signature verification") + })?; + serde_json::from_slice(&bytes).context("parsing cached index") } fn cache_is_fresh(path: &PathBuf) -> bool { @@ -98,13 +190,31 @@ fn cache_is_fresh(path: &PathBuf) -> bool { .unwrap_or(false) } -fn fetch_and_cache(cache_path: &PathBuf) -> Result { - let text = fetch_text(PRIMARY_URL)?; +fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf, track: Track) -> Result { + let bytes = fetch_bytes(&primary_url(track)).with_context(|| { + format!( + "fetching {track} index — has a {track} build been published yet? \ + run 'bakery track set stable' to switch back" + ) + })?; + let sig_text = fetch_text(&sig_url(track)).context( + "fetching index.json.minisig — the index must be signed before it can be trusted", + )?; + verify_index_signature(&bytes, &sig_text) + .with_context(|| format!("freshly fetched {track} index failed signature verification"))?; + if let Some(dir) = cache_path.parent() { std::fs::create_dir_all(dir)?; } - std::fs::write(cache_path, &text)?; - serde_json::from_str(&text).context("parsing index.json") + std::fs::write(cache_path, &bytes)?; + std::fs::write(sig_cache_path, &sig_text)?; + serde_json::from_slice(&bytes).context("parsing index.json") +} + +fn sig_cache_path(cache_path: &Path) -> PathBuf { + let mut name = cache_path.file_name().unwrap_or_default().to_os_string(); + name.push(".minisig"); + cache_path.with_file_name(name) } fn fetch_text(url: &str) -> Result { @@ -115,10 +225,19 @@ fn fetch_text(url: &str) -> Result { .context("reading response body") } -pub fn cache_path() -> PathBuf { +/// Cache filename for `track`. `Stable` keeps the pre-track filename +/// (`index.json`) so an existing warm cache survives an upgrade to a +/// track-aware bakery; `Beta`/`Dev` get their own sibling files so switching +/// tracks doesn't clobber each other's cache. +pub fn cache_path(track: Track) -> PathBuf { + let file_name = match track { + Track::Stable => "index.json".to_string(), + Track::Beta | Track::Dev => format!("index-{}.json", track.as_str()), + }; dirs::cache_dir() .unwrap_or_else(|| PathBuf::from("~/.cache")) - .join("bakery/index.json") + .join("bakery") + .join(file_name) } /// Download a binary blob from `primary_url`, falling back to `fallback_url` @@ -151,3 +270,84 @@ fn fetch_bytes(url: &str) -> Result> { .context("reading response")?; Ok(buf) } + +#[cfg(test)] +mod tests { + use super::*; + + // A throwaway test-only minisign keypair, generated solely to produce + // these fixtures (`minisign -G` then `minisign -S`). It has no + // relationship to the real bakery signing key (PUBKEY above) and the + // matching secret key was discarded — these are just fixed vectors to + // exercise the verification code path deterministically. + const TEST_PUBKEY: &str = "RWQTYQi9Fe4trQDQmbb9txWDxzUIPYs57J//A5wG9BHcZXgC8YP0Cf59"; + const TEST_DATA: &[u8] = b"{\"hello\":\"world\"}\n"; + const TEST_SIG: &str = "untrusted comment: signature from minisign secret key\n\ +RUQTYQi9Fe4trXY/WBxk++476WhTqtVd3hlNWQj5h5DF8keP8sEJn22LDG2hloNgJesXt6HsTQs9uktayRVp/HB4XfC6e+rhYAs=\n\ +trusted comment: timestamp:1784230084\tfile:test-data.json\thashed\n\ +znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+NR/1AA==\n"; + + #[test] + fn valid_signature_verifies() { + verify_against_key(TEST_DATA, TEST_SIG, TEST_PUBKEY) + .expect("known-good signature must verify"); + } + + #[test] + fn tampered_bytes_fail_verification() { + let tampered = b"{\"hello\":\"world!\"}\n".to_vec(); + assert!(verify_against_key(&tampered, TEST_SIG, TEST_PUBKEY).is_err()); + } + + #[test] + fn wrong_key_fails_verification() { + // PUBKEY is the real production key — unrelated to the throwaway + // TEST_PUBKEY the fixture was signed with, so it must not verify. + assert!(verify_against_key(TEST_DATA, TEST_SIG, PUBKEY).is_err()); + } + + #[test] + fn malformed_signature_text_errors_cleanly() { + assert!(verify_against_key(TEST_DATA, "not a real signature", TEST_PUBKEY).is_err()); + } + + #[test] + fn production_pubkey_constant_is_well_formed() { + // Guards against a future typo/truncation in the hardcoded PUBKEY — + // it must at least parse as a valid minisign public key. + PublicKey::from_base64(PUBKEY).expect("PUBKEY must be a valid minisign public key"); + } + + #[test] + fn stable_cache_path_matches_pre_track_filename() { + // Must stay exactly "index.json" so an existing warm cache from a + // pre-track bakery binary is still used after an upgrade. + assert_eq!( + cache_path(Track::Stable).file_name().unwrap(), + "index.json" + ); + } + + #[test] + fn beta_and_dev_cache_paths_are_distinct_siblings() { + let stable = cache_path(Track::Stable); + let beta = cache_path(Track::Beta); + let dev = cache_path(Track::Dev); + assert_ne!(stable, beta); + assert_ne!(stable, dev); + assert_ne!(beta, dev); + assert_eq!(beta.parent(), stable.parent()); + assert_eq!(dev.parent(), stable.parent()); + } + + #[test] + fn stable_url_has_no_track_prefix() { + assert_eq!(primary_url(Track::Stable), format!("{}/index.json", base_url())); + } + + #[test] + fn beta_and_dev_urls_are_track_prefixed() { + assert_eq!(primary_url(Track::Beta), format!("{}/beta/index.json", base_url())); + assert_eq!(primary_url(Track::Dev), format!("{}/dev/index.json", base_url())); + } +} diff --git a/bakery/src/state.rs b/bakery/src/state.rs index 92b9aa9..7bf0ad8 100644 --- a/bakery/src/state.rs +++ b/bakery/src/state.rs @@ -1,3 +1,4 @@ +use crate::track::Track; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -14,6 +15,11 @@ pub struct InstalledPackage { #[derive(Debug, Default, Deserialize, Serialize)] pub struct State { + // `#[serde(default)]` lets an installed.json written by a pre-track + // bakery binary deserialize straight into Track::Stable with no + // migration step. + #[serde(default)] + pub track: Track, pub packages: HashMap, } @@ -52,6 +58,10 @@ impl State { pub fn remove(&mut self, name: &str) -> Option { self.packages.remove(name) } + + pub fn set_track(&mut self, track: Track) { + self.track = track; + } } fn state_path() -> PathBuf { @@ -101,6 +111,23 @@ mod tests { assert!(state.remove("nope").is_none()); } + #[test] + fn track_defaults_to_stable_on_old_shape_json() { + // Simulates installed.json written before the track field existed. + let old_shape = r#"{"packages":{}}"#; + let state: State = serde_json::from_str(old_shape).unwrap(); + assert_eq!(state.track, Track::Stable); + } + + #[test] + fn set_track_updates_and_roundtrips() { + let mut state = State::default(); + state.set_track(Track::Dev); + let json = serde_json::to_string(&state).unwrap(); + let restored: State = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.track, Track::Dev); + } + #[test] fn json_roundtrip() { let mut state = State::default(); diff --git a/bakery/src/track.rs b/bakery/src/track.rs new file mode 100644 index 0000000..1c16ce5 --- /dev/null +++ b/bakery/src/track.rs @@ -0,0 +1,90 @@ +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +/// Which build of a package bakery follows: the tagged stable release, a +/// deliberately-promoted beta, or the continuously-published `dev` branch +/// build. Not to be confused with the *distribution* channel (bakery vs. +/// pacman) documented in `docs/release-channels.md` — that's an orthogonal, +/// pre-existing use of the word "channel", which is why this is called a +/// "track" instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +pub enum Track { + Stable, + Beta, + Dev, +} + +impl Default for Track { + fn default() -> Self { + Track::Stable + } +} + +impl Track { + pub fn as_str(&self) -> &'static str { + match self { + Track::Stable => "stable", + Track::Beta => "beta", + Track::Dev => "dev", + } + } +} + +impl fmt::Display for Track { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for Track { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "stable" => Ok(Track::Stable), + "beta" => Ok(Track::Beta), + "dev" => Ok(Track::Dev), + other => Err(format!("unknown track '{other}' — expected stable, beta, or dev")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_stable() { + assert_eq!(Track::default(), Track::Stable); + } + + #[test] + fn display_roundtrips_through_from_str() { + for track in [Track::Stable, Track::Beta, Track::Dev] { + let s = track.to_string(); + assert_eq!(s.parse::().unwrap(), track); + } + } + + #[test] + fn from_str_is_case_insensitive() { + assert_eq!("DEV".parse::().unwrap(), Track::Dev); + assert_eq!("Beta".parse::().unwrap(), Track::Beta); + } + + #[test] + fn from_str_rejects_unknown() { + assert!("nightly".parse::().is_err()); + } + + #[test] + fn json_roundtrip_uses_lowercase() { + let json = serde_json::to_string(&Track::Dev).unwrap(); + assert_eq!(json, "\"dev\""); + let back: Track = serde_json::from_str(&json).unwrap(); + assert_eq!(back, Track::Dev); + } +} diff --git a/bakery/src/ui.rs b/bakery/src/ui.rs new file mode 100644 index 0000000..8f99505 --- /dev/null +++ b/bakery/src/ui.rs @@ -0,0 +1,60 @@ +use crate::track::Track; +use std::io::IsTerminal; + +pub const RESET: &str = "\x1b[0m"; +pub const BOLD: &str = "\x1b[1m"; +pub const DIM: &str = "\x1b[2m"; +pub const RED: &str = "\x1b[31m"; +pub const GREEN: &str = "\x1b[32m"; +pub const YELLOW: &str = "\x1b[33m"; +pub const CYAN: &str = "\x1b[36m"; +pub const MAGENTA: &str = "\x1b[35m"; + +/// Colors are on only when stdout is a real terminal and `NO_COLOR` isn't +/// set — the ecosystem's existing CLI (breadcrumbs) hardcodes ANSI +/// unconditionally, which leaks escape codes into piped/logged output; this +/// is the hardening fix for that gap. +pub fn colors_enabled() -> bool { + std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() +} + +pub fn style(s: &str, code: &str) -> String { + if colors_enabled() { + format!("{code}{s}{RESET}") + } else { + s.to_string() + } +} + +/// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the +/// common-case output is unchanged. +pub fn track_badge(track: Track) -> String { + match track { + Track::Stable => String::new(), + Track::Beta => format!(" {}", style("[beta]", YELLOW)), + Track::Dev => format!(" {}", style("[dev]", MAGENTA)), + } +} + +pub fn ok(s: &str) -> String { + style(&format!("✓ {s}"), GREEN) +} + +pub fn fail(s: &str) -> String { + style(&format!("✗ {s}"), RED) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_badge_is_empty() { + assert_eq!(track_badge(Track::Stable), ""); + } + + #[test] + fn dev_badge_is_nonempty() { + assert!(!track_badge(Track::Dev).is_empty()); + } +} diff --git a/bread-onnx/Cargo.toml b/bread-onnx/Cargo.toml new file mode 100644 index 0000000..170bdfc --- /dev/null +++ b/bread-onnx/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "bread-onnx" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Shared ONNX Runtime plumbing for the bread ecosystem: session building, execution-provider fallback with loud diagnostics, embedding-pipeline tensor math, and verified model downloads" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" +keywords = ["onnx", "onnxruntime", "ml", "embeddings"] + +[dependencies] +bread-utils = { path = "../bread-utils" } +# Left at default-features = false, with no api-XX/download-binaries/ +# load-dynamic/tls-native features of our own: those choices (how each app +# obtains/links its onnxruntime .so, and which ONNX Runtime C API version to +# bind) are consumer-build-environment decisions that stay in each app's own +# Cargo.toml (breadarr, breadmill, and breadpad already each pin different +# ones). Cargo's feature unification means this crate's minimal declaration +# just rides along with whatever the consuming app already selected. +ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "tracing"] } +# Default features left on (unlike `ort` above) — breadarr and breadmill +# both already build against plain default-featured tokenizers; only +# breadpad customizes this (http, fancy-regex), and Cargo's feature +# unification only ever adds features on top of this minimal baseline, so +# breadpad's own selection still applies in its own build. +tokenizers = "0.23" +tracing = { workspace = true } +ureq = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +anyhow = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/bread-onnx/src/download.rs b/bread-onnx/src/download.rs new file mode 100644 index 0000000..64dd443 --- /dev/null +++ b/bread-onnx/src/download.rs @@ -0,0 +1,112 @@ +//! Model download + integrity checking. +//! +//! `breadarrd/src/matcher/mod.rs::download` (async, `reqwest`) and +//! `breadmill/src/main.rs::download_if_missing` (sync, `ureq`) independently +//! implement "download to a temp file, then rename over the destination" +//! for fetching an ONNX model/tokenizer if it isn't already present — +//! genuinely duplicated intent, different HTTP clients. Neither verifies +//! the download's integrity beyond "the response wasn't empty". This module +//! is a fresh, shared implementation (sync, `ureq` — matching this +//! workspace's existing `bakery` convention for downloads) that adds an +//! optional SHA-256 check, built on [`bread_utils::atomic::write_atomic_bytes`] +//! for the same crash-safety property both originals already had. +//! +//! `breadarrd`'s async caller should wrap a call to [`ensure_file`] in +//! `tokio::task::spawn_blocking` rather than block its async runtime +//! directly — see that crate's migration for the concrete pattern. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +/// Download `url` to `dest` if `dest` doesn't already exist. If +/// `expected_sha256` is given, verifies the downloaded bytes against it +/// (case-insensitive hex) before the atomic rename and returns an error on +/// mismatch — the temp file is discarded, `dest` is left untouched. An +/// already-present `dest` is trusted as-is and not re-verified (matches +/// both original implementations' "if it exists, skip" behavior; re-hashing +/// a ~90MB+ model file on every startup would be wasted work for the common +/// case of a stable, previously-verified file). +pub fn ensure_file(url: &str, dest: &Path, expected_sha256: Option<&str>) -> anyhow::Result { + if dest.exists() { + return Ok(dest.to_path_buf()); + } + + tracing::info!("bread-onnx: downloading {url} -> {}", dest.display()); + let agent = ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(300)) + .build(); + let response = agent + .get(url) + .call() + .map_err(|e| anyhow::anyhow!("failed to download {url}: {e}"))?; + + let mut bytes = Vec::new(); + response + .into_reader() + .read_to_end(&mut bytes) + .map_err(|e| anyhow::anyhow!("failed to read response body from {url}: {e}"))?; + + if bytes.is_empty() { + anyhow::bail!("empty download from {url}"); + } + + if let Some(expected) = expected_sha256 { + let actual = sha256_hex(&bytes); + if !actual.eq_ignore_ascii_case(expected) { + anyhow::bail!( + "checksum mismatch for {url}: expected {expected}, got {actual} — refusing to install" + ); + } + tracing::info!("bread-onnx: verified sha256 for {}", dest.display()); + } + + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + bread_utils::atomic::write_atomic_bytes(dest, &bytes, None) + .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", dest.display()))?; + + tracing::info!( + "bread-onnx: saved {} ({:.1} MB)", + dest.display(), + bytes.len() as f64 / 1_048_576.0 + ); + Ok(dest.to_path_buf()) +} + +pub fn sha256_hex(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + hex::encode(hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha256_hex_matches_known_vector() { + // sha256("") — well-known empty-input digest. + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn ensure_file_skips_download_when_already_present() { + let dir = std::env::temp_dir().join(format!("bread-onnx-download-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("model.onnx"); + std::fs::write(&dest, b"already here").unwrap(); + + // A bogus URL would fail if actually requested — success here proves + // the existing-file short-circuit fired instead of dialing out. + let result = ensure_file("http://127.0.0.1:1/unreachable", &dest, None); + assert!(result.is_ok()); + assert_eq!(std::fs::read(&dest).unwrap(), b"already here"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/bread-onnx/src/embedding.rs b/bread-onnx/src/embedding.rs new file mode 100644 index 0000000..e8ddb88 --- /dev/null +++ b/bread-onnx/src/embedding.rs @@ -0,0 +1,220 @@ +//! Shared BERT-family embedding pipeline: tokenize → build `input_ids`/ +//! `attention_mask`/`token_type_ids` tensors → run → mean-pool the +//! non-padded positions of `last_hidden_state` → L2-normalize → clamp/pad to +//! a configured output dimension. +//! +//! This is extracted from two independently-written but essentially +//! byte-identical implementations: +//! - `breadarrd/src/matcher/embed.rs::OrtEmbedder::embed` (lines 45-111) and +//! its `l2_normalize` (lines 114-121) +//! - `breadmill/src/embed.rs::OrtEmbedder::embed_with_prefix` (lines 65-153) +//! and its `l2_normalize` (lines 156-163) +//! +//! Both truncate to a max sequence length, build the same three `i64` +//! tensors, run the same `input_ids`/`attention_mask`/`token_type_ids` → +//! `last_hidden_state` shape contract, mean-pool over `actual_seq.min(mask.len())` +//! positions (both already independently arrived at the same `.min()` guard +//! for execution providers that pad the output sequence dimension), and +//! L2-normalize with the same `1e-10` epsilon. `breadmill`'s only real +//! difference is prepending a document/query prefix string before +//! tokenizing, which stays the caller's responsibility here — pass the +//! already-prefixed text to [`EmbeddingSession::embed`]. + +use std::path::Path; + +use ort::session::builder::GraphOptimizationLevel; +use ort::session::Session; +use ort::value::Tensor; +use tokenizers::Tokenizer; + +use crate::provider::Provider; +use crate::session::build_session; + +pub struct EmbeddingSession { + session: Session, + tokenizer: Tokenizer, + dim: usize, + max_seq_len: usize, +} + +impl EmbeddingSession { + /// Load a BERT-family embedding model + tokenizer, selecting execution + /// providers via [`build_session`]. `dim` is the output embedding + /// dimension (results are truncated/zero-padded to it — matches how + /// both original implementations handled a model whose `dim` config + /// might not exactly match `last_hidden_state`'s actual width). `max_seq_len` + /// caps tokenized input length before inference (truncating, not + /// erroring) to bound attention memory on pathological inputs. + pub fn load( + model_path: &Path, + tokenizer_path: &Path, + dim: usize, + max_seq_len: usize, + providers: &[Provider], + ) -> anyhow::Result { + let session = build_session(model_path, GraphOptimizationLevel::Level3, providers)?; + let tokenizer = Tokenizer::from_file(tokenizer_path) + .map_err(|e| anyhow::anyhow!("failed to load tokenizer: {e}"))?; + Ok(Self { session, tokenizer, dim, max_seq_len }) + } + + /// Embed `text` (already prefixed by the caller, if the model expects a + /// document/query prefix). Returns an L2-normalized vector of length + /// `dim`. + pub fn embed(&mut self, text: &str) -> anyhow::Result> { + let encoding = self + .tokenizer + .encode(text, true) + .map_err(|e| anyhow::anyhow!("tokenization failed: {e}"))?; + + let mut ids: Vec = encoding.get_ids().iter().map(|&x| x as i64).collect(); + let mut mask: Vec = encoding.get_attention_mask().iter().map(|&x| x as i64).collect(); + let mut type_ids: Vec = encoding.get_type_ids().iter().map(|&x| x as i64).collect(); + + ids.truncate(self.max_seq_len); + mask.truncate(self.max_seq_len); + type_ids.truncate(self.max_seq_len); + + let seq_len = ids.len() as i64; + let id_tensor = Tensor::::from_array((vec![1i64, seq_len], ids)) + .map_err(|e| anyhow::anyhow!("failed to build input_ids tensor: {e}"))?; + let mask_tensor = Tensor::::from_array((vec![1i64, seq_len], mask.clone())) + .map_err(|e| anyhow::anyhow!("failed to build attention_mask tensor: {e}"))?; + let type_tensor = Tensor::::from_array((vec![1i64, seq_len], type_ids)) + .map_err(|e| anyhow::anyhow!("failed to build token_type_ids tensor: {e}"))?; + + let outputs = self + .session + .run(ort::inputs! { + "input_ids" => id_tensor, + "attention_mask" => mask_tensor, + "token_type_ids" => type_tensor, + }) + .map_err(|e| anyhow::anyhow!("ort inference failed: {e}"))?; + + let (shape, data) = outputs["last_hidden_state"] + .try_extract_tensor::() + .map_err(|e| anyhow::anyhow!("failed to extract last_hidden_state: {e}"))?; + + let actual_seq = shape[1] as usize; + let actual_dim = shape[2] as usize; + + Ok(mean_pool_normalize(data, &mask, actual_seq, actual_dim, self.dim)) + } +} + +/// Mean-pool `data` (flattened `[1, actual_seq, actual_dim]`) over the +/// positions `mask` marks as non-padding, L2-normalize the result, then +/// clamp/zero-pad to `target_dim`. `actual_seq.min(mask.len())` guards +/// against execution providers (MIGraphX observed doing this) that pad the +/// output sequence dimension for kernel efficiency, making `actual_seq` +/// exceed the caller's own `mask` length. +fn mean_pool_normalize(data: &[f32], mask: &[i64], actual_seq: usize, actual_dim: usize, target_dim: usize) -> Vec { + let mut result = vec![0.0f32; actual_dim]; + let mut count = 0usize; + for t in 0..actual_seq.min(mask.len()) { + if mask[t] > 0 { + for d in 0..actual_dim { + result[d] += data[t * actual_dim + d]; + } + count += 1; + } + } + if count > 0 { + for x in &mut result { + *x /= count as f32; + } + } + + l2_normalize(&mut result); + result.truncate(target_dim); + while result.len() < target_dim { + result.push(0.0); + } + result +} + +fn l2_normalize(v: &mut [f32]) { + let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + if norm > 1e-10 { + for x in v.iter_mut() { + *x /= norm; + } + } +} + +pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).map(|(x, y)| x * y).sum() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn l2_normalize_produces_unit_vector() { + let mut v = vec![3.0, 4.0]; + l2_normalize(&mut v); + let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + assert!((norm - 1.0).abs() < 1e-6); + } + + #[test] + fn l2_normalize_leaves_zero_vector_untouched() { + let mut v = vec![0.0, 0.0, 0.0]; + l2_normalize(&mut v); + assert_eq!(v, vec![0.0, 0.0, 0.0]); + } + + #[test] + fn cosine_similarity_of_identical_unit_vectors_is_one() { + let mut v = vec![1.0, 2.0, 3.0]; + l2_normalize(&mut v); + let sim = cosine_similarity(&v, &v); + assert!((sim - 1.0).abs() < 1e-6); + } + + #[test] + fn cosine_similarity_of_orthogonal_vectors_is_zero() { + let a = vec![1.0, 0.0]; + let b = vec![0.0, 1.0]; + assert!(cosine_similarity(&a, &b).abs() < 1e-6); + } + + #[test] + fn mean_pool_ignores_padded_positions() { + // actual_dim = 2, 3 positions: two real tokens + one padded (mask=0) + let data = vec![ + 1.0, 1.0, // t0: real + 9.0, 9.0, // t1: padded, should be ignored + 3.0, 3.0, // t2: real + ]; + let mask = vec![1, 0, 1]; + let pooled = mean_pool_normalize(&data, &mask, 3, 2, 2); + // Mean of (1,1) and (3,3) is (2,2), normalized to unit length. + let expected_norm = (2.0f32 * 2.0 + 2.0 * 2.0).sqrt(); + assert!((pooled[0] - 2.0 / expected_norm).abs() < 1e-5); + assert!((pooled[1] - 2.0 / expected_norm).abs() < 1e-5); + } + + #[test] + fn mean_pool_clamps_actual_seq_to_mask_len_for_padded_ep_output() { + // Regression guard for the MIGraphX-padded-output-sequence case both + // original implementations independently guarded against: actual_seq + // (4) exceeds mask.len() (2) — must not index out of the mask. + let data = vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]; + let mask = vec![1, 1]; + let pooled = mean_pool_normalize(&data, &mask, 4, 2, 2); + assert!(pooled.iter().all(|x| x.is_finite())); + } + + #[test] + fn mean_pool_pads_short_result_to_target_dim() { + let data = vec![1.0, 1.0]; + let mask = vec![1]; + let pooled = mean_pool_normalize(&data, &mask, 1, 1, 4); + assert_eq!(pooled.len(), 4); + assert_eq!(pooled[2], 0.0); + assert_eq!(pooled[3], 0.0); + } +} diff --git a/bread-onnx/src/lib.rs b/bread-onnx/src/lib.rs new file mode 100644 index 0000000..1ac4581 --- /dev/null +++ b/bread-onnx/src/lib.rs @@ -0,0 +1,32 @@ +//! Shared ONNX Runtime plumbing for the bread ecosystem. +//! +//! Extracted from breadarr, breadsearch, and breadpad during the +//! 2026-07-16 ecosystem-wide utility audit — see each module's doc comment +//! for the original file:line duplication it replaces. +//! +//! **Important**: [`session::build_session`] logs execution-provider +//! selection via the `tracing` crate, but does *not* initialize a +//! subscriber itself. Without one, ONNX Runtime's own "successfully +//! registered `XExecutionProvider`" log line (and this crate's own +//! selection logging) go nowhere — which is exactly how a GPU execution +//! provider can silently no-op back to CPU with zero visible error (see +//! [`provider`]'s doc comment for the concrete history behind this). All +//! three current consumers already call `tracing_subscriber::fmt().init()` +//! (or an `EnvFilter`-configured equivalent) at startup; any new consumer +//! must do the same before calling [`session::build_session`]. +//! +//! - [`provider`] — the [`provider::Provider`] enum and the +//! MIGraphX-not-ROCm default rationale. +//! - [`session`] — session construction with EP fallback + loud logging. +//! - [`embedding`] — the shared tokenize → tensor → mean-pool → normalize +//! pipeline for BERT-family embedding models. +//! - [`download`] — model download with atomic write + optional SHA-256 +//! integrity check. + +pub mod download; +pub mod embedding; +pub mod provider; +pub mod session; + +pub use provider::Provider; +pub use session::build_session; diff --git a/bread-onnx/src/provider.rs b/bread-onnx/src/provider.rs new file mode 100644 index 0000000..02460e5 --- /dev/null +++ b/bread-onnx/src/provider.rs @@ -0,0 +1,152 @@ +//! Execution-provider selection. +//! +//! This crate defaults AMD iGPU acceleration to +//! [`ort::ep::MIGraphX`](ort::ep::MIGraphX), *not* +//! [`ort::ep::ROCm`](ort::ep::ROCm), on purpose. `breadpad-shared/src/ +//! classifier.rs::try_load_session` used the classic `ROCMExecutionProvider` +//! and — per the hard-won lesson recorded in this machine's own operator +//! notes (`breadsearch-gpu-backends`, from `breadsearch`'s own history) — +//! that EP silently no-ops on this class of system and falls back to CPU +//! with zero visible error: distro ROCm ONNX Runtime builds (e.g. Arch's +//! `onnxruntime-rocm`) are commonly compiled with `--use_migraphx`, not +//! `--use_rocm`, so `ROCMExecutionProvider` never actually registers, and +//! nothing surfaces that fact unless a `tracing` subscriber is initialized +//! to catch ONNX Runtime's own EP-registration log line. `breadmill/src/ +//! embed.rs::rocm_session` already got this right; this module promotes +//! that provider choice (and the loud logging around it) to the shared +//! crate so it can't silently regress in any consumer again. + +use std::path::PathBuf; + +/// A requested execution provider, in the shared vocabulary consumers use. +/// Convert to an `ort` dispatch entry with [`Provider::to_dispatch`]. +#[derive(Debug, Clone)] +pub enum Provider { + Cpu, + /// AMD iGPU/dGPU via MIGraphX (ROCm-backed onnxruntime builds). See this + /// module's doc comment for why this — not `ROCm` — is the correct + /// choice on this class of system. + MiGraphX { device_id: i32 }, + /// NVIDIA GPU via CUDA. + Cuda { device_id: i32 }, + /// Intel iGPU/dGPU (Arc) via OpenVINO. `cache_dir` stores OpenVINO's + /// compiled-model blobs between runs. + OpenVino { device_type: String, cache_dir: PathBuf }, + /// AMD XDNA NPU via the VitisAI execution provider (Ryzen AI SDK). + /// `cache_dir` stores the compiled NPU model between runs. + Vitis { + config_file: PathBuf, + cache_dir: PathBuf, + cache_key: String, + }, +} + +impl Provider { + pub fn name(&self) -> &'static str { + match self { + Provider::Cpu => "CPU", + Provider::MiGraphX { .. } => "MIGraphX (AMD iGPU/dGPU)", + Provider::Cuda { .. } => "CUDA (NVIDIA GPU)", + Provider::OpenVino { .. } => "OpenVINO (Intel iGPU/dGPU)", + Provider::Vitis { .. } => "VitisAI (AMD XDNA NPU)", + } + } + + /// The literal execution-provider name ONNX Runtime's own log line + /// reports on successful registration (e.g. `"Successfully registered + /// \`MIGraphXExecutionProvider\`"`) — used to build the loud log hint in + /// [`crate::session::build_session`]. + fn ort_registration_name(&self) -> &'static str { + match self { + Provider::Cpu => "CPUExecutionProvider", + Provider::MiGraphX { .. } => "MIGraphXExecutionProvider", + Provider::Cuda { .. } => "CUDAExecutionProvider", + Provider::OpenVino { .. } => "OpenVINOExecutionProvider", + Provider::Vitis { .. } => "VitisAIExecutionProvider", + } + } + + pub(crate) fn to_dispatch(&self) -> anyhow::Result { + Ok(match self { + Provider::Cpu => ort::ep::CPU::default().build(), + Provider::MiGraphX { device_id } => { + ensure_migraphx_cache_path_default()?; + ort::ep::MIGraphX::default().with_device_id(*device_id).build() + } + Provider::Cuda { device_id } => { + ort::ep::CUDA::default().with_device_id(*device_id).build() + } + Provider::OpenVino { device_type, cache_dir } => { + std::fs::create_dir_all(cache_dir)?; + ort::ep::OpenVINO::default() + .with_device_type(device_type.clone()) + .with_cache_dir(cache_dir.to_string_lossy()) + .build() + } + Provider::Vitis { config_file, cache_dir, cache_key } => { + std::fs::create_dir_all(cache_dir)?; + ort::ep::Vitis::default() + .with_config_file(config_file.to_string_lossy()) + .with_cache_dir(cache_dir.to_string_lossy()) + .with_cache_key(cache_key.clone()) + .build() + } + }) + } + + /// Log a loud, consistent "using X" line plus (for non-CPU providers) a + /// reminder of exactly what to grep ONNX Runtime's own log output for — + /// this is the "at minimum log EP registration success/failure loudly + /// by default" half of the fix, independent of whether the caller has + /// wired up `tracing_subscriber` (all three current consumers already + /// do, at their own startup). + pub(crate) fn log_selection(&self) { + tracing::info!("bread-onnx: requesting {} execution provider", self.name()); + if !matches!(self, Provider::Cpu) { + tracing::info!( + "bread-onnx: check ONNX Runtime's own log output for \"Successfully registered \ + `{}`\" — if it's missing, the ONNX Runtime build in use wasn't compiled/shipped \ + with this provider and inference silently fell back to CPU. This line only \ + appears if a `tracing` subscriber is initialized.", + self.ort_registration_name() + ); + } + } +} + +/// MIGraphX has no Rust-level "cache directory" builder (unlike OpenVINO/ +/// Vitis above) — it's controlled purely by the `ORT_MIGRAPHX_MODEL_CACHE_PATH` +/// environment variable, read by the underlying MIGraphX library at EP- +/// registration time. Left unset, MIGraphX still *works*, but recompiles +/// every kernel from scratch on every single session build — no persistence +/// between runs, or even between two sessions in the same process. Found via +/// this pass's own migration: `breadmill`'s packaged systemd unit already +/// sets this explicitly (`packaging/breadmill.service`), but nothing +/// enforced any other consumer doing the same, and `breadpad` (which had no +/// systemd unit or cache path at all) hit exactly this — every +/// `Classifier::load` call during its own test suite recompiled from a cold +/// cache, visible as repeated `migraphx_save: Error: ... write_buffer: +/// Failure opening file: ""/.mxr` log lines (an empty path prefix, +/// i.e. the env var was never set) and multi-minute test runs. +/// +/// This sets a sensible shared default (`~/.cache/bread-onnx/migraphx`) if +/// the caller hasn't already set one — so every consumer gets kernel-cache +/// persistence for free instead of only the ones that remembered to +/// configure it themselves. +fn ensure_migraphx_cache_path_default() -> anyhow::Result<()> { + if std::env::var_os("ORT_MIGRAPHX_MODEL_CACHE_PATH").is_some() { + return Ok(()); + } + let dir = bread_utils::xdg::cache_dir("bread-onnx").join("migraphx"); + std::fs::create_dir_all(&dir)?; + tracing::info!( + "bread-onnx: ORT_MIGRAPHX_MODEL_CACHE_PATH not set; defaulting to {} \ + so MIGraphX kernel compiles persist across runs", + dir.display() + ); + // SAFETY: this runs before any session build spawns worker threads that + // might read the environment concurrently — same caveat as any + // `set_var` call, documented here rather than papered over. + unsafe { std::env::set_var("ORT_MIGRAPHX_MODEL_CACHE_PATH", &dir) }; + Ok(()) +} diff --git a/bread-onnx/src/session.rs b/bread-onnx/src/session.rs new file mode 100644 index 0000000..83dfa21 --- /dev/null +++ b/bread-onnx/src/session.rs @@ -0,0 +1,49 @@ +//! Session construction with execution-provider fallback. +//! +//! Builds one `ort::session::Session` whose execution-provider dispatch +//! list is exactly `providers` (in order) with an implicit `CPU` appended +//! if the caller didn't already include one — ONNX Runtime tries each +//! listed EP per-node and falls through the list on failure, so this +//! mirrors (and replaces) the identical `.with_execution_providers([primary, +//! CPU])` pattern already proven out in `breadmill/src/embed.rs::rocm_session` +//! /`cuda_session`/`openvino_session`/`npu_session`. + +use std::path::Path; + +use ort::session::builder::GraphOptimizationLevel; +use ort::session::Session; + +use crate::provider::Provider; + +/// Build a session, trying each of `providers` in order (ONNX Runtime falls +/// through per-node on registration failure) with a trailing `CPU` fallback +/// implicitly appended if not already present. Always logs which provider +/// was requested — see [`Provider::log_selection`] — regardless of whether +/// `tracing_subscriber` is initialized, so at minimum the *attempt* is +/// visible even without wired-up logging; the actual per-EP success/failure +/// detail only surfaces once a subscriber is listening. +pub fn build_session( + model_path: &Path, + opt_level: GraphOptimizationLevel, + providers: &[Provider], +) -> anyhow::Result { + let mut dispatch = Vec::with_capacity(providers.len() + 1); + for p in providers { + p.log_selection(); + dispatch.push(p.to_dispatch()?); + } + if !providers.iter().any(|p| matches!(p, Provider::Cpu)) { + dispatch.push(Provider::Cpu.to_dispatch()?); + } + + let mut builder = Session::builder() + .map_err(|e| anyhow::anyhow!("failed to create ort session builder: {e}"))? + .with_optimization_level(opt_level) + .map_err(|e| anyhow::anyhow!("failed to set optimization level: {e}"))? + .with_execution_providers(dispatch) + .map_err(|e| anyhow::anyhow!("failed to configure execution providers: {e}"))?; + + builder + .commit_from_file(model_path) + .map_err(|e| anyhow::anyhow!("failed to load model from {}: {e}", model_path.display())) +} diff --git a/bread-theme/Cargo.toml b/bread-theme/Cargo.toml index 8dc41e7..43c3952 100644 --- a/bread-theme/Cargo.toml +++ b/bread-theme/Cargo.toml @@ -4,8 +4,8 @@ version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true -description = "Shared pywal + Catppuccin theming crate for the bread ecosystem" -repository = "https://github.com/Breadway/bread-ecosystem" +description = "Shared pywal-accented, fixed-dark-base theming crate for the bread ecosystem" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" keywords = ["theming", "pywal", "gtk4", "wayland"] [dependencies] diff --git a/bread-theme/bakery.toml b/bread-theme/bakery.toml new file mode 100644 index 0000000..548fd3e --- /dev/null +++ b/bread-theme/bakery.toml @@ -0,0 +1,11 @@ +name = "bread-theme" +description = "Shared pywal-accented, fixed-dark-base theming CLI for the bread ecosystem — generates the shared GTK4 stylesheet every bread app loads" +binaries = ["bread-theme"] +system_deps = [] +optional_system_deps = ["python-pywal"] +bread_deps = [] + +[install] +post_install = [ + "bread-theme generate || true", +] diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index 2ee2307..ab0156b 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -24,31 +24,23 @@ pub mod tokens { pub const RADIUS_PILL: u16 = 999; } -/// Emit the `@define-color` block that all bread apps use. -/// Apps append their own rules below this; user CSS goes on top. +/// Emit the `@define-color` block that all bread apps use, plus the shared +/// font rule. +/// +/// Kept for API compatibility with older callers that only want the color +/// variables (not the full [`stylesheet`] component rules). It used to carry +/// its own hand-written `@define-color` block that predated the `accent` and +/// computed-ink (`on-*`) colors — that duplication is exactly what let it +/// drift out of sync and reintroduce the illegible-text bug (light pywal +/// colors + no computed ink meant white-on-white / black-on-black text +/// wherever a caller's own CSS referenced `@on-surface`, `@on-accent`, etc., +/// since those names simply didn't exist in this block). It now delegates +/// to the same [`define_colors`] the full stylesheet uses, so there is only +/// one color-block implementation and it cannot drift again. pub fn css_vars(p: &Palette) -> String { format!( - "@define-color bg {bg};\n\ - @define-color fg {fg};\n\ - @define-color surface {c0};\n\ - @define-color red {c1};\n\ - @define-color green {c2};\n\ - @define-color yellow {c3};\n\ - @define-color blue {c4};\n\ - @define-color pink {c5};\n\ - @define-color teal {c6};\n\ - @define-color overlay {c7};\n\ - * {{ font-family: '{font}'; font-size: {size}px; }}\n", - bg = p.background, - fg = p.foreground, - c0 = p.color0, - c1 = p.color1, - c2 = p.color2, - c3 = p.color3, - c4 = p.color4, - c5 = p.color5, - c6 = p.color6, - c7 = p.color7, + "{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n", + vars = define_colors(p), font = tokens::FONT_FAMILY, size = tokens::FONT_SIZE_BASE, ) @@ -241,6 +233,33 @@ mod tests { assert!(css.contains("14px")); } + #[test] + fn css_vars_includes_accent_and_computed_ink_colors() { + // Regression test: css_vars() used to be a second, hand-written + // @define-color block that predated `accent` and the computed `on-*` + // ink colors. Any caller whose own CSS referenced `@on-surface` / + // `@on-accent` etc. against that older block would hit an undefined + // color name — the illegible-text bug. css_vars() must now emit + // exactly the same color set as the full stylesheet. + let css = css_vars(&Palette::default()); + for name in &["accent", "on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] { + assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); + } + } + + #[test] + fn css_vars_and_stylesheet_agree_on_color_block() { + // Both must derive their color variables from the same + // `define_colors` implementation, so they can't drift apart again. + let p = Palette::default(); + let vars = css_vars(&p); + let sheet = stylesheet(&p); + for name in &["bg", "fg", "surface", "overlay", "accent", "on-bg", "on-surface", "on-accent"] { + let needle = format!("@define-color {name} "); + assert!(vars.contains(&needle) && sheet.contains(&needle)); + } + } + #[test] fn stylesheet_defines_canonical_colors_and_components() { let css = stylesheet(&Palette::default()); diff --git a/bread-theme/src/palette.rs b/bread-theme/src/palette.rs index 9f311b6..85a8aa7 100644 --- a/bread-theme/src/palette.rs +++ b/bread-theme/src/palette.rs @@ -2,42 +2,66 @@ use serde::Deserialize; use std::collections::HashMap; use std::path::PathBuf; -/// Full 8-colour pywal palette. Catppuccin Mocha is the fallback. +/// BOS's fixed dark theme — background, surface, overlay, and foreground never +/// come from pywal. Only the accent slots (color1-6) track the wallpaper. +/// Without this, a light or muddy-toned wallpaper (a beige bread photo, a +/// snapshot, a bright desktop screenshot) makes pywal hand back a light or +/// off-hue background, and every bread GUI's panels inherit it — the app +/// stops looking like a dark BOS tool and starts looking like whatever colour +/// the wallpaper happened to be. +const FIXED_BACKGROUND: &str = "#0c0c0c"; +const FIXED_FOREGROUND: &str = "#e8e8e8"; +const FIXED_SURFACE: &str = "#1a1a1a"; +const FIXED_OVERLAY: &str = "#d8d8d8"; + +/// Accent fallback when no pywal palette exists yet (fresh install, before +/// any wallpaper has been set for real) — BOS's own bread-toned accents, +/// matching the curated default `colors.json` baked into every install. +const DEFAULT_COLOR1: &str = "#b98749"; +const DEFAULT_COLOR2: &str = "#cd9450"; +const DEFAULT_COLOR3: &str = "#e3a85c"; +const DEFAULT_COLOR4: &str = "#eab672"; +const DEFAULT_COLOR5: &str = "#f6c477"; +const DEFAULT_COLOR6: &str = "#eabe82"; + +/// Full 8-colour pywal palette. background/foreground/color0/color7 are +/// BOS's fixed dark theme (see [`FIXED_BACKGROUND`] etc.); only color1-6 +/// are ever pywal-derived. #[derive(Debug, Clone)] pub struct Palette { pub background: String, pub foreground: String, - /// ANSI color0 — darkest surface / overlay + /// Fixed — darkest surface / overlay, never pywal-derived. pub color0: String, - /// ANSI color1 — red + /// ANSI color1 — red (pywal accent) pub color1: String, - /// ANSI color2 — green + /// ANSI color2 — green (pywal accent) pub color2: String, - /// ANSI color3 — yellow + /// ANSI color3 — yellow (pywal accent) pub color3: String, - /// ANSI color4 — blue (primary accent) + /// ANSI color4 — blue / primary accent (pywal accent) pub color4: String, - /// ANSI color5 — pink / magenta + /// ANSI color5 — pink / magenta (pywal accent) pub color5: String, - /// ANSI color6 — teal / cyan + /// ANSI color6 — teal / cyan (pywal accent) pub color6: String, - /// ANSI color7 — light overlay / muted fg + /// Fixed — light overlay / muted fg, never pywal-derived. pub color7: String, } impl Default for Palette { fn default() -> Self { Palette { - background: "#1e1e2e".into(), - foreground: "#cdd6f4".into(), - color0: "#45475a".into(), - color1: "#f38ba8".into(), - color2: "#a6e3a1".into(), - color3: "#f9e2af".into(), - color4: "#89b4fa".into(), - color5: "#f5c2e7".into(), - color6: "#94e2d5".into(), - color7: "#bac2de".into(), + background: FIXED_BACKGROUND.into(), + foreground: FIXED_FOREGROUND.into(), + color0: FIXED_SURFACE.into(), + color1: DEFAULT_COLOR1.into(), + color2: DEFAULT_COLOR2.into(), + color3: DEFAULT_COLOR3.into(), + color4: DEFAULT_COLOR4.into(), + color5: DEFAULT_COLOR5.into(), + color6: DEFAULT_COLOR6.into(), + color7: FIXED_OVERLAY.into(), } } } @@ -46,16 +70,9 @@ impl Default for Palette { struct WalColors { #[serde(default)] colors: HashMap, - special: Option, } -#[derive(Deserialize)] -struct WalSpecial { - background: Option, - foreground: Option, -} - -/// Load palette from pywal's `colors.json`. Falls back to Catppuccin Mocha. +/// Load palette from pywal's `colors.json`. Falls back to [`Palette::default`]. pub fn load_palette() -> Palette { let path = wal_path(); std::fs::read_to_string(&path) @@ -70,18 +87,16 @@ pub(crate) fn from_wal_json(json: &str) -> Option { wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into()) }; Some(Palette { - background: wal.special.as_ref().and_then(|s| s.background.clone()) - .unwrap_or_else(|| "#1e1e2e".into()), - foreground: wal.special.as_ref().and_then(|s| s.foreground.clone()) - .unwrap_or_else(|| "#cdd6f4".into()), - color0: c("color0", "#45475a"), - color1: c("color1", "#f38ba8"), - color2: c("color2", "#a6e3a1"), - color3: c("color3", "#f9e2af"), - color4: c("color4", "#89b4fa"), - color5: c("color5", "#f5c2e7"), - color6: c("color6", "#94e2d5"), - color7: c("color7", "#bac2de"), + background: FIXED_BACKGROUND.into(), + foreground: FIXED_FOREGROUND.into(), + color0: FIXED_SURFACE.into(), + color1: c("color1", DEFAULT_COLOR1), + color2: c("color2", DEFAULT_COLOR2), + color3: c("color3", DEFAULT_COLOR3), + color4: c("color4", DEFAULT_COLOR4), + color5: c("color5", DEFAULT_COLOR5), + color6: c("color6", DEFAULT_COLOR6), + color7: FIXED_OVERLAY.into(), }) } @@ -105,39 +120,39 @@ mod tests { }"##; #[test] - fn default_is_catppuccin_mocha() { + fn default_is_bos_fixed_dark_theme() { let p = Palette::default(); - assert_eq!(p.background, "#1e1e2e"); - assert_eq!(p.foreground, "#cdd6f4"); - assert_eq!(p.color4, "#89b4fa"); + assert_eq!(p.background, "#0c0c0c"); + assert_eq!(p.foreground, "#e8e8e8"); + assert_eq!(p.color0, "#1a1a1a"); + assert_eq!(p.color7, "#d8d8d8"); + assert_eq!(p.color4, "#eab672"); } #[test] - fn wal_json_parses_special() { + fn wal_json_background_and_surface_ignore_pywal() { + // TOKYO_NIGHT's special/color0/color7 must NOT leak through — bg, + // surface, overlay, and fg are always BOS's fixed dark values, + // whatever pywal extracted from the wallpaper. let p = from_wal_json(TOKYO_NIGHT).unwrap(); - assert_eq!(p.background, "#1a1b26"); - assert_eq!(p.foreground, "#c0caf5"); + assert_eq!(p.background, "#0c0c0c"); + assert_eq!(p.foreground, "#e8e8e8"); + assert_eq!(p.color0, "#1a1a1a"); + assert_eq!(p.color7, "#d8d8d8"); } #[test] - fn wal_json_parses_colors() { + fn wal_json_parses_accent_colors() { let p = from_wal_json(TOKYO_NIGHT).unwrap(); - assert_eq!(p.color0, "#15161e"); + assert_eq!(p.color1, "#f7768e"); assert_eq!(p.color4, "#7aa2f7"); - assert_eq!(p.color7, "#a9b1d6"); + assert_eq!(p.color6, "#7dcfff"); } #[test] - fn wal_json_missing_special_uses_catppuccin_fallback() { + fn wal_json_missing_accent_color_uses_bos_default() { let p = from_wal_json(r#"{"colors":{}}"#).unwrap(); - assert_eq!(p.background, "#1e1e2e"); - assert_eq!(p.foreground, "#cdd6f4"); - } - - #[test] - fn wal_json_missing_color_uses_catppuccin_fallback() { - let p = from_wal_json(r##"{"special":{"background":"#ff0000","foreground":"#ffffff"},"colors":{}}"##).unwrap(); - assert_eq!(p.color4, "#89b4fa"); + assert_eq!(p.color4, "#eab672"); } #[test] @@ -147,9 +162,10 @@ mod tests { } #[test] - fn empty_object_returns_all_defaults() { + fn empty_object_returns_bos_defaults() { let p = from_wal_json("{}").unwrap(); - assert_eq!(p.background, "#1e1e2e"); + assert_eq!(p.background, "#0c0c0c"); + assert_eq!(p.color4, "#eab672"); } #[test] diff --git a/bread-utils/Cargo.toml b/bread-utils/Cargo.toml new file mode 100644 index 0000000..69e2172 --- /dev/null +++ b/bread-utils/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "bread-utils" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Shared plumbing for the bread ecosystem: Hyprland IPC, single-instance toggling, timeout-guarded subprocess execution, atomic file writes, XDG paths, and a GTK4 layer-shell popup scaffold" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" +keywords = ["hyprland", "wayland", "xdg", "gtk4"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +dirs = { workspace = true } +gtk4 = { version = "0.11", features = ["v4_12"], optional = true } +gtk4-layer-shell = { version = "0.8", optional = true } +toml_edit = { version = "0.22", optional = true } +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0", optional = true } + +[features] +# Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional +# so headless/daemon consumers (breadmon, breadhelp's CLI half, breadcrumbs) +# don't have to pull in GTK4 + layer-shell just for `hypr`/`proc`/`xdg`. +gtk = ["dep:gtk4", "dep:gtk4-layer-shell"] +# Enable the non-destructive TOML doc load/save discipline (bos-settings, +# breadhelp). Optional so consumers that don't edit TOML configs (breadbox, +# breadclip, breadmon, ...) don't pull in toml_edit. +toml = ["dep:toml_edit"] +# Enable BreadClient, a persistent-connection client for breadd's IPC +# socket (emit + subscribe), for sibling bread* app daemons that want to +# integrate with the bread automation fabric. Optional so consumers that +# don't talk to breadd at all aren't forced to pull in bread-shared. +bread-client = ["dep:bread-shared"] + +[dev-dependencies] +tempfile = "3" diff --git a/bread-utils/src/atomic.rs b/bread-utils/src/atomic.rs new file mode 100644 index 0000000..3ee6d32 --- /dev/null +++ b/bread-utils/src/atomic.rs @@ -0,0 +1,185 @@ +//! Atomic file writes: write to a sibling temp file, then `rename` over the +//! target so a crash, power loss, or disk-full error mid-write never leaves +//! a truncated/corrupt file behind (a same-filesystem rename is atomic). +//! +//! Two flavors, both extracted from real (and identical) duplication: +//! +//! - [`write_atomic`] — temp-then-rename, with an optional Unix `mode` set +//! up front (so secrets never exist world-readable even briefly). This is +//! `breadcrumbs/src/util.rs::write_atomic`, promoted verbatim. +//! - [`write_atomic_backed_up`] — temp-then-rename *plus* a best-effort +//! `.bak` copy of whatever was there before, so a successful-but-wrong +//! write is always recoverable. This is `bos-settings/src/config/mod.rs`'s +//! `atomic_write`, which `breadhelp/src/config.rs` re-implemented +//! byte-for-byte in the same fix pass that introduced it (its own doc +//! comment says "same discipline as bos-settings/src/config/mod.rs") — +//! exactly the kind of fresh duplication this crate exists to remove. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Write `contents` to `path` atomically. `mode` (Unix only) is applied to +/// the temp file *before* any data is written, so a file that must stay +/// private (secrets, tokens) is never briefly world-readable. +pub fn write_atomic(path: &Path, contents: &str, mode: Option) -> io::Result<()> { + write_atomic_bytes(path, contents.as_bytes(), mode) +} + +/// Byte-oriented sibling of [`write_atomic`], for binary payloads (e.g. a +/// downloaded ONNX model file — see `bread-onnx`'s downloader). +pub fn write_atomic_bytes(path: &Path, contents: &[u8], mode: Option) -> io::Result<()> { + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(dir)?; + let tmp = tmp_path(path, dir); + + let mut open = fs::OpenOptions::new(); + open.write(true).create(true).truncate(true); + #[cfg(unix)] + if let Some(mode) = mode { + use std::os::unix::fs::OpenOptionsExt; + open.mode(mode); + } + #[cfg(not(unix))] + let _ = mode; + + let res = (|| { + use std::io::Write; + let mut f = open.open(&tmp)?; + f.write_all(contents)?; + f.sync_all()?; + fs::rename(&tmp, path) + })(); + if res.is_err() { + let _ = fs::remove_file(&tmp); + } + res +} + +/// Like [`write_atomic`] (no `mode`), but first best-effort copies whatever +/// is currently at `path` to `.bak`. The backup is best-effort — a +/// failure to back up (e.g. read-only source, first-ever write) does not +/// block the write itself. +pub fn write_atomic_backed_up(path: &Path, contents: &str) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + if path.exists() { + let backup = backup_path(path); + let _ = fs::copy(path, &backup); + } + write_atomic(path, contents, None) +} + +fn tmp_path(path: &Path, dir: &Path) -> PathBuf { + let stem = path.file_name().and_then(|s| s.to_str()).unwrap_or("bread"); + dir.join(format!(".{stem}.tmp.{}", std::process::id())) +} + +fn backup_path(path: &Path) -> PathBuf { + backup_path_for(path) +} + +/// `.bak` — shared with [`crate::tomlcfg`] so its own backup-before- +/// falling-back-to-defaults logging points at the same file this module +/// would have backed up to on a write. +pub(crate) fn backup_path_for(path: &Path) -> PathBuf { + PathBuf::from(format!("{}.bak", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + + fn tmp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("bread-utils-atomic-test-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn write_atomic_creates_file_with_contents() { + let dir = tmp_dir("basic"); + let path = dir.join("config.toml"); + write_atomic(&path, "hello", None).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "hello"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn write_atomic_leaves_no_tmp_file_behind() { + let dir = tmp_dir("no-leftover"); + let path = dir.join("config.toml"); + write_atomic(&path, "hello", None).unwrap(); + let leftover: Vec<_> = fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp.")) + .collect(); + assert!(leftover.is_empty(), "leftover tmp files: {leftover:?}"); + let _ = fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn write_atomic_applies_mode_before_any_data_hits_disk() { + use std::os::unix::fs::PermissionsExt; + let dir = tmp_dir("mode"); + let path = dir.join("secret"); + write_atomic(&path, "token", Some(0o600)).unwrap(); + let perms = fs::metadata(&path).unwrap().permissions(); + assert_eq!(perms.mode() & 0o777, 0o600); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn write_atomic_backed_up_backs_up_previous_contents() { + let dir = tmp_dir("backup"); + let path = dir.join("state.toml"); + let backup = dir.join("state.toml.bak"); + + write_atomic_backed_up(&path, "first").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "first"); + assert!(!backup.exists(), "no backup should exist before the first overwrite"); + + write_atomic_backed_up(&path, "second").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "second"); + assert_eq!(fs::read_to_string(&backup).unwrap(), "first"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn write_atomic_backed_up_leaves_no_tmp_file_behind() { + let dir = tmp_dir("backup-no-leftover"); + let path = dir.join("state.toml"); + write_atomic_backed_up(&path, "first").unwrap(); + write_atomic_backed_up(&path, "second").unwrap(); + let leftover: Vec<_> = fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.contains(".tmp.")) + .collect(); + assert!(leftover.is_empty(), "leftover tmp files: {leftover:?}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn write_atomic_overwrite_never_leaves_partial_contents_visible() { + // Not a true crash-injection test (hard to do portably), but pins + // down the observable contract: after a successful call, the file + // is either fully old or fully new, never truncated. + let dir = tmp_dir("no-partial"); + let path = dir.join("f"); + write_atomic(&path, "aaaaaaaaaa", None).unwrap(); + write_atomic(&path, "b", None).unwrap(); + let mut s = String::new(); + fs::File::open(&path).unwrap().read_to_string(&mut s).unwrap(); + assert_eq!(s, "b"); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/bread-utils/src/bread_client.rs b/bread-utils/src/bread_client.rs new file mode 100644 index 0000000..3f4adcf --- /dev/null +++ b/bread-utils/src/bread_client.rs @@ -0,0 +1,302 @@ +//! A persistent-connection client for breadd's IPC socket, for sibling +//! `bread*` app daemons that run continuously. +//! +//! This is deliberately a *second* client alongside `bread-emit` (the +//! fire-and-forget CLI binary in the `bread` repo), not a replacement for +//! it: `bread-emit` skips holding a connection open at all, which is right +//! for occasional/hook-style callers (a git hook, a shell prompt) but wrong +//! for a long-running daemon like breadclipd that wants to publish an +//! event on every clipboard change and subscribe to a command stream — +//! reconnecting from scratch for every single emit would be wasteful, and +//! subscribing needs a held-open connection by nature. +//! +//! # Graceful degradation +//! +//! A sibling app must never crash or block because breadd is down, +//! restarting, or was never installed. Concretely: +//! - [`BreadClient::emit`] is a best-effort, fire-and-forget single-shot +//! connection (mirroring `bread-emit`'s own stance) — if breadd is +//! unreachable, the event is silently dropped, not an error the caller +//! has to handle. +//! - [`BreadClient::subscribe`] runs its read loop on a background thread +//! that reconnects with exponential backoff on any disconnect. The +//! caller's callback simply stops being invoked while disconnected; it +//! resumes automatically once breadd comes back. +//! +//! # Namespace enforcement +//! +//! `emit` refuses locally (no network round trip) to publish an event +//! outside the app's own `bread..*` segment, so a misconfigured +//! caller fails fast instead of discovering the mistake from the daemon's +//! rejection. The daemon enforces the same rule server-side regardless. + +use std::io::{BufRead, BufReader, Write}; +use std::net::Shutdown; +use std::os::unix::net::UnixStream; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use bread_shared::apps::validate_app_namespace; +use serde_json::{json, Value}; + +/// A normalized event as delivered by breadd's `events.subscribe` stream. +#[derive(Debug, Clone)] +pub struct BreadEvent { + /// Dotted event name, e.g. `bread.command.clip.clear`. + pub event: String, + /// Unix epoch milliseconds when the daemon observed the originating signal. + pub timestamp: u64, + /// Structured event data; shape depends on the event family. + pub data: Value, +} + +/// A client bound to one sibling app's identity, used to `emit` within that +/// app's namespace and `subscribe` to events (typically its own +/// `bread.command..**` verb namespace). +/// +/// Cheap to clone (just an `Arc`-free `String`); safe to share across +/// threads by cloning, or to construct fresh per call site. +#[derive(Clone)] +pub struct BreadClient { + app_id: String, +} + +impl BreadClient { + /// Bind a client to `app_id` (e.g. `"clip"`). Does not connect yet — + /// there is no persistent connection to "fail" at construction time; + /// `emit` and `subscribe` each connect (or reconnect) as needed. This + /// is itself part of the graceful-degradation story: constructing a + /// `BreadClient` can never fail just because breadd isn't running yet. + pub fn connect(app_id: impl Into) -> Self { + Self { + app_id: app_id.into(), + } + } + + /// The app id this client is bound to. + pub fn app_id(&self) -> &str { + &self.app_id + } + + /// Publish `event` (must be within `bread..*`) with `data`. + /// Fire-and-forget: a single short-lived connection is opened, the + /// request is written, and the reply is never read (mirroring + /// `bread-emit`). If breadd is unreachable or slow, this silently does + /// nothing — it never blocks or errors the caller. + pub fn emit(&self, event: &str, data: Value) { + if !validate_app_namespace(&self.app_id, event) { + eprintln!( + "bread-client: refusing to emit '{event}' outside the '{}' namespace", + self.app_id + ); + return; + } + + let request = json!({ + "id": "0", + "method": "emit", + "params": { + "event": event, + "source": self.app_id, + "kind": event, + "data": data, + } + }); + let Ok(line) = serde_json::to_string(&request) else { + return; + }; + + let Ok(mut stream) = UnixStream::connect(bread_shared::resolve_socket_path()) else { + return; + }; + let _ = stream.set_write_timeout(Some(Duration::from_millis(200))); + let _ = writeln!(stream, "{line}"); + } + + /// Subscribe to events matching `pattern` (glob: `*`/`**`/`?`), invoking + /// `on_event` for each one on a dedicated background thread. Typically + /// called with `"bread.command..**"` to receive commands + /// addressed to this app. + /// + /// Returns a [`Subscription`] handle; drop or call [`Subscription::stop`] + /// to end it. The background thread reconnects with exponential backoff + /// (500ms, capped at ~32s) whenever the connection drops, so a restart + /// of breadd is transparent to the caller — `on_event` simply pauses + /// and resumes. + pub fn subscribe(&self, pattern: impl Into, on_event: F) -> Subscription + where + F: Fn(BreadEvent) + Send + 'static, + { + let pattern = pattern.into(); + let stop = Arc::new(AtomicBool::new(false)); + let current_stream: Arc>> = Arc::new(Mutex::new(None)); + + let stop_for_thread = stop.clone(); + let stream_for_thread = current_stream.clone(); + let handle = thread::spawn(move || { + let mut attempt: u32 = 0; + while !stop_for_thread.load(Ordering::Relaxed) { + match run_subscription_once(&pattern, &on_event, &stream_for_thread) { + Ok(()) => attempt = 0, // clean end (stop() closed the socket) + Err(_) => attempt = attempt.saturating_add(1), + } + + *stream_for_thread.lock().unwrap_or_else(|p| p.into_inner()) = None; + + if stop_for_thread.load(Ordering::Relaxed) { + break; + } + let backoff_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6))); + thread::sleep(Duration::from_millis(backoff_ms)); + } + }); + + Subscription { + stop, + current_stream, + handle: Some(handle), + } + } +} + +/// Connects once, sends `events.subscribe`, and invokes `on_event` for every +/// matching line until the connection ends (cleanly or with an error). +/// Stores the live stream in `current_stream` so [`Subscription::stop`] can +/// shut it down from another thread to interrupt the blocking read promptly. +fn run_subscription_once( + pattern: &str, + on_event: &impl Fn(BreadEvent), + current_stream: &Mutex>, +) -> std::io::Result<()> { + let stream = UnixStream::connect(bread_shared::resolve_socket_path())?; + let read_stream = stream.try_clone()?; + *current_stream.lock().unwrap_or_else(|p| p.into_inner()) = Some(stream); + + // Re-borrow to write the subscribe request through the stored copy so + // there is exactly one owner performing I/O per direction. + { + let guard = current_stream.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(stream) = guard.as_ref() { + let mut writer = stream; + let request = json!({ + "id": "sub", + "method": "events.subscribe", + "params": { "filter": pattern } + }); + let line = serde_json::to_string(&request).unwrap_or_default(); + writeln!(writer, "{line}")?; + } + } + + for line in BufReader::new(read_stream).lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + // The first line is the subscribe ack ({"result": {"subscribed": true}}); + // only lines with an "event" field are actual BreadEvents. + if let Some(event_name) = value.get("event").and_then(Value::as_str) { + let timestamp = value.get("timestamp").and_then(Value::as_u64).unwrap_or(0); + let data = value.get("data").cloned().unwrap_or(Value::Null); + on_event(BreadEvent { + event: event_name.to_string(), + timestamp, + data, + }); + } + } + Ok(()) +} + +/// Handle to a running [`BreadClient::subscribe`] background thread. +pub struct Subscription { + stop: Arc, + current_stream: Arc>>, + handle: Option>, +} + +impl Subscription { + /// Stop the subscription and block until its background thread exits. + /// Shuts down the live socket (if connected) so a thread blocked in a + /// read wakes up immediately, rather than waiting for the next event or + /// a future reconnect attempt to notice the stop flag. + pub fn stop(mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(stream) = self + .current_stream + .lock() + .unwrap_or_else(|p| p.into_inner()) + .as_ref() + { + let _ = stream.shutdown(Shutdown::Both); + } + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + } +} + +impl Drop for Subscription { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(stream) = self + .current_stream + .lock() + .unwrap_or_else(|p| p.into_inner()) + .as_ref() + { + let _ = stream.shutdown(Shutdown::Both); + } + // Best-effort on drop: don't block a caller who simply let the + // handle go out of scope. Explicit `stop()` is what actually waits. + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connect_never_fails_even_with_no_daemon_present() { + // Constructing a client must not depend on breadd actually running — + // that's the whole point of the graceful-degradation design. + let _client = BreadClient::connect("clip"); + } + + #[test] + fn emit_is_a_silent_no_op_when_daemon_is_unreachable() { + // Point at a socket path that can't possibly exist by using an + // app id that still passes namespace validation; the daemon being + // absent must not panic or block this call. + let client = BreadClient::connect("clip"); + client.emit("bread.clip.copied", json!({ "len": 1 })); + } + + #[test] + fn emit_refuses_event_outside_own_namespace_without_connecting() { + // "pad" events are not this client's to publish — this must be + // caught locally (and cheaply) rather than round-tripped to a + // daemon that isn't even running in this test. + let client = BreadClient::connect("clip"); + client.emit("bread.pad.reminder.due", json!({})); + // No assertion beyond "did not panic" — there is no daemon to + // observe the (correctly suppressed) call against in a unit test; + // the cross-process behavior is covered by breadd's own + // integration tests for the IPC-side of namespace validation. + } + + #[test] + fn subscription_stop_joins_the_background_thread() { + let client = BreadClient::connect("clip"); + let sub = client.subscribe("bread.command.clip.**", |_event| {}); + // Even with no daemon present (so the thread is spinning on + // connect-refused + backoff), stop() must return promptly rather + // than hanging. + sub.stop(); + } +} diff --git a/bread-utils/src/gtk_popup.rs b/bread-utils/src/gtk_popup.rs new file mode 100644 index 0000000..60c7f97 --- /dev/null +++ b/bread-utils/src/gtk_popup.rs @@ -0,0 +1,111 @@ +//! Shared GTK4 layer-shell popup scaffold: the full-screen transparent +//! overlay window setup, `ListBox` up/down visible-row navigation, and +//! click-outside-to-close gesture were duplicated near-verbatim between +//! `breadbox/src/main.rs` and `breadclip/src/main.rs`: +//! +//! - Layer-shell window setup: `breadbox/src/main.rs:357-365` / +//! `breadclip/src/main.rs:231-239` — identical `init_layer_shell` + +//! namespace + `Layer::Overlay` + `KeyboardMode::Exclusive` + anchor all +//! four edges + zero exclusive zone. +//! - Up/Down navigation loop: `breadbox/src/main.rs:515-546` / +//! `breadclip/src/main.rs:423-454` — byte-for-byte identical "find the +//! next/previous *visible* row" loop (breadclip's own comment even reads +//! `// ---- Keyboard handler (capture phase, same as breadbox) ----`). +//! - Click-outside-close: `breadbox/src/main.rs:566-581` / +//! `breadclip/src/main.rs:474-...` — identical bounds-check against a +//! content widget (breadclip: `// ---- Click outside panel → close (same +//! pattern as breadbox) ----`). +//! +//! Deliberately *not* extracted: the rest of each app's `EventControllerKey` +//! handling (Enter/Delete semantics, filter chips, search) — those differ +//! per app (`do_launch` vs `do_copy`+`Delete`-to-remove) and forcing them +//! into one callback-owning "scaffold" struct would be a leakier +//! abstraction than the ~5 free functions below. +//! +//! Requires the `gtk` feature. + +use gtk4::prelude::*; +use gtk4::{ApplicationWindow, GestureClick}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; + +/// Build the full-screen transparent overlay window every layer-shell popup +/// in this ecosystem starts from: layered above normal windows, keyboard- +/// exclusive (so Escape/Enter/arrow keys reach the popup instead of the +/// focused client behind it), anchored to all four edges with zero +/// exclusive zone (so it doesn't reserve screen space or push other layer +/// clients around). +pub fn new_overlay_window(app: >k4::Application, namespace: &str) -> ApplicationWindow { + let window = ApplicationWindow::builder().application(app).build(); + window.init_layer_shell(); + window.set_namespace(Some(namespace)); + window.set_layer(Layer::Overlay); + window.set_keyboard_mode(KeyboardMode::Exclusive); + for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { + window.set_anchor(edge, true); + } + window.set_exclusive_zone(0); + window +} + +/// Select the next *visible* row after the current selection (rows can be +/// hidden by a live search filter — a plain "select index + 1" would land +/// on a filtered-out row). No-op if there is no next visible row. +pub fn select_next_visible(list: >k4::ListBox) { + let cur = list.selected_row().map(|r| r.index()).unwrap_or(-1); + let mut i = cur + 1; + loop { + match list.row_at_index(i) { + Some(r) if r.is_visible() => { + list.select_row(Some(&r)); + break; + } + Some(_) => i += 1, + None => break, + } + } +} + +/// Select the previous *visible* row before the current selection. No-op if +/// there is no previous visible row. +pub fn select_prev_visible(list: >k4::ListBox) { + let cur = list.selected_row().map(|r| r.index()).unwrap_or(0); + let mut i = cur - 1; + loop { + if i < 0 { + break; + } + match list.row_at_index(i) { + Some(r) if r.is_visible() => { + list.select_row(Some(&r)); + break; + } + Some(_) => i -= 1, + None => break, + } + } +} + +/// Attach a click gesture to `window` that calls `on_outside` whenever a +/// click lands outside `content`'s bounds (e.g. clicking the transparent +/// full-screen backdrop around a centered launcher/panel widget). +pub fn close_on_outside_click( + window: &ApplicationWindow, + content: &impl IsA, + on_outside: impl Fn() + 'static, +) { + let content = content.clone().upcast::(); + let win_ref = window.clone(); + let gesture = GestureClick::new(); + gesture.connect_pressed(move |_, _, x, y| { + if let Some(b) = content.compute_bounds(&win_ref) { + if x < b.x() as f64 + || x > (b.x() + b.width()) as f64 + || y < b.y() as f64 + || y > (b.y() + b.height()) as f64 + { + on_outside(); + } + } + }); + window.add_controller(gesture); +} diff --git a/bread-utils/src/hypr.rs b/bread-utils/src/hypr.rs new file mode 100644 index 0000000..def3e98 --- /dev/null +++ b/bread-utils/src/hypr.rs @@ -0,0 +1,252 @@ +//! Hyprland IPC client: socket1 request/response (JSON) and socket2 path +//! resolution. +//! +//! The socket-path resolution + raw request/response round trip was +//! duplicated near-verbatim in `breadbox/src/main.rs` (`get_active_workspace`, +//! lines 26-42) and `breadclip/src/position.rs` (`hyprctl_json`, lines +//! 58-71) — same `HYPRLAND_INSTANCE_SIGNATURE`/`XDG_RUNTIME_DIR` env lookup, +//! same `.socket.sock` path format, same connect/write/shutdown-write/ +//! read-to-string sequence. `breadmon/src/main.rs`'s `hyprland_socket2_path` +//! duplicates just the path-resolution half for the event socket. +//! +//! `active_window`'s `fullscreen` field deserializes leniently as either a +//! JSON bool or integer: Hyprland has changed this field's type across +//! versions (older releases emit a bool, `0`/`1`; newer ones emit an +//! integer fullscreen *mode* — `0` none, `1` maximized, `2` fullscreen), and +//! a client hard-coded to one shape silently misreads the other instead of +//! erroring. `breadclip`'s own version (`as_i64().unwrap_or(0) != 0`) only +//! handles the integer shape; a bool `true` would `.as_i64()` to `None` and +//! silently read as "not fullscreen". + +use serde::Deserialize; +use std::env; +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; + +/// Which of Hyprland's two IPC sockets: `.socket.sock` (request/response) or +/// `.socket2.sock` (event stream). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Socket { + Request, + Events, +} + +/// Resolve the path to one of Hyprland's IPC sockets from +/// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if +/// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're +/// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to +/// `/run/user/1000` if unset, matching `breadmon`'s existing fallback. +pub fn socket_path(kind: Socket) -> Option { + let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; + let rt = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string()); + let file = match kind { + Socket::Request => ".socket.sock", + Socket::Events => ".socket2.sock", + }; + Some(PathBuf::from(format!("{rt}/hypr/{sig}/{file}"))) +} + +/// Send `request` (e.g. `"j/activewindow"`, `"j/monitors"`) to the socket1 +/// IPC socket and return the raw response body. Blocking/synchronous — this +/// matches every current consumer (breadbox, breadclip), which call it from +/// non-async GTK app code. +/// +/// Read/write timeouts are set on the socket (both original hand-rolled +/// implementations this replaces — breadbox's `get_active_workspace`, +/// breadclip's `hyprctl_json` — had none): a Hyprland instance that's +/// wedged or mid-reload could otherwise hang this call, and every current +/// caller runs it on the GTK main thread, so a hang here freezes the whole +/// UI, not just this query. +const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +pub fn request(request: &str) -> Option { + let socket = socket_path(Socket::Request)?; + let mut stream = UnixStream::connect(&socket).ok()?; + stream.set_read_timeout(Some(REQUEST_TIMEOUT)).ok()?; + stream.set_write_timeout(Some(REQUEST_TIMEOUT)).ok()?; + stream.write_all(request.as_bytes()).ok()?; + stream.shutdown(std::net::Shutdown::Write).ok()?; + + let mut buf = String::new(); + stream.read_to_string(&mut buf).ok()?; + Some(buf) +} + +/// Like [`request`], parsed as JSON. `request` should already carry the `j/` +/// prefix Hyprland expects for JSON responses (e.g. `"j/activewindow"`). +pub fn request_json(request_str: &str) -> Option { + serde_json::from_str(&request(request_str)?).ok() +} + +/// Connect to the socket2 event stream. Callers read newline-delimited +/// `EVENT>>DATA` lines from the returned stream themselves — event framing +/// and reconnect/backoff policy are genuinely per-consumer (see +/// `breadmon`'s hotplug listener), so this only replaces the duplicated +/// path-resolution + connect boilerplate, not a full event-loop +/// abstraction. +pub fn connect_events() -> Option { + let socket = socket_path(Socket::Events)?; + UnixStream::connect(&socket).ok() +} + +/// Hyprland's `fullscreen` field, tolerant of either representation it has +/// shipped across versions: a plain bool, or an integer fullscreen mode +/// (`0` = none, nonzero = some fullscreen mode). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct FullscreenState(bool); + +impl FullscreenState { + pub fn is_fullscreen(self) -> bool { + self.0 + } +} + +impl<'de> Deserialize<'de> for FullscreenState { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Bool(bool), + Int(i64), + } + Ok(match Repr::deserialize(deserializer)? { + Repr::Bool(b) => FullscreenState(b), + Repr::Int(i) => FullscreenState(i != 0), + }) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ActiveWindow { + #[serde(default)] + pub class: String, + #[serde(default)] + pub fullscreen: FullscreenState, + pub at: (i32, i32), + pub size: (i32, i32), +} + +impl ActiveWindow { + pub fn x(&self) -> i32 { + self.at.0 + } + pub fn y(&self) -> i32 { + self.at.1 + } + pub fn width(&self) -> i32 { + self.size.0 + } + pub fn height(&self) -> i32 { + self.size.1 + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Monitor { + pub name: String, + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, + #[serde(default)] + pub focused: bool, +} + +/// Query the currently active (focused) window. Returns `None` if the +/// window is fullscreen or no window is focused — same "centre the popup +/// instead" contract `breadclip`'s original `get_active_window` had. +pub fn active_window() -> Option { + let win: ActiveWindow = serde_json::from_value(request_json("j/activewindow")?).ok()?; + if win.fullscreen.is_fullscreen() || win.class.is_empty() { + return None; + } + Some(win) +} + +/// Query all monitors and return the focused one (or the first, if none +/// report as focused). +pub fn focused_monitor() -> Option { + let monitors: Vec = serde_json::from_value(request_json("j/monitors")?).ok()?; + monitors + .iter() + .find(|m| m.focused) + .or_else(|| monitors.first()) + .cloned() +} + +/// The active workspace's name (e.g. `"1"`, `"special:scratch"`). +pub fn active_workspace_name() -> Option { + request_json("j/activeworkspace")? + .get("name") + .and_then(|v| v.as_str()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fullscreen_state_deserializes_from_bool() { + let s: FullscreenState = serde_json::from_str("true").unwrap(); + assert!(s.is_fullscreen()); + let s: FullscreenState = serde_json::from_str("false").unwrap(); + assert!(!s.is_fullscreen()); + } + + #[test] + fn fullscreen_state_deserializes_from_int() { + let s: FullscreenState = serde_json::from_str("0").unwrap(); + assert!(!s.is_fullscreen()); + let s: FullscreenState = serde_json::from_str("2").unwrap(); + assert!(s.is_fullscreen()); + } + + #[test] + fn active_window_parses_bool_fullscreen_shape() { + let json = r#"{"class":"kitty","fullscreen":true,"at":[10,20],"size":[300,400]}"#; + let win: ActiveWindow = serde_json::from_str(json).unwrap(); + assert!(win.fullscreen.is_fullscreen()); + assert_eq!(win.x(), 10); + assert_eq!(win.height(), 400); + } + + #[test] + fn active_window_parses_int_fullscreen_shape() { + let json = r#"{"class":"kitty","fullscreen":1,"at":[0,0],"size":[100,100]}"#; + let win: ActiveWindow = serde_json::from_str(json).unwrap(); + assert!(win.fullscreen.is_fullscreen()); + } + + // Both env-var-dependent cases share one test function: `set_var`/ + // `remove_var` are process-global, and cargo runs tests in parallel + // threads by default, so two separate #[test] fns racing on the same + // vars would be flaky. + #[test] + fn socket_path_env_var_behavior() { + let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + unsafe { env::remove_var("HYPRLAND_INSTANCE_SIGNATURE") }; + assert!(socket_path(Socket::Request).is_none()); + + unsafe { + env::set_var("HYPRLAND_INSTANCE_SIGNATURE", "test-sig"); + env::set_var("XDG_RUNTIME_DIR", "/run/user/9999"); + } + assert_eq!( + socket_path(Socket::Request).unwrap(), + PathBuf::from("/run/user/9999/hypr/test-sig/.socket.sock") + ); + assert_eq!( + socket_path(Socket::Events).unwrap(), + PathBuf::from("/run/user/9999/hypr/test-sig/.socket2.sock") + ); + unsafe { + env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"); + env::remove_var("XDG_RUNTIME_DIR"); + } + } +} diff --git a/bread-utils/src/lib.rs b/bread-utils/src/lib.rs new file mode 100644 index 0000000..0817ad3 --- /dev/null +++ b/bread-utils/src/lib.rs @@ -0,0 +1,50 @@ +//! Shared plumbing for the bread desktop-automation ecosystem. +//! +//! Extracted from genuine, verified duplication across breadbox, breadclip, +//! breadmon, breadcrumbs, bos-settings, and breadhelp during the 2026-07-16 +//! ecosystem-wide utility audit. Each module's doc comment cites the +//! original file:line locations the code was extracted from. +//! +//! - [`hypr`] — Hyprland IPC: socket path resolution, socket1 +//! request/response, typed `activewindow`/`monitors` queries with +//! version-tolerant `fullscreen` field parsing. +//! - [`singleton`] — correct, TOCTOU-free single-instance/PID-toggle. +//! - [`proc`] — timeout-guarded subprocess execution. +//! - [`atomic`] — atomic (temp-then-rename) file writes, with an optional +//! `.bak`-before-overwrite variant. +//! - [`xdg`] — XDG base directory helpers with a real (never literal-tilde) +//! `$HOME` fallback. +//! - [`tomlcfg`] (feature `toml`) — non-destructive TOML document +//! load/save discipline built on [`atomic`]. +//! - [`gtk_popup`] (feature `gtk`) — shared layer-shell popup window setup, +//! list navigation, and click-outside-to-close. +//! - [`bread_client`] (feature `bread-client`) — a persistent-connection +//! client for breadd's IPC socket (emit + subscribe), for sibling +//! `bread*` app daemons integrating with the bread automation fabric. + +pub mod atomic; +pub mod hypr; +pub mod proc; +pub mod singleton; +pub mod xdg; + +/// Serializes tests that read or mutate process-global env vars +/// (`XDG_RUNTIME_DIR`, `HYPRLAND_INSTANCE_SIGNATURE`) — `cargo test` runs +/// tests in parallel threads within one process by default, and +/// `std::env::set_var` is process-wide, so a `hypr` test temporarily +/// pointing `XDG_RUNTIME_DIR` at a nonexistent path can otherwise race a +/// concurrently-running `singleton` or `xdg` test that expects the real one. +#[cfg(test)] +pub(crate) fn env_test_lock() -> &'static std::sync::Mutex<()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) +} + +#[cfg(feature = "toml")] +pub mod tomlcfg; + +#[cfg(feature = "gtk")] +pub mod gtk_popup; + +#[cfg(feature = "bread-client")] +pub mod bread_client; diff --git a/bread-utils/src/proc.rs b/bread-utils/src/proc.rs new file mode 100644 index 0000000..fe4b29b --- /dev/null +++ b/bread-utils/src/proc.rs @@ -0,0 +1,174 @@ +//! Timeout-guarded subprocess execution. +//! +//! Promoted verbatim from `breadcrumbs/src/util.rs` (the one implementation +//! in the ecosystem that already got this right — see the audit note in +//! `bread-utils`'s crate root). Several other repos shell out to +//! Wayland/Hyprland tools (`hyprctl`, `grim`, `wl-paste`, ...) via bare +//! `std::process::Command` with no timeout at all, so a hung child can wedge +//! the whole caller indefinitely. `run`/`run_with_stdin` below kill the +//! child and return a failed [`Output`] once `timeout` elapses instead. + +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone)] +pub struct Output { + pub success: bool, + pub stdout: String, + pub stderr: String, +} + +impl Output { + pub fn failed() -> Output { + Output { + success: false, + stdout: String::new(), + stderr: String::new(), + } + } +} + +/// Run a command with a hard timeout. The child is killed if it overruns so +/// a hung subprocess can never wedge the caller. +pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output { + run_with_stdin(prog, args, None, timeout) +} + +/// Like [`run`], but feeds `stdin` to the child's standard input. Useful for +/// handing secrets (e.g. Wi-Fi PSKs, API tokens) to a CLI without exposing +/// them in argv, where any local user could read them via `ps`. +pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output { + let stdin_cfg = if stdin.is_some() { + Stdio::piped() + } else { + Stdio::null() + }; + let mut child = match Command::new(prog) + .args(args) + // Pin the C locale so message text callers parse (hyprctl JSON keys, + // status output, ...) is stable regardless of the user's LANG. + .env("LC_ALL", "C") + .env("LANG", "C") + .stdin(stdin_cfg) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(_) => return Output::failed(), + }; + + let mut stdout_pipe = child.stdout.take(); + let mut stderr_pipe = child.stderr.take(); + + let out_handle = thread::spawn(move || { + let mut buf = String::new(); + if let Some(ref mut p) = stdout_pipe { + let _ = p.read_to_string(&mut buf); + } + buf + }); + let err_handle = thread::spawn(move || { + let mut buf = String::new(); + if let Some(ref mut p) = stderr_pipe { + let _ = p.read_to_string(&mut buf); + } + buf + }); + + // Feed stdin only after the reader threads are draining stdout/stderr, so + // a child that writes more than a pipe buffer before consuming stdin + // can't deadlock against our blocking write. + if let Some(data) = stdin { + if let Some(mut sink) = child.stdin.take() { + let _ = sink.write_all(data.as_bytes()); + // Drop closes the pipe so the child's read sees EOF. + } + } + + let start = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(s)) => break Some(s), + Ok(None) => { + if start.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + thread::sleep(Duration::from_millis(50)); + } + Err(_) => break None, + } + }; + + let stdout = out_handle.join().unwrap_or_default(); + let stderr = err_handle.join().unwrap_or_default(); + + Output { + success: status.map(|s| s.success()).unwrap_or(false), + stdout, + stderr, + } +} + +pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool { + run(prog, args, timeout).success +} + +/// Run a command and parse its stdout as JSON on success. Convenience for the +/// very common `hyprctl -j ` / ` --json` pattern. +pub fn run_json(prog: &str, args: &[&str], timeout: Duration) -> Option { + let out = run(prog, args, timeout); + if !out.success { + return None; + } + serde_json::from_str(&out.stdout).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_captures_stdout() { + let out = run("printf", &["hello"], Duration::from_secs(2)); + assert!(out.success); + assert_eq!(out.stdout, "hello"); + } + + #[test] + fn run_reports_failure_for_nonzero_exit() { + let out = run("sh", &["-c", "exit 3"], Duration::from_secs(2)); + assert!(!out.success); + } + + #[test] + fn run_kills_hung_child_after_timeout() { + let start = Instant::now(); + let out = run("sleep", &["30"], Duration::from_millis(200)); + assert!(!out.success); + assert!(start.elapsed() < Duration::from_secs(5), "child was not killed promptly"); + } + + #[test] + fn run_with_stdin_feeds_child_input() { + let out = run_with_stdin("cat", &[], Some("secret-data"), Duration::from_secs(2)); + assert!(out.success); + assert_eq!(out.stdout, "secret-data"); + } + + #[test] + fn run_json_parses_stdout() { + let out = run_json("printf", &["{\"a\":1}"], Duration::from_secs(2)); + assert_eq!(out.unwrap()["a"], 1); + } + + #[test] + fn run_json_returns_none_on_failure() { + let out = run_json("sh", &["-c", "exit 1"], Duration::from_secs(2)); + assert!(out.is_none()); + } +} diff --git a/bread-utils/src/singleton.rs b/bread-utils/src/singleton.rs new file mode 100644 index 0000000..6809747 --- /dev/null +++ b/bread-utils/src/singleton.rs @@ -0,0 +1,209 @@ +//! Correct single-instance / PID-toggle, replacing the TOCTOU-prone pattern +//! duplicated in `breadbox/src/main.rs` (`toggle_or_continue`/`pid_file`, +//! ~30 lines) and `breadclip/src/main.rs` (same function names, whose own +//! comment reads `// ---- PID file toggle (single-instance, matches breadbox +//! pattern) ----`). +//! +//! The old pattern: read the PID file, `/proc//comm`-check whether it's +//! still this app, `kill` it if so, otherwise `fs::write` our own PID over +//! it. That's three separate, non-atomic steps — two instances launched at +//! once can both read "no valid PID" and both proceed as the "first" +//! instance; a stale PID file left by a crash can also collide with an +//! unrelated process that was later assigned the same PID by the kernel, +//! sending it a `kill` it never asked for. +//! +//! This module instead holds an exclusive, kernel-atomic advisory lock +//! (`std::fs::File::try_lock`, i.e. `flock(2)`) on the PID file for the +//! entire lifetime of the process that acquires it. Lock ownership itself +//! *is* the liveness check — there is no window where two processes can +//! both believe they're the sole instance, and a crashed process's lock is +//! released by the kernel the instant it dies, so there's no stale-lock +//! case to reason about at all. +//! +//! [`try_acquire`] is the side-effect-free primitive (no signals sent); +//! [`toggle_or_kill`] layers breadbox/breadclip's actual desired behavior +//! (kill whoever's running, then exit) on top of it. + +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::PathBuf; + +/// Held for the lifetime of the running instance. Dropping it releases the +/// flock and removes the PID file. Keep this alive (e.g. in a `let _guard = +/// ...` bound in `main`) for as long as the app should be considered "the" +/// running instance. +pub struct Guard { + _file: File, + path: PathBuf, +} + +impl Drop for Guard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +pub enum Acquire { + /// No other instance was running; we now hold the lock. + Acquired(Guard), + /// Another instance already holds the lock and is therefore alive right + /// now. Carries whatever PID it last recorded, if the file contents + /// parsed as one. + HeldByOther(Option), +} + +pub enum Toggle { + /// No other instance was running; we now hold the lock. Keep the guard + /// alive for the process's lifetime. + Started(Guard), + /// Another instance was already running and (if a PID could be read + /// from the file) has been sent `SIGTERM`. The caller should exit + /// immediately without starting. + KilledExisting, +} + +/// `$XDG_RUNTIME_DIR/.pid` (falling back to `/tmp`, matching every +/// existing consumer's own fallback) — same location `breadbox`/`breadclip` +/// already used. +pub fn pid_file_path(app: &str) -> PathBuf { + crate::xdg::runtime_dir().join(format!("{app}.pid")) +} + +/// Try to become the single instance of `app`, with no side effects beyond +/// the lock/file itself — in particular, unlike [`toggle_or_kill`], this +/// never signals another process. Prefer this if your app wants different +/// behavior than "kill the existing instance" (e.g. just refuse to start a +/// second copy). +pub fn try_acquire(app: &str) -> std::io::Result { + let path = pid_file_path(app); + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&path)?; + + match file.try_lock() { + Ok(()) => { + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + write!(file, "{}", std::process::id())?; + file.sync_all()?; + Ok(Acquire::Acquired(Guard { _file: file, path })) + } + Err(_) => { + let mut contents = String::new(); + let _ = file.read_to_string(&mut contents); + Ok(Acquire::HeldByOther(contents.trim().parse::().ok())) + } + } +} + +/// Toggle behavior: acquire the single-instance lock for `app`. If already +/// held by another live process, signal it to quit (`SIGTERM` via `kill`) +/// and return [`Toggle::KilledExisting`] — the caller should exit. Otherwise +/// take the lock and return [`Toggle::Started`] — the caller should proceed +/// and keep the guard alive. +pub fn toggle_or_kill(app: &str) -> std::io::Result { + Ok(match try_acquire(app)? { + Acquire::Acquired(guard) => Toggle::Started(guard), + Acquire::HeldByOther(Some(pid)) => { + kill(pid); + Toggle::KilledExisting + } + Acquire::HeldByOther(None) => Toggle::KilledExisting, + }) +} + +#[cfg(unix)] +fn kill(pid: u32) { + // Shells out rather than binding libc directly, matching how every + // existing consumer already did this (`Command::new("kill")`) — no new + // dependency for a one-shot signal. + let _ = std::process::Command::new("kill") + .arg(pid.to_string()) + .status(); +} + +#[cfg(not(unix))] +fn kill(_pid: u32) {} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn unique_app(name: &str) -> String { + format!("bread-utils-singleton-test-{name}-{}", std::process::id()) + } + + #[test] + fn first_acquire_succeeds_and_releases_on_drop() { + // Guards against `hypr`'s env-var test concurrently pointing + // XDG_RUNTIME_DIR at a nonexistent path mid-test — see `env_test_lock`. + let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let app = unique_app("first"); + match try_acquire(&app).unwrap() { + Acquire::Acquired(_guard) => {} + Acquire::HeldByOther(_) => panic!("expected to be the first instance"), + } + // Guard dropped at end of scope; pid file should be gone. + std::thread::sleep(Duration::from_millis(10)); + assert!(!pid_file_path(&app).exists()); + } + + #[test] + fn second_acquire_while_first_is_held_reports_held_by_other_with_our_pid() { + let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let app = unique_app("second"); + let guard = match try_acquire(&app).unwrap() { + Acquire::Acquired(g) => g, + Acquire::HeldByOther(_) => panic!("expected to be the first instance"), + }; + + // A second attempt while the first guard is still held must not be + // able to acquire the lock too — that's the whole point. No signal + // is sent by `try_acquire` itself (that's `toggle_or_kill`'s job), + // so this is safe to assert without affecting the test process. + match try_acquire(&app).unwrap() { + Acquire::HeldByOther(pid) => assert_eq!(pid, Some(std::process::id())), + Acquire::Acquired(_) => panic!("second acquire succeeded while the first still holds the lock"), + } + + drop(guard); + } + + #[test] + fn lock_is_released_after_guard_drop_so_a_later_instance_can_acquire() { + let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let app = unique_app("release"); + let guard = match try_acquire(&app).unwrap() { + Acquire::Acquired(g) => g, + Acquire::HeldByOther(_) => panic!("expected to be the first instance"), + }; + drop(guard); + + match try_acquire(&app).unwrap() { + Acquire::Acquired(_g) => {} + Acquire::HeldByOther(_) => panic!("lock should have been released when the guard was dropped"), + } + } + + #[test] + fn toggle_or_kill_starts_when_nothing_else_is_running() { + let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let app = unique_app("toggle-start"); + match toggle_or_kill(&app).unwrap() { + Toggle::Started(_guard) => {} + Toggle::KilledExisting => panic!("expected to start as the first instance"), + } + } + + // Deliberately not unit-tested: `toggle_or_kill`'s kill-the-existing- + // instance branch. Exercising it for real means sending a real SIGTERM + // to a real process; the only PID a test process can safely target is + // its own (as a stand-in "other instance" via a shared PID file), and + // doing that would SIGTERM the test binary itself. The branch is a + // two-line, directly-inspectable call to `kill()` gated on + // `HeldByOther(Some(pid))`, which the `second_acquire_...` test above + // already exercises up to (and excluding) the signal send. +} diff --git a/bread-utils/src/tomlcfg.rs b/bread-utils/src/tomlcfg.rs new file mode 100644 index 0000000..bc4c841 --- /dev/null +++ b/bread-utils/src/tomlcfg.rs @@ -0,0 +1,100 @@ +//! Non-destructive TOML config editing discipline. +//! +//! Extracted from `bos-settings/src/config/mod.rs` (`load_doc`/`save_doc`) +//! and `breadhelp/src/config.rs`, which re-implemented the exact same +//! function bodies in the same fix pass that introduced `bos-settings`'s +//! version — right down to the eprintln wording template. Both parse into a +//! `toml_edit::DocumentMut` (preserving keys/comments/formatting this app +//! doesn't model) and back up a file that exists but fails to parse, once, +//! before falling back to an empty document — so a bad edit is always +//! recoverable from `.bak` instead of silently destroying whatever the +//! file used to hold. +//! +//! Requires the `toml` feature. + +use std::path::Path; +use toml_edit::DocumentMut; + +/// Load a TOML file into an editable document. A missing file yields an +/// empty document (normal for a fresh install). A file that *exists* but +/// fails to parse is backed up to `.bak` once before falling back to +/// an empty document, so the next [`save_doc`] doesn't silently overwrite an +/// unparseable-but-recoverable file with only the caller's modelled keys. +/// +/// `app` is used only to prefix the parse-failure log line (e.g. +/// `"breadhelp"`, `"bos-settings"`). +pub fn load_doc(app: &str, path: &Path) -> DocumentMut { + let Ok(text) = std::fs::read_to_string(path) else { + return DocumentMut::default(); + }; + match text.parse::() { + Ok(doc) => doc, + Err(e) => { + let backup = super::atomic::backup_path_for(path); + eprintln!( + "{app}: {} failed to parse ({e}); backed up to {} before falling back to defaults", + path.display(), + backup.display() + ); + let _ = std::fs::write(&backup, &text); + DocumentMut::default() + } + } +} + +/// Write the document back to disk atomically (temp-then-rename), backing up +/// whatever was there before overwriting it — see +/// [`crate::atomic::write_atomic_backed_up`]. +pub fn save_doc(path: &Path, doc: &DocumentMut) -> std::io::Result<()> { + super::atomic::write_atomic_backed_up(path, &doc.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use toml_edit::value; + + fn tmp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("bread-utils-tomlcfg-test-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn missing_file_yields_empty_document() { + let dir = tmp_dir("missing"); + let doc = load_doc("test", &dir.join("nope.toml")); + assert!(doc.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn save_then_load_round_trips() { + let dir = tmp_dir("roundtrip"); + let path = dir.join("state.toml"); + let mut doc = DocumentMut::default(); + doc["general"]["mode"] = value("dad"); + save_doc(&path, &doc).unwrap(); + + let loaded = load_doc("test", &path); + assert_eq!( + loaded.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()), + Some("dad") + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unparseable_existing_file_is_backed_up_before_falling_back() { + let dir = tmp_dir("bad-parse"); + let path = dir.join("state.toml"); + std::fs::write(&path, "this is not [ valid toml").unwrap(); + + let doc = load_doc("test", &path); + assert!(doc.is_empty()); + let backup = dir.join("state.toml.bak"); + assert_eq!(std::fs::read_to_string(&backup).unwrap(), "this is not [ valid toml"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/bread-utils/src/xdg.rs b/bread-utils/src/xdg.rs new file mode 100644 index 0000000..4409fd0 --- /dev/null +++ b/bread-utils/src/xdg.rs @@ -0,0 +1,123 @@ +//! XDG base directory helpers. +//! +//! Several repos independently rolled `dirs::data_local_dir().unwrap_or_else(|| +//! PathBuf::from("~/.local/share"))`-shaped fallbacks. The literal-tilde +//! string is the bug: `PathBuf`/`std::fs` never expand `~`, so on the rare +//! box where `dirs` can't resolve a home directory (no `HOME` env var, e.g. +//! some container/systemd-service contexts) the fallback silently resolves +//! to a directory literally named `~` in the process's current working +//! directory instead of the user's actual home. Confirmed present in: +//! - `breadclip-core/src/lib.rs:171-175` (`data_dir`) +//! - `breadpad-shared/src/classifier.rs:34-39` (`model_dir`) +//! - `breadpad-shared/src/config.rs:214-219` and `:221-226` +//! (`config_path`, `style_css_path`) +//! - `breadmon/src/profile.rs:31-35` (`profiles_dir`) +//! - `breadarr-shared/src/config.rs:316-321`'s own `expand_home` helper, +//! which had the same bug in a different shape: its *own* fallback (when +//! `HOME` itself isn't set) returned the literal, unexpanded input string +//! rather than a real path. +//! +//! The helpers here resolve a real `$HOME` (via `dirs::home_dir()`, which +//! itself falls back to reading `HOME` directly) before ever falling back, +//! so the fallback path is always an absolute, expanded path. + +use std::path::PathBuf; + +/// A real, absolute home directory — `dirs::home_dir()`, falling back to +/// `/root` only if that itself fails (no `HOME` env var *and* no passwd-db +/// entry, e.g. some minimal container contexts). Never a literal `"~"`. +pub fn home_dir() -> PathBuf { + home_or_root() +} + +fn home_or_root() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root")) +} + +/// `$XDG_CONFIG_HOME` (only if it's set to an absolute path) or `~/.config`, +/// joined with `app`. +pub fn config_dir(app: &str) -> PathBuf { + config_home().join(app) +} + +/// The bare `$XDG_CONFIG_HOME` (or `~/.config`) directory, with no app name +/// joined on — for callers that build up multiple sub-paths themselves +/// (e.g. `bos-settings`, which joins a different bread* app's name per +/// config file it edits). +pub fn config_home() -> PathBuf { + base_config_dir() +} + +/// `$XDG_DATA_HOME` (only if absolute) or `~/.local/share`, joined with `app`. +pub fn data_dir(app: &str) -> PathBuf { + dirs::data_local_dir() + .unwrap_or_else(|| home_or_root().join(".local/share")) + .join(app) +} + +/// `$XDG_CACHE_HOME` (only if absolute) or `~/.cache`, joined with `app`. +pub fn cache_dir(app: &str) -> PathBuf { + dirs::cache_dir() + .unwrap_or_else(|| home_or_root().join(".cache")) + .join(app) +} + +/// `$XDG_RUNTIME_DIR`, falling back to `/tmp` — matches the fallback every +/// consumer (breadbox, breadclip, breadmon) already used for PID/socket +/// scratch files, which don't need to survive a reboot. +pub fn runtime_dir() -> PathBuf { + std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")) +} + +fn base_config_dir() -> PathBuf { + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + let p = PathBuf::from(xdg); + if p.is_absolute() { + return p; + } + } + dirs::config_dir().unwrap_or_else(|| home_or_root().join(".config")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_dir_joins_app_name() { + let d = config_dir("breadpad"); + assert!(d.ends_with("breadpad")); + assert!(d.is_absolute()); + } + + #[test] + fn home_dir_is_absolute_and_never_a_literal_tilde() { + let d = home_dir(); + assert!(d.is_absolute()); + assert!(!d.components().any(|c| c.as_os_str() == "~")); + } + + #[test] + fn data_dir_never_contains_literal_tilde() { + // Regression guard for the exact bug this module replaces: the + // fallback must never be a literal "~/..." path component. + let d = data_dir("breadclip"); + assert!(!d.components().any(|c| c.as_os_str() == "~")); + assert!(d.is_absolute()); + } + + #[test] + fn cache_dir_is_absolute() { + assert!(cache_dir("breadsearch").is_absolute()); + } + + #[test] + fn runtime_dir_falls_back_to_tmp() { + let _lock = crate::env_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + // We don't unset XDG_RUNTIME_DIR here (test isolation), just confirm + // the function returns *something* absolute either way. + assert!(runtime_dir().is_absolute()); + } +} diff --git a/docs/release-channels.md b/docs/release-channels.md new file mode 100644 index 0000000..d717ff4 --- /dev/null +++ b/docs/release-channels.md @@ -0,0 +1,133 @@ +# Release channel policy + +There are two independent distribution channels in the bread ecosystem, plus +a third "neither" state for repos that aren't distributed yet. Every repo +under `Breadway/` should sit in exactly one of these three buckets, and its +`.forgejo/workflows/` directory + packaging metadata should match that +bucket exactly — no more files, no fewer. + +## The two channels + +**bakery channel** (`bakery install `, `curl .../get | sh`, or a raw +binary download from dl.breadway.dev / the GitHub release page). A repo is on +this channel if and only if **all** of the following are true: + +1. It has a `bakery.toml` at the root (or, for a multi-product repo like + bread-ecosystem, one per product directory). +2. It has an entry in `bread-ecosystem`'s `registry/bread-ecosystem.toml`. + `scripts/gen-index.sh` only ever looks at repos listed there — a + `bakery.toml` that isn't backed by a registry entry is inert. +3. It has a `.forgejo/workflows/release.yml` (or a product-specific name + like `release-bread-theme.yml` / `release-bakery.yml` for multi-product + repos) that builds the binary, drops it under `/srv/breadway-dl//`, + copies `bakery.toml` alongside it, regenerates `index.json` via + `bread-ecosystem/scripts/gen-index.sh`, and uploads the same artifacts to + a GitHub release as a fallback mirror. + +All three must be present together. Two out of three is a bug, not a +partial rollout — either finish the third piece or remove the other two. + +**pacman channel** (`pacman -S ` from the self-hosted `[breadway]` +repo, built via AUR-style `PKGBUILD`s). A repo is on this channel if and +only if: + +1. It has a `PKGBUILD` under `packaging/` (either `packaging/PKGBUILD` or + `packaging/arch/PKGBUILD` — both patterns exist in the wild, pick + whichever a sibling repo of the same shape already uses). +2. It has a `.forgejo/workflows/package.yml` that builds the package in an + `archlinux:latest` container and `curl -X PUT`s the resulting + `.pkg.tar.zst` to `https://git.breadway.dev/api/packages/Breadway/arch/os`. + +A repo can be on **both** channels (most GUI/daemon apps are — see +breadbar, breadbox, breadcrumbs, bread, breadpad, breadpaper), **bakery +only** (breadclip, breadmon, breadsearch, breadshot, bread-theme, bakery +itself), **pacman only** (breadlock, breadhelp — both are OS-integration +pieces where package-manager rigor matters more than a curl-script), or +**neither** (dev-only / not yet released; no bakery.toml, no PKGBUILD, no +release or package workflow — just the repo itself, e.g. breadarr today). + +`bos` is a fourth, deliberately special case: it ships as an ISO, not a +binary, via its own `release-iso.yml`. It is never on either channel and +should never carry a `bakery.toml` or `PKGBUILD`. + +## Build tracks (stable/beta/dev) — orthogonal to channels + +Within the **bakery channel only**, a repo can additionally publish up to +three **tracks**: `stable` (the existing tag-triggered `v*` flow, unchanged), +`beta` (a deliberate promotion triggered by a `beta-v*` tag), and `dev` +(published automatically on every push to the `dev` branch). Don't confuse +"track" with "channel" above — channel is *how* a binary reaches a user +(bakery vs. pacman); track is *which build* of a bakery-channel package they +get. + +Each track lives in its own subtree so they never collide: + +| Track | Index URL | Artifact root | Trigger | +|---|---|---|---| +| stable | `dl.breadway.dev/index.json` | `/srv/breadway-dl///` | push tag `v*` | +| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta///` | push tag `beta-v*` | +| dev | `dl.breadway.dev/dev/index.json` | `/srv/breadway-dl/dev///` | push to branch `dev` | + +`scripts/gen-index.sh` takes a `TRACK` env var (default `stable`) to select +which subtree it reads/writes — every existing stable release workflow needs +zero changes. Dev/beta builds skip the GitHub Release upload step entirely +(no release-per-commit spam for dev, and beta doesn't need a GitHub mirror +either) — `dl.breadway.dev` is their only distribution point. + +Adding beta/dev to a bakery-channel repo: copy `dev-bakery.yml` / +`beta-bakery.yml` (or `bread`'s `dev-release.yml` / `beta-release.yml` if the +repo isn't part of this monorepo) from `bread-ecosystem`/`bread`, and swap +the repo/binary names the same way the checklist below describes for +`release.yml`. Not every bakery-channel repo needs beta/dev on day one — +`gen-index.sh` silently skips any product with no release dir under a given +track's tree, same as it already does for an unreleased product on stable. + +Client side: `bakery track show` / `bakery track set ` +remembers a global track preference (`~/.local/state/bakery/installed.json`) +and validates the target track's index is reachable and signed before +switching — it never auto-reinstalls on switch, run `bakery update --all` +afterwards. + +## mirror.yml is not part of this policy + +Every repo previously carried its own `.forgejo/workflows/mirror.yml` doing +a `git clone --mirror` + push to GitHub with a per-repo `MIRROR_TOKEN` +secret. That pattern is being replaced ecosystem-wide by Forgejo's native +Push Mirror feature, provisioned centrally by +`bread-ecosystem/scripts/setup-push-mirrors.sh` against the live repo list +— see that script and `scripts/cleanup-old-mirror-workflows.sh`. Once the +migration is confirmed working, no repo should have a `mirror.yml` and this +document doesn't require one. Don't add `mirror.yml` to a repo that's +missing it; that gap is intentional and about to be moot everywhere. + +## Checklist for adding a repo to a channel + +- **Bakery**: write `bakery.toml`, add a `[[products]]` entry to + `bread-ecosystem/registry/bread-ecosystem.toml`, copy a sibling's + `release.yml` (prefer one with the same shape: single binary vs. binary + + systemd service — compare against `bread/release.yml` if there's a + service to install, `breadmon/release.yml` if not) and swap the repo + name / binary name / `PKG_DIR`. +- **Pacman**: write `packaging/PKGBUILD` (or `packaging/arch/PKGBUILD`), + copy a sibling's `package.yml` and swap the repo/package name and + `system_deps`→`pacman -Syu` package list. +- Never add either file type "just in case." An unused `bakery.toml` or + `PKGBUILD` is exactly the kind of drift this document exists to prevent + (see the breadlock/breadarr/bos-settings history in the audit that + produced this doc — two of those had a stray `bakery.toml` nothing + served, one was missing the registry entry + release.yml that would have + made an existing `bakery.toml` real). + +## Current state (as of this pass) + +| Repo | bakery | pacman | tracks | notes | +|---|---|---|---|---| +| bread-ecosystem (bakery product) | yes | yes | stable, beta, dev | `release-bakery.yml` recovered from a dead `.github/workflows/release.yml` that referenced a `hestia` self-hosted runner GitHub never had registered | +| bread-ecosystem (bread-theme product) | yes | no | stable, beta, dev | | +| bread | yes | yes | stable, beta, dev | pilot repo for the beta/dev track rollout | +| breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | yes | stable only | complete, used as templates; not yet rolled out to beta/dev | +| breadclip, breadmon, breadsearch, breadshot | yes | no | stable only | complete | +| breadlock, breadhelp | no | yes | n/a | breadlock's `bakery.toml` was removed as orphaned; its README wrongly claimed it was a registry entry | +| bos-settings | yes | yes | stable only | was missing both the registry entry and `release.yml`; both added | +| bos | no | no | n/a | ISO-only via `release-iso.yml`; had an erroneous `bakery.toml` copy-pasted from bos-settings, removed | +| breadarr | no | no | n/a | had an orphaned `bakery.toml` with no registry entry and zero workflows; removed. Not yet assigned a channel — do that deliberately when it's ready to ship, don't infer it from a stray config file | diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 793c187..6a3f8bf 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,11 +1,11 @@ -# Maintainer: Breadway +# Maintainer: Breadway pkgname=bakery pkgver=0.2.3 pkgrel=1 pkgdesc="Package manager for the bread ecosystem" arch=('x86_64') -url="https://github.com/Breadway/bread-ecosystem" +url="https://git.breadway.dev/Breadway/bread-ecosystem" license=('MIT') # Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's # default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, diff --git a/registry/bread-ecosystem.toml b/registry/bread-ecosystem.toml index 96887cb..4620361 100644 --- a/registry/bread-ecosystem.toml +++ b/registry/bread-ecosystem.toml @@ -12,6 +12,11 @@ name = "bakery" repo = "Breadway/bread-ecosystem" description = "Bread ecosystem package manager" +[[products]] +name = "bread-theme" +repo = "Breadway/bread-ecosystem" +description = "Shared pywal-accented, fixed-dark-base theming CLI for the bread ecosystem" + [[products]] name = "bread" repo = "Breadway/bread" @@ -41,3 +46,28 @@ description = "Quick-capture scratchpad and note viewer with AI classification" name = "breadpaper" repo = "Breadway/breadpaper" description = "Wallpaper manager for the bread desktop" + +[[products]] +name = "breadmon" +repo = "Breadway/breadmon" +description = "Terminal UI monitor manager for Hyprland" + +[[products]] +name = "breadsearch" +repo = "Breadway/breadsearch" +description = "Semantic system-wide search for BOS" + +[[products]] +name = "breadclip" +repo = "Breadway/breadclip" +description = "Wayland clipboard history manager for Hyprland" + +[[products]] +name = "breadshot" +repo = "Breadway/breadshot" +description = "Screenshot utility for the bread ecosystem" + +[[products]] +name = "bos-settings" +repo = "Breadway/bos-settings" +description = "System settings app for Bread OS" diff --git a/scripts/cleanup-old-mirror-workflows.sh b/scripts/cleanup-old-mirror-workflows.sh new file mode 100755 index 0000000..3a507a8 --- /dev/null +++ b/scripts/cleanup-old-mirror-workflows.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# cleanup-old-mirror-workflows.sh — retire the per-repo GitHub mirroring +# pattern now that Forgejo native Push Mirrors (see setup-push-mirrors.sh) +# do the same job centrally. +# +# THIS SCRIPT IS DESTRUCTIVE AND TOUCHES LIVE, RUNNING INFRASTRUCTURE: +# 1. Deletes .forgejo/workflows/mirror.yml from the DEFAULT BRANCH of every +# repo returned by the Forgejo API that has one (via the contents API — +# this is a real commit to each repo's default branch, not a local/ +# worktree change). +# 2. Deletes the MIRROR_TOKEN Actions secret from every repo that has one. +# +# Do not run this until you have confirmed, for real, that push mirrors +# created by setup-push-mirrors.sh are actually syncing to GitHub (check +# a repo's Settings > Push Mirrors in the Forgejo web UI, or GET +# /repos/{owner}/{repo}/push_mirrors and look at last_update / last_error, +# and confirm commits are actually landing on the GitHub side). Until then, +# removing mirror.yml would silently kill the only thing currently keeping +# GitHub in sync. +# +# As a guardrail, this script refuses to do anything unless invoked with +# --i-have-verified-push-mirrors-work. There is no way around that flag +# short of editing this script, which is the point. +# +# Requires: bash, curl, jq +# +# Reads the same token file as setup-push-mirrors.sh: +# FORGEJO_TOKEN_FILE default ~/.config/forgejo/token +# +# Env vars: +# FORGEJO_BASE https://git.breadway.dev +# FORGEJO_OWNER Breadway +# +# Flags: +# --i-have-verified-push-mirrors-work required, see above +# --dry-run print what would be deleted, make +# no changes (combine with the +# confirmation flag or this refuses +# to run at all — even dry-run mode +# is gated, so nobody can quietly +# drop the guardrail out of the +# invocation by force of habit) +# --only repo1,repo2 comma-separated allowlist +# +# Usage (once verified): +# scripts/cleanup-old-mirror-workflows.sh --i-have-verified-push-mirrors-work --dry-run +# scripts/cleanup-old-mirror-workflows.sh --i-have-verified-push-mirrors-work + +set -euo pipefail + +FORGEJO_BASE="${FORGEJO_BASE:-https://git.breadway.dev}" +FORGEJO_OWNER="${FORGEJO_OWNER:-Breadway}" +FORGEJO_TOKEN_FILE="${FORGEJO_TOKEN_FILE:-${HOME}/.config/forgejo/token}" + +CONFIRMED=0 +DRY_RUN=0 +ONLY_REPOS="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --i-have-verified-push-mirrors-work) CONFIRMED=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + --only) ONLY_REPOS="$2"; shift 2 ;; + -h|--help) + sed -n '2,42p' "$0" + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +if [[ "${CONFIRMED}" != 1 ]]; then + cat >&2 <<'EOF' +error: refusing to run. + +This script deletes mirror.yml from the default branch of every mirrored +repo and removes the MIRROR_TOKEN secret. That's a real, immediate change +to production CI on every one of those repos, and it also permanently +disables the *old* mirroring path. + +Before running this: + 1. Run setup-push-mirrors.sh for real (not --dry-run). + 2. Confirm, for at least one repo, that the push mirror actually synced + (Forgejo web UI: repo Settings > Push Mirrors > check "Last Update" + and that there's no "Last Error"; and check the GitHub side directly). + 3. Only then re-run this script with: + --i-have-verified-push-mirrors-work + +Add --dry-run (in addition to the flag above) to preview without changing +anything. +EOF + exit 1 +fi + +for bin in curl jq; do + command -v "${bin}" >/dev/null 2>&1 || { echo "error: ${bin} is required" >&2; exit 2; } +done + +[[ -f "${FORGEJO_TOKEN_FILE}" ]] || { echo "error: Forgejo token file not found at ${FORGEJO_TOKEN_FILE}" >&2; exit 2; } +FORGEJO_TOKEN="$(<"${FORGEJO_TOKEN_FILE}")" + +api() { + # api METHOD PATH [JSON_BODY] -> prints response body, exits nonzero on HTTP error + local method="$1" path="$2" body="${3:-}" + if [[ -n "${body}" ]]; then + curl -fsS -X "${method}" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${body}" \ + "${FORGEJO_BASE}/api/v1${path}" + else + curl -fsS -X "${method}" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1${path}" + fi +} + +owner_kind="org" +if ! curl -fsS -o /dev/null -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1/orgs/${FORGEJO_OWNER}" 2>/dev/null; then + owner_kind="user" +fi + +if [[ "${owner_kind}" == "org" ]]; then + repos_json="$(api GET "/orgs/${FORGEJO_OWNER}/repos?limit=50")" +else + repos_json="$(api GET "/users/${FORGEJO_OWNER}/repos?limit=50")" +fi + +mapfile -t repo_names < <(echo "${repos_json}" | jq -r '.[].name') + +if [[ "${DRY_RUN}" == 1 ]]; then + echo "# --dry-run: no deletions will be made" +fi +echo + +for name in "${repo_names[@]}"; do + if [[ -n "${ONLY_REPOS}" ]]; then + IFS=',' read -ra allow <<< "${ONLY_REPOS}" + match=0 + for a in "${allow[@]}"; do [[ "${a}" == "${name}" ]] && match=1; done + [[ "${match}" == 1 ]] || continue + fi + + default_branch="$(echo "${repos_json}" | jq -r --arg n "${name}" '.[] | select(.name==$n) | .default_branch')" + + # Contents API: GET returns the file's sha, which the DELETE call needs. + file_info="$(curl -fsS -o /tmp/cleanup_probe.json -w '%{http_code}' \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1/repos/${FORGEJO_OWNER}/${name}/contents/.forgejo/workflows/mirror.yml?ref=${default_branch}" || true)" + + if [[ "${file_info}" == "200" ]]; then + sha="$(jq -r '.sha' /tmp/cleanup_probe.json)" + if [[ "${DRY_RUN}" == 1 ]]; then + echo "WOULD-DELETE ${name}: .forgejo/workflows/mirror.yml (sha ${sha}) from ${default_branch}" + else + echo "DELETE ${name}: .forgejo/workflows/mirror.yml from ${default_branch}" + del_body="$(jq -n --arg msg "ci: remove mirror.yml, superseded by native push mirror" \ + --arg sha "${sha}" --arg branch "${default_branch}" \ + '{message: $msg, sha: $sha, branch: $branch}')" + api DELETE "/repos/${FORGEJO_OWNER}/${name}/contents/.forgejo/workflows/mirror.yml" "${del_body}" >/dev/null + fi + else + echo "SKIP ${name}: no .forgejo/workflows/mirror.yml on ${default_branch}" + fi + + secret_check="$(curl -fsS -o /dev/null -w '%{http_code}' \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1/repos/${FORGEJO_OWNER}/${name}/actions/secrets" || true)" + has_mirror_token="$(curl -fsS -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1/repos/${FORGEJO_OWNER}/${name}/actions/secrets" \ + | jq -r '[.[] | select(.name=="MIRROR_TOKEN")] | length')" + + if [[ "${has_mirror_token}" -gt 0 ]]; then + if [[ "${DRY_RUN}" == 1 ]]; then + echo "WOULD-DELETE ${name}: MIRROR_TOKEN secret" + else + echo "DELETE ${name}: MIRROR_TOKEN secret" + curl -fsS -X DELETE -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1/repos/${FORGEJO_OWNER}/${name}/actions/secrets/MIRROR_TOKEN" >/dev/null + fi + else + echo "SKIP ${name}: no MIRROR_TOKEN secret" + fi +done + +rm -f /tmp/cleanup_probe.json diff --git a/scripts/doctor-channels.sh b/scripts/doctor-channels.sh new file mode 100755 index 0000000..9164565 --- /dev/null +++ b/scripts/doctor-channels.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# doctor-channels.sh — detect drift between a repo's declared distribution +# channel(s) and its actual .forgejo/workflows/ + packaging metadata. +# +# See docs/release-channels.md for the policy this checks against. +# +# Usage: +# scripts/doctor-channels.sh [BASE_DIR] +# +# BASE_DIR defaults to the parent of this repo checkout (i.e. run from a +# normal ~/Projects/bread-ecosystem checkout, it scans sibling ~/Projects/* +# repos). Point it at a directory of worktrees (e.g. ~/Projects, which is +# also where *-fix-worktree checkouts live) to check those instead: +# +# scripts/doctor-channels.sh ~/Projects +# +# Exits 0 if no drift found, 1 if any repo has drift (so it's CI-friendly). +# +# Requires: python3 (tomllib, stdlib since 3.11) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BASE_DIR="${1:-$(dirname "${SCRIPT_DIR}")}" +REGISTRY="${SCRIPT_DIR}/registry/bread-ecosystem.toml" + +if [[ ! -f "${REGISTRY}" ]]; then + echo "error: registry not found at ${REGISTRY}" >&2 + exit 2 +fi + +# repo (last path segment of registry `repo = "Breadway/x"`) -> 1 +mapfile -t registry_repos < <(python3 -c " +import tomllib +with open('${REGISTRY}', 'rb') as f: + d = tomllib.load(f) +for p in d['products']: + print(p['repo'].split('/')[-1]) +") + +is_in_registry() { + local name="$1" + for r in "${registry_repos[@]}"; do + [[ "${r}" == "${name}" ]] && return 0 + done + return 1 +} + +# Repos with a deliberately non-standard packaging shape that the +# single-PKGBUILD/single-package.yml heuristic below doesn't fit. Extend +# this if another repo grows a legitimately special-cased layout. +PACKAGE_CHECK_EXEMPT=("bos") # ships an ISO via release-iso.yml; its PKGBUILDs + # under packaging/*/ build bundled AUR deps + # (bibata, calamares, ...), each with its own + # dedicated workflow — not a pacman-channel package. + +is_package_check_exempt() { + local name="$1" + for r in "${PACKAGE_CHECK_EXEMPT[@]}"; do + [[ "${r}" == "${name}" ]] && return 0 + done + return 1 +} + +drift=0 +checked=0 + +for dir in "${BASE_DIR}"/*/; do + name="$(basename "${dir}")" + name="${name%-fix-worktree}" # normalize worktree checkouts back to the repo name + [[ -d "${dir}/.git" || -f "${dir}/.git" ]] || continue + # Skip bread-ecosystem itself — it's a multi-product repo the registry + # membership check above doesn't map 1:1, and it's already reviewed by + # hand above (bakery + bread-theme products). + [[ "${name}" == "bread-ecosystem" ]] && continue + + checked=$((checked + 1)) + has_bakery_toml=0 + [[ -f "${dir}/bakery.toml" ]] && has_bakery_toml=1 + + has_release_wf=0 + compgen -G "${dir}/.forgejo/workflows/release*.yml" >/dev/null 2>&1 && has_release_wf=1 + + in_registry=0 + is_in_registry "${name}" && in_registry=1 + + has_pkgbuild=0 + find "${dir}" -maxdepth 3 -iname 'PKGBUILD' -not -path '*/.git/*' 2>/dev/null \ + | grep -q . && has_pkgbuild=1 + + has_package_wf=0 + [[ -f "${dir}/.forgejo/workflows/package.yml" ]] && has_package_wf=1 + + issues=() + + if [[ "${has_bakery_toml}" == 1 && "${in_registry}" == 0 ]]; then + issues+=("has bakery.toml but no registry/bread-ecosystem.toml entry") + fi + if [[ "${in_registry}" == 1 && "${has_bakery_toml}" == 0 ]]; then + issues+=("registered in bread-ecosystem.toml but has no bakery.toml") + fi + if [[ "${in_registry}" == 1 && "${has_release_wf}" == 0 ]]; then + issues+=("registered + has bakery.toml but no release*.yml workflow") + fi + if [[ "${has_bakery_toml}" == 1 && "${in_registry}" == 0 && "${has_release_wf}" == 1 ]]; then + issues+=("has a release workflow for a product not in the registry (index.json will never include it)") + fi + if ! is_package_check_exempt "${name}"; then + if [[ "${has_pkgbuild}" == 1 && "${has_package_wf}" == 0 ]]; then + issues+=("has a PKGBUILD but no package.yml workflow") + fi + if [[ "${has_package_wf}" == 1 && "${has_pkgbuild}" == 0 ]]; then + issues+=("has package.yml but no PKGBUILD") + fi + fi + + if [[ ${#issues[@]} -gt 0 ]]; then + drift=1 + echo "${name}:" + for i in "${issues[@]}"; do + echo " - ${i}" + done + fi +done + +echo +echo "checked ${checked} repos under ${BASE_DIR}" +if [[ "${drift}" == 0 ]]; then + echo "no channel drift found" +else + echo "drift found — see docs/release-channels.md for the policy" +fi +exit "${drift}" diff --git a/scripts/gen-index.sh b/scripts/gen-index.sh index 86d3f40..05b0c20 100755 --- a/scripts/gen-index.sh +++ b/scripts/gen-index.sh @@ -1,19 +1,38 @@ #!/usr/bin/env bash -# Generate dl.breadway.dev/index.json from: -# - registry/bread-ecosystem.toml (product list) -# - //bakery.toml (per-product metadata, uploaded by release.yml) -# - / (built binaries + sha256 files) +# Generate dl.breadway.dev/index.json (or a track-prefixed sibling — see +# TRACK below) from: +# - registry/bread-ecosystem.toml (product list) +# - //bakery.toml (per-product metadata, uploaded by release.yml) +# - / (built binaries + sha256 files) # # Fallback for local dev: looks for ../name/bakery.toml (sibling repo checkout). # Run on hestia after each product build, before the dl server is refreshed. +# +# TRACK selects which build track to generate an index for: "stable" +# (default — reads/writes DL_DIR directly, byte-for-byte the same behavior +# as before tracks existed), "beta", or "dev" (both read/write a +# DL_DIR// subtree, so they never collide with stable's paths). A +# product with no release dir under the selected track's tree is skipped +# with a warning, same as an unreleased product is today — most products +# won't have a beta/dev build for a while after this lands. # Requires: jq, python3 (tomllib, stdlib since 3.11), sha256sum set -euo pipefail SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" DL_DIR="${DL_DIR:-/srv/breadway-dl}" DL_BASE="${DL_BASE:-https://dl.breadway.dev}" +TRACK="${TRACK:-stable}" GH_BASE="https://github.com" -OUT="${DL_DIR}/index.json" + +if [[ "${TRACK}" == "stable" ]]; then + PKG_ROOT="${DL_DIR}" + URL_ROOT="${DL_BASE}" + OUT="${DL_DIR}/index.json" +else + PKG_ROOT="${DL_DIR}/${TRACK}" + URL_ROOT="${DL_BASE}/${TRACK}" + OUT="${DL_DIR}/${TRACK}/index.json" +fi # Read the product list from the registry TOML instead of a hardcoded array. mapfile -t products < <(python3 -c " @@ -30,8 +49,8 @@ build_package_json() { local name="$1" local repo="$2" - # Find the latest version dir under DL_DIR// - local pkg_dir="${DL_DIR}/${name}" + # Find the latest version dir under PKG_ROOT// + local pkg_dir="${PKG_ROOT}/${name}" if [[ ! -d "${pkg_dir}" ]]; then echo " warning: no release dir for ${name} at ${pkg_dir}" >&2 return 1 @@ -56,6 +75,7 @@ build_package_json() { [[ "${bin_path}" == *.service ]] && continue [[ "${bin_path}" == *.css ]] && continue [[ "${bin_path}" == *.txt ]] && continue + [[ "${bin_path}" == *.minisig ]] && continue [[ -f "${bin_path}" ]] || continue local bin_name bin_name="$(basename "${bin_path}")" @@ -64,8 +84,17 @@ build_package_json() { if [[ -f "${sha256_path}" ]]; then sha256="$(awk '{print $1}' "${sha256_path}")" fi - local dl_url="${DL_BASE}/${name}/${version}/${bin_name}" - local gh_url="${GH_BASE}/${repo}/releases/download/v${version}/${bin_name}" + local dl_url="${URL_ROOT}/${name}/${version}/${bin_name}" + # dev/beta builds never get a real GitHub Release (see the dev/beta + # CI workflows — that step is intentionally skipped for those + # tracks), so github_url just mirrors dl_url rather than pointing at + # a release asset that doesn't exist. + local gh_url + if [[ "${TRACK}" == "stable" ]]; then + gh_url="${GH_BASE}/${repo}/releases/download/v${version}/${bin_name}" + else + gh_url="${dl_url}" + fi local entry entry="$(jq -n \ @@ -85,7 +114,7 @@ build_package_json() { bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml" fi if [[ ! -f "${bakery_toml}" ]]; then - echo "ERROR: bakery.toml not found for ${name} — release.yml must copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2 + echo "ERROR: bakery.toml not found for ${name} — the release workflow must copy it to \${PKG_ROOT}/${name}/\${VERSION}/bakery.toml" >&2 return 1 fi @@ -119,8 +148,12 @@ with open('${bakery_toml}', 'rb') as f: print(json.dumps(d.get('bread_deps', []))) " 2>/dev/null || echo "[]")" - # [[service]] entries → [{unit, enable}] - services="$(python3 -c " + # [[service]] entries → [{unit, enable, sha256}]. sha256 comes from the + # actual unit file shipped in this version dir — the same + # artifact-integrity guarantee binaries already get. A missing unit file + # gets an empty sha256; install.rs refuses to install an unverified + # download rather than silently skipping the check. + service_units="$(python3 -c " import tomllib, json with open('${bakery_toml}', 'rb') as f: d = tomllib.load(f) @@ -128,7 +161,24 @@ svcs = d.get('service', []) print(json.dumps([{'unit': s['unit'], 'enable': s.get('enable', False)} for s in svcs])) " 2>/dev/null || echo "[]")" - # [config] → {dir, example?} or null + services="[]" + while IFS= read -r svc_entry; do + [[ -z "${svc_entry}" ]] && continue + unit_name="$(echo "${svc_entry}" | jq -r '.unit')" + enable="$(echo "${svc_entry}" | jq -r '.enable')" + unit_path="${version_dir}/${unit_name}" + unit_sha256="" + if [[ -f "${unit_path}" ]]; then + unit_sha256="$(sha256sum "${unit_path}" | awk '{print $1}')" + else + echo " warning: service unit '${unit_name}' not found at ${unit_path}" >&2 + fi + svc_json="$(jq -n --arg unit "${unit_name}" --argjson enable "${enable}" --arg sha256 "${unit_sha256}" \ + '{unit: $unit, enable: $enable, sha256: $sha256}')" + services="$(jq -n --argjson arr "${services}" --argjson e "${svc_json}" '$arr + [$e]')" + done < <(echo "${service_units}" | jq -c '.[]') + + # [config] → {dir, example?, example_sha256?} or null config="$(python3 -c " import tomllib, json with open('${bakery_toml}', 'rb') as f: @@ -142,6 +192,19 @@ if cfg: else: print('null') " 2>/dev/null || echo "null")" + if [[ "${config}" != "null" ]]; then + example_name="$(echo "${config}" | jq -r '.example // empty')" + if [[ -n "${example_name}" ]]; then + example_path="${version_dir}/${example_name}" + example_sha256="" + if [[ -f "${example_path}" ]]; then + example_sha256="$(sha256sum "${example_path}" | awk '{print $1}')" + else + echo " warning: config.example '${example_name}' not found at ${example_path}" >&2 + fi + config="$(echo "${config}" | jq -c --arg sha "${example_sha256}" '. + {example_sha256: $sha}')" + fi + fi post_install="$(python3 -c " import tomllib, json @@ -194,3 +257,42 @@ jq -n \ > "${OUT}" echo "wrote ${OUT}" + +# Sign the index so `bakery` can verify it before trusting a single byte. +# Every artifact sha256 and post_install hook string lives inside index.json, +# so a valid signature over these raw bytes transitively covers all of it — +# no separate per-artifact signing is needed. +# +# MINISIGN_SEC_KEY must point at the *secret* key file generated with +# `minisign -G`. It is intentionally never read from inside either git repo; +# point it at wherever the key actually lives on the machine that runs this +# script (e.g. a root-only path on hestia), and set MINISIGN_SEC_KEY_PASSWORD +# too if the key was generated with a password. +# +# This step is a no-op (with a loud warning) if the key isn't configured, so +# existing unsigned publishing flows don't break until the key is actually +# wired up — see the handoff note in the fix commit for this repo. +if [[ -n "${MINISIGN_SEC_KEY:-}" ]]; then + if [[ ! -f "${MINISIGN_SEC_KEY}" ]]; then + echo "ERROR: MINISIGN_SEC_KEY=${MINISIGN_SEC_KEY} does not exist" >&2 + exit 1 + fi + if ! command -v minisign >/dev/null 2>&1; then + echo "ERROR: MINISIGN_SEC_KEY is set but the 'minisign' binary is not installed" >&2 + exit 1 + fi + sign_args=(-S -s "${MINISIGN_SEC_KEY}" -m "${OUT}" -x "${OUT}.minisig") + if [[ -n "${MINISIGN_SEC_KEY_PASSWORD:-}" ]]; then + MINISIGN_PASSWORD="${MINISIGN_SEC_KEY_PASSWORD}" minisign "${sign_args[@]}" ${OUT}.minisig" +else + echo "WARNING: MINISIGN_SEC_KEY not set — index.json was NOT signed." >&2 + echo " bakery clients built with signature verification will reject" >&2 + echo " this index. Set MINISIGN_SEC_KEY before running this in" >&2 + echo " production once the signing key has been provisioned." >&2 +fi diff --git a/scripts/get.sh b/scripts/get.sh index cc343f0..6a1707e 100755 --- a/scripts/get.sh +++ b/scripts/get.sh @@ -4,6 +4,13 @@ # Or: curl -sSfL https://breadway.dev/get | sh set -eu +# Pinned minisign public key for the bakery release binary. Matches the +# PUBKEY constant in bakery/src/manifest.rs (same keypair signs both +# index.json and the bakery binary itself). Do not source this from the +# network — it must be baked into this script so a compromised dl server +# can't swap it out along with a malicious binary. +BAKERY_MINISIGN_PUBKEY="RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8" + BAKERY_VERSION="${BAKERY_VERSION:-latest}" BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}" @@ -19,12 +26,16 @@ if [ "${BAKERY_VERSION}" = "latest" ]; then DL_PRIMARY="https://dl.breadway.dev/bakery/latest/bakery-x86_64" DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/latest/download/bakery-x86_64" SHA256_URL="https://dl.breadway.dev/bakery/latest/bakery-x86_64.sha256" + SIG_URL="https://dl.breadway.dev/bakery/latest/bakery-x86_64.minisig" + SIG_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/latest/download/bakery-x86_64.minisig" else # Strip a leading 'v' if the caller included it, then add it back consistently. ver="${BAKERY_VERSION#v}" DL_PRIMARY="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64" DL_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/v${ver}/bakery-x86_64" SHA256_URL="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64.sha256" + SIG_URL="https://dl.breadway.dev/bakery/${ver}/bakery-x86_64.minisig" + SIG_FALLBACK="https://github.com/Breadway/bread-ecosystem/releases/download/v${ver}/bakery-x86_64.minisig" fi # Pick a download tool. @@ -38,30 +49,63 @@ fi mkdir -p "${BIN_DIR}" TMP="$(mktemp)" -trap 'rm -f "${TMP}" "${TMP}.sha256"' EXIT +trap 'rm -f "${TMP}" "${TMP}.sha256" "${TMP}.minisig"' EXIT echo "downloading bakery…" if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then echo " from dl.breadway.dev" - # Verify checksum when available from primary. - if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then - expected="$(awk '{print $1}' "${TMP}.sha256")" - actual="$(sha256sum "${TMP}" | awk '{print $1}')" - if [ "${expected}" != "${actual}" ]; then - die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})" - fi - echo " checksum verified" - else - echo " warning: could not fetch checksum — skipping verification" - fi + sig_url="${SIG_URL}" + checksum_only_fallback_note=" warning: could not fetch checksum — skipping verification" elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then echo " from GitHub (fallback)" - # No .sha256 on the GitHub fallback path; proceed without verification. - echo " warning: checksum not verified for GitHub fallback download" + sig_url="${SIG_FALLBACK}" + checksum_only_fallback_note=" warning: no checksum available for GitHub fallback download" else die "failed to download bakery from both primary and fallback URLs" fi +# Signature verification is the authoritative check: it proves the binary +# was produced by whoever holds the bakery signing key, not just that bytes +# match whatever the same (possibly compromised) server also reports as the +# checksum. Prefer it whenever both a .minisig is published and a minisign +# verifier is available on this machine. +sig_verified=0 +if fetch "${sig_url}" "${TMP}.minisig" 2>/dev/null; then + if command -v minisign >/dev/null 2>&1; then + if minisign -V -q -m "${TMP}" -x "${TMP}.minisig" -P "${BAKERY_MINISIGN_PUBKEY}"; then + echo " signature verified (minisign)" + sig_verified=1 + else + die "minisign signature verification FAILED — refusing to install a binary that doesn't match the pinned bakery key" + fi + else + echo " warning: 'minisign' is not installed — cannot verify the binary's" >&2 + echo " warning: signature, only its checksum. Install minisign for the" >&2 + echo " warning: strongest guarantee: pacman -S minisign / apt install minisign" >&2 + fi +else + echo " warning: no .minisig published for this release yet — signature not verified" >&2 +fi + +# Checksum is a secondary, best-effort check (kept for defense in depth and +# for the case where minisign isn't installed). It is not a substitute for +# signature verification: both the binary and its checksum typically come +# from the same server, so a compromised server can serve a matching pair. +if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then + expected="$(awk '{print $1}' "${TMP}.sha256")" + actual="$(sha256sum "${TMP}" | awk '{print $1}')" + if [ "${expected}" != "${actual}" ]; then + die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})" + fi + echo " checksum verified" +else + echo "${checksum_only_fallback_note}" +fi + +if [ "${sig_verified}" -ne 1 ]; then + echo " warning: proceeding WITHOUT a verified signature on the bakery binary" >&2 +fi + chmod +x "${TMP}" cp "${TMP}" "${BIN_DIR}/bakery" echo "installed bakery to ${BIN_DIR}/bakery" diff --git a/scripts/setup-push-mirrors.sh b/scripts/setup-push-mirrors.sh new file mode 100755 index 0000000..6dd3081 --- /dev/null +++ b/scripts/setup-push-mirrors.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# setup-push-mirrors.sh — provision Forgejo native Push Mirrors to GitHub for +# every repo under the Breadway account, replacing the old per-repo +# .forgejo/workflows/mirror.yml + MIRROR_TOKEN pattern. +# +# THIS SCRIPT MUTATES LIVE FORGEJO STATE WHEN RUN WITHOUT --dry-run. +# Always run with --dry-run first and review the output before running for +# real. Nothing in this script deletes anything — see the separate +# cleanup-old-mirror-workflows.sh for removing the old mirror.yml files, +# which should only be run after confirming push mirrors are syncing. +# +# What it does, per repo returned by the Forgejo API: +# 1. GET /repos/{owner}/{repo}/push_mirrors — list existing push mirrors +# 2. If one already targets https://github.com//.git, skip +# (idempotent — safe to re-run). +# 3. Otherwise POST /repos/{owner}/{repo}/push_mirrors to create one, with +# sync_on_commit=true and a periodic interval as belt-and-suspenders. +# +# Requires: bash, curl, jq +# +# Reads (never prints the contents of either): +# - A Forgejo API token from FORGEJO_TOKEN_FILE (default: +# ~/.config/forgejo/token). Needs at least write access to repository +# settings for every repo under the target account. +# - A GitHub PAT from a dotenv-style `GH_TOKEN=...` line in +# MIRROR_ENV_FILE (default: ~/.config/bread/mirror.env). The token needs +# `repo` scope (classic PAT) or Contents: Read & Write (fine-grained) on +# every target GitHub repo, since it's what actually pushes commits. +# +# Env vars (all optional, shown with defaults): +# FORGEJO_BASE https://git.breadway.dev +# FORGEJO_OWNER Breadway # Forgejo account that owns the repos +# GITHUB_OWNER same as FORGEJO_OWNER # GitHub account/org to mirror into +# FORGEJO_TOKEN_FILE ~/.config/forgejo/token +# MIRROR_ENV_FILE ~/.config/bread/mirror.env +# SYNC_INTERVAL 8h0m0s # Forgejo duration string; periodic resync +# # on top of sync_on_commit +# +# Flags: +# --dry-run Print every GET/POST this script would make +# (including full request bodies except the +# GitHub token, which is redacted) without +# actually issuing any POST. GETs (listing repos, +# listing existing push mirrors) always happen — +# they're read-only and needed to print accurate +# dry-run output. +# --include-private By default, private Forgejo repos are SKIPPED +# and reported, not mirrored — pushing a private +# repo's history to a public GitHub repo is a +# one-way disclosure decision this script should +# never make silently. Pass this flag to include +# them anyway, after you've confirmed the target +# GitHub repo is also private (this script does +# not create or check GitHub-side repos or their +# visibility). +# --only repo1,repo2 Comma-separated allowlist of repo names. +# Default: every repo the Forgejo API returns. +# +# Usage: +# scripts/setup-push-mirrors.sh --dry-run +# scripts/setup-push-mirrors.sh --dry-run --include-private +# scripts/setup-push-mirrors.sh # the real thing + +set -euo pipefail + +FORGEJO_BASE="${FORGEJO_BASE:-https://git.breadway.dev}" +FORGEJO_OWNER="${FORGEJO_OWNER:-Breadway}" +GITHUB_OWNER="${GITHUB_OWNER:-${FORGEJO_OWNER}}" +FORGEJO_TOKEN_FILE="${FORGEJO_TOKEN_FILE:-${HOME}/.config/forgejo/token}" +MIRROR_ENV_FILE="${MIRROR_ENV_FILE:-${HOME}/.config/bread/mirror.env}" +SYNC_INTERVAL="${SYNC_INTERVAL:-8h0m0s}" + +DRY_RUN=0 +INCLUDE_PRIVATE=0 +ONLY_REPOS="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --include-private) INCLUDE_PRIVATE=1; shift ;; + --only) ONLY_REPOS="$2"; shift 2 ;; + -h|--help) + sed -n '2,55p' "$0" + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +for bin in curl jq; do + command -v "${bin}" >/dev/null 2>&1 || { echo "error: ${bin} is required" >&2; exit 2; } +done + +[[ -f "${FORGEJO_TOKEN_FILE}" ]] || { echo "error: Forgejo token file not found at ${FORGEJO_TOKEN_FILE}" >&2; exit 2; } +[[ -f "${MIRROR_ENV_FILE}" ]] || { echo "error: mirror env file not found at ${MIRROR_ENV_FILE}" >&2; exit 2; } + +FORGEJO_TOKEN="$(<"${FORGEJO_TOKEN_FILE}")" +GH_TOKEN="$(grep -m1 '^GH_TOKEN=' "${MIRROR_ENV_FILE}" | cut -d= -f2-)" +[[ -n "${GH_TOKEN}" ]] || { echo "error: no GH_TOKEN= line found in ${MIRROR_ENV_FILE}" >&2; exit 2; } + +api() { + # api METHOD PATH [JSON_BODY] + local method="$1" path="$2" body="${3:-}" + if [[ -n "${body}" ]]; then + curl -fsS -X "${method}" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${body}" \ + "${FORGEJO_BASE}/api/v1${path}" + else + curl -fsS -X "${method}" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1${path}" + fi +} + +# Determine whether FORGEJO_OWNER is an org or a user — orgs and users use +# different list-repos endpoints. +owner_kind="org" +if ! curl -fsS -o /dev/null -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_BASE}/api/v1/orgs/${FORGEJO_OWNER}" 2>/dev/null; then + owner_kind="user" +fi +echo "# ${FORGEJO_OWNER} is a Forgejo ${owner_kind} account" + +if [[ "${owner_kind}" == "org" ]]; then + repos_json="$(api GET "/orgs/${FORGEJO_OWNER}/repos?limit=50")" +else + repos_json="$(api GET "/users/${FORGEJO_OWNER}/repos?limit=50")" +fi + +mapfile -t repo_names < <(echo "${repos_json}" | jq -r '.[].name') +echo "# ${#repo_names[@]} repos found under ${FORGEJO_OWNER}" +echo + +if [[ "${DRY_RUN}" == 1 ]]; then + echo "# --dry-run: no POST requests will be made. GETs below are real, live reads." + echo +fi + +skipped_private=() +would_create=() +already_present=() + +for name in "${repo_names[@]}"; do + if [[ -n "${ONLY_REPOS}" ]]; then + IFS=',' read -ra allow <<< "${ONLY_REPOS}" + match=0 + for a in "${allow[@]}"; do [[ "${a}" == "${name}" ]] && match=1; done + [[ "${match}" == 1 ]] || continue + fi + + is_private="$(echo "${repos_json}" | jq -r --arg n "${name}" '.[] | select(.name==$n) | .private')" + if [[ "${is_private}" == "true" && "${INCLUDE_PRIVATE}" == 0 ]]; then + skipped_private+=("${name}") + echo "SKIP ${name}: private repo, pass --include-private to mirror it anyway" + continue + fi + + target_url="https://github.com/${GITHUB_OWNER}/${name}.git" + + existing="$(api GET "/repos/${FORGEJO_OWNER}/${name}/push_mirrors")" + already="$(echo "${existing}" | jq -r --arg u "${target_url}" '[.[] | select(.remote_address==$u)] | length')" + + if [[ "${already}" -gt 0 ]]; then + already_present+=("${name}") + echo "OK ${name}: push mirror to ${target_url} already exists, skipping" + continue + fi + + would_create+=("${name}") + body="$(jq -n \ + --arg addr "${target_url}" \ + --arg user "x-access-token" \ + --arg pass "${GH_TOKEN}" \ + --arg interval "${SYNC_INTERVAL}" \ + '{remote_address: $addr, remote_username: $user, remote_password: $pass, + sync_on_commit: true, interval: $interval, use_ssh: false}')" + + if [[ "${DRY_RUN}" == 1 ]]; then + redacted="$(echo "${body}" | jq '.remote_password = "***REDACTED***"')" + echo "WOULD-POST ${name}: /repos/${FORGEJO_OWNER}/${name}/push_mirrors" + echo "${redacted}" | sed 's/^/ /' + else + echo "CREATE ${name}: push mirror -> ${target_url}" + api POST "/repos/${FORGEJO_OWNER}/${name}/push_mirrors" "${body}" >/dev/null + fi +done + +echo +echo "# summary" +echo "# already had a matching push mirror: ${#already_present[@]}" +echo "# private, skipped (--include-private to override): ${#skipped_private[@]}" +if [[ "${DRY_RUN}" == 1 ]]; then + echo "# would create: ${#would_create[@]}" +else + echo "# created: ${#would_create[@]}" +fi diff --git a/scripts/test-gen-index.sh b/scripts/test-gen-index.sh index 5a2733a..a496ca0 100755 --- a/scripts/test-gen-index.sh +++ b/scripts/test-gen-index.sh @@ -110,4 +110,12 @@ check "post_install[0]" \ "echo installed" \ "$(jq -r '.packages.fakepkg.post_install[0]' "${OUT}")" +check "services[0].sha256" \ + "$(sha256sum "${PKG_VER_DIR}/fakepkg.service" | awk '{print $1}')" \ + "$(jq -r '.packages.fakepkg.services[0].sha256' "${OUT}")" + +check "config.example_sha256" \ + "$(sha256sum "${PKG_VER_DIR}/fakepkg.example.toml" | awk '{print $1}')" \ + "$(jq -r '.packages.fakepkg.config.example_sha256' "${OUT}")" + echo "OK: all gen-index assertions passed"