Compare commits
17 commits
10f62fb1a6
...
4ac54c610d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ac54c610d | ||
|
|
db2fa3c4b4 | ||
|
|
0b272838df | ||
|
|
157ed6e378 | ||
|
|
8e82d2d833 | ||
|
|
ab4e882baa | ||
|
|
98812af020 | ||
|
|
025e27b496 | ||
|
|
6ae7edb83c | ||
|
|
49b384c549 | ||
|
|
41276479aa | ||
|
|
49c63c8ccf | ||
|
|
853ee33415 | ||
|
|
394a252f9e | ||
|
|
fa0597f482 | ||
|
|
17d1bb8580 | ||
|
|
5bc4796a98 |
52 changed files with 5096 additions and 550 deletions
61
.forgejo/workflows/beta-bakery.yml
Normal file
61
.forgejo/workflows/beta-bakery.yml
Normal file
|
|
@ -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" </dev/null
|
||||
echo "signed bakery-x86_64"
|
||||
else
|
||||
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bakery-x86_64 UNSIGNED"
|
||||
fi
|
||||
|
||||
# No GitHub Release upload — beta, like dev, is only distributed via
|
||||
# dl.breadway.dev/beta/.
|
||||
- name: regenerate beta index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
TRACK: beta
|
||||
run: cd src && bash scripts/gen-index.sh
|
||||
56
.forgejo/workflows/beta-bread-theme.yml
Normal file
56
.forgejo/workflows/beta-bread-theme.yml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
name: beta bread-theme
|
||||
|
||||
# Publishes a beta-track build when a `beta-v*` tag is pushed — a deliberate
|
||||
# promotion step, distinct from dev-bread-theme.yml's build-on-every-push.
|
||||
# See docs/release-channels.md.
|
||||
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 bread-theme --bin bread-theme
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#beta-v}"
|
||||
PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64"
|
||||
strip "${PKG_DIR}/bread-theme-x86_64"
|
||||
sha256sum "${PKG_DIR}/bread-theme-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/bread-theme-x86_64.sha256"
|
||||
cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bread-theme/latest"
|
||||
|
||||
- name: sign beta binary
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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" </dev/null
|
||||
echo "signed bread-theme-x86_64"
|
||||
else
|
||||
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bread-theme-x86_64 UNSIGNED"
|
||||
fi
|
||||
|
||||
- name: regenerate beta index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
TRACK: beta
|
||||
run: cd src && bash scripts/gen-index.sh
|
||||
80
.forgejo/workflows/dev-bakery.yml
Normal file
80
.forgejo/workflows/dev-bakery.yml
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
name: dev bakery
|
||||
|
||||
# Publishes a dev-track build on every push to `dev` — separate from
|
||||
# release-bakery.yml's tag-triggered stable releases. See docs/release-channels.md
|
||||
# for the three-track policy (stable/beta/dev) this is part of.
|
||||
on:
|
||||
push:
|
||||
branches: ['dev']
|
||||
paths:
|
||||
- 'bakery/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- '.forgejo/workflows/dev-bakery.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf src && mkdir src
|
||||
git clone --branch dev --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
- name: build
|
||||
run: cd src && cargo build --release --locked -p bakery
|
||||
|
||||
- name: test
|
||||
run: cd src && cargo test --release --locked -p bakery
|
||||
|
||||
# Auto-bumps the patch version from Cargo.toml and appends a
|
||||
# timestamp+sha dev suffix — no developer discipline required, and the
|
||||
# result is visibly "ahead of" the last stable patch release while
|
||||
# staying valid semver (comparable within the dev track by bakery's
|
||||
# `is_newer`).
|
||||
- name: compute dev version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src
|
||||
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
||||
IFS='.' read -r MA MI PA <<< "${CUR}"
|
||||
SHA="$(git rev-parse --short HEAD)"
|
||||
TS="$(date -u +%Y%m%d%H%M%S)"
|
||||
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_DIR="/srv/breadway-dl/dev/bakery/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64"
|
||||
strip "${PKG_DIR}/bakery-x86_64"
|
||||
sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/bakery-x86_64.sha256"
|
||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/bakery/latest"
|
||||
|
||||
- name: sign dev binary
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_DIR="/srv/breadway-dl/dev/bakery/${VERSION}"
|
||||
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \
|
||||
-x "${PKG_DIR}/bakery-x86_64.minisig" </dev/null
|
||||
echo "signed bakery-x86_64"
|
||||
else
|
||||
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bakery-x86_64 UNSIGNED"
|
||||
fi
|
||||
|
||||
# No GitHub Release upload step here, unlike release-bakery.yml — dev
|
||||
# builds happen on every push and would spam a release per commit, so
|
||||
# dl.breadway.dev/dev/ is the only distribution point for this track.
|
||||
- name: regenerate dev index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
TRACK: dev
|
||||
run: cd src && bash scripts/gen-index.sh
|
||||
69
.forgejo/workflows/dev-bread-theme.yml
Normal file
69
.forgejo/workflows/dev-bread-theme.yml
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
name: dev bread-theme
|
||||
|
||||
# Publishes a dev-track build on every push to `dev` — separate from
|
||||
# release-bread-theme.yml's tag-triggered stable releases. See
|
||||
# docs/release-channels.md for the three-track policy this is part of.
|
||||
on:
|
||||
push:
|
||||
branches: ['dev']
|
||||
paths:
|
||||
- 'bread-theme/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- '.forgejo/workflows/dev-bread-theme.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf src && mkdir src
|
||||
git clone --branch dev --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: compute dev version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src
|
||||
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
||||
IFS='.' read -r MA MI PA <<< "${CUR}"
|
||||
SHA="$(git rev-parse --short HEAD)"
|
||||
TS="$(date -u +%Y%m%d%H%M%S)"
|
||||
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_DIR="/srv/breadway-dl/dev/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" </dev/null
|
||||
echo "signed bread-theme-x86_64"
|
||||
else
|
||||
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bread-theme-x86_64 UNSIGNED"
|
||||
fi
|
||||
|
||||
- name: regenerate dev index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
TRACK: dev
|
||||
run: cd src && bash scripts/gen-index.sh
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['**']
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: Mirror to GitHub
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
|
||||
cd repo.git
|
||||
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
|
||||
# --prune deletes GitHub refs that no longer exist on Forgejo.
|
||||
git push --prune \
|
||||
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bread-ecosystem.git" \
|
||||
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||
74
.forgejo/workflows/release-bakery.yml
Normal file
74
.forgejo/workflows/release-bakery.yml
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
name: release bakery
|
||||
|
||||
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 bakery
|
||||
|
||||
- name: test
|
||||
run: cd src && cargo test --release --locked -p bakery
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="/srv/breadway-dl/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/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
|
||||
echo "signed bakery-x86_64"
|
||||
else
|
||||
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bakery-x86_64 UNSIGNED"
|
||||
fi
|
||||
|
||||
- name: regenerate index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: cd src && bash scripts/gen-index.sh
|
||||
|
||||
- name: upload to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="/srv/breadway-dl/bakery/${VERSION}"
|
||||
gh release create "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem \
|
||||
--title "bakery ${GITHUB_REF_NAME}" --generate-notes 2>/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
|
||||
72
.forgejo/workflows/release-bread-theme.yml
Normal file
72
.forgejo/workflows/release-bread-theme.yml
Normal file
|
|
@ -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
|
||||
echo "signed bread-theme-x86_64"
|
||||
else
|
||||
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping bread-theme-x86_64 UNSIGNED"
|
||||
fi
|
||||
|
||||
- name: regenerate index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: cd src && bash scripts/gen-index.sh
|
||||
|
||||
- name: upload to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="/srv/breadway-dl/bread-theme/${VERSION}"
|
||||
gh release create "${GITHUB_REF_NAME}" --repo Breadway/bread-ecosystem \
|
||||
--title "bread-ecosystem ${GITHUB_REF_NAME}" --generate-notes 2>/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
|
||||
53
.github/workflows/release.yml
vendored
53
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1161
Cargo.lock
generated
1161
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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 <rileyhorsham@gmail.com>"]
|
||||
authors = ["Breadway <plasticbread849@gmail.com>"]
|
||||
|
||||
[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"
|
||||
|
|
|
|||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||
35
README.md
35
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/<app>/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 <pkg> # remove a package (data files are never deleted)
|
|||
|
||||
Install all required deps with `sudo pacman -S <packages>`. Use `pacman -Q <pkg>` 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/<app>/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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
},
|
||||
/// 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<String> = 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<String> = 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[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<Index> {
|
||||
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<Index> {
|
||||
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<Index> {
|
||||
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<Index> {
|
||||
let text = fetch_text(PRIMARY_URL)?;
|
||||
fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf, track: Track) -> Result<Index> {
|
||||
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<String> {
|
||||
|
|
@ -115,10 +225,19 @@ fn fetch_text(url: &str) -> Result<String> {
|
|||
.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<Vec<u8>> {
|
|||
.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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, InstalledPackage>,
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +58,10 @@ impl State {
|
|||
pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
|
||||
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();
|
||||
|
|
|
|||
90
bakery/src/track.rs
Normal file
90
bakery/src/track.rs
Normal file
|
|
@ -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<Self, Self::Err> {
|
||||
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::<Track>().unwrap(), track);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_is_case_insensitive() {
|
||||
assert_eq!("DEV".parse::<Track>().unwrap(), Track::Dev);
|
||||
assert_eq!("Beta".parse::<Track>().unwrap(), Track::Beta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_rejects_unknown() {
|
||||
assert!("nightly".parse::<Track>().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);
|
||||
}
|
||||
}
|
||||
60
bakery/src/ui.rs
Normal file
60
bakery/src/ui.rs
Normal file
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
34
bread-onnx/Cargo.toml
Normal file
34
bread-onnx/Cargo.toml
Normal file
|
|
@ -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"
|
||||
112
bread-onnx/src/download.rs
Normal file
112
bread-onnx/src/download.rs
Normal file
|
|
@ -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<PathBuf> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
220
bread-onnx/src/embedding.rs
Normal file
220
bread-onnx/src/embedding.rs
Normal file
|
|
@ -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<Self> {
|
||||
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<Vec<f32>> {
|
||||
let encoding = self
|
||||
.tokenizer
|
||||
.encode(text, true)
|
||||
.map_err(|e| anyhow::anyhow!("tokenization failed: {e}"))?;
|
||||
|
||||
let mut ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
|
||||
let mut mask: Vec<i64> = encoding.get_attention_mask().iter().map(|&x| x as i64).collect();
|
||||
let mut type_ids: Vec<i64> = 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::<i64>::from_array((vec![1i64, seq_len], ids))
|
||||
.map_err(|e| anyhow::anyhow!("failed to build input_ids tensor: {e}"))?;
|
||||
let mask_tensor = Tensor::<i64>::from_array((vec![1i64, seq_len], mask.clone()))
|
||||
.map_err(|e| anyhow::anyhow!("failed to build attention_mask tensor: {e}"))?;
|
||||
let type_tensor = Tensor::<i64>::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::<f32>()
|
||||
.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<f32> {
|
||||
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::<f32>().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::<f32>().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);
|
||||
}
|
||||
}
|
||||
32
bread-onnx/src/lib.rs
Normal file
32
bread-onnx/src/lib.rs
Normal file
|
|
@ -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;
|
||||
152
bread-onnx/src/provider.rs
Normal file
152
bread-onnx/src/provider.rs
Normal file
|
|
@ -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<ort::ep::ExecutionProviderDispatch> {
|
||||
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: ""/<hash>.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(())
|
||||
}
|
||||
49
bread-onnx/src/session.rs
Normal file
49
bread-onnx/src/session.rs
Normal file
|
|
@ -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<Session> {
|
||||
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()))
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
11
bread-theme/bakery.toml
Normal file
11
bread-theme/bakery.toml
Normal file
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<String, String>,
|
||||
special: Option<WalSpecial>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WalSpecial {
|
||||
background: Option<String>,
|
||||
foreground: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Palette> {
|
|||
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]
|
||||
|
|
|
|||
36
bread-utils/Cargo.toml
Normal file
36
bread-utils/Cargo.toml
Normal file
|
|
@ -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"
|
||||
185
bread-utils/src/atomic.rs
Normal file
185
bread-utils/src/atomic.rs
Normal file
|
|
@ -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
|
||||
//! `<path>.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<u32>) -> 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<u32>) -> 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 `<path>.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)
|
||||
}
|
||||
|
||||
/// `<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);
|
||||
}
|
||||
}
|
||||
302
bread-utils/src/bread_client.rs
Normal file
302
bread-utils/src/bread_client.rs
Normal file
|
|
@ -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.<app_id>.*` 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.<app_id>.**` 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<String>) -> 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.<app_id>.*`) 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.<app_id>.**"` 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<F>(&self, pattern: impl Into<String>, 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<Mutex<Option<UnixStream>>> = 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<Option<UnixStream>>,
|
||||
) -> 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::<Value>(&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<AtomicBool>,
|
||||
current_stream: Arc<Mutex<Option<UnixStream>>>,
|
||||
handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
111
bread-utils/src/gtk_popup.rs
Normal file
111
bread-utils/src/gtk_popup.rs
Normal file
|
|
@ -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<gtk4::Widget>,
|
||||
on_outside: impl Fn() + 'static,
|
||||
) {
|
||||
let content = content.clone().upcast::<gtk4::Widget>();
|
||||
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);
|
||||
}
|
||||
252
bread-utils/src/hypr.rs
Normal file
252
bread-utils/src/hypr.rs
Normal file
|
|
@ -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<PathBuf> {
|
||||
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<String> {
|
||||
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::Value> {
|
||||
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<UnixStream> {
|
||||
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<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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<ActiveWindow> {
|
||||
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<Monitor> {
|
||||
let monitors: Vec<Monitor> = 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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
50
bread-utils/src/lib.rs
Normal file
50
bread-utils/src/lib.rs
Normal file
|
|
@ -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::Mutex<()>> = 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;
|
||||
174
bread-utils/src/proc.rs
Normal file
174
bread-utils/src/proc.rs
Normal file
|
|
@ -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 <subcommand>` / `<tool> --json` pattern.
|
||||
pub fn run_json(prog: &str, args: &[&str], timeout: Duration) -> Option<serde_json::Value> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
209
bread-utils/src/singleton.rs
Normal file
209
bread-utils/src/singleton.rs
Normal file
|
|
@ -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/<pid>/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<u32>),
|
||||
}
|
||||
|
||||
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/<app>.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<Acquire> {
|
||||
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::<u32>().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<Toggle> {
|
||||
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.
|
||||
}
|
||||
100
bread-utils/src/tomlcfg.rs
Normal file
100
bread-utils/src/tomlcfg.rs
Normal file
|
|
@ -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 `<path>.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 `<path>.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::<DocumentMut>() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
123
bread-utils/src/xdg.rs
Normal file
123
bread-utils/src/xdg.rs
Normal file
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
133
docs/release-channels.md
Normal file
133
docs/release-channels.md
Normal file
|
|
@ -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 <name>`, `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/<name>/`,
|
||||
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 <name>` 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/<pkg>/<ver>/` | push tag `v*` |
|
||||
| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta/<pkg>/<ver>/` | push tag `beta-v*` |
|
||||
| dev | `dl.breadway.dev/dev/index.json` | `/srv/breadway-dl/dev/<pkg>/<ver>/` | 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 <stable|beta|dev>`
|
||||
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 |
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
||||
# Maintainer: Breadway <plasticbread849@gmail.com>
|
||||
|
||||
pkgname=bakery
|
||||
pkgver=0.2.3
|
||||
pkgrel=1
|
||||
pkgdesc="Package manager for the bread ecosystem"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Breadway/bread-ecosystem"
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
190
scripts/cleanup-old-mirror-workflows.sh
Executable file
190
scripts/cleanup-old-mirror-workflows.sh
Executable file
|
|
@ -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
|
||||
133
scripts/doctor-channels.sh
Executable file
133
scripts/doctor-channels.sh
Executable file
|
|
@ -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}"
|
||||
|
|
@ -1,19 +1,38 @@
|
|||
#!/usr/bin/env bash
|
||||
# Generate dl.breadway.dev/index.json from:
|
||||
# - registry/bread-ecosystem.toml (product list)
|
||||
# - <DL_DIR>/<name>/bakery.toml (per-product metadata, uploaded by release.yml)
|
||||
# - <DL_DIR>/ (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)
|
||||
# - <PKG_ROOT>/<name>/bakery.toml (per-product metadata, uploaded by release.yml)
|
||||
# - <PKG_ROOT>/ (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/<track>/ 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/<name>/
|
||||
local pkg_dir="${DL_DIR}/${name}"
|
||||
# Find the latest version dir under PKG_ROOT/<name>/
|
||||
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[@]}" </dev/null
|
||||
else
|
||||
# -W: the key has no password (matches how CI-facing signing keys are
|
||||
# normally generated, since there's no human to type a passphrase).
|
||||
minisign -W "${sign_args[@]}" </dev/null
|
||||
fi
|
||||
echo "signed ${OUT} -> ${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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
201
scripts/setup-push-mirrors.sh
Executable file
201
scripts/setup-push-mirrors.sh
Executable file
|
|
@ -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/<gh-owner>/<repo>.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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue