Compare commits

..

No commits in common. "4ac54c610d6db7c5e5a8dd31542022a8f83b5209" and "10f62fb1a62fc5ca4eab90ae5e1bfc8cd9bf9fc9" have entirely different histories.

52 changed files with 546 additions and 5092 deletions

View file

@ -1,61 +0,0 @@
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

View file

@ -1,56 +0,0 @@
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

View file

@ -1,80 +0,0 @@
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

View file

@ -1,69 +0,0 @@
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

View file

@ -0,0 +1,21 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
# --prune deletes GitHub refs that no longer exist on Forgejo.
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bread-ecosystem.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

@ -1,74 +0,0 @@
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

View file

@ -1,72 +0,0 @@
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 Normal file
View file

@ -0,0 +1,53 @@
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
View file

@ -1,7 +1 @@
/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

View file

@ -48,27 +48,16 @@ Establish a visual hierarchy with consistent rounding:
## Color System
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 (color16) track the current wallpaper:
All projects use **pywal dynamic theming** with **Catppuccin Mocha** as the fallback palette:
- **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.
- **Background**: `#1e1e2e` (Catppuccin)
- **Foreground**: `#cdd6f4` (Catppuccin)
- **Surface**: `#181825` (Catppuccin)
- **Accent**: Dynamic (from pywal)
Color palette slots (via wal):
- color0color7: ANSI colors (0 and 7 fixed, 16 pywal-derived)
- color0color7: ANSI colors
- 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
@ -115,9 +104,8 @@ add only app-specific rules:
hardcoded Nord palette; migrated to the shared stylesheet).
- **breadcrumbs** — CLI tool; ANSI colours only, no GUI styling.
> Palette note: background/surface/overlay/foreground are a fixed BOS dark
> base, never pywal-derived; only the accent slots (color16) track the
> current wallpaper via pywal.
> Palette note: the fallback is Catppuccin Mocha, but installs (e.g. BOS) drive
> the real palette from pywal — BOS ships a black-base palette.
## Future Consistency Checks

1153
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,12 @@
[workspace]
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx"]
members = ["bakery", "bread-theme"]
resolver = "2"
[workspace.package]
version = "0.3.1"
version = "0.2.3"
edition = "2021"
license = "MIT"
authors = ["Breadway <plasticbread849@gmail.com>"]
authors = ["Breadway <rileyhorsham@gmail.com>"]
[workspace.dependencies]
anyhow = "1"
@ -19,9 +19,6 @@ 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
View file

@ -1,21 +0,0 @@
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.

View file

@ -17,7 +17,6 @@ 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
@ -36,31 +35,17 @@ to keys.
## Theming
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`.
All GUIs share one look via `bread-theme`. The `bread-theme` CLI renders the
component stylesheet from your pywal palette (Catppuccin Mocha fallback) to
`$XDG_RUNTIME_DIR/bread/theme.css`; every app loads that file and **live-reloads**
it, so changing your wallpaper recolours the whole ecosystem with no rebuilds:
```sh
wal -i ~/Pictures/wall.png # regenerate pywal palette
bread-theme generate # render the shared stylesheet (run from a wal hook)
```
`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,
See [`BREAD_DESIGN_SYSTEM.md`](BREAD_DESIGN_SYSTEM.md) for the tokens (fonts,
spacing, radii, colour roles) the stylesheet is built from.
## Installing bakery
@ -107,6 +92,14 @@ 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:
@ -114,7 +107,7 @@ This repo is a Cargo workspace:
```
bread-ecosystem/
├── bakery/ # package manager binary
├── bread-theme/ # shared pywal + fixed-dark-base theming crate
├── bread-theme/ # shared pywal + Catppuccin theming crate
├── registry/ # bread-ecosystem.toml — product registry
└── scripts/
├── get.sh # curl | sh bootstrap

View file

@ -5,7 +5,7 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Package manager for the bread ecosystem"
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
repository = "https://github.com/Breadway/bread-ecosystem"
[dependencies]
anyhow = { workspace = true }
@ -18,8 +18,6 @@ sha2 = { workspace = true }
hex = { workspace = true }
clap = { workspace = true }
chrono = { workspace = true }
minisign-verify = { workspace = true }
semver = { workspace = true }
[dev-dependencies]
tempfile = "3"

View file

@ -1,4 +1,3 @@
use crate::ui;
use anyhow::Result;
use std::process::Command;
@ -53,37 +52,28 @@ 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!(" {}", ui::ok(&format!("{package_name}: no system deps required")));
println!(" {package_name}: no system deps required");
return true;
}
match check_deps(required, optional) {
Err(e) => {
eprintln!(" {}", ui::fail(&format!("error running doctor for {package_name}: {e}")));
eprintln!(" error running doctor for {package_name}: {e}");
false
}
Ok(rep) => {
for warn in &rep.warnings {
eprintln!(
" {}",
ui::style(
&format!(
"{package_name}: optional dep not found: {warn} \
(install for full functionality)"
),
ui::YELLOW
)
" {package_name}: optional dep not found: {warn} \
(install for full functionality)"
);
}
if rep.missing.is_empty() {
println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied")));
println!(" {package_name}: all required system deps satisfied");
true
} else {
eprintln!(
" {}",
ui::fail(&format!(
"{package_name}: missing system deps: {}",
rep.missing.join(", ")
))
" {package_name}: missing system deps: {}",
rep.missing.join(", ")
);
eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" "));
false

View file

@ -32,12 +32,7 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
Ok(())
}
/// 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<()> {
fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
let mut hasher = Sha256::new();
hasher.update(bytes);
let actual = hex::encode(hasher.finalize());

View file

@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::download::{fetch_and_place, verify_sha256};
use crate::download::fetch_and_place;
use crate::manifest::{fetch_binary, Package, Service};
use crate::state::{InstalledPackage, State};
@ -119,28 +119,11 @@ 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) => 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());
}
},
Ok(bytes) => {
std::fs::write(&dest, &bytes)
.with_context(|| format!("writing {}", dest.display()))?;
println!(" installed example config at {}", dest.display());
}
Err(e) => {
eprintln!(" warning: could not download example config {example}: {e}");
println!(" config dir created at {}", dir.display());
@ -168,19 +151,11 @@ 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) => 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
);
}
},
Ok(bytes) => {
std::fs::write(&unit_path, &bytes)
.with_context(|| format!("writing {}", unit_path.display()))?;
println!(" downloaded unit {}", unit_path.display());
}
Err(e) => {
eprintln!(" warning: could not download {}: {e}", svc.unit);
}

View file

@ -3,14 +3,11 @@ mod download;
mod install;
mod manifest;
mod state;
mod track;
mod ui;
use anyhow::{bail, Context, Result};
use anyhow::{bail, 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)]
@ -57,20 +54,6 @@ 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 {
@ -82,52 +65,23 @@ 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, track)?;
let index = manifest::load(true)?;
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, 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),
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()),
}
}
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)
@ -176,8 +130,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, track: Track) -> Result<()> {
let index = manifest::load(true, track)?;
fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path) -> Result<()> {
let index = manifest::load(true)?;
let state = state::State::load()?;
let targets: Vec<String> = if all || name.is_none() {
@ -208,16 +162,14 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: T
}
};
if !is_newer(&installed.version, &latest.version) {
println!("{}", ui::style(&format!("{pkg_name} is already at {}", installed.version), ui::GREEN));
if installed.version == latest.version {
println!("{pkg_name} is already at {}", installed.version);
continue;
}
println!(
"updating {pkg_name} {} {} {}",
ui::style(&installed.version, ui::DIM),
ui::style("", ui::CYAN),
ui::style(&latest.version, ui::BOLD)
"updating {pkg_name} {} → {}",
installed.version, latest.version
);
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
@ -252,28 +204,7 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: T
Ok(())
}
/// 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<()> {
fn cmd_list(installed_only: bool) -> Result<()> {
let state = state::State::load()?;
if installed_only {
@ -286,39 +217,35 @@ fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
return Ok(());
}
if !matches!(track, Track::Stable) {
println!("tracking:{}\n", ui::track_badge(track));
}
let index = manifest::load(false, track)?;
let index = manifest::load(false)?;
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) {
ui::style(&format!(" [installed {}]", state.packages[name].version), ui::GREEN)
format!(" [installed {}]", state.packages[name].version)
} else {
String::new()
};
println!(" {:<14} {:<10}{}{}", pkg.name, pkg.version, pkg.description, tag);
println!(" {} {}{}{}", pkg.name, pkg.version, pkg.description, tag);
}
Ok(())
}
fn cmd_info(name: &str, track: Track) -> Result<()> {
let index = manifest::load(false, track)?;
fn cmd_info(name: &str) -> Result<()> {
let index = manifest::load(false)?;
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) {
ui::style(&format!("installed ({})", inst.version), ui::GREEN)
format!("installed ({})", inst.version)
} else {
ui::style("not installed", ui::DIM)
"not installed".to_string()
};
println!("{}{} {}", ui::style(&pkg.name, ui::BOLD), ui::track_badge(track), pkg.version);
println!("{} {}", pkg.name, pkg.version);
println!(" {}", pkg.description);
println!(" status: {status}");
println!(
@ -351,8 +278,8 @@ fn cmd_info(name: &str, track: Track) -> Result<()> {
Ok(())
}
fn cmd_doctor(name: Option<&str>, track: Track) -> Result<()> {
let index = manifest::load(false, track)?;
fn cmd_doctor(name: Option<&str>) -> Result<()> {
let index = manifest::load(false)?;
let state = state::State::load()?;
let targets: Vec<String> = match name {
@ -383,41 +310,7 @@ fn cmd_doctor(name: Option<&str>, track: Track) -> Result<()> {
}
if all_ok {
println!("{}", ui::ok("all checks passed"));
println!("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"));
}
}

View file

@ -1,66 +1,11 @@
use crate::track::Track;
use anyhow::{bail, Context, Result};
use minisign_verify::{PublicKey, Signature};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
const DEFAULT_BASE_URL: &str = "https://dl.breadway.dev";
const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json";
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,
@ -73,10 +18,6 @@ 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)]
@ -84,10 +25,6 @@ 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)]
@ -141,46 +78,17 @@ impl Index {
}
}
/// 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);
/// 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();
if !force_refresh && cache_is_fresh(&cache_path) {
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…"
);
}
}
let text = std::fs::read_to_string(&cache_path).context("reading cached index")?;
return serde_json::from_str(&text).context("parsing cached index");
}
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")
fetch_and_cache(&cache_path)
}
fn cache_is_fresh(path: &PathBuf) -> bool {
@ -190,31 +98,13 @@ fn cache_is_fresh(path: &PathBuf) -> bool {
.unwrap_or(false)
}
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"))?;
fn fetch_and_cache(cache_path: &PathBuf) -> Result<Index> {
let text = fetch_text(PRIMARY_URL)?;
if let Some(dir) = cache_path.parent() {
std::fs::create_dir_all(dir)?;
}
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)
std::fs::write(cache_path, &text)?;
serde_json::from_str(&text).context("parsing index.json")
}
fn fetch_text(url: &str) -> Result<String> {
@ -225,19 +115,10 @@ fn fetch_text(url: &str) -> Result<String> {
.context("reading response body")
}
/// 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()),
};
pub fn cache_path() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("~/.cache"))
.join("bakery")
.join(file_name)
.join("bakery/index.json")
}
/// Download a binary blob from `primary_url`, falling back to `fallback_url`
@ -270,84 +151,3 @@ 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()));
}
}

View file

@ -1,4 +1,3 @@
use crate::track::Track;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@ -15,11 +14,6 @@ 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>,
}
@ -58,10 +52,6 @@ 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 {
@ -111,23 +101,6 @@ 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();

View file

@ -1,90 +0,0 @@
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);
}
}

View file

@ -1,60 +0,0 @@
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());
}
}

View file

@ -1,34 +0,0 @@
[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"

View file

@ -1,112 +0,0 @@
//! 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);
}
}

View file

@ -1,220 +0,0 @@
//! 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);
}
}

View file

@ -1,32 +0,0 @@
//! 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;

View file

@ -1,152 +0,0 @@
//! 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(())
}

View file

@ -1,49 +0,0 @@
//! 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()))
}

View file

@ -4,8 +4,8 @@ version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Shared pywal-accented, fixed-dark-base theming crate for the bread ecosystem"
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
description = "Shared pywal + Catppuccin theming crate for the bread ecosystem"
repository = "https://github.com/Breadway/bread-ecosystem"
keywords = ["theming", "pywal", "gtk4", "wayland"]
[dependencies]

View file

@ -1,11 +0,0 @@
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",
]

View file

@ -24,23 +24,31 @@ pub mod tokens {
pub const RADIUS_PILL: u16 = 999;
}
/// 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.
/// Emit the `@define-color` block that all bread apps use.
/// Apps append their own rules below this; user CSS goes on top.
pub fn css_vars(p: &Palette) -> String {
format!(
"{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n",
vars = define_colors(p),
"@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,
font = tokens::FONT_FAMILY,
size = tokens::FONT_SIZE_BASE,
)
@ -233,33 +241,6 @@ 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());

View file

@ -2,66 +2,42 @@ use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
/// 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.
/// Full 8-colour pywal palette. Catppuccin Mocha is the fallback.
#[derive(Debug, Clone)]
pub struct Palette {
pub background: String,
pub foreground: String,
/// Fixed — darkest surface / overlay, never pywal-derived.
/// ANSI color0 — darkest surface / overlay
pub color0: String,
/// ANSI color1 — red (pywal accent)
/// ANSI color1 — red
pub color1: String,
/// ANSI color2 — green (pywal accent)
/// ANSI color2 — green
pub color2: String,
/// ANSI color3 — yellow (pywal accent)
/// ANSI color3 — yellow
pub color3: String,
/// ANSI color4 — blue / primary accent (pywal accent)
/// ANSI color4 — blue (primary accent)
pub color4: String,
/// ANSI color5 — pink / magenta (pywal accent)
/// ANSI color5 — pink / magenta
pub color5: String,
/// ANSI color6 — teal / cyan (pywal accent)
/// ANSI color6 — teal / cyan
pub color6: String,
/// Fixed — light overlay / muted fg, never pywal-derived.
/// ANSI color7 — light overlay / muted fg
pub color7: String,
}
impl Default for Palette {
fn default() -> Self {
Palette {
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(),
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(),
}
}
}
@ -70,9 +46,16 @@ impl Default for Palette {
struct WalColors {
#[serde(default)]
colors: HashMap<String, String>,
special: Option<WalSpecial>,
}
/// Load palette from pywal's `colors.json`. Falls back to [`Palette::default`].
#[derive(Deserialize)]
struct WalSpecial {
background: Option<String>,
foreground: Option<String>,
}
/// Load palette from pywal's `colors.json`. Falls back to Catppuccin Mocha.
pub fn load_palette() -> Palette {
let path = wal_path();
std::fs::read_to_string(&path)
@ -87,16 +70,18 @@ pub(crate) fn from_wal_json(json: &str) -> Option<Palette> {
wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into())
};
Some(Palette {
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(),
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"),
})
}
@ -120,39 +105,39 @@ mod tests {
}"##;
#[test]
fn default_is_bos_fixed_dark_theme() {
fn default_is_catppuccin_mocha() {
let p = Palette::default();
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");
assert_eq!(p.background, "#1e1e2e");
assert_eq!(p.foreground, "#cdd6f4");
assert_eq!(p.color4, "#89b4fa");
}
#[test]
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.
fn wal_json_parses_special() {
let p = from_wal_json(TOKYO_NIGHT).unwrap();
assert_eq!(p.background, "#0c0c0c");
assert_eq!(p.foreground, "#e8e8e8");
assert_eq!(p.color0, "#1a1a1a");
assert_eq!(p.color7, "#d8d8d8");
assert_eq!(p.background, "#1a1b26");
assert_eq!(p.foreground, "#c0caf5");
}
#[test]
fn wal_json_parses_accent_colors() {
fn wal_json_parses_colors() {
let p = from_wal_json(TOKYO_NIGHT).unwrap();
assert_eq!(p.color1, "#f7768e");
assert_eq!(p.color0, "#15161e");
assert_eq!(p.color4, "#7aa2f7");
assert_eq!(p.color6, "#7dcfff");
assert_eq!(p.color7, "#a9b1d6");
}
#[test]
fn wal_json_missing_accent_color_uses_bos_default() {
fn wal_json_missing_special_uses_catppuccin_fallback() {
let p = from_wal_json(r#"{"colors":{}}"#).unwrap();
assert_eq!(p.color4, "#eab672");
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");
}
#[test]
@ -162,10 +147,9 @@ mod tests {
}
#[test]
fn empty_object_returns_bos_defaults() {
fn empty_object_returns_all_defaults() {
let p = from_wal_json("{}").unwrap();
assert_eq!(p.background, "#0c0c0c");
assert_eq!(p.color4, "#eab672");
assert_eq!(p.background, "#1e1e2e");
}
#[test]

View file

@ -1,36 +0,0 @@
[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"

View file

@ -1,185 +0,0 @@
//! 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);
}
}

View file

@ -1,302 +0,0 @@
//! 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();
}
}

View file

@ -1,111 +0,0 @@
//! 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: &gtk4::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: &gtk4::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: &gtk4::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);
}

View file

@ -1,252 +0,0 @@
//! 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");
}
}
}

View file

@ -1,50 +0,0 @@
//! 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;

View file

@ -1,174 +0,0 @@
//! 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());
}
}

View file

@ -1,209 +0,0 @@
//! 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.
}

View file

@ -1,100 +0,0 @@
//! 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);
}
}

View file

@ -1,123 +0,0 @@
//! 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());
}
}

View file

@ -1,133 +0,0 @@
# 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 |

View file

@ -1,11 +1,11 @@
# Maintainer: Breadway <plasticbread849@gmail.com>
# Maintainer: Breadway <rileyhorsham@gmail.com>
pkgname=bakery
pkgver=0.2.3
pkgrel=1
pkgdesc="Package manager for the bread ecosystem"
arch=('x86_64')
url="https://git.breadway.dev/Breadway/bread-ecosystem"
url="https://github.com/Breadway/bread-ecosystem"
license=('MIT')
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,

View file

@ -12,11 +12,6 @@ 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"
@ -46,28 +41,3 @@ 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"

View file

@ -1,190 +0,0 @@
#!/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

View file

@ -1,133 +0,0 @@
#!/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}"

View file

@ -1,38 +1,19 @@
#!/usr/bin/env bash
# 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)
# 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)
#
# 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"
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
OUT="${DL_DIR}/index.json"
# Read the product list from the registry TOML instead of a hardcoded array.
mapfile -t products < <(python3 -c "
@ -49,8 +30,8 @@ build_package_json() {
local name="$1"
local repo="$2"
# Find the latest version dir under PKG_ROOT/<name>/
local pkg_dir="${PKG_ROOT}/${name}"
# Find the latest version dir under DL_DIR/<name>/
local pkg_dir="${DL_DIR}/${name}"
if [[ ! -d "${pkg_dir}" ]]; then
echo " warning: no release dir for ${name} at ${pkg_dir}" >&2
return 1
@ -75,7 +56,6 @@ 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}")"
@ -84,17 +64,8 @@ build_package_json() {
if [[ -f "${sha256_path}" ]]; then
sha256="$(awk '{print $1}' "${sha256_path}")"
fi
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 dl_url="${DL_BASE}/${name}/${version}/${bin_name}"
local gh_url="${GH_BASE}/${repo}/releases/download/v${version}/${bin_name}"
local entry
entry="$(jq -n \
@ -114,7 +85,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}the release workflow must copy it to \${PKG_ROOT}/${name}/\${VERSION}/bakery.toml" >&2
echo "ERROR: bakery.toml not found for ${name}release.yml must copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2
return 1
fi
@ -148,12 +119,8 @@ with open('${bakery_toml}', 'rb') as f:
print(json.dumps(d.get('bread_deps', [])))
" 2>/dev/null || echo "[]")"
# [[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 "
# [[service]] entries → [{unit, enable}]
services="$(python3 -c "
import tomllib, json
with open('${bakery_toml}', 'rb') as f:
d = tomllib.load(f)
@ -161,24 +128,7 @@ svcs = d.get('service', [])
print(json.dumps([{'unit': s['unit'], 'enable': s.get('enable', False)} for s in svcs]))
" 2>/dev/null || echo "[]")"
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] → {dir, example?} or null
config="$(python3 -c "
import tomllib, json
with open('${bakery_toml}', 'rb') as f:
@ -192,19 +142,6 @@ 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
@ -257,42 +194,3 @@ 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

View file

@ -4,13 +4,6 @@
# 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}"
@ -26,16 +19,12 @@ 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.
@ -49,63 +38,30 @@ fi
mkdir -p "${BIN_DIR}"
TMP="$(mktemp)"
trap 'rm -f "${TMP}" "${TMP}.sha256" "${TMP}.minisig"' EXIT
trap 'rm -f "${TMP}" "${TMP}.sha256"' EXIT
echo "downloading bakery…"
if fetch "${DL_PRIMARY}" "${TMP}" 2>/dev/null; then
echo " from dl.breadway.dev"
sig_url="${SIG_URL}"
checksum_only_fallback_note=" warning: could not fetch checksum — skipping verification"
# Verify checksum when available from primary.
if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then
expected="$(awk '{print $1}' "${TMP}.sha256")"
actual="$(sha256sum "${TMP}" | awk '{print $1}')"
if [ "${expected}" != "${actual}" ]; then
die "SHA-256 checksum mismatch (expected ${expected}, got ${actual})"
fi
echo " checksum verified"
else
echo " warning: could not fetch checksum — skipping verification"
fi
elif fetch "${DL_FALLBACK}" "${TMP}" 2>/dev/null; then
echo " from GitHub (fallback)"
sig_url="${SIG_FALLBACK}"
checksum_only_fallback_note=" warning: no checksum available for GitHub fallback download"
# No .sha256 on the GitHub fallback path; proceed without verification.
echo " warning: checksum not verified 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"

View file

@ -1,201 +0,0 @@
#!/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

View file

@ -110,12 +110,4 @@ 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"