Compare commits
77 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
347f356b1d | ||
|
|
f5a47490f7 | ||
|
|
4f8859527d | ||
|
|
e2c6452e4f | ||
|
|
a9754d90ed | ||
|
|
fcba376038 | ||
|
|
11c0e844e5 | ||
|
|
c296d26408 | ||
|
|
b20e4bcec1 | ||
|
|
3f1caa99f5 | ||
|
|
30517f1617 | ||
|
|
25fde8e4f0 | ||
|
|
f3d946f325 | ||
|
|
08b71262da | ||
|
|
69ce2d67a8 | ||
|
|
1322fc31ac | ||
|
|
2a23d81e39 | ||
| 6057ed39b0 | |||
|
|
b907b92faf | ||
|
|
147cfbbf96 | ||
|
|
a4f0c96b90 | ||
|
|
eba8cb6c44 | ||
|
|
227247907b | ||
|
|
d45fc422f2 | ||
|
|
620c5a1317 | ||
|
|
69c24f6d04 | ||
|
|
cd5da468b3 | ||
|
|
3f5f241985 | ||
|
|
f86e299f4a | ||
|
|
036f270b07 | ||
|
|
4f0fe2571d | ||
|
|
3caad809a3 | ||
|
|
f8f69f4ae5 | ||
|
|
594c18bf1b | ||
|
|
9b097218fd | ||
|
|
4e76bc7077 | ||
|
|
e898535bb4 | ||
|
|
6eb3479529 | ||
|
|
7c7881ddf4 | ||
|
|
79d4d737cf | ||
|
|
7ec232b86d | ||
|
|
94feaa6f9b | ||
|
|
669ca64284 | ||
|
|
271555fb80 | ||
|
|
c0b489fa67 | ||
|
|
64cc17905d | ||
|
|
e402bb3cb7 | ||
|
|
e3df426996 | ||
|
|
ff17a028a3 | ||
|
|
cdcb931f37 | ||
|
|
c93f4cf4d0 | ||
|
|
af796253e9 | ||
|
|
1ce5514939 | ||
|
|
03749787c5 | ||
|
|
f06ba904b7 | ||
|
|
231f71e586 | ||
|
|
1a3475bd23 | ||
|
|
21099065c4 | ||
|
|
27b3b17c58 | ||
|
|
d059d99437 | ||
|
|
686af0d3dc | ||
|
|
bcd57b7b54 | ||
|
|
eb090b198f | ||
|
|
007082374d | ||
|
|
77bca8a1cf | ||
|
|
c7abfae630 | ||
|
|
5afe12d70f | ||
|
|
02aeb0406a | ||
|
|
0425c64214 | ||
|
|
2b05a4f6c2 | ||
|
|
afa3686e9a | ||
|
|
d6e20a082a | ||
|
|
86e712d726 | ||
|
|
4ac54c610d | ||
|
|
db2fa3c4b4 | ||
|
|
0b272838df | ||
|
|
157ed6e378 |
69 changed files with 9287 additions and 597 deletions
99
.forgejo/workflows/dev-bakery.yml
Normal file
99
.forgejo/workflows/dev-bakery.yml
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
name: dev bakery
|
||||
|
||||
# Publishes a dev-track build on every push to `main` (the trunk branch —
|
||||
# there is no separate `dev` branch). See docs/release-channels.md for the
|
||||
# release-track policy this is part of.
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'bakery/**'
|
||||
- 'bread-utils/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- '.forgejo/workflows/dev-bakery.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf src && mkdir src
|
||||
git clone --branch main --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
- name: build
|
||||
run: cd src && cargo build --release --locked -p bakery
|
||||
|
||||
- name: test
|
||||
run: cd src && cargo test --release --locked -p bakery
|
||||
|
||||
# Auto-bumps the patch version from Cargo.toml and appends a
|
||||
# timestamp+sha dev suffix — no developer discipline required, and the
|
||||
# result is visibly "ahead of" the last stable patch release while
|
||||
# staying valid semver (comparable within the dev track by bakery's
|
||||
# `is_newer`).
|
||||
- name: compute dev version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src
|
||||
# Base the dev version off the latest published stable tag,
|
||||
# not Cargo.toml — Cargo.toml can go stale relative to the last
|
||||
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
|
||||
# breadpaper), which would make a dev build sort as OLDER than
|
||||
# what's already installed and bakery would correctly refuse it.
|
||||
LATEST_TAG="$(git ls-remote --tags --refs \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
|
||||
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
|
||||
if [ -n "${LATEST_TAG}" ]; then
|
||||
CUR="${LATEST_TAG}"
|
||||
else
|
||||
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
||||
fi
|
||||
IFS='.' read -r MA MI PA <<< "${CUR}"
|
||||
SHA="$(git rev-parse --short HEAD)"
|
||||
TS="$(date -u +%Y%m%d%H%M%S)"
|
||||
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_DIR="/srv/breadway-dl/dev/bakery/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64"
|
||||
strip "${PKG_DIR}/bakery-x86_64"
|
||||
sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/bakery-x86_64.sha256"
|
||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/bakery/latest"
|
||||
|
||||
- name: sign dev binary
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_DIR="/srv/breadway-dl/dev/bakery/${VERSION}"
|
||||
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \
|
||||
-x "${PKG_DIR}/bakery-x86_64.minisig" </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: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
|
||||
exit 1
|
||||
fi
|
||||
cd src && bash scripts/gen-index.sh
|
||||
87
.forgejo/workflows/dev-bread-theme.yml
Normal file
87
.forgejo/workflows/dev-bread-theme.yml
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
name: dev bread-theme
|
||||
|
||||
# Publishes a dev-track build on every push to `main` (the trunk branch —
|
||||
# there is no separate `dev` branch). See docs/release-channels.md for the
|
||||
# release-track policy this is part of.
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- '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 main --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
|
||||
# Base the dev version off the latest published stable tag,
|
||||
# not Cargo.toml — Cargo.toml can go stale relative to the last
|
||||
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
|
||||
# breadpaper), which would make a dev build sort as OLDER than
|
||||
# what's already installed and bakery would correctly refuse it.
|
||||
LATEST_TAG="$(git ls-remote --tags --refs \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
|
||||
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
|
||||
if [ -n "${LATEST_TAG}" ]; then
|
||||
CUR="${LATEST_TAG}"
|
||||
else
|
||||
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
||||
fi
|
||||
IFS='.' read -r MA MI PA <<< "${CUR}"
|
||||
SHA="$(git rev-parse --short HEAD)"
|
||||
TS="$(date -u +%Y%m%d%H%M%S)"
|
||||
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_DIR="/srv/breadway-dl/dev/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: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
|
||||
exit 1
|
||||
fi
|
||||
cd src && bash scripts/gen-index.sh
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['**']
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: Mirror to GitHub
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
|
||||
cd repo.git
|
||||
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
|
||||
# --prune deletes GitHub refs that no longer exist on Forgejo.
|
||||
git push --prune \
|
||||
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bread-ecosystem.git" \
|
||||
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||
|
|
@ -6,6 +6,9 @@ on:
|
|||
|
||||
jobs:
|
||||
package:
|
||||
# PKGBUILD pkgver cannot contain `-`; skip RC tags the same way
|
||||
# release-bakery.yml does.
|
||||
if: ${{ !contains(github.ref_name, '-rc.') }}
|
||||
runs-on: [self-hosted, hestia]
|
||||
container:
|
||||
image: archlinux:latest
|
||||
|
|
|
|||
73
.forgejo/workflows/rc-bakery.yml
Normal file
73
.forgejo/workflows/rc-bakery.yml
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
name: beta (rc) bakery
|
||||
|
||||
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag pushed
|
||||
# to `main` — there is no separate `beta` branch; "freezing" is just
|
||||
# pausing pushes to main while an RC gets tested. See
|
||||
# docs/release-channels.md for the release-track policy.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
# No paths: filter. Tag pushes compare against an unrelated commit and
|
||||
# would skip the RC publish if bakery/** wasn't in that diff; the job
|
||||
# `if: contains -rc.` is the real gate.
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ contains(github.ref_name, '-rc.') }}
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
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}"
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64"
|
||||
strip "${PKG_DIR}/bakery-x86_64"
|
||||
sha256sum "${PKG_DIR}/bakery-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/bakery-x86_64.sha256"
|
||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bakery/latest"
|
||||
|
||||
- name: sign beta binary
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}"
|
||||
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bakery-x86_64" \
|
||||
-x "${PKG_DIR}/bakery-x86_64.minisig" </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 — beta
|
||||
# builds happen on every push while the branch is frozen for testing,
|
||||
# so dl.breadway.dev/beta/ is the only distribution point for this track.
|
||||
- name: regenerate beta index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
TRACK: beta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
|
||||
exit 1
|
||||
fi
|
||||
cd src && bash scripts/gen-index.sh
|
||||
67
.forgejo/workflows/rc-bread-theme.yml
Normal file
67
.forgejo/workflows/rc-bread-theme.yml
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
name: beta (rc) bread-theme
|
||||
|
||||
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag pushed
|
||||
# to `main` — there is no separate `beta` branch; "freezing" is just
|
||||
# pausing pushes to main while an RC gets tested. See
|
||||
# docs/release-channels.md for the release-track policy.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
# No paths: filter. Tag pushes compare against an unrelated commit and
|
||||
# would skip the RC publish if bread-theme/** wasn't in that diff; the
|
||||
# job `if: contains -rc.` is the real gate.
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ contains(github.ref_name, '-rc.') }}
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
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}"
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64"
|
||||
strip "${PKG_DIR}/bread-theme-x86_64"
|
||||
sha256sum "${PKG_DIR}/bread-theme-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/bread-theme-x86_64.sha256"
|
||||
cp src/bread-theme/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bread-theme/latest"
|
||||
|
||||
- name: sign beta binary
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}"
|
||||
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/bread-theme-x86_64" \
|
||||
-x "${PKG_DIR}/bread-theme-x86_64.minisig" </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: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
|
||||
exit 1
|
||||
fi
|
||||
cd src && bash scripts/gen-index.sh
|
||||
|
|
@ -6,6 +6,7 @@ on:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ !contains(github.ref_name, '-rc.') }}
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
|
|
@ -58,7 +59,13 @@ jobs:
|
|||
- name: regenerate index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: cd src && bash scripts/gen-index.sh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate stable index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the stable track)"
|
||||
exit 1
|
||||
fi
|
||||
cd src && bash scripts/gen-index.sh
|
||||
|
||||
- name: upload to GitHub Release
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ on:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ !contains(github.ref_name, '-rc.') }}
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
|
|
@ -56,7 +57,13 @@ jobs:
|
|||
- name: regenerate index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: cd src && bash scripts/gen-index.sh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate stable index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the stable track)"
|
||||
exit 1
|
||||
fi
|
||||
cd src && bash scripts/gen-index.sh
|
||||
|
||||
- name: upload to GitHub Release
|
||||
env:
|
||||
|
|
|
|||
24
AGENTS.md
Normal file
24
AGENTS.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# AGENTS.md — Repo hygiene
|
||||
|
||||
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation.
|
||||
|
||||
Follow [`CONTRIBUTING.md`](CONTRIBUTING.md) for any git, branch, or release work. Channel/track policy lives in [`docs/release-channels.md`](docs/release-channels.md). The product list is [`registry/bread-ecosystem.toml`](registry/bread-ecosystem.toml) — regenerate the README table with `scripts/gen-readme-products.sh` after editing it. Don't invent a second long-lived branch; there is only `main`. Bakery's package version **must** match `[workspace.package] version` in the root `Cargo.toml` at tag time (`bakery --version` is compiled from that field; `bakery list` reports the git tag) — never push a `v*` tag without bumping Cargo.toml to the same `X.Y.Z`.
|
||||
|
||||
## Remotes
|
||||
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
|
||||
- `github` — GitHub mirror. Push both when publishing.
|
||||
|
||||
## CI
|
||||
- `.forgejo/workflows/package.yml`, `release-bakery.yml`, `release-bread-theme.yml` all trigger on `push: tags: ['v*']`, gated to skip any tag containing `-rc.` — pushing to `main` doesn't run these. Tag a release to trigger packaging.
|
||||
- `dev-bakery.yml` / `dev-bread-theme.yml` trigger on `push: branches: ['main']`; `rc-bakery.yml` / `rc-bread-theme.yml` trigger on `push: tags: ['v*']` gated to *only* run for `-rc.` tags — both auto-publish a signed, auto-versioned build to `dl.breadway.dev/{dev,beta}/`. See `docs/release-channels.md` for the full track (stable/beta/dev) policy.
|
||||
- No build/lint/test CI runs on ordinary commits or PRs to `main` beyond the dev-track workflow above — there's no separate lint/PR-check pipeline.
|
||||
|
||||
## Cleanup
|
||||
- Delete feature/fix branches (local + remote) once merged. Check with `git branch --merged main`.
|
||||
- A `fix/audit-findings` branch and a merged `copilot/create-readme-md` branch (both local and on `origin`/`github`) were found stale and fully merged here on 2026-07-21 and removed.
|
||||
|
||||
## Don't
|
||||
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||
- Don't flip bakery's default install prefix. System prefix (`/usr/local` via
|
||||
`/etc/bakery/config.toml` or `BAKERY_PREFIX`) is for BOS; hermes and
|
||||
`get.sh` stay on `~/.local`. See [`bakery/README.md`](bakery/README.md).
|
||||
120
CONTRIBUTING.md
Normal file
120
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Contributing
|
||||
|
||||
This repo is a Cargo workspace. Bakery-channel products shipped from here
|
||||
are `bakery` (the ecosystem package manager) and `bread-theme` (the shared
|
||||
theming crate). Shared crates that sibling apps pin — not bakery packages
|
||||
of their own — are `bread-utils`, `bread-app`, `bread-onnx`,
|
||||
`bread-screenshots`, and `bread-capture`. `bread-polkit` is an in-tree
|
||||
session agent: it has `bread-polkit/bakery.toml` so it *can* be published,
|
||||
but it is not in `registry/bread-ecosystem.toml` (unpublished — not on
|
||||
the bakery index, not on the BOS ISO). Other ecosystem products
|
||||
(`bread`, `breadbar`, `breadbox`, …) live in their own repos under
|
||||
`Breadway/` but follow the same workflow described here. The product list
|
||||
is `registry/bread-ecosystem.toml`. New GTK tools should depend on
|
||||
`bread-app` instead of copying another app's bootstrap.
|
||||
|
||||
## Branches
|
||||
|
||||
There is one long-lived branch: **`main`**. All day-to-day work lands here.
|
||||
Every push to `main` automatically builds and publishes a **dev-track**
|
||||
build for both products (see Tracks below) — use this to test your change
|
||||
in a real install before cutting anything more formal.
|
||||
|
||||
New work — features and bug fixes alike — goes on a short-lived branch:
|
||||
|
||||
```
|
||||
feature/<short-name>
|
||||
fix/<issue-number-or-short-name>
|
||||
```
|
||||
|
||||
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
|
||||
branches get deleted on merge — they never accumulate the kind of drift a
|
||||
second long-lived branch does.
|
||||
|
||||
## The release cycle
|
||||
|
||||
There's no separate `beta` or release branch — "stable" and "beta" are both
|
||||
just **tags** on `main`, not branches that need to be kept in sync:
|
||||
|
||||
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
|
||||
auto-publishes a dev build for both `bakery` and `bread-theme` — install
|
||||
with `bakery track set dev` and `bakery update --all`, then fix anything
|
||||
broken with another push.
|
||||
2. When you want to stabilize before a real release, tag a release
|
||||
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
|
||||
both remotes). That tag alone triggers a beta-track build —
|
||||
"freezing" is just pausing pushes to `main` while you test it, not a
|
||||
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
|
||||
3. Once an RC has gone without issues, tag the real release:
|
||||
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
|
||||
signed stable release build.
|
||||
|
||||
**Version honesty**: bakery's compiled `--version` is
|
||||
`[workspace.package] version` in the root `Cargo.toml`. The bakery
|
||||
package version in the index (what `bakery list` shows) is the git tag.
|
||||
Those must match at tag time — bump `workspace.package.version` to
|
||||
`X.Y.Z` *before* pushing `vX.Y.Z` or `vX.Y.Z-rc.N`. Never jump a tag
|
||||
(e.g. `v0.3.1` → `v0.7.1`) without that Cargo.toml bump; the resulting
|
||||
binary will report the old workspace version while the index claims the
|
||||
new tag.
|
||||
|
||||
**Note**: `bakery` and `bread-theme` share the same `v*` tag pattern
|
||||
(both `release-bakery.yml` and `release-bread-theme.yml` trigger on
|
||||
`tags: ['v*']`, pre-existing behavior this doc isn't changing) — a single
|
||||
tag push builds and publishes a release for *both* products at once. If
|
||||
you ever need to release one independently of the other, that's a real gap
|
||||
worth fixing in the workflow files themselves, not something to work around
|
||||
by hand.
|
||||
|
||||
## Tracks, from a user's perspective
|
||||
|
||||
```
|
||||
bakery track show # what you're currently on (defaults to stable)
|
||||
bakery track set dev # or beta, or stable
|
||||
bakery update --all # pull the latest build on your current track
|
||||
```
|
||||
|
||||
| Track | What it is | Published from |
|
||||
|--------|-----------|-----------------|
|
||||
| `stable` | The last tagged release | a `vX.Y.Z` tag |
|
||||
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
|
||||
| `dev` | Bleeding edge | `main`, on every push |
|
||||
|
||||
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
|
||||
latest published stable tag, so they always sort as newer than what you
|
||||
have installed — no manual version bumping needed. Beta versions are just
|
||||
the RC tag itself (already valid semver, already sorts below the real
|
||||
release it's a candidate for).
|
||||
|
||||
## Local development
|
||||
|
||||
```sh
|
||||
cargo build --release -p bakery
|
||||
cargo test --release -p bakery
|
||||
```
|
||||
|
||||
`bakery`, `bread-theme`, `bread-utils`, `bread-app`, `bread-polkit`,
|
||||
`bread-onnx`, `bread-screenshots`, and `bread-capture` are all workspace
|
||||
members. Run the same commands with `-p bread-theme --bin bread-theme`
|
||||
for that crate, `-p bread-utils --features bread-client` for the IPC
|
||||
client, or `-p bread-app --features bread-client` for the GTK bootstrap
|
||||
helpers.
|
||||
|
||||
## CI
|
||||
|
||||
- `dev-bakery.yml` / `dev-bread-theme.yml` — triggered on push to `main`.
|
||||
- `rc-bakery.yml` / `rc-bread-theme.yml` — triggered on any `vX.Y.Z-rc.N`
|
||||
tag push.
|
||||
- `release-bakery.yml` / `release-bread-theme.yml` — triggered on any other
|
||||
`v*` tag push, cuts the actual stable release.
|
||||
- `package.yml` — publishes `bakery` to the `[breadway]` pacman repo, also
|
||||
tag-triggered.
|
||||
|
||||
All CI runs on a self-hosted runner; nothing runs automatically on plain
|
||||
commits or PRs beyond the track builds above. See
|
||||
[`docs/release-channels.md`](docs/release-channels.md) for the full policy,
|
||||
including how a new product gets wired onto these tracks.
|
||||
|
||||
## Questions
|
||||
|
||||
Open an issue on this repo's Forgejo tracker.
|
||||
669
Cargo.lock
generated
669
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,9 @@
|
|||
[workspace]
|
||||
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx"]
|
||||
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.7.4"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Breadway <plasticbread849@gmail.com>"]
|
||||
|
|
@ -21,6 +21,7 @@ clap = { version = "4", features = ["derive", "env"] }
|
|||
chrono = "0.4"
|
||||
minisign-verify = "0.2"
|
||||
tracing = "0.1"
|
||||
semver = "1"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
|
|
|
|||
118
README.md
118
README.md
|
|
@ -4,20 +4,36 @@ A collection of Rust tools for the Linux desktop (Hyprland / Wayland / Arch).
|
|||
Install any product with a single command — no Rust toolchain required.
|
||||
|
||||
```sh
|
||||
curl https://breadway.dev/get | sh
|
||||
curl -fsSL https://get.breadway.dev | sh
|
||||
bakery install breadbar
|
||||
```
|
||||
|
||||
## Products
|
||||
|
||||
The table below is generated from [`registry/bread-ecosystem.toml`](registry/bread-ecosystem.toml). Regenerate with `scripts/gen-readme-products.sh`.
|
||||
|
||||
<!-- gen-readme-products:start -->
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `bread` | Reactive automation daemon (`breadd`) + CLI — Lua scripting over Hyprland, udev, power, network, and Bluetooth events |
|
||||
| `breadbar` | GTK4 status bar (workspaces, clock, CPU/RAM/battery/WiFi/Bluetooth) and D-Bus notification daemon for Hyprland |
|
||||
| `breadbox` | GTK4 fuzzy app launcher for Hyprland with context-aware sorting; ships an icon-sync daemon (`breadbox-sync`) |
|
||||
| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale exit-node management and a self-healing watch daemon |
|
||||
| `breadpad` | Quick-capture scratchpad popup with AI-powered note classification, reminders, recurrence, and a full note viewer (`breadman`) |
|
||||
| `bakery` | Bread ecosystem package manager |
|
||||
| `bread-theme` | Shared pywal-accented, fixed-dark-base theming CLI for the bread ecosystem |
|
||||
| `bread` | Reactive automation daemon and CLI for Linux desktops |
|
||||
| `breadbar` | Minimal status bar and notification daemon for Hyprland |
|
||||
| `breadbox` | App launcher for Hyprland / Wayland |
|
||||
| `breadcrumbs` | Profile-aware Wi-Fi state machine with Tailscale integration |
|
||||
| `breadpad` | Quick-capture scratchpad and note viewer with AI classification |
|
||||
| `breadpaper` | Wallpaper manager for the bread desktop |
|
||||
| `breadmon` | Terminal UI monitor manager for Hyprland |
|
||||
| `breadsearch` | Semantic system-wide search for BOS |
|
||||
| `breadclip` | Wayland clipboard history manager for Hyprland |
|
||||
| `breadshot` | Screenshot utility for the bread ecosystem |
|
||||
| `bos-settings` | System settings app for Bread OS |
|
||||
| `breadhelp` | Onboarding and help center for Bread OS |
|
||||
| `breadcast` | Cast your screen to any Chromecast/Google TV or DLNA renderer — daemon + GTK4 popup — Bakery product; not included in the BOS ISO |
|
||||
| `breadarr` | Single-daemon Sonarr+Radarr+Prowlarr replacement — release watching, matching, grabbing, importing, and a terminal UI, no web UI — Homelab, not shipped on BOS |
|
||||
|
||||
<!-- gen-readme-products:end -->
|
||||
|
||||
## Recommended keybinds
|
||||
|
||||
|
|
@ -68,9 +84,7 @@ spacing, radii, colour roles) the stylesheet is built from.
|
|||
`bakery` is the package manager for the ecosystem. Install it with the bootstrap script:
|
||||
|
||||
```sh
|
||||
curl https://breadway.dev/get | sh
|
||||
# or
|
||||
curl -sSfL https://get.breadway.dev | sh
|
||||
curl -fsSL https://get.breadway.dev | sh
|
||||
```
|
||||
|
||||
The script downloads the prebuilt `bakery` binary to `~/.local/bin/bakery` and prints a note if that directory isn't on your `PATH` yet.
|
||||
|
|
@ -92,6 +106,26 @@ bakery remove <pkg> # remove a package (data files are never deleted)
|
|||
|
||||
`bakery install` runs `doctor` first and bails with a clear message if any system dependency is missing. Binaries land in `~/.local/bin` (override with `BAKERY_BIN_DIR`).
|
||||
|
||||
## System prefix (BOS)
|
||||
|
||||
Default install root is `~/.local`. BOS sets a system prefix so bakery-managed
|
||||
desktop apps live on the `@` root subvolume and ride along with
|
||||
snapper/grub-btrfs snapshots:
|
||||
|
||||
```toml
|
||||
# /etc/bakery/config.toml
|
||||
prefix = "/usr/local"
|
||||
```
|
||||
|
||||
`BAKERY_PREFIX` overrides the config file. A non-home prefix installs bins to
|
||||
`$prefix/bin`, share/data/desktop/licenses to `$prefix/share/...`, and systemd
|
||||
user units to `/usr/lib/systemd/user`. Per-user state (`installed.json`,
|
||||
update backups) stays in `~/.local/state/bakery`. Writes that need root use
|
||||
`sudo -n`, then `pkexec`. `bakery doctor` prints the active prefix.
|
||||
|
||||
Hermes and `get.sh` are unchanged — they keep the user-local default. See
|
||||
[`bakery/README.md`](bakery/README.md).
|
||||
|
||||
## System dependencies by product
|
||||
|
||||
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.
|
||||
|
|
@ -109,22 +143,66 @@ Install all required deps with `sudo pacman -S <packages>`. Use `pacman -Q <pkg>
|
|||
|
||||
## Workspace
|
||||
|
||||
This repo is a Cargo workspace:
|
||||
This repo is a Cargo workspace. Bakery-channel products shipped from here
|
||||
are `bakery` and `bread-theme`; the other members are shared crates sibling
|
||||
apps pin, or in-tree tools that are not bakery packages of their own.
|
||||
|
||||
```
|
||||
bread-ecosystem/
|
||||
├── bakery/ # package manager binary
|
||||
├── bread-theme/ # shared pywal + fixed-dark-base theming crate
|
||||
├── registry/ # bread-ecosystem.toml — product registry
|
||||
├── bakery/ # package manager binary
|
||||
├── bread-theme/ # shared pywal + fixed-dark-base theming crate
|
||||
├── bread-utils/ # shared plumbing (Hyprland IPC, singleton, XDG, BreadClient, …)
|
||||
├── bread-app/ # GTK bootstrap new tools should use (app id, singleton, overlay, command listen)
|
||||
├── bread-polkit/ # themed PolicyKit agent (bakery.toml present; unpublished)
|
||||
├── bread-onnx/ # shared ONNX runtime helpers
|
||||
├── bread-screenshots/ # grim capture primitive used by app `--screenshot` modes
|
||||
├── bread-capture/ # orchestrator that drives those `--screenshot` modes
|
||||
├── registry/ # bread-ecosystem.toml — product registry
|
||||
└── scripts/
|
||||
├── get.sh # curl | sh bootstrap
|
||||
└── gen-index.sh # generates dl.breadway.dev/index.json from release artifacts
|
||||
├── get.sh # curl | sh bootstrap
|
||||
├── gen-index.sh # generates dl.breadway.dev/index.json from release artifacts
|
||||
└── gen-readme-products.sh # rewrites the Products table from the registry
|
||||
```
|
||||
|
||||
### New GTK tools
|
||||
|
||||
Do not copy another app's `main.rs`. Depend on `bread-app`:
|
||||
|
||||
- `bread_app::application_id` / `try_acquire` / `toggle_or_kill` for the
|
||||
`com.breadway.*` application id and single-instance lock
|
||||
- feature `gtk` re-exports `bread_utils::gtk_popup` (layer-shell overlay)
|
||||
- feature `bread-client` for `listen_commands` on `bread.command.<app>.**`
|
||||
|
||||
See the `bread-app` crate docs. Existing apps are not migrated in this
|
||||
tree; `bread-polkit` is the first in-tree consumer.
|
||||
|
||||
### bread-polkit
|
||||
|
||||
A session PolicyKit authentication agent (password prompt, cancel,
|
||||
identity). Not a wrapper around `polkit-gnome`. `bread-polkit/bakery.toml`
|
||||
exists so it can be published via bakery; it is not in
|
||||
`registry/bread-ecosystem.toml` and is therefore unpublished — not on the
|
||||
bakery index and not on the BOS ISO lockfile.
|
||||
|
||||
```sh
|
||||
cargo run -p bread-polkit
|
||||
```
|
||||
|
||||
Autostart — pick one:
|
||||
|
||||
```sh
|
||||
cp bread-polkit/contrib/bread-polkit.desktop ~/.config/autostart/
|
||||
```
|
||||
|
||||
```
|
||||
# hyprland.conf
|
||||
exec-once = bread-polkit
|
||||
```
|
||||
|
||||
## Release pipeline
|
||||
|
||||
Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has a
|
||||
`.github/workflows/release.yml` that triggers on `v*` tags. The workflow
|
||||
Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has
|
||||
`.forgejo/workflows/release-*.yml` that triggers on `v*` tags. The workflow
|
||||
runs on a self-hosted runner on hestia, builds a stripped x86_64 binary,
|
||||
deposits it at `dl.breadway.dev/<pkg>/<version>/`, updates `index.json`,
|
||||
and mirrors the binary to GitHub Releases as a fallback.
|
||||
|
|
@ -132,6 +210,12 @@ and mirrors the binary to GitHub Releases as a fallback.
|
|||
`bakery` always tries `dl.breadway.dev` first and transparently falls back
|
||||
to the GitHub Release URL recorded in the manifest.
|
||||
|
||||
Beyond stable releases, most products also publish **dev** and **beta**
|
||||
tracks — continuous builds off `main` (dev) and `vX.Y.Z-rc.N` tags (beta).
|
||||
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the branch/release workflow and
|
||||
[`docs/release-channels.md`](docs/release-channels.md) for the full track
|
||||
policy. Switch tracks with `bakery track set <stable|beta|dev>`.
|
||||
|
||||
### Release artifact contract
|
||||
|
||||
Each product's `release.yml` **must** upload the following files alongside
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ ureq = { workspace = true }
|
|||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
clap_complete = "4"
|
||||
chrono = { workspace = true }
|
||||
minisign-verify = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
semver = { workspace = true }
|
||||
bread-utils = { path = "../bread-utils" }
|
||||
fs4 = { version = "0.8", features = ["sync"] }
|
||||
tempfile = "3"
|
||||
|
|
|
|||
30
bakery/README.md
Normal file
30
bakery/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# bakery
|
||||
|
||||
Package manager for the bread ecosystem. Usage lives in the
|
||||
[repo README](../README.md).
|
||||
|
||||
## Install prefix
|
||||
|
||||
Default root is `~/.local` (bins in `~/.local/bin`, data in
|
||||
`~/.local/share`). That is the hermes / `get.sh` path and must stay the
|
||||
default.
|
||||
|
||||
BOS sets a system prefix so bakery-managed desktop apps live on the `@`
|
||||
root subvolume and are included in snapper/grub-btrfs snapshots:
|
||||
|
||||
```toml
|
||||
# /etc/bakery/config.toml
|
||||
prefix = "/usr/local"
|
||||
```
|
||||
|
||||
`BAKERY_PREFIX` overrides the config file. A non-home prefix installs:
|
||||
|
||||
| Thing | Path |
|
||||
|-------|------|
|
||||
| bins | `$prefix/bin` |
|
||||
| share / desktop / licenses / data | `$prefix/share/...` |
|
||||
| systemd user units | `/usr/lib/systemd/user` |
|
||||
|
||||
Per-user state (`installed.json` and pre-update backups) stays in
|
||||
`~/.local/state/bakery`. Writes that need root use `sudo -n`, then
|
||||
`pkexec`. `bakery doctor` prints the active prefix.
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::ui;
|
||||
use anyhow::Result;
|
||||
use std::process::Command;
|
||||
|
||||
|
|
@ -10,18 +11,48 @@ pub struct DepReport {
|
|||
|
||||
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> {
|
||||
Ok(DepReport {
|
||||
missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(),
|
||||
warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(),
|
||||
missing: required
|
||||
.iter()
|
||||
.filter(|d| !dep_present(d))
|
||||
.cloned()
|
||||
.collect(),
|
||||
warnings: optional
|
||||
.iter()
|
||||
.filter(|d| !dep_present(d))
|
||||
.cloned()
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Arch package name -> Debian/Ubuntu package name, for the few cases where
|
||||
/// they differ *and* the Debian package's own binaries don't share a name
|
||||
/// with either package (so `path_has` can't bridge the gap the way it
|
||||
/// already does for e.g. `ffmpeg`/`openssl`, whose package name matches
|
||||
/// their own binary name on both distros). `system_deps` in `bakery.toml`
|
||||
/// is always written as the Arch name — this is what makes that same
|
||||
/// declaration also resolve correctly on a Debian-family bakery host like
|
||||
/// hestia.
|
||||
const ARCH_TO_DEBIAN_PKG: &[(&str, &str)] = &[("mkvtoolnix-cli", "mkvtoolnix")];
|
||||
|
||||
fn debian_name(pkg: &str) -> &str {
|
||||
ARCH_TO_DEBIAN_PKG
|
||||
.iter()
|
||||
.find(|(arch, _)| *arch == pkg)
|
||||
.map(|(_, debian)| *debian)
|
||||
.unwrap_or(pkg)
|
||||
}
|
||||
|
||||
fn dep_present(pkg: &str) -> bool {
|
||||
// Primary: `pacman -Q` uses the exact Arch package name — no name mapping needed.
|
||||
if pacman_installed(pkg) {
|
||||
return true;
|
||||
}
|
||||
// Fallback for environments without pacman: native PATH search then pkg-config.
|
||||
path_has(pkg) || pkg_config_exists(pkg)
|
||||
if path_has(pkg) || pkg_config_exists(pkg) {
|
||||
return true;
|
||||
}
|
||||
// Further fallback for Debian/Ubuntu hosts: dpkg, via the name map above.
|
||||
dpkg_installed(debian_name(pkg))
|
||||
}
|
||||
|
||||
fn pacman_installed(pkg: &str) -> bool {
|
||||
|
|
@ -32,6 +63,17 @@ fn pacman_installed(pkg: &str) -> bool {
|
|||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn dpkg_installed(pkg: &str) -> bool {
|
||||
Command::new("dpkg-query")
|
||||
.args(["-W", "-f=${Status}", pkg])
|
||||
.output()
|
||||
.map(|o| {
|
||||
o.status.success()
|
||||
&& String::from_utf8_lossy(&o.stdout).contains("install ok installed")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check PATH without shelling out to `which` (avoids the external dependency).
|
||||
fn path_has(bin: &str) -> bool {
|
||||
std::env::var_os("PATH")
|
||||
|
|
@ -48,34 +90,75 @@ fn pkg_config_exists(lib: &str) -> bool {
|
|||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Builds the "install with: ..." hint for a list of missing Arch package
|
||||
/// names, picking the command for whichever package manager is actually on
|
||||
/// this host — `sudo pacman -S ...` is meaningless advice on a Debian-family
|
||||
/// bakery host like hestia, which has neither `pacman` nor the Arch names.
|
||||
pub fn install_hint(missing: &[String]) -> String {
|
||||
if path_has("pacman") {
|
||||
format!("sudo pacman -S {}", missing.join(" "))
|
||||
} else if path_has("apt") {
|
||||
let names: Vec<&str> = missing.iter().map(|p| debian_name(p)).collect();
|
||||
format!("sudo apt install {}", names.join(" "))
|
||||
} else {
|
||||
format!("install: {}", missing.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a formatted doctor report for a package's system deps.
|
||||
/// Returns true if all *required* deps are satisfied.
|
||||
pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool {
|
||||
pub fn report(
|
||||
package_name: &str,
|
||||
required: &[String],
|
||||
optional: &[String],
|
||||
name_width: usize,
|
||||
) -> bool {
|
||||
if required.is_empty() && optional.is_empty() {
|
||||
println!(" {package_name}: no system deps required");
|
||||
ui::check_row(true, package_name, name_width, "no system deps required");
|
||||
return true;
|
||||
}
|
||||
match check_deps(required, optional) {
|
||||
Err(e) => {
|
||||
eprintln!(" error running doctor for {package_name}: {e}");
|
||||
ui::check_row(
|
||||
false,
|
||||
package_name,
|
||||
name_width,
|
||||
&format!("error running doctor: {e}"),
|
||||
);
|
||||
false
|
||||
}
|
||||
Ok(rep) => {
|
||||
for warn in &rep.warnings {
|
||||
eprintln!(
|
||||
" {package_name}: optional dep not found: {warn} \
|
||||
(install for full functionality)"
|
||||
" {}",
|
||||
ui::style(
|
||||
&format!(
|
||||
"{package_name}: optional dep not found: {warn} \
|
||||
(install for full functionality)"
|
||||
),
|
||||
ui::YELLOW
|
||||
)
|
||||
);
|
||||
}
|
||||
if rep.missing.is_empty() {
|
||||
println!(" {package_name}: all required system deps satisfied");
|
||||
ui::check_row(
|
||||
true,
|
||||
package_name,
|
||||
name_width,
|
||||
"all required system deps satisfied",
|
||||
);
|
||||
true
|
||||
} else {
|
||||
eprintln!(
|
||||
" {package_name}: missing system deps: {}",
|
||||
rep.missing.join(", ")
|
||||
ui::check_row(
|
||||
false,
|
||||
package_name,
|
||||
name_width,
|
||||
&format!("missing: {}", rep.missing.join(", ")),
|
||||
);
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::dim(&format!("install with: {}", install_hint(&rep.missing)))
|
||||
);
|
||||
eprintln!(" install with: sudo pacman -S {}", rep.missing.join(" "));
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
@ -105,24 +188,38 @@ mod tests {
|
|||
assert!(path_has("sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debian_name_maps_known_alias() {
|
||||
assert_eq!(debian_name("mkvtoolnix-cli"), "mkvtoolnix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debian_name_passes_through_unmapped() {
|
||||
assert_eq!(debian_name("ffmpeg"), "ffmpeg");
|
||||
}
|
||||
|
||||
// This test only runs on systems with dpkg (Debian/Ubuntu).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn dpkg_finds_dpkg_itself() {
|
||||
assert!(dpkg_installed("dpkg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dpkg_missing_package_not_present() {
|
||||
assert!(!dpkg_installed("this-package-does-not-exist-xyzzy42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_dep_detected() {
|
||||
let rep = check_deps(
|
||||
&["this-package-does-not-exist-xyzzy42".to_string()],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let rep = check_deps(&["this-package-does-not-exist-xyzzy42".to_string()], &[]).unwrap();
|
||||
assert_eq!(rep.missing.len(), 1);
|
||||
assert!(rep.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_optional_dep_becomes_warning_not_error() {
|
||||
let rep = check_deps(
|
||||
&[],
|
||||
&["this-package-does-not-exist-xyzzy42".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
let rep = check_deps(&[], &["this-package-does-not-exist-xyzzy42".to_string()]).unwrap();
|
||||
assert!(rep.missing.is_empty());
|
||||
assert_eq!(rep.warnings.len(), 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,33 +3,27 @@ use sha2::{Digest, Sha256};
|
|||
use std::path::Path;
|
||||
|
||||
use crate::manifest::{fetch_binary, Binary};
|
||||
use crate::ui;
|
||||
|
||||
/// Download a binary to a temp path, verify its SHA-256, then atomically move
|
||||
/// it into place. Bails before touching `dest` if the checksum fails.
|
||||
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
|
||||
println!(" downloading {}…", binary.name);
|
||||
/// Download a binary, verify its SHA-256, then atomically write it into
|
||||
/// place (fsynced, temp-in-same-dir-with-unique-name then rename — see
|
||||
/// `bread_utils::atomic`). Bails before touching `dest` if the checksum
|
||||
/// fails. Returns the verified hex sha256 so callers (`install::
|
||||
/// install_package`) can record it for `bakery verify` without hashing the
|
||||
/// bytes a second time — `verify_sha256` already confirmed `bytes` matches
|
||||
/// `binary.sha256`, so that's the value to return.
|
||||
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<String> {
|
||||
ui::step("downloading", &binary.name);
|
||||
let bytes = fetch_binary(&binary.dl_url, &binary.github_url)
|
||||
.with_context(|| format!("downloading {}", binary.name))?;
|
||||
|
||||
verify_sha256(&bytes, &binary.sha256)
|
||||
.with_context(|| format!("checksum mismatch for {}", binary.name))?;
|
||||
|
||||
if let Some(dir) = dest.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
|
||||
let tmp = dest.with_extension("tmp");
|
||||
std::fs::write(&tmp, &bytes).context("writing binary to tmp")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
|
||||
std::fs::rename(&tmp, dest).context("placing binary")?;
|
||||
println!(" installed {}", dest.display());
|
||||
Ok(())
|
||||
crate::prefix::write_bytes(dest, &bytes, 0o755)
|
||||
.with_context(|| format!("placing binary at {}", dest.display()))?;
|
||||
ui::step("placed", &dest.display().to_string());
|
||||
Ok(binary.sha256.clone())
|
||||
}
|
||||
|
||||
/// Verify that `bytes` hashes to `expected_hex` under SHA-256.
|
||||
|
|
@ -38,6 +32,9 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
|
|||
/// [`fetch_and_place`]), and config-example / systemd-unit downloads in
|
||||
/// `install.rs` — so all downloaded artifacts get the same integrity check.
|
||||
pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
|
||||
if expected_hex.is_empty() {
|
||||
bail!("index entry has no sha256 recorded — refusing to trust an unverifiable download");
|
||||
}
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
let actual = hex::encode(hasher.finalize());
|
||||
|
|
@ -80,4 +77,14 @@ mod tests {
|
|||
let hash = sha256_hex(bytes);
|
||||
assert!(verify_sha256(bytes, &hash).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_missing_sha256_gives_a_clear_error() {
|
||||
// gen-index.sh emits an empty sha256 string when a .sha256 sidecar
|
||||
// is missing — must not fall through to the generic mismatch
|
||||
// message ("expected: \n actual: <hex>"), which is confusing about
|
||||
// what actually went wrong.
|
||||
let err = verify_sha256(b"anything", "").unwrap_err();
|
||||
assert!(err.to_string().contains("no sha256 recorded"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1084
bakery/src/main.rs
1084
bakery/src/main.rs
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,34 @@
|
|||
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::time::{Duration, SystemTime};
|
||||
|
||||
const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json";
|
||||
const SIG_URL: &str = "https://dl.breadway.dev/index.json.minisig";
|
||||
const DEFAULT_BASE_URL: &str = "https://dl.breadway.dev";
|
||||
const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600);
|
||||
|
||||
/// The `https://dl.breadway.dev` base can be overridden for local/staging
|
||||
/// testing (e.g. serving a fake index from `python3 -m http.server`) without
|
||||
/// rebuilding bakery — same pattern as `main.rs`'s `BAKERY_BIN_DIR` override.
|
||||
fn base_url() -> String {
|
||||
std::env::var("BAKERY_INDEX_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
|
||||
}
|
||||
|
||||
/// Index URL for `track`. `Stable` keeps the exact pre-track path
|
||||
/// (`{base}/index.json`) so existing infra and warm caches are unaffected;
|
||||
/// `Beta`/`Dev` live under a track-prefixed subpath.
|
||||
fn primary_url(track: Track) -> String {
|
||||
match track {
|
||||
Track::Stable => format!("{}/index.json", base_url()),
|
||||
Track::Beta | Track::Dev => format!("{}/{}/index.json", base_url(), track.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
fn sig_url(track: Track) -> String {
|
||||
format!("{}.minisig", primary_url(track))
|
||||
}
|
||||
|
||||
/// The bakery index-signing public key.
|
||||
///
|
||||
/// The matching secret key is used offline (never on this machine, never in
|
||||
|
|
@ -17,7 +38,7 @@ const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600);
|
|||
/// 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 = "RWRh2Zr5SUinvVFCtD7S7HwGjfrye6j31Xq2mYXRdkGFDWe3yHF7W11K";
|
||||
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 —
|
||||
|
|
@ -31,8 +52,7 @@ fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> {
|
|||
/// 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 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
|
||||
|
|
@ -86,6 +106,30 @@ pub struct Package {
|
|||
pub config: Option<ConfigScaffold>,
|
||||
#[serde(default)]
|
||||
pub post_install: Vec<String>,
|
||||
/// License artifact filename (e.g. "LICENSE"), installed to
|
||||
/// `$prefix/share/licenses/<name>/LICENSE` (`~/.local/share/...` by
|
||||
/// default) — the bakery equivalent of a PKGBUILD's `package()` step.
|
||||
#[serde(default)]
|
||||
pub license_file: Option<String>,
|
||||
#[serde(default)]
|
||||
pub license_file_sha256: Option<String>,
|
||||
/// Desktop entry artifact filename (e.g. "breadhelp.desktop"),
|
||||
/// installed to `$prefix/share/applications/<name>.desktop` so the
|
||||
/// app shows up in any XDG-compliant launcher.
|
||||
#[serde(default)]
|
||||
pub desktop_file: Option<String>,
|
||||
#[serde(default)]
|
||||
pub desktop_file_sha256: Option<String>,
|
||||
/// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz`
|
||||
/// in the release dir, extracted to `$prefix/share/<name>/` on
|
||||
/// install. For arbitrary data a package needs at runtime beyond a
|
||||
/// config example (e.g. breadhelp's guide content), where a single
|
||||
/// downloadable file + `tar` extraction is simpler than teaching
|
||||
/// bakery to mirror a whole directory tree file-by-file.
|
||||
#[serde(default)]
|
||||
pub data_archive: Option<String>,
|
||||
#[serde(default)]
|
||||
pub data_archive_sha256: Option<String>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
|
|
@ -120,8 +164,8 @@ impl Index {
|
|||
}
|
||||
}
|
||||
|
||||
/// Load the manifest, using the on-disk cache when it is fresh enough.
|
||||
/// Always fetches if `force_refresh` is true.
|
||||
/// 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
|
||||
|
|
@ -130,52 +174,73 @@ impl Index {
|
|||
/// (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) -> Result<Index> {
|
||||
let cache_path = cache_path();
|
||||
pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
|
||||
let cache_path = cache_path(track);
|
||||
let sig_cache_path = sig_cache_path(&cache_path);
|
||||
|
||||
if !force_refresh && cache_is_fresh(&cache_path) {
|
||||
match read_and_verify_cache(&cache_path, &sig_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…"
|
||||
);
|
||||
eprintln!(" warning: cached index.json failed verification ({err}), re-fetching…");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetch_and_cache(&cache_path, &sig_cache_path)
|
||||
match fetch_and_cache(&cache_path, &sig_cache_path, track) {
|
||||
Ok(index) => Ok(index),
|
||||
Err(fetch_err) => {
|
||||
// A network error shouldn't be a hard failure when a valid
|
||||
// signed cache is sitting right there on disk, even if it's
|
||||
// stale (or freshness was never checked because force_refresh
|
||||
// was set) — fall back to it rather than bricking the CLI.
|
||||
match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
|
||||
Ok(index) => {
|
||||
eprintln!(
|
||||
" warning: could not refresh {track} index ({fetch_err}) — \
|
||||
using possibly-stale cached index"
|
||||
);
|
||||
Ok(index)
|
||||
}
|
||||
Err(_) => Err(fetch_err),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_and_verify_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result<Index> {
|
||||
fn read_and_verify_cache(cache_path: &Path, sig_cache_path: &Path, 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)?;
|
||||
verify_index_signature(&bytes, &sig_text)
|
||||
.with_context(|| format!("cached {track} index failed signature verification"))?;
|
||||
serde_json::from_slice(&bytes).context("parsing cached index")
|
||||
}
|
||||
|
||||
fn cache_is_fresh(path: &PathBuf) -> bool {
|
||||
fn cache_is_fresh(path: &Path) -> bool {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.map(|t| SystemTime::now().duration_since(t).unwrap_or(CACHE_MAX_AGE) < CACHE_MAX_AGE)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf) -> Result<Index> {
|
||||
let bytes = fetch_bytes(PRIMARY_URL)?;
|
||||
let sig_text = fetch_text(SIG_URL).context(
|
||||
fn fetch_and_cache(cache_path: &Path, sig_cache_path: &Path, 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)
|
||||
.context("freshly fetched index.json failed signature verification")?;
|
||||
.with_context(|| format!("freshly fetched {track} index failed signature verification"))?;
|
||||
|
||||
if let Some(dir) = cache_path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(cache_path, &bytes)?;
|
||||
std::fs::write(sig_cache_path, &sig_text)?;
|
||||
bread_utils::atomic::write_atomic_bytes(cache_path, &bytes, None)
|
||||
.with_context(|| format!("writing cached {track} index"))?;
|
||||
bread_utils::atomic::write_atomic_bytes(sig_cache_path, sig_text.as_bytes(), None)
|
||||
.with_context(|| format!("writing cached {track} index signature"))?;
|
||||
serde_json::from_slice(&bytes).context("parsing index.json")
|
||||
}
|
||||
|
||||
|
|
@ -186,17 +251,23 @@ fn sig_cache_path(cache_path: &Path) -> PathBuf {
|
|||
}
|
||||
|
||||
fn fetch_text(url: &str) -> Result<String> {
|
||||
ureq::get(url)
|
||||
.call()
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?
|
||||
.into_string()
|
||||
.context("reading response body")
|
||||
let bytes = fetch_bytes(url)?;
|
||||
String::from_utf8(bytes).context("response is not valid UTF-8")
|
||||
}
|
||||
|
||||
pub fn cache_path() -> PathBuf {
|
||||
/// Cache filename for `track`. `Stable` keeps the pre-track filename
|
||||
/// (`index.json`) so an existing warm cache survives an upgrade to a
|
||||
/// track-aware bakery; `Beta`/`Dev` get their own sibling files so switching
|
||||
/// tracks doesn't clobber each other's cache.
|
||||
pub fn cache_path(track: Track) -> PathBuf {
|
||||
let file_name = match track {
|
||||
Track::Stable => "index.json".to_string(),
|
||||
Track::Beta | Track::Dev => format!("index-{}.json", track.as_str()),
|
||||
};
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.cache"))
|
||||
.join("bakery/index.json")
|
||||
.join("bakery")
|
||||
.join(file_name)
|
||||
}
|
||||
|
||||
/// Download a binary blob from `primary_url`, falling back to `fallback_url`
|
||||
|
|
@ -206,27 +277,69 @@ pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result<Vec<u8>> {
|
|||
Ok(bytes) => Ok(bytes),
|
||||
Err(primary_err) => {
|
||||
eprintln!(
|
||||
" primary URL failed ({}), trying GitHub fallback…",
|
||||
primary_err
|
||||
" {}",
|
||||
crate::ui::note(&format!(
|
||||
"primary URL failed ({primary_err}), trying GitHub fallback…"
|
||||
))
|
||||
);
|
||||
fetch_bytes(fallback_url).context("both primary and GitHub fallback failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Comfortably above any real bakery artifact — caps how much of a response
|
||||
/// gets buffered into memory before any trust check runs on it.
|
||||
const MAX_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// How often (at most) the `\r`-overwritten progress line refreshes — a
|
||||
/// LAN-speed download can push way more than one chunk per 100ms, and
|
||||
/// printing on every chunk would flood the terminal instead of reassuring it.
|
||||
const PROGRESS_THROTTLE: Duration = Duration::from_millis(100);
|
||||
const CHUNK_SIZE: usize = 64 * 1024;
|
||||
|
||||
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let resp = ureq::get(url)
|
||||
.call()
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
use std::io::{IsTerminal, Read};
|
||||
let resp = ureq::get(url).call().map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let status = resp.status();
|
||||
if status != 200 {
|
||||
bail!("HTTP {status} from {url}");
|
||||
}
|
||||
|
||||
// Progress feedback only when there's a Content-Length to show progress
|
||||
// against and stderr is an actual terminal — a multi-MB binary with no
|
||||
// feedback at all looks like a hang, but piped/CI output shouldn't get
|
||||
// `\r` noise. A manual chunked read loop (instead of one `read_to_end`)
|
||||
// is what makes printing partway through the download possible, without
|
||||
// pulling in a progress-bar crate for what's meant to just be reassurance.
|
||||
let content_length: Option<u64> = resp.header("Content-Length").and_then(|v| v.parse().ok());
|
||||
// Progress is reassurance for multi-MB binaries. A 4 KB index fetch
|
||||
// drawing a 100% / 0.0 MB bar is noise, not feedback.
|
||||
const MIN_PROGRESS_BYTES: u64 = 256 * 1024;
|
||||
let show_progress =
|
||||
content_length.is_some_and(|n| n >= MIN_PROGRESS_BYTES) && std::io::stderr().is_terminal();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
resp.into_reader()
|
||||
.read_to_end(&mut buf)
|
||||
.context("reading response")?;
|
||||
let mut reader = resp.into_reader();
|
||||
let mut chunk = [0u8; CHUNK_SIZE];
|
||||
let mut last_print = std::time::Instant::now();
|
||||
loop {
|
||||
let n = reader.read(&mut chunk).context("reading response")?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
if buf.len() as u64 > MAX_RESPONSE_BYTES {
|
||||
bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit");
|
||||
}
|
||||
if show_progress && last_print.elapsed() >= PROGRESS_THROTTLE {
|
||||
crate::ui::print_progress(buf.len() as u64, content_length.unwrap());
|
||||
last_print = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
if show_progress {
|
||||
crate::ui::print_progress(buf.len() as u64, content_length.unwrap());
|
||||
crate::ui::finish_progress();
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
|
|
@ -276,4 +389,80 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
|
|||
// 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())
|
||||
);
|
||||
}
|
||||
|
||||
fn minimal_package_json() -> &'static str {
|
||||
r#"{
|
||||
"name": "breadhelp",
|
||||
"description": "test",
|
||||
"version": "1.0.0",
|
||||
"binaries": [],
|
||||
"config": null
|
||||
}"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_and_desktop_fields_default_to_none_on_old_shape_json() {
|
||||
// Simulates an index.json produced before license_file/desktop_file
|
||||
// existed — must not fail to parse.
|
||||
let pkg: Package = serde_json::from_str(minimal_package_json()).unwrap();
|
||||
assert!(pkg.license_file.is_none());
|
||||
assert!(pkg.license_file_sha256.is_none());
|
||||
assert!(pkg.desktop_file.is_none());
|
||||
assert!(pkg.desktop_file_sha256.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_and_desktop_fields_roundtrip() {
|
||||
let mut pkg: Package = serde_json::from_str(minimal_package_json()).unwrap();
|
||||
pkg.license_file = Some("LICENSE".to_string());
|
||||
pkg.license_file_sha256 = Some("abc123".to_string());
|
||||
pkg.desktop_file = Some("breadhelp.desktop".to_string());
|
||||
pkg.desktop_file_sha256 = Some("def456".to_string());
|
||||
|
||||
let json = serde_json::to_string(&pkg).unwrap();
|
||||
let restored: Package = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(restored.license_file.as_deref(), Some("LICENSE"));
|
||||
assert_eq!(restored.license_file_sha256.as_deref(), Some("abc123"));
|
||||
assert_eq!(restored.desktop_file.as_deref(), Some("breadhelp.desktop"));
|
||||
assert_eq!(restored.desktop_file_sha256.as_deref(), Some("def456"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
546
bakery/src/prefix.rs
Normal file
546
bakery/src/prefix.rs
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
//! Install prefix: default `~/.local`, or a system root for BOS.
|
||||
//!
|
||||
//! Hermes and `get.sh` keep the user-local default. BOS sets
|
||||
//! `prefix = "/usr/local"` in `/etc/bakery/config.toml` (or `BAKERY_PREFIX`)
|
||||
//! so bakery-managed desktop apps live on the `@` root subvolume and ride
|
||||
//! along with snapper/grub-btrfs snapshots. Per-user state stays under
|
||||
//! `~/.local/state/bakery` either way — bakery still records what *this*
|
||||
//! user asked for; the prefix only changes where bits land on disk.
|
||||
//!
|
||||
//! Writes that hit `EACCES` use `sudo -n` first, then `pkexec` if a
|
||||
//! graphical session is available. Interactive `sudo` (password on stdin)
|
||||
//! is never used — a GUI hook must not block on a TTY prompt.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::ffi::OsStr;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Default user-local prefix when no config/env override is set.
|
||||
const DEFAULT_USER_PREFIX: &str = ".local";
|
||||
|
||||
/// System-wide user units, used when the prefix is not under `$HOME`.
|
||||
const SYSTEM_USER_UNIT_DIR: &str = "/usr/lib/systemd/user";
|
||||
|
||||
const SYSTEM_CONFIG_PATH: &str = "/etc/bakery/config.toml";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Layout {
|
||||
pub prefix: PathBuf,
|
||||
pub bin_dir: PathBuf,
|
||||
pub share_dir: PathBuf,
|
||||
pub systemd_user_dir: PathBuf,
|
||||
/// True when `prefix` is not under the user's home directory.
|
||||
pub is_system: bool,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
if self.is_system {
|
||||
"system"
|
||||
} else {
|
||||
"user"
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a (possibly custom) prefix onto bin/share/unit paths.
|
||||
/// `bin_override` is `--bin-dir` / `BAKERY_BIN_DIR` and wins for bins only.
|
||||
pub fn from_prefix(prefix: &Path, bin_override: Option<PathBuf>) -> Self {
|
||||
let prefix = normalize_prefix_path(prefix);
|
||||
let is_system = is_system_prefix(&prefix);
|
||||
let bin_dir = bin_override.unwrap_or_else(|| prefix.join("bin"));
|
||||
let share_dir = prefix.join("share");
|
||||
let systemd_user_dir = if is_system {
|
||||
PathBuf::from(SYSTEM_USER_UNIT_DIR)
|
||||
} else {
|
||||
user_systemd_dir()
|
||||
};
|
||||
Self {
|
||||
prefix,
|
||||
bin_dir,
|
||||
share_dir,
|
||||
systemd_user_dir,
|
||||
is_system,
|
||||
}
|
||||
}
|
||||
|
||||
/// Historical default: `~/.local` bins, XDG data dir for share,
|
||||
/// `~/.config/systemd/user` for units. Used when neither `BAKERY_PREFIX`
|
||||
/// nor `/etc/bakery/config.toml` sets a prefix — hermes / get.sh.
|
||||
pub fn user_default(bin_override: Option<PathBuf>) -> Self {
|
||||
let prefix = default_user_prefix();
|
||||
let bin_dir = bin_override.unwrap_or_else(|| prefix.join("bin"));
|
||||
let share_dir = dirs::data_dir().unwrap_or_else(|| prefix.join("share"));
|
||||
Self {
|
||||
prefix,
|
||||
bin_dir,
|
||||
share_dir,
|
||||
systemd_user_dir: user_systemd_dir(),
|
||||
is_system: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the active layout. `BAKERY_PREFIX` wins over `/etc/bakery/config.toml`;
|
||||
/// neither set keeps the `~/.local` default. `bin_override` is the existing
|
||||
/// `--bin-dir` / `BAKERY_BIN_DIR` knob.
|
||||
pub fn resolve(bin_override: Option<PathBuf>) -> Layout {
|
||||
let env = std::env::var("BAKERY_PREFIX").ok();
|
||||
resolve_from(env.as_deref(), Path::new(SYSTEM_CONFIG_PATH), bin_override)
|
||||
}
|
||||
|
||||
/// Same as [`resolve`] with the env value and config path injected, so
|
||||
/// tests don't have to mutate process-global env or touch `/etc`.
|
||||
pub fn resolve_from(
|
||||
env_prefix: Option<&str>,
|
||||
config_path: &Path,
|
||||
bin_override: Option<PathBuf>,
|
||||
) -> Layout {
|
||||
match configured_prefix_from(env_prefix, config_path) {
|
||||
Some(prefix) => Layout::from_prefix(&prefix, bin_override),
|
||||
None => Layout::user_default(bin_override),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configured_prefix_from(env_prefix: Option<&str>, config_path: &Path) -> Option<PathBuf> {
|
||||
if let Some(raw) = env_prefix {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(normalize_prefix(trimmed));
|
||||
}
|
||||
}
|
||||
load_config_prefix(config_path)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct BakeryConfig {
|
||||
prefix: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads `prefix = "..."` from a bakery config file. Missing file or empty
|
||||
/// key → `None` (caller falls back to the user-local default). A file that
|
||||
/// exists but fails to parse is warned about, not treated as fatal — a typo
|
||||
/// in `/etc/bakery/config.toml` must not take down `bakery list`.
|
||||
pub fn load_config_prefix(path: &Path) -> Option<PathBuf> {
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {}",
|
||||
crate::ui::warn(&format!("could not read {}: {e}", path.display()))
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match toml::from_str::<BakeryConfig>(&text) {
|
||||
Ok(cfg) => cfg
|
||||
.prefix
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(normalize_prefix),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {}",
|
||||
crate::ui::warn(&format!("could not parse {}: {e}", path.display()))
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_user_prefix() -> PathBuf {
|
||||
home_dir().join(DEFAULT_USER_PREFIX)
|
||||
}
|
||||
|
||||
fn home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"))
|
||||
}
|
||||
|
||||
fn user_systemd_dir() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| home_dir().join(".config"))
|
||||
.join("systemd/user")
|
||||
}
|
||||
|
||||
fn is_system_prefix(prefix: &Path) -> bool {
|
||||
match dirs::home_dir() {
|
||||
Some(home) => !prefix.starts_with(&home),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_prefix(raw: &str) -> PathBuf {
|
||||
normalize_prefix_path(&expand_tilde(raw))
|
||||
}
|
||||
|
||||
fn normalize_prefix_path(path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_tilde(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
home_dir()
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
home_dir().join(rest)
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_permission_denied(err: &io::Error) -> bool {
|
||||
err.kind() == io::ErrorKind::PermissionDenied
|
||||
}
|
||||
|
||||
pub fn privilege_denied_msg(dest: &Path) -> String {
|
||||
format!(
|
||||
"permission denied writing {} — need root for this prefix. \
|
||||
bakery tried `sudo -n` then `pkexec`; neither succeeded. \
|
||||
Run from a root shell, grant passwordless sudo -n for install/rm/tar, \
|
||||
or install a polkit rule. bakery will not prompt for a sudo password.",
|
||||
dest.display()
|
||||
)
|
||||
}
|
||||
|
||||
fn has_graphical_session() -> bool {
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// Write `bytes` to `dest`, creating parent dirs. Escalates on `EACCES`.
|
||||
pub fn write_bytes(dest: &Path, bytes: &[u8], mode: u32) -> Result<()> {
|
||||
match bread_utils::atomic::write_atomic_bytes(dest, bytes, Some(mode)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if is_permission_denied(&e) => write_bytes_privileged(dest, bytes, mode),
|
||||
Err(e) => Err(e).with_context(|| format!("writing {}", dest.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_bytes_privileged(dest: &Path, bytes: &[u8], mode: u32) -> Result<()> {
|
||||
let mut tmp =
|
||||
tempfile::NamedTempFile::new().context("creating temp file for privileged write")?;
|
||||
tmp.write_all(bytes)
|
||||
.and_then(|_| tmp.flush())
|
||||
.and_then(|_| tmp.as_file().sync_all())
|
||||
.context("writing temp file for privileged write")?;
|
||||
let mode_str = format!("{mode:o}");
|
||||
run_privileged(
|
||||
Path::new("/usr/bin/install"),
|
||||
&[
|
||||
OsStr::new("-D"),
|
||||
OsStr::new("-m"),
|
||||
OsStr::new(&mode_str),
|
||||
tmp.path().as_os_str(),
|
||||
dest.as_os_str(),
|
||||
],
|
||||
dest,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_dir_all(path: &Path) -> Result<()> {
|
||||
match std::fs::create_dir_all(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if is_permission_denied(&e) => run_privileged(
|
||||
Path::new("/usr/bin/install"),
|
||||
&[
|
||||
OsStr::new("-d"),
|
||||
OsStr::new("-m"),
|
||||
OsStr::new("755"),
|
||||
path.as_os_str(),
|
||||
],
|
||||
path,
|
||||
),
|
||||
Err(e) => Err(e).with_context(|| format!("creating directory {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_file(path: &Path) -> Result<()> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) if is_permission_denied(&e) => run_privileged(
|
||||
Path::new("/usr/bin/rm"),
|
||||
&[OsStr::new("-f"), path.as_os_str()],
|
||||
path,
|
||||
),
|
||||
Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_dir_all(path: &Path) -> Result<()> {
|
||||
match std::fs::remove_dir_all(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) if is_permission_denied(&e) => run_privileged(
|
||||
Path::new("/usr/bin/rm"),
|
||||
&[OsStr::new("-rf"), path.as_os_str()],
|
||||
path,
|
||||
),
|
||||
Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract `archive` (a `.tar.gz`) into `dest_dir`. Escalates the `tar`
|
||||
/// invocation when `dest_dir` is not writable by this user — typical for
|
||||
/// `$prefix/share/<pkg>` under `/usr/local`.
|
||||
pub fn extract_tar_gz(archive: &Path, dest_dir: &Path) -> Result<()> {
|
||||
create_dir_all(dest_dir)?;
|
||||
if dir_writable_by_self(dest_dir) {
|
||||
let status = Command::new("tar")
|
||||
.args([
|
||||
"xzf",
|
||||
&archive.to_string_lossy(),
|
||||
"--no-same-owner",
|
||||
"--no-same-permissions",
|
||||
"-C",
|
||||
])
|
||||
.arg(dest_dir)
|
||||
.status()
|
||||
.with_context(|| format!("running tar to extract {}", archive.display()))?;
|
||||
if !status.success() {
|
||||
bail!("tar exited with {status} extracting {}", archive.display());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
run_privileged(
|
||||
Path::new("/usr/bin/tar"),
|
||||
&[
|
||||
OsStr::new("xzf"),
|
||||
archive.as_os_str(),
|
||||
OsStr::new("--no-same-owner"),
|
||||
OsStr::new("--no-same-permissions"),
|
||||
OsStr::new("-C"),
|
||||
dest_dir.as_os_str(),
|
||||
],
|
||||
dest_dir,
|
||||
)
|
||||
}
|
||||
|
||||
fn dir_writable_by_self(dir: &Path) -> bool {
|
||||
tempfile::Builder::new()
|
||||
.prefix(".bakery-wprobe-")
|
||||
.tempfile_in(dir)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn run_privileged(program: &Path, args: &[&OsStr], dest: &Path) -> Result<()> {
|
||||
// `sudo -n` never prompts; stdin is null so a misconfigured sudoers
|
||||
// can't fall through to a password read on a GUI hook's non-tty stdin.
|
||||
let sudo = Command::new("sudo")
|
||||
.arg("-n")
|
||||
.arg(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.status();
|
||||
if matches!(sudo, Ok(status) if status.success()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// pkexec pops a polkit dialog — only useful with a display, and the
|
||||
// one acceptable password prompt (GUI, not a stolen sudo TTY).
|
||||
if has_graphical_session() {
|
||||
let pk = Command::new("pkexec").arg(program).args(args).status();
|
||||
if matches!(pk, Ok(status) if status.success()) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("{}", privilege_denied_msg(dest))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn user_default_is_not_system_and_uses_local_bin() {
|
||||
let layout = Layout::user_default(None);
|
||||
assert!(!layout.is_system);
|
||||
assert_eq!(layout.prefix, default_user_prefix());
|
||||
assert_eq!(layout.bin_dir, default_user_prefix().join("bin"));
|
||||
assert_eq!(layout.kind_label(), "user");
|
||||
assert!(layout.systemd_user_dir.ends_with(Path::new("systemd/user")));
|
||||
assert_ne!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_default_honors_bin_override() {
|
||||
let layout = Layout::user_default(Some(PathBuf::from("/tmp/custom-bins")));
|
||||
assert_eq!(layout.bin_dir, PathBuf::from("/tmp/custom-bins"));
|
||||
assert!(!layout.is_system);
|
||||
assert_eq!(layout.prefix, default_user_prefix());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usr_local_is_system_layout() {
|
||||
let layout = Layout::from_prefix(Path::new("/usr/local"), None);
|
||||
assert!(layout.is_system);
|
||||
assert_eq!(layout.prefix, PathBuf::from("/usr/local"));
|
||||
assert_eq!(layout.bin_dir, PathBuf::from("/usr/local/bin"));
|
||||
assert_eq!(layout.share_dir, PathBuf::from("/usr/local/share"));
|
||||
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
||||
assert_eq!(layout.kind_label(), "system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_home_prefix_is_not_system() {
|
||||
let home = dirs::home_dir().expect("home dir");
|
||||
let prefix = home.join("apps");
|
||||
let layout = Layout::from_prefix(&prefix, None);
|
||||
assert!(!layout.is_system);
|
||||
assert_eq!(layout.bin_dir, prefix.join("bin"));
|
||||
assert_eq!(layout.share_dir, prefix.join("share"));
|
||||
assert_ne!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_prefix_maps_bin_and_share_under_prefix() {
|
||||
let dir = tempdir().unwrap();
|
||||
let layout = Layout::from_prefix(dir.path(), None);
|
||||
assert_eq!(layout.bin_dir, dir.path().join("bin"));
|
||||
assert_eq!(layout.share_dir, dir.path().join("share"));
|
||||
// /tmp is not under $HOME, so this is a system-shaped prefix —
|
||||
// units would go to /usr/lib/systemd/user. Writes still try
|
||||
// unprivileged first, so tests can use a temp prefix without sudo.
|
||||
assert!(layout.is_system);
|
||||
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bin_override_does_not_move_share_or_units() {
|
||||
let layout = Layout::from_prefix(
|
||||
Path::new("/usr/local"),
|
||||
Some(PathBuf::from("/opt/override/bin")),
|
||||
);
|
||||
assert_eq!(layout.bin_dir, PathBuf::from("/opt/override/bin"));
|
||||
assert_eq!(layout.share_dir, PathBuf::from("/usr/local/share"));
|
||||
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_prefix_reads_value() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
|
||||
assert_eq!(load_config_prefix(&path), Some(PathBuf::from("/usr/local")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_prefix_expands_tilde() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "prefix = \"~/.local\"\n").unwrap();
|
||||
assert_eq!(load_config_prefix(&path), Some(default_user_prefix()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_prefix_missing_file_is_none() {
|
||||
assert_eq!(
|
||||
load_config_prefix(Path::new("/no/such/bakery-config.toml")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_prefix_ignores_empty_value() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "prefix = \"\"\n").unwrap();
|
||||
assert_eq!(load_config_prefix(&path), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_prefix_malformed_is_none() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "prefix = [\n").unwrap();
|
||||
assert_eq!(load_config_prefix(&path), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_prefix_wins_over_config() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
|
||||
let layout = resolve_from(Some("/opt/bread"), &path, None);
|
||||
assert_eq!(layout.prefix, PathBuf::from("/opt/bread"));
|
||||
assert_eq!(layout.bin_dir, PathBuf::from("/opt/bread/bin"));
|
||||
assert!(layout.is_system);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_env_falls_through_to_config() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
|
||||
let layout = resolve_from(Some(" "), &path, None);
|
||||
assert_eq!(layout.prefix, PathBuf::from("/usr/local"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_env_no_config_is_user_default() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("missing.toml");
|
||||
let layout = resolve_from(None, &path, None);
|
||||
assert_eq!(layout, Layout::user_default(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_bytes_to_writable_temp_prefix_needs_no_root() {
|
||||
let dir = tempdir().unwrap();
|
||||
let dest = dir.path().join("bin").join("foo");
|
||||
write_bytes(&dest, b"hello", 0o755).unwrap();
|
||||
assert_eq!(fs::read(&dest).unwrap(), b"hello");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
fs::metadata(&dest).unwrap().permissions().mode() & 0o777,
|
||||
0o755
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_remove_under_temp_prefix() {
|
||||
let dir = tempdir().unwrap();
|
||||
let nested = dir.path().join("share/licenses/pkg");
|
||||
create_dir_all(&nested).unwrap();
|
||||
assert!(nested.is_dir());
|
||||
let file = nested.join("LICENSE");
|
||||
write_bytes(&file, b"MIT\n", 0o644).unwrap();
|
||||
remove_file(&file).unwrap();
|
||||
assert!(!file.exists());
|
||||
remove_dir_all(&dir.path().join("share")).unwrap();
|
||||
assert!(!dir.path().join("share").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privilege_denied_msg_names_the_dest() {
|
||||
let msg = privilege_denied_msg(Path::new("/usr/local/bin/breadd"));
|
||||
assert!(msg.contains("/usr/local/bin/breadd"));
|
||||
assert!(msg.contains("sudo -n"));
|
||||
assert!(msg.contains("pkexec"));
|
||||
assert!(msg.contains("will not prompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_system_prefix_classifies_home_and_usr() {
|
||||
let home = dirs::home_dir().expect("home dir");
|
||||
assert!(!is_system_prefix(&home.join(".local")));
|
||||
assert!(is_system_prefix(Path::new("/usr/local")));
|
||||
assert!(is_system_prefix(Path::new("/opt/bread")));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
use crate::track::Track;
|
||||
use anyhow::{Context, Result};
|
||||
use fs4::FileExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -10,10 +12,34 @@ pub struct InstalledPackage {
|
|||
pub binaries: Vec<String>,
|
||||
pub services: Vec<String>,
|
||||
pub installed_at: String,
|
||||
// `#[serde(default)]` so an installed.json written before per-package
|
||||
// track tracking existed still deserializes — defaults to Stable, same
|
||||
// convention as `State.track` above.
|
||||
#[serde(default)]
|
||||
pub track: Track,
|
||||
/// The version this package was upgraded from, if any — `bakery
|
||||
/// rollback` uses this to find the matching local backup dir. `None` on
|
||||
/// a fresh first-time install. `#[serde(default)]` for the same
|
||||
/// old-shape-json reason as `track` above.
|
||||
#[serde(default)]
|
||||
pub previous_version: Option<String>,
|
||||
/// SHA-256 (hex) of each installed binary, captured at install time.
|
||||
/// `bakery verify` recomputes these from disk and compares against this
|
||||
/// recorded value rather than a fresh index lookup — the index only
|
||||
/// carries the checksum for whatever the *current latest* release is,
|
||||
/// which may not match what's actually installed. Empty on installs
|
||||
/// that predate this field.
|
||||
#[serde(default)]
|
||||
pub binary_sha256: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
|
|
@ -29,16 +55,36 @@ impl State {
|
|||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = state_path();
|
||||
if let Some(dir) = path.parent() {
|
||||
let text = serde_json::to_string_pretty(self)?;
|
||||
bread_utils::atomic::write_atomic(&path, &text, None).context("writing installed.json")
|
||||
}
|
||||
|
||||
/// Runs `f` against a freshly-loaded `State` while holding an exclusive
|
||||
/// lock on a sibling `installed.json.lock` file, saving the result if `f`
|
||||
/// succeeds. Without this, two concurrent `bakery` invocations each
|
||||
/// load-mutate-save `installed.json` independently and the second save
|
||||
/// silently drops the first's change — the lock serializes the whole
|
||||
/// read-modify-write instead of just the final write.
|
||||
pub fn with_lock<T>(f: impl FnOnce(&mut State) -> Result<T>) -> Result<T> {
|
||||
let lock_path = PathBuf::from(format!("{}.lock", state_path().display()));
|
||||
if let Some(dir) = lock_path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(self)?;
|
||||
// Write to a temp file then rename for atomicity — avoids a torn write
|
||||
// if the process is killed mid-save.
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, &text).context("writing installed.json.tmp")?;
|
||||
std::fs::rename(&tmp, &path).context("atomically replacing installed.json")?;
|
||||
Ok(())
|
||||
let lock_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(&lock_path)
|
||||
.context("opening installed.json.lock")?;
|
||||
lock_file
|
||||
.lock_exclusive()
|
||||
.context("locking installed.json.lock")?;
|
||||
|
||||
let mut state = Self::load()?;
|
||||
let result = f(&mut state)?;
|
||||
state.save()?;
|
||||
// Lock releases when `lock_file` drops at end of scope.
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn is_installed(&self, name: &str) -> bool {
|
||||
|
|
@ -52,16 +98,40 @@ 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_base_dir() -> PathBuf {
|
||||
dirs::state_dir().unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~"))
|
||||
.join(".local/state")
|
||||
})
|
||||
}
|
||||
|
||||
fn state_path() -> PathBuf {
|
||||
dirs::state_dir()
|
||||
.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~"))
|
||||
.join(".local/state")
|
||||
})
|
||||
.join("bakery/installed.json")
|
||||
bakery_state_dir().join("installed.json")
|
||||
}
|
||||
|
||||
/// Per-user bakery state dir (`~/.local/state/bakery`). Independent of the
|
||||
/// install prefix — system-prefix installs still record what this user asked for.
|
||||
pub fn bakery_state_dir() -> PathBuf {
|
||||
state_base_dir().join("bakery")
|
||||
}
|
||||
|
||||
/// Local backup dir for `pkg_name`'s `version` binaries, populated by
|
||||
/// `install::install_package` right before an update overwrites the
|
||||
/// previous binaries and consumed by `bakery rollback`. See
|
||||
/// `install::backup_current_binary`'s doc comment for why this is a local
|
||||
/// snapshot rather than a re-fetch of the old version from the server.
|
||||
pub fn backup_dir(pkg_name: &str, version: &str) -> PathBuf {
|
||||
state_base_dir()
|
||||
.join("bakery/backups")
|
||||
.join(pkg_name)
|
||||
.join(version)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -75,6 +145,9 @@ mod tests {
|
|||
binaries: vec![],
|
||||
services: vec![],
|
||||
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
track: Track::Stable,
|
||||
previous_version: None,
|
||||
binary_sha256: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +174,23 @@ mod tests {
|
|||
assert!(state.remove("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_defaults_to_stable_on_old_shape_json() {
|
||||
// Simulates installed.json written before the track field existed.
|
||||
let old_shape = r#"{"packages":{}}"#;
|
||||
let state: State = serde_json::from_str(old_shape).unwrap();
|
||||
assert_eq!(state.track, Track::Stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_track_updates_and_roundtrips() {
|
||||
let mut state = State::default();
|
||||
state.set_track(Track::Dev);
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
let restored: State = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(restored.track, Track::Dev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_roundtrip() {
|
||||
let mut state = State::default();
|
||||
|
|
@ -110,11 +200,76 @@ mod tests {
|
|||
binaries: vec!["bar".to_string()],
|
||||
services: vec!["bar.service".to_string()],
|
||||
installed_at: "2026-06-01T00:00:00Z".to_string(),
|
||||
track: Track::Beta,
|
||||
previous_version: Some("1.0.0".to_string()),
|
||||
binary_sha256: HashMap::from([("bar".to_string(), "abc123".to_string())]),
|
||||
});
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
let restored: State = serde_json::from_str(&json).unwrap();
|
||||
assert!(restored.is_installed("bar"));
|
||||
assert_eq!(restored.packages["bar"].version, "2.0.0");
|
||||
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
|
||||
assert_eq!(restored.packages["bar"].track, Track::Beta);
|
||||
assert_eq!(
|
||||
restored.packages["bar"].previous_version.as_deref(),
|
||||
Some("1.0.0")
|
||||
);
|
||||
assert_eq!(restored.packages["bar"].binary_sha256["bar"], "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_package_track_defaults_to_stable_on_old_shape_json() {
|
||||
// Simulates an installed.json entry written before per-package track
|
||||
// tracking existed.
|
||||
let old_shape = r#"{"name":"foo","version":"1.0.0","binaries":[],"services":[],"installed_at":"2026-01-01T00:00:00Z"}"#;
|
||||
let installed: InstalledPackage = serde_json::from_str(old_shape).unwrap();
|
||||
assert_eq!(installed.track, Track::Stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_package_previous_version_and_binary_sha256_default_on_old_shape_json() {
|
||||
// Simulates an installed.json entry written before rollback/verify
|
||||
// support existed.
|
||||
let old_shape = r#"{"name":"foo","version":"1.0.0","binaries":[],"services":[],"installed_at":"2026-01-01T00:00:00Z","track":"stable"}"#;
|
||||
let installed: InstalledPackage = serde_json::from_str(old_shape).unwrap();
|
||||
assert!(installed.previous_version.is_none());
|
||||
assert!(installed.binary_sha256.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bakery_state_dir_is_under_state_home_and_independent_of_prefix() {
|
||||
let dir = bakery_state_dir();
|
||||
assert!(dir.ends_with("bakery"));
|
||||
// Must not follow BAKERY_PREFIX — state is always per-user.
|
||||
assert!(!dir.starts_with("/usr/local"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_dir_is_distinct_per_package_and_version() {
|
||||
let a = backup_dir("bakery", "0.3.1");
|
||||
let b = backup_dir("bakery", "0.3.2");
|
||||
let c = backup_dir("breadhelp", "0.3.1");
|
||||
assert_ne!(a, b);
|
||||
assert_ne!(a, c);
|
||||
assert!(a.ends_with("bakery/backups/bakery/0.3.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_lock_persists_mutation_across_reload() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// SAFETY (test-only): temporarily redirects the state dir env var so
|
||||
// this test doesn't touch the real ~/.local/state/bakery/installed.json.
|
||||
std::env::set_var("XDG_STATE_HOME", dir.path());
|
||||
|
||||
State::with_lock(|state| {
|
||||
state.record(pkg("foo", "1.0.0"));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let reloaded = State::load().unwrap();
|
||||
assert!(reloaded.is_installed("foo"));
|
||||
|
||||
std::env::remove_var("XDG_STATE_HOME");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
85
bakery/src/track.rs
Normal file
85
bakery/src/track.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
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, Default, Deserialize, Serialize, ValueEnum)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Track {
|
||||
#[default]
|
||||
Stable,
|
||||
Beta,
|
||||
Dev,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
457
bakery/src/ui.rs
Normal file
457
bakery/src/ui.rs
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
use crate::track::Track;
|
||||
use clap::builder::styling::{AnsiColor, Effects, Styles};
|
||||
use std::io::{IsTerminal, Write};
|
||||
|
||||
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";
|
||||
pub const BOLD_CYAN: &str = "\x1b[1;36m";
|
||||
|
||||
/// Clap help styling — same cyan headers / green literals / dim placeholders
|
||||
/// as the rest of bakery, so `bakery --help` doesn't look like a different
|
||||
/// program from `bakery list`.
|
||||
pub const CLAP_STYLES: Styles = Styles::styled()
|
||||
.header(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
|
||||
.usage(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
|
||||
.literal(AnsiColor::Green.on_default().effects(Effects::BOLD))
|
||||
.placeholder(AnsiColor::BrightBlack.on_default())
|
||||
.error(AnsiColor::Red.on_default().effects(Effects::BOLD))
|
||||
.valid(AnsiColor::Green.on_default().effects(Effects::BOLD))
|
||||
.invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD));
|
||||
|
||||
/// 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 colors_enabled_err() -> bool {
|
||||
std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
|
||||
}
|
||||
|
||||
pub fn style(s: &str, code: &str) -> String {
|
||||
paint(s, code, colors_enabled())
|
||||
}
|
||||
|
||||
fn style_err(s: &str, code: &str) -> String {
|
||||
paint(s, code, colors_enabled_err())
|
||||
}
|
||||
|
||||
fn paint(s: &str, code: &str, on: bool) -> String {
|
||||
if on {
|
||||
format!("{code}{s}{RESET}")
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bold(s: &str) -> String {
|
||||
style(s, BOLD)
|
||||
}
|
||||
|
||||
pub fn dim(s: &str) -> String {
|
||||
style(s, DIM)
|
||||
}
|
||||
|
||||
/// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the
|
||||
/// common-case output is unchanged.
|
||||
#[allow(dead_code)]
|
||||
pub fn track_badge(track: Track) -> String {
|
||||
let tag = track_tag(track);
|
||||
if tag.is_empty() {
|
||||
tag
|
||||
} else {
|
||||
format!(" {tag}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `[beta]` / `[dev]` with no leading space; empty for `Stable`.
|
||||
pub fn track_tag(track: Track) -> String {
|
||||
match track {
|
||||
Track::Stable => String::new(),
|
||||
Track::Beta => style("[beta]", YELLOW),
|
||||
Track::Dev => style("[dev]", MAGENTA),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ok(s: &str) -> String {
|
||||
style(&format!("✓ {s}"), GREEN)
|
||||
}
|
||||
|
||||
pub fn fail(s: &str) -> String {
|
||||
style(&format!("✗ {s}"), RED)
|
||||
}
|
||||
|
||||
/// Neutral "nothing to do" glyph, dim rather than green — for steady-state
|
||||
/// noise like "already at latest" in `bakery update --all`, where most
|
||||
/// packages hit this every run. Reusing GREEN there drowns out the
|
||||
/// packages that actually changed, and meaning shouldn't depend on color
|
||||
/// alone (an unusual terminal palette can make BOLD/GREEN/DIM look similar),
|
||||
/// so this also carries its own glyph the way `ok`/`fail` do.
|
||||
pub fn unchanged(s: &str) -> String {
|
||||
style(&format!("· {s}"), DIM)
|
||||
}
|
||||
|
||||
pub fn warn(s: &str) -> String {
|
||||
style(&format!("warning: {s}"), YELLOW)
|
||||
}
|
||||
|
||||
pub fn note(s: &str) -> String {
|
||||
style(&format!("note: {s}"), DIM)
|
||||
}
|
||||
|
||||
/// Cyan verb + bold name + dim version — the install/update/remove banner.
|
||||
pub fn action(verb: &str, name: &str, version: Option<&str>) {
|
||||
let mut line = format!("{} {}", style(verb, BOLD_CYAN), style(name, BOLD));
|
||||
if let Some(v) = version {
|
||||
line.push_str(" ");
|
||||
line.push_str(&style(v, DIM));
|
||||
}
|
||||
println!("{line}");
|
||||
}
|
||||
|
||||
/// Section title plus dim meta (`Packages 16 · 15 installed`).
|
||||
pub fn heading(title: &str, parts: &[&str]) {
|
||||
let mut line = style(title, BOLD_CYAN);
|
||||
let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect();
|
||||
for (i, part) in visible.iter().enumerate() {
|
||||
line.push_str(" ");
|
||||
if i > 0 {
|
||||
line.push_str(&style("·", DIM));
|
||||
line.push_str(" ");
|
||||
}
|
||||
line.push_str(part);
|
||||
}
|
||||
println!("{line}");
|
||||
println!();
|
||||
}
|
||||
|
||||
pub fn summary(parts: &[&str]) {
|
||||
let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect();
|
||||
if visible.is_empty() {
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
println!("{}", style(&visible.join(" · "), BOLD));
|
||||
}
|
||||
|
||||
/// Left-aligned verb column so install chatter (`downloading` / `placed` /
|
||||
/// `unit`) lines up instead of drifting with the verb length.
|
||||
pub fn step(verb: &str, detail: &str) {
|
||||
println!(" {:<12} {}", dim(verb), detail);
|
||||
}
|
||||
|
||||
pub fn kv(key: &str, value: &str) {
|
||||
println!(" {:<12} {}", dim(key), value);
|
||||
}
|
||||
|
||||
pub fn check_row(ok_flag: bool, name: &str, name_width: usize, message: &str) {
|
||||
let glyph = if ok_flag {
|
||||
style("✓", GREEN)
|
||||
} else {
|
||||
style("✗", RED)
|
||||
};
|
||||
println!(" {glyph} {:<name_width$} {message}", name);
|
||||
}
|
||||
|
||||
pub fn unknown_row(name: &str, name_width: usize, message: &str) {
|
||||
println!(
|
||||
" {} {:<name_width$} {}",
|
||||
style("?", DIM),
|
||||
name,
|
||||
dim(message)
|
||||
);
|
||||
}
|
||||
|
||||
pub struct CatalogRow {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub installed: bool,
|
||||
/// Wrapped onto following lines (descriptions).
|
||||
pub detail: String,
|
||||
/// Same-line suffix after the version (short dates). Empty for catalog
|
||||
/// views that already use `detail`.
|
||||
pub aside: String,
|
||||
}
|
||||
|
||||
/// Two-line catalog: status glyph + aligned name/version, then a hanging
|
||||
/// description (or date) wrapped to the terminal width. Column widths are
|
||||
/// computed from the row set so long `-dev.` versions no longer smash the
|
||||
/// old `{: <10}` pad.
|
||||
pub fn print_catalog(rows: &[CatalogRow]) {
|
||||
for line in format_catalog(rows, term_width()) {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_catalog(rows: &[CatalogRow], width: usize) -> Vec<String> {
|
||||
if rows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let name_w = rows.iter().map(|r| r.name.len()).max().unwrap_or(0);
|
||||
let indent = 5; // " ✓ " / " "
|
||||
let detail_width = width.saturating_sub(indent).max(24);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for row in rows {
|
||||
let glyph = if row.installed {
|
||||
style("✓", GREEN)
|
||||
} else {
|
||||
" ".to_string()
|
||||
};
|
||||
let name = style(&format!("{:<name_w$}", row.name), BOLD);
|
||||
let version = style(&row.version, DIM);
|
||||
let mut line = format!(" {glyph} {name} {version}");
|
||||
if !row.aside.is_empty() {
|
||||
line.push_str(" ");
|
||||
line.push_str(&dim(&row.aside));
|
||||
}
|
||||
lines.push(line);
|
||||
if !row.detail.is_empty() {
|
||||
for wrapped in wrap_words(&row.detail, detail_width) {
|
||||
lines.push(format!(" {}", dim(&wrapped)));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
pub fn wrap_words(text: &str, width: usize) -> Vec<String> {
|
||||
if width == 0 {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
if cur.is_empty() {
|
||||
cur = word.to_string();
|
||||
} else if cur.len() + 1 + word.len() <= width {
|
||||
cur.push(' ');
|
||||
cur.push_str(word);
|
||||
} else {
|
||||
lines.push(std::mem::take(&mut cur));
|
||||
cur = word.to_string();
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
lines.push(cur);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
pub fn short_date(rfc3339: &str) -> String {
|
||||
chrono::DateTime::parse_from_rfc3339(rfc3339)
|
||||
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_else(|_| rfc3339.to_string())
|
||||
}
|
||||
|
||||
pub fn name_width<S: AsRef<str>>(names: impl IntoIterator<Item = S>) -> usize {
|
||||
names
|
||||
.into_iter()
|
||||
.map(|s| s.as_ref().len())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `\r`-overwritten download bar on stderr. Pads to a stable width so a
|
||||
/// shorter later frame doesn't leave leftover characters from a longer one.
|
||||
pub fn print_progress(downloaded: u64, total: u64) {
|
||||
let width = term_width().clamp(40, 72);
|
||||
let line = progress_line(downloaded, total, 20);
|
||||
let padded = fit_width(&line, width);
|
||||
eprint!("\r{padded}");
|
||||
let _ = std::io::stderr().flush();
|
||||
}
|
||||
|
||||
pub fn finish_progress() {
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
pub fn progress_line(downloaded: u64, total: u64, bar_width: usize) -> String {
|
||||
let dl = downloaded as f64 / 1_048_576.0;
|
||||
let tot = total as f64 / 1_048_576.0;
|
||||
let frac = if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(downloaded as f64 / total as f64).clamp(0.0, 1.0)
|
||||
};
|
||||
let filled = ((bar_width as f64) * frac).round() as usize;
|
||||
let filled = filled.min(bar_width);
|
||||
let bar = format!("{}{}", "█".repeat(filled), "░".repeat(bar_width - filled));
|
||||
let pct = (frac * 100.0).round() as u32;
|
||||
format!(
|
||||
" ⇣ {} {:>3}% {:.1}/{:.1} MB",
|
||||
style_err(&bar, CYAN),
|
||||
pct,
|
||||
dl,
|
||||
tot
|
||||
)
|
||||
}
|
||||
|
||||
fn fit_width(s: &str, width: usize) -> String {
|
||||
let visible = visible_len(s);
|
||||
if visible >= width {
|
||||
return s.to_string();
|
||||
}
|
||||
format!("{s}{}", " ".repeat(width - visible))
|
||||
}
|
||||
|
||||
fn visible_len(s: &str) -> usize {
|
||||
let mut n = 0;
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\u{1b}' {
|
||||
if chars.peek() == Some(&'[') {
|
||||
chars.next();
|
||||
for next in chars.by_ref() {
|
||||
if next.is_ascii_alphabetic() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
pub fn term_width() -> usize {
|
||||
if let Ok(w) = std::env::var("COLUMNS") {
|
||||
if let Ok(n) = w.parse::<usize>() {
|
||||
if n >= 40 {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
ioctl_width().filter(|&n| n >= 40).unwrap_or(80)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn ioctl_width() -> Option<usize> {
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
#[repr(C)]
|
||||
struct WinSize {
|
||||
row: u16,
|
||||
col: u16,
|
||||
x: u16,
|
||||
y: u16,
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn ioctl(fd: i32, request: u64, argp: *mut WinSize) -> i32;
|
||||
}
|
||||
|
||||
let mut ws = WinSize {
|
||||
row: 0,
|
||||
col: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
// TIOCGWINSZ on Linux.
|
||||
let fd = std::io::stdout().as_raw_fd();
|
||||
let ret = unsafe { ioctl(fd, 0x5413, &mut ws) };
|
||||
if ret == 0 && ws.col > 0 {
|
||||
Some(ws.col as usize)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn ioctl_width() -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_carries_a_distinct_glyph_from_ok_and_fail() {
|
||||
// Meaning must survive even with colors stripped (NO_COLOR, or a
|
||||
// terminal palette that makes ANSI codes look alike) — so the glyph
|
||||
// itself has to differ, not just the color.
|
||||
assert!(unchanged("foo").contains('·'));
|
||||
assert!(!ok("foo").contains('·'));
|
||||
assert!(!fail("foo").contains('·'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_aligns_names_and_versions() {
|
||||
let lines = format_catalog(
|
||||
&[
|
||||
CatalogRow {
|
||||
name: "bakery".into(),
|
||||
version: "0.7.2-dev.20260815142350+30517f1".into(),
|
||||
installed: true,
|
||||
detail: "Package manager".into(),
|
||||
aside: String::new(),
|
||||
},
|
||||
CatalogRow {
|
||||
name: "breadarr".into(),
|
||||
version: "0.1.2".into(),
|
||||
installed: false,
|
||||
detail: "Homelab arr stack".into(),
|
||||
aside: String::new(),
|
||||
},
|
||||
],
|
||||
80,
|
||||
);
|
||||
assert_eq!(lines.len(), 4);
|
||||
assert!(lines[0].contains("bakery"));
|
||||
assert!(lines[0].contains("0.7.2-dev.20260815142350+30517f1"));
|
||||
assert!(lines[1].contains("Package manager"));
|
||||
// Shorter version is padded so the columns stay a block, not a
|
||||
// ragged list — the long bakery version used to overflow `{: <10}`.
|
||||
// Compare display columns, not byte offsets: the installed glyph
|
||||
// is a 3-byte checkmark sitting in a 1-column slot.
|
||||
let bakery_col = visible_len(&lines[0][..lines[0].find("0.7.2-dev").unwrap()]);
|
||||
let breadarr_col = visible_len(&lines[2][..lines[2].find("0.1.2").unwrap()]);
|
||||
assert_eq!(bakery_col, breadarr_col);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_words_breaks_on_width() {
|
||||
let lines = wrap_words("one two three four", 9);
|
||||
assert_eq!(lines, vec!["one two", "three", "four"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_line_has_bar_and_percent() {
|
||||
let line = progress_line(1_048_576, 2_097_152, 10);
|
||||
assert!(line.contains('█'));
|
||||
assert!(line.contains('░'));
|
||||
assert!(line.contains("50%"));
|
||||
assert!(line.contains("1.0/2.0 MB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_len_ignores_ansi() {
|
||||
assert_eq!(visible_len("hello"), 5);
|
||||
assert_eq!(visible_len(&format!("{CYAN}hello{RESET}")), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_date_from_rfc3339() {
|
||||
assert_eq!(short_date("2026-08-15T14:23:50+00:00"), "2026-08-15");
|
||||
}
|
||||
}
|
||||
20
bread-app/Cargo.toml
Normal file
20
bread-app/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "bread-app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "GTK application bootstrap for bread desktop tools: app id, singleton, optional overlay popup, and command listen loop"
|
||||
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
||||
keywords = ["gtk4", "wayland", "hyprland"]
|
||||
|
||||
[dependencies]
|
||||
bread-utils = { path = "../bread-utils" }
|
||||
|
||||
[features]
|
||||
# Layer-shell overlay helper (`gtk_popup`). Matches `bread-utils/gtk` so a
|
||||
# consumer that only wants app-id / singleton helpers does not pull GTK4.
|
||||
gtk = ["bread-utils/gtk"]
|
||||
# `BreadClient` listen loop on `bread.command.<app>.**`. Matches
|
||||
# `bread-utils/bread-client`.
|
||||
bread-client = ["bread-utils/bread-client"]
|
||||
121
bread-app/src/command.rs
Normal file
121
bread-app/src/command.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
//! Command-bus helpers for `bread.command.<app>.**`.
|
||||
//!
|
||||
//! The `command_id` here is the breadd sibling-app id (`clip`, `box`,
|
||||
//! `shot`) — often shorter than the GTK / singleton name (`breadclip`).
|
||||
|
||||
use crate::id::{parse_app_name, InvalidAppId};
|
||||
use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
|
||||
|
||||
/// Same charset as [`parse_app_name`]: a single command-bus segment.
|
||||
pub fn parse_command_id(command_id: &str) -> Result<&str, InvalidAppId> {
|
||||
parse_app_name(command_id)
|
||||
}
|
||||
|
||||
/// Subscribe glob: `bread.command.<app>.**`.
|
||||
pub fn command_pattern(command_id: &str) -> Result<String, InvalidAppId> {
|
||||
let id = parse_command_id(command_id)?;
|
||||
Ok(format!("bread.command.{id}.**"))
|
||||
}
|
||||
|
||||
/// The verb segment of `bread.command.<app>.<verb>` (and extra trailing
|
||||
/// segments, if any). `None` when the event is not addressed to
|
||||
/// `command_id` or the verb is missing.
|
||||
///
|
||||
/// Extra dotted remainder (`bread.command.clip.stack.clear`) yields the
|
||||
/// first remaining segment (`stack`) — a verb is one segment, matching
|
||||
/// [`BreadClient::command`].
|
||||
pub fn command_verb<'a>(event: &'a str, command_id: &str) -> Option<&'a str> {
|
||||
if command_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let prefix = format!("bread.command.{command_id}.");
|
||||
let rest = event.strip_prefix(&prefix)?;
|
||||
let verb = rest.split('.').next()?;
|
||||
if verb.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(verb)
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to `bread.command.<command_id>.**` and invoke `on_verb` with
|
||||
/// the parsed verb plus the raw event.
|
||||
///
|
||||
/// Fail-silent: constructing the client and holding the subscription never
|
||||
/// requires breadd to be running. Drop the returned [`Subscription`] (or
|
||||
/// call [`Subscription::stop`]) to end the loop.
|
||||
pub fn listen_commands<F>(command_id: &str, on_verb: F) -> Result<Subscription, InvalidAppId>
|
||||
where
|
||||
F: Fn(&str, BreadEvent) + Send + 'static,
|
||||
{
|
||||
let id = parse_command_id(command_id)?.to_string();
|
||||
let client = BreadClient::connect(id.clone());
|
||||
let pattern = format!("bread.command.{id}.**");
|
||||
Ok(client.subscribe(pattern, move |event| {
|
||||
let Some(verb) = command_verb(&event.event, &id).map(str::to_owned) else {
|
||||
return;
|
||||
};
|
||||
on_verb(&verb, event);
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_pattern_uses_double_star() {
|
||||
assert_eq!(command_pattern("clip").unwrap(), "bread.command.clip.**");
|
||||
assert_eq!(command_pattern("shot").unwrap(), "bread.command.shot.**");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_pattern_rejects_invalid_id() {
|
||||
assert!(command_pattern("").is_err());
|
||||
assert!(command_pattern("clip.clear").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_verb_strips_app_prefix() {
|
||||
assert_eq!(
|
||||
command_verb("bread.command.clip.clear", "clip"),
|
||||
Some("clear")
|
||||
);
|
||||
assert_eq!(
|
||||
command_verb("bread.command.shot.region", "shot"),
|
||||
Some("region")
|
||||
);
|
||||
assert_eq!(
|
||||
command_verb("bread.command.shot.annotate", "shot"),
|
||||
Some("annotate")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_verb_takes_first_segment_only() {
|
||||
assert_eq!(
|
||||
command_verb("bread.command.clip.stack.clear", "clip"),
|
||||
Some("stack")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_verb_rejects_other_apps_and_missing_verb() {
|
||||
assert_eq!(command_verb("bread.command.clip.clear", "shot"), None);
|
||||
assert_eq!(command_verb("bread.command.clip", "clip"), None);
|
||||
assert_eq!(command_verb("bread.command.clip.", "clip"), None);
|
||||
assert_eq!(command_verb("bread.clip.copied", "clip"), None);
|
||||
assert_eq!(command_verb("bread.command.clip.clear", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_commands_rejects_invalid_id() {
|
||||
assert!(listen_commands("", |_, _| {}).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_commands_stop_joins_without_a_daemon() {
|
||||
let sub = listen_commands("clip", |_, _| {}).unwrap();
|
||||
sub.stop();
|
||||
}
|
||||
}
|
||||
145
bread-app/src/id.rs
Normal file
145
bread-app/src/id.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
//! App-id helpers shared by GTK tools and the singleton lock.
|
||||
//!
|
||||
//! The process / pid-file name (`breadbox`, `bread-polkit`) is also the
|
||||
//! last segment of the GApplication id (`com.breadway.breadbox`). That is
|
||||
//! *not* always the breadd command-bus id (`box`, `clip`) — see
|
||||
//! [`crate::command_verb`] under feature `bread-client`.
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::singleton::{self, Acquire, Toggle};
|
||||
|
||||
/// Why [`parse_app_name`] / [`application_id`] rejected a string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InvalidAppId {
|
||||
/// The rejected input, owned so the error is `'static`.
|
||||
pub name: String,
|
||||
/// Short reason suitable for an `io::Error` / clap message.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InvalidAppId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "invalid app id '{}': {}", self.name, self.reason)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InvalidAppId {}
|
||||
|
||||
/// Accept a process / GTK application name (`breadbox`, `bread-polkit`).
|
||||
///
|
||||
/// Rules match a GApplication id *element*: non-empty, ASCII letter first,
|
||||
/// then ASCII alphanumeric / `-` / `_`. Dots are rejected so the name can
|
||||
/// sit in `com.breadway.<name>` without creating extra segments.
|
||||
pub fn parse_app_name(name: &str) -> Result<&str, InvalidAppId> {
|
||||
if name.is_empty() {
|
||||
return Err(InvalidAppId {
|
||||
name: name.to_string(),
|
||||
reason: "must not be empty",
|
||||
});
|
||||
}
|
||||
let mut chars = name.chars();
|
||||
let first = chars.next().expect("non-empty");
|
||||
if !first.is_ascii_alphabetic() {
|
||||
return Err(InvalidAppId {
|
||||
name: name.to_string(),
|
||||
reason: "must start with an ASCII letter",
|
||||
});
|
||||
}
|
||||
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err(InvalidAppId {
|
||||
name: name.to_string(),
|
||||
reason: "only ASCII letters, digits, '-' and '_' are allowed",
|
||||
});
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Reverse-DNS GApplication id: `com.breadway.<name>`.
|
||||
pub fn application_id(app_name: &str) -> Result<String, InvalidAppId> {
|
||||
let name = parse_app_name(app_name)?;
|
||||
Ok(format!("com.breadway.{name}"))
|
||||
}
|
||||
|
||||
/// [`singleton::try_acquire`] after [`parse_app_name`].
|
||||
///
|
||||
/// Invalid names become [`io::ErrorKind::InvalidInput`] and never touch
|
||||
/// the pid file.
|
||||
pub fn try_acquire(app_name: &str) -> io::Result<Acquire> {
|
||||
let name =
|
||||
parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
singleton::try_acquire(name)
|
||||
}
|
||||
|
||||
/// [`singleton::toggle_or_kill`] after [`parse_app_name`].
|
||||
pub fn toggle_or_kill(app_name: &str) -> io::Result<Toggle> {
|
||||
let name =
|
||||
parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
singleton::toggle_or_kill(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_app_name_accepts_existing_tool_names() {
|
||||
for name in ["breadbox", "breadclip", "bread-polkit", "breadcast"] {
|
||||
assert_eq!(parse_app_name(name), Ok(name));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_name_rejects_empty_dot_and_leading_digit() {
|
||||
assert!(parse_app_name("").is_err());
|
||||
assert!(parse_app_name("bread.box").is_err());
|
||||
assert!(parse_app_name("1box").is_err());
|
||||
assert!(parse_app_name("-box").is_err());
|
||||
assert!(parse_app_name("bread box").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_id_uses_com_breadway_prefix() {
|
||||
assert_eq!(application_id("breadbox").unwrap(), "com.breadway.breadbox");
|
||||
assert_eq!(
|
||||
application_id("bread-polkit").unwrap(),
|
||||
"com.breadway.bread-polkit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_id_rejects_invalid_name() {
|
||||
assert!(application_id("").is_err());
|
||||
assert!(application_id("bread.box").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_acquire_rejects_invalid_name_before_lock() {
|
||||
match try_acquire("") {
|
||||
Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput),
|
||||
Ok(_) => panic!("empty name must not acquire a lock"),
|
||||
}
|
||||
match try_acquire("bread.box") {
|
||||
Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput),
|
||||
Ok(_) => panic!("dotted name must not acquire a lock"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_acquire_accepts_valid_name() {
|
||||
let name = format!("bread-app-id-test-{}", std::process::id());
|
||||
match try_acquire(&name).unwrap() {
|
||||
Acquire::Acquired(_guard) => {}
|
||||
Acquire::HeldByOther(_) => panic!("expected first acquire to succeed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_or_kill_starts_when_nothing_else_is_running() {
|
||||
let name = format!("bread-app-toggle-test-{}", std::process::id());
|
||||
match toggle_or_kill(&name).unwrap() {
|
||||
Toggle::Started(_guard) => {}
|
||||
Toggle::KilledExisting => panic!("expected to start as the first instance"),
|
||||
}
|
||||
}
|
||||
}
|
||||
67
bread-app/src/lib.rs
Normal file
67
bread-app/src/lib.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
//! GTK application bootstrap for bread desktop tools.
|
||||
//!
|
||||
//! New GTK tools should depend on this crate instead of copying a sixth
|
||||
//! `main.rs` that wires a `com.breadway.*` application id, a
|
||||
//! [`bread_utils::singleton`] lock, a layer-shell overlay, and a
|
||||
//! `bread.command.<app>.**` listen loop.
|
||||
//!
|
||||
//! # What this is
|
||||
//!
|
||||
//! The pieces every bread GTK binary already copies:
|
||||
//!
|
||||
//! - [`application_id`] / [`parse_app_name`] — reverse-DNS id
|
||||
//! (`com.breadway.breadbox`) and the same name used for the singleton
|
||||
//! pid file.
|
||||
//! - [`try_acquire`] / [`toggle_or_kill`] — [`bread_utils::singleton`]
|
||||
//! wrappers that reject an invalid name before touching the lock.
|
||||
//! - feature `gtk` — re-exports [`gtk_popup`] (`bread_utils::gtk_popup`)
|
||||
//! for the full-screen overlay breadbox / breadclip / breadcast start
|
||||
//! from.
|
||||
//! - feature `bread-client` — [`listen_commands`] plus [`command_verb`] /
|
||||
//! [`command_pattern`] so a tool can honor `bread.command.<app>.**`
|
||||
//! without re-deriving the prefix strip.
|
||||
//!
|
||||
//! This crate does **not** migrate existing apps. Callers still own their
|
||||
//! widgets, CSS, and clap. Screenshot / `--screenshot` helpers stay in
|
||||
//! [`bread_utils::screenshot_cli`].
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let _guard = match bread_app::try_acquire("breadbox")? {
|
||||
//! bread_app::singleton::Acquire::Acquired(g) => g,
|
||||
//! bread_app::singleton::Acquire::HeldByOther(_) => return Ok(()),
|
||||
//! };
|
||||
//! let app = gtk4::Application::builder()
|
||||
//! .application_id(&bread_app::application_id("breadbox")?)
|
||||
//! .build();
|
||||
//!
|
||||
//! #[cfg(feature = "gtk")]
|
||||
//! app.connect_activate(|app| {
|
||||
//! let window = bread_app::gtk_popup::new_overlay_window(app, "breadbox");
|
||||
//! window.present();
|
||||
//! });
|
||||
//!
|
||||
//! #[cfg(feature = "bread-client")]
|
||||
//! let _commands = bread_app::listen_commands("box", |verb, event| {
|
||||
//! // verb is the single segment after `bread.command.box.`
|
||||
//! let _ = (verb, event);
|
||||
//! })?;
|
||||
//! ```
|
||||
|
||||
pub use bread_utils::singleton;
|
||||
|
||||
#[cfg(feature = "gtk")]
|
||||
pub use bread_utils::gtk_popup;
|
||||
|
||||
mod id;
|
||||
|
||||
pub use id::{application_id, parse_app_name, toggle_or_kill, try_acquire, InvalidAppId};
|
||||
|
||||
#[cfg(feature = "bread-client")]
|
||||
mod command;
|
||||
|
||||
#[cfg(feature = "bread-client")]
|
||||
pub use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
|
||||
#[cfg(feature = "bread-client")]
|
||||
pub use command::{command_pattern, command_verb, listen_commands, parse_command_id};
|
||||
21
bread-capture/Cargo.toml
Normal file
21
bread-capture/Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "bread-capture"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Orchestrator for the bread ecosystem's UI screenshot tooling: drives each app's --screenshot mode and collects the resulting PNGs"
|
||||
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
||||
keywords = ["screenshot", "ci", "tooling"]
|
||||
|
||||
[[bin]]
|
||||
name = "bread-capture"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
bread-utils = { path = "../bread-utils" }
|
||||
clap = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
# Generates the isolated capture canvas's rainbow-gradient background — see
|
||||
# isolation.rs. png-only: no decoding, no other format support needed.
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
233
bread-capture/src/isolation.rs
Normal file
233
bread-capture/src/isolation.rs
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
//! Runs each capture target inside a headless Sway instance instead of the
|
||||
//! operator's live desktop, so nothing on their screen (other windows, a
|
||||
//! differently-themed real bar, whatever's behind a popover) can leak into a
|
||||
//! capture, and the capture never flashes across their desktop either.
|
||||
//!
|
||||
//! This replaced an earlier nested-Hyprland approach (see git history for
|
||||
//! `feature/capture-isolation` if you want the gory details). That worked,
|
||||
//! but Hyprland's own backend library (Aquamarine) has no genuinely headless
|
||||
//! mode when a live session already holds the seat — the only path was
|
||||
//! nesting a full second Hyprland as an ordinary Wayland *client* of the
|
||||
//! outer session, which meant: the outer compositor deciding the nested
|
||||
//! window's pixel size (so every capture needed an outer-session
|
||||
//! float+resize dispatch), the outer compositor throttling frame callbacks
|
||||
//! for occluded surfaces (so the nested window also had to be *focused*, or
|
||||
//! `grim` run inside it hung forever waiting on a frame that never came),
|
||||
//! and — the thing that ultimately motivated dropping this approach — no way
|
||||
//! to fully suppress the brief real, visible flash of that window on the
|
||||
//! operator's actual screen (Lua-config Hyprland has no `keyword`-based
|
||||
//! pre-emptive windowrule injection, and parking it on an untoggled special
|
||||
//! workspace produced broken, half-rendered captures instead).
|
||||
//!
|
||||
//! wlroots (which Sway, not Hyprland, is built directly on) has a real
|
||||
//! headless backend: `WLR_BACKENDS=headless` skips DRM and Wayland-client
|
||||
//! backends entirely and synthesizes a virtual output with no seat/DRM-master
|
||||
//! claim at all — no fight with logind over the live session's seat, and no
|
||||
//! window anywhere, nested or otherwise, for the operator to ever see. Empirically
|
||||
//! confirmed on this machine: zero visible footprint, `zwlr_layer_shell_v1`
|
||||
//! and `zwlr_screencopy_manager_v1` both present (so a layer-shell bar and
|
||||
//! `grim` both work), and a manual `grim` capture against it completes
|
||||
//! instantly with no focus/occlusion dance required.
|
||||
//!
|
||||
//! One consequence of not nesting inside Hyprland at all: breadbar's
|
||||
//! workspace list (`src/bar/workspaces.rs`, via the `hyprland` crate) talks
|
||||
//! to whatever `HYPRLAND_INSTANCE_SIGNATURE` points at. Left alone, that
|
||||
//! still points at the operator's real, live Hyprland instance — a data leak
|
||||
//! into an otherwise-isolated capture (real workspace names/count showing up
|
||||
//! in a bar screenshot that's supposed to be clean). Sway has no equivalent
|
||||
//! IPC this needs to keep working, so [`Isolation::start`] unsets it;
|
||||
//! breadbar already has to tolerate a missing/dead Hyprland connection
|
||||
//! gracefully (it survives Hyprland restarting), so this just exercises that
|
||||
//! same fallback path instead of a real error case.
|
||||
//!
|
||||
//! The background is a generated rainbow gradient, not a flat colour —
|
||||
//! deliberately: a solid fill can't tell you whether a window that's
|
||||
//! *supposed* to be translucent (breadbox/breadclip/breadsearch's
|
||||
//! full-screen overlay windows, breadbar's notification/OSD surfaces) is
|
||||
//! actually compositing as translucent, since a flat colour showing through
|
||||
//! a flat colour still just looks flat. A continuously-varying gradient
|
||||
//! makes any real transparency immediately obvious (multiple hues bleed
|
||||
//! through) and any accidentally-opaque surface just as obvious (it blocks
|
||||
//! the gradient out entirely, a flat rectangle where there should be colour
|
||||
//! variation).
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
pub struct Isolation {
|
||||
child: Child,
|
||||
pub wayland_display: String,
|
||||
config_path: PathBuf,
|
||||
background_path: PathBuf,
|
||||
runtime_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Isolation {
|
||||
/// Spawn the headless instance sized to `width`x`height`, and set
|
||||
/// `WAYLAND_DISPLAY` on *this* process's own environment (and unset
|
||||
/// `HYPRLAND_INSTANCE_SIGNATURE`) so every subsequent
|
||||
/// `bread_utils::proc::run` spawn (the target app, and in turn its own
|
||||
/// `grim` calls) inherits them and lands inside the isolated instance.
|
||||
pub fn start(width: u32, height: u32) -> Result<Self> {
|
||||
let runtime_dir = PathBuf::from(
|
||||
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string()),
|
||||
);
|
||||
let background_path = write_rainbow_background(width, height)?;
|
||||
let config_path = write_headless_config(width, height, &background_path)?;
|
||||
let before_sockets: HashSet<String> = dir_names(&runtime_dir)
|
||||
.into_iter()
|
||||
.filter(|n| is_wayland_socket_name(n))
|
||||
.collect();
|
||||
|
||||
let child = Command::new("sway")
|
||||
.arg("-c")
|
||||
.arg(&config_path)
|
||||
.env("WLR_BACKENDS", "headless")
|
||||
.env("XDG_RUNTIME_DIR", &runtime_dir)
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.context("spawning headless sway")?;
|
||||
|
||||
let mut isolation = Isolation {
|
||||
child,
|
||||
wayland_display: String::new(),
|
||||
config_path,
|
||||
background_path,
|
||||
runtime_dir: runtime_dir.clone(),
|
||||
};
|
||||
|
||||
match poll_for_new(&runtime_dir, &before_sockets, DISCOVERY_TIMEOUT, is_wayland_socket_name)
|
||||
.context("waiting for headless sway's Wayland socket to appear")
|
||||
{
|
||||
Ok(name) => isolation.wayland_display = name,
|
||||
Err(e) => {
|
||||
// Best-effort teardown of the half-started instance before
|
||||
// propagating — the normal Drop impl still runs too, but
|
||||
// doing it here as well means a failure this early doesn't
|
||||
// depend on isolation ever being bound to a variable that
|
||||
// outlives this function.
|
||||
let _ = isolation.child.kill();
|
||||
let _ = isolation.child.wait();
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
std::env::set_var("WAYLAND_DISPLAY", &isolation.wayland_display);
|
||||
std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE");
|
||||
|
||||
Ok(isolation)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Isolation {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
let _ = std::fs::remove_file(&self.config_path);
|
||||
let _ = std::fs::remove_file(&self.background_path);
|
||||
// Killing sway doesn't unlink the socket it bound — confirmed
|
||||
// empirically, a killed instance leaves both files behind — so
|
||||
// without this, every capture run permanently orphans a
|
||||
// `wayland-N`/`wayland-N.lock` pair in the runtime dir.
|
||||
if !self.wayland_display.is_empty() {
|
||||
let _ = std::fs::remove_file(self.runtime_dir.join(&self.wayland_display));
|
||||
let _ = std::fs::remove_file(self.runtime_dir.join(format!("{}.lock", self.wayland_display)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_headless_config(width: u32, height: u32, background_path: &Path) -> Result<PathBuf> {
|
||||
let path = std::env::temp_dir().join(format!("bread-capture-sway-{}.conf", std::process::id()));
|
||||
let bg = background_path.display();
|
||||
let contents = format!(
|
||||
"output HEADLESS-1 resolution {width}x{height}\n\
|
||||
output HEADLESS-1 bg {bg} stretch\n"
|
||||
);
|
||||
std::fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Renders a diagonal rainbow (full hue sweep, both x and y contribute) to a
|
||||
/// PNG at exactly `width`x`height`, for use as the isolated canvas's
|
||||
/// background — see the module doc for why a gradient instead of a flat
|
||||
/// colour. Diagonal rather than a simple left-to-right sweep so a capture
|
||||
/// showing color variation isn't just luck-of-the-x-position: a purely
|
||||
/// horizontal gradient would still make a tall, narrow surface look like a
|
||||
/// near-flat single hue.
|
||||
fn write_rainbow_background(width: u32, height: u32) -> Result<PathBuf> {
|
||||
let path = std::env::temp_dir().join(format!("bread-capture-bg-{}.png", std::process::id()));
|
||||
let mut img = image::RgbImage::new(width.max(1), height.max(1));
|
||||
let denom = (width + height).max(1) as f32;
|
||||
for y in 0..img.height() {
|
||||
for x in 0..img.width() {
|
||||
let hue = ((x + y) as f32 / denom) * 360.0;
|
||||
img.put_pixel(x, y, image::Rgb(hsv_to_rgb(hue, 0.85, 0.95)));
|
||||
}
|
||||
}
|
||||
img.save(&path).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Standard HSV -> RGB conversion. `h` in degrees [0, 360), `s`/`v` in [0, 1].
|
||||
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [u8; 3] {
|
||||
let c = v * s;
|
||||
let h_prime = (h / 60.0) % 6.0;
|
||||
let x = c * (1.0 - (h_prime % 2.0 - 1.0).abs());
|
||||
let m = v - c;
|
||||
let (r1, g1, b1) = match h_prime as u32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
[
|
||||
((r1 + m) * 255.0).round() as u8,
|
||||
((g1 + m) * 255.0).round() as u8,
|
||||
((b1 + m) * 255.0).round() as u8,
|
||||
]
|
||||
}
|
||||
|
||||
fn dir_names(path: &Path) -> HashSet<String> {
|
||||
std::fs::read_dir(path)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_wayland_socket_name(name: &str) -> bool {
|
||||
name.strip_prefix("wayland-")
|
||||
.is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
fn poll_for_new(
|
||||
dir: &Path,
|
||||
before: &HashSet<String>,
|
||||
timeout: Duration,
|
||||
relevant: impl Fn(&str) -> bool,
|
||||
) -> Result<String> {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
let after = dir_names(dir);
|
||||
if let Some(name) = after.iter().find(|n| relevant(n) && !before.contains(*n)) {
|
||||
return Ok(name.clone());
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
bail!(
|
||||
"timed out after {timeout:?} waiting for a new entry in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
251
bread-capture/src/main.rs
Normal file
251
bread-capture/src/main.rs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
//! Orchestrator for the bread ecosystem's UI screenshot tooling.
|
||||
//!
|
||||
//! Drives each target app's `--screenshot <view> --output <path>` mode (see
|
||||
//! `bread-screenshots` for what that mode does inside the app) and reports
|
||||
//! pass/fail per view/app. Plain `bread-capture` with no flags captures
|
||||
//! every known app's every view in one run — each app's binary is resolved
|
||||
//! by its own bare name via `$PATH`, same as running it directly by name
|
||||
//! would. `--app <name>` restricts to one app; `--app-path <path>`
|
||||
//! overrides where its binary is found (and, without `--app`, also selects
|
||||
//! which app by its file stem — so `--app-path ./target/release/breadbox`
|
||||
//! alone still works); `--view <name>` further restricts to one view. The
|
||||
//! view list for each app is looked up from [`TARGETS`] below. Each app
|
||||
//! gets its own subdirectory under `--out-dir` (`<out-dir>/<app>/<view>.png`)
|
||||
//! — no versioned `screenshots/vX.Y.Z/latest` structure or manifest file
|
||||
//! yet, since that's still not earning its complexity over a handful of
|
||||
//! apps.
|
||||
//!
|
||||
//! By default every capture runs inside a throwaway headless Sway instance
|
||||
//! (see [`isolation`]) rather than the operator's live desktop, so another
|
||||
//! window (or their own differently-themed real bar) can't leak into a
|
||||
//! capture. `--no-isolate` skips that and captures directly against whatever
|
||||
//! session bread-capture itself is running in — useful for debugging the
|
||||
//! capture sequence itself, since you can then actually watch it happen.
|
||||
|
||||
mod isolation;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
|
||||
const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Per-app (view name, output filename) lists. Keyed by the app's binary
|
||||
/// name — see `--app-name`. Filenames are plain (no app prefix): each app
|
||||
/// gets its own subdirectory under `--out-dir` (`<out-dir>/<app>/<file>`),
|
||||
/// so the prefix would just be redundant with the folder name.
|
||||
const TARGETS: &[(&str, &[(&str, &str)])] = &[
|
||||
(
|
||||
"breadbar",
|
||||
&[
|
||||
("bar", "bar.png"),
|
||||
("control-panel", "control-panel.png"),
|
||||
("connectivity-wifi", "connectivity-wifi.png"),
|
||||
("connectivity-bluetooth", "connectivity-bluetooth.png"),
|
||||
("media-popover", "media-popover.png"),
|
||||
("notification", "notification.png"),
|
||||
("notification-critical", "notification-critical.png"),
|
||||
("osd-volume", "osd-volume.png"),
|
||||
("osd-brightness", "osd-brightness.png"),
|
||||
("wifi-add-dialog", "wifi-add-dialog.png"),
|
||||
],
|
||||
),
|
||||
("breadbox", &[("launcher", "launcher.png")]),
|
||||
("breadclip", &[("history", "history.png")]),
|
||||
("breadsearch", &[("search", "search.png")]),
|
||||
(
|
||||
"breadpad",
|
||||
&[
|
||||
("popup", "popup.png"),
|
||||
("reminder", "reminder.png"),
|
||||
("reminder-snooze", "reminder-snooze.png"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"breadhelp",
|
||||
&[
|
||||
("home", "home.png"),
|
||||
("learn", "learn.png"),
|
||||
("ask", "ask.png"),
|
||||
("troubleshoot-wizard", "troubleshoot-wizard.png"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"breadman",
|
||||
&[
|
||||
("all", "all.png"),
|
||||
("upcoming", "upcoming.png"),
|
||||
("todo", "todo.png"),
|
||||
("reminder", "reminder.png"),
|
||||
("idea", "idea.png"),
|
||||
("note", "note.png"),
|
||||
("question", "question.png"),
|
||||
("archive", "archive.png"),
|
||||
("settings", "settings.png"),
|
||||
("errors", "errors.png"),
|
||||
("editor", "editor.png"),
|
||||
("new-note", "new-note.png"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"bos-settings",
|
||||
&[
|
||||
("network", "network.png"),
|
||||
("breadcrumbs", "breadcrumbs.png"),
|
||||
("bluetooth", "bluetooth.png"),
|
||||
("firewall", "firewall.png"),
|
||||
("sound", "sound.png"),
|
||||
("power", "power.png"),
|
||||
("datetime", "datetime.png"),
|
||||
("hyprland", "hyprland.png"),
|
||||
("keybinds", "keybinds.png"),
|
||||
("autostart", "autostart.png"),
|
||||
("users", "users.png"),
|
||||
("appearance", "appearance.png"),
|
||||
("breadpaper", "breadpaper.png"),
|
||||
("breadbar", "breadbar.png"),
|
||||
("breadbox", "breadbox.png"),
|
||||
("breadclip", "breadclip.png"),
|
||||
("breadpad", "breadpad.png"),
|
||||
("breadsearch", "breadsearch.png"),
|
||||
("bread", "bread.png"),
|
||||
("packages", "packages.png"),
|
||||
("aur", "aur.png"),
|
||||
("firmware", "firmware.png"),
|
||||
("snapshots", "snapshots.png"),
|
||||
("about", "about.png"),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
/// Restrict to one app (see `TARGETS` for known names). Omit to capture
|
||||
/// every known app's every view in one run.
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
|
||||
/// Path to that app's binary (resolved via $PATH if not a path).
|
||||
/// Without `--app`, this also selects *which* app by its file stem
|
||||
/// (e.g. `./target/release/breadbox` -> `breadbox`) — so a single-app
|
||||
/// run never needs both flags. Ignored (with a warning) if given
|
||||
/// together with a multi-app run (no `--app`, and the path isn't
|
||||
/// resolvable to exactly one app).
|
||||
#[arg(long)]
|
||||
app_path: Option<String>,
|
||||
|
||||
/// Restrict to one view within the selected app(s) (see each app's
|
||||
/// entry in `TARGETS` for known view names). Apps that don't have a
|
||||
/// view by this name are skipped, not treated as an error, since a
|
||||
/// multi-app run's view names naturally don't all overlap.
|
||||
#[arg(long)]
|
||||
view: Option<String>,
|
||||
|
||||
/// Directory to write captured PNGs into.
|
||||
#[arg(long, default_value = "./screenshots")]
|
||||
out_dir: PathBuf,
|
||||
|
||||
/// Capture directly against the current session instead of a headless,
|
||||
/// throwaway Sway instance. Off by default so captures can't pick up
|
||||
/// whatever else is on the operator's desktop.
|
||||
#[arg(long)]
|
||||
no_isolate: bool,
|
||||
|
||||
/// Width of the isolated session's capture canvas.
|
||||
#[arg(long, default_value_t = 1920)]
|
||||
isolate_width: u32,
|
||||
|
||||
/// Height of the isolated session's capture canvas.
|
||||
#[arg(long, default_value_t = 1080)]
|
||||
isolate_height: u32,
|
||||
}
|
||||
|
||||
fn known_app_names() -> String {
|
||||
TARGETS.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// (app_name, binary_path, views) per selected app.
|
||||
type SelectedTarget = (&'static str, String, &'static [(&'static str, &'static str)]);
|
||||
|
||||
/// Resolves which `TARGETS` entries this run covers, and the binary path
|
||||
/// to use for each.
|
||||
fn selected_targets(cli: &Cli) -> Result<Vec<SelectedTarget>> {
|
||||
if let Some(app) = &cli.app {
|
||||
let Some((name, views)) = TARGETS.iter().find(|(n, _)| n == app) else {
|
||||
bail!("no known view list for app '{app}' (known: {})", known_app_names());
|
||||
};
|
||||
let path = cli.app_path.clone().unwrap_or_else(|| name.to_string());
|
||||
return Ok(vec![(name, path, views)]);
|
||||
}
|
||||
|
||||
if let Some(path) = &cli.app_path {
|
||||
let stem = PathBuf::from(path)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.clone());
|
||||
let Some((name, views)) = TARGETS.iter().find(|(n, _)| *n == stem) else {
|
||||
bail!("no known view list for app '{stem}' (known: {})", known_app_names());
|
||||
};
|
||||
return Ok(vec![(name, path.clone(), views)]);
|
||||
}
|
||||
|
||||
// No --app / --app-path at all: every known app, resolved by its own
|
||||
// bare name via $PATH.
|
||||
Ok(TARGETS.iter().map(|(name, views)| (*name, name.to_string(), *views)).collect())
|
||||
}
|
||||
|
||||
fn main() -> Result<ExitCode> {
|
||||
let cli = Cli::parse();
|
||||
let targets = selected_targets(&cli)?;
|
||||
|
||||
if let Some(view) = &cli.view {
|
||||
if !targets.iter().any(|(_, _, views)| views.iter().any(|(v, _)| v == view)) {
|
||||
bail!("view '{view}' doesn't match any selected app's views");
|
||||
}
|
||||
}
|
||||
|
||||
// Bound, not dropped-and-discarded: `_isolation`'s teardown (kill the
|
||||
// compositor, remove its socket/config) must run via Drop regardless of
|
||||
// how this function returns below — returning an ExitCode rather than
|
||||
// calling `std::process::exit` (which skips destructors entirely) is
|
||||
// what makes that true on the failure path too.
|
||||
let _isolation = if cli.no_isolate {
|
||||
None
|
||||
} else {
|
||||
Some(isolation::Isolation::start(cli.isolate_width, cli.isolate_height)?)
|
||||
};
|
||||
|
||||
let width_str = cli.isolate_width.to_string();
|
||||
let height_str = cli.isolate_height.to_string();
|
||||
|
||||
let mut failed = false;
|
||||
for (app_name, app_path, views) in &targets {
|
||||
for (view, filename) in *views {
|
||||
if cli.view.as_deref().is_some_and(|v| v != *view) {
|
||||
continue;
|
||||
}
|
||||
let out_path = cli.out_dir.join(app_name).join(filename);
|
||||
let out_str = out_path.to_string_lossy();
|
||||
let result = bread_utils::proc::run(
|
||||
app_path,
|
||||
&[
|
||||
"--screenshot", view,
|
||||
"--output", &out_str,
|
||||
"--width", &width_str,
|
||||
"--height", &height_str,
|
||||
],
|
||||
CAPTURE_TIMEOUT,
|
||||
);
|
||||
if result.success {
|
||||
println!("ok {app_name}/{view} -> {}", out_path.display());
|
||||
} else {
|
||||
failed = true;
|
||||
println!("FAIL {app_name}/{view}: {}", result.stderr.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS })
|
||||
}
|
||||
|
|
@ -32,3 +32,12 @@ anyhow = { workspace = true }
|
|||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
# Dev-dependency features unify into this crate's own test/bench builds
|
||||
# only, never into downstream consumers (they aren't part of the dependency
|
||||
# graph a consuming app resolves) — so this doesn't compromise the
|
||||
# consumer-chooses-the-backend policy above. Without it, `cargo test` here
|
||||
# fails at link time (undefined OrtGetApiBase) because nothing in this
|
||||
# workspace supplies a backend; none of bread-onnx's own unit tests open a
|
||||
# real ONNX session, so `load-dynamic` (dlopen at runtime, no static link)
|
||||
# is enough to satisfy the linker.
|
||||
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "tracing", "load-dynamic", "api-24"] }
|
||||
|
|
|
|||
27
bread-polkit/Cargo.toml
Normal file
27
bread-polkit/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "bread-polkit"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Themed PolicyKit authentication agent for the bread desktop"
|
||||
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
||||
keywords = ["polkit", "gtk4", "wayland"]
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bread-polkit"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
bread-app = { path = "../bread-app", features = ["gtk"] }
|
||||
bread-theme = { path = "../bread-theme", features = ["gtk"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||
serde = { workspace = true }
|
||||
tokio = { version = "1", features = ["rt", "net", "sync", "time", "macros", "io-util", "process"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "std"] }
|
||||
zbus = { version = "5", default-features = false, features = ["tokio"] }
|
||||
11
bread-polkit/bakery.toml
Normal file
11
bread-polkit/bakery.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
name = "bread-polkit"
|
||||
description = "Themed PolicyKit authentication agent for the bread desktop"
|
||||
binaries = ["bread-polkit"]
|
||||
system_deps = ["gtk4", "gtk4-layer-shell", "polkit"]
|
||||
optional_system_deps = ["hyprland"]
|
||||
bread_deps = []
|
||||
license_file = "LICENSE"
|
||||
desktop_file = "bread-polkit.desktop"
|
||||
|
||||
[install]
|
||||
post_install = []
|
||||
12
bread-polkit/contrib/bread-polkit.desktop
Normal file
12
bread-polkit/contrib/bread-polkit.desktop
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Bread PolicyKit Agent
|
||||
Comment=Themed PolicyKit authentication agent for the bread desktop
|
||||
Exec=bread-polkit
|
||||
Icon=dialog-password
|
||||
Terminal=false
|
||||
Categories=System;Security;
|
||||
StartupNotify=false
|
||||
X-GNOME-Autostart-Phase=Initialization
|
||||
X-GNOME-AutoRestart=true
|
||||
X-GNOME-Autostart-Notify=false
|
||||
10
bread-polkit/contrib/hyprland.conf
Normal file
10
bread-polkit/contrib/hyprland.conf
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# bread-polkit — add to hyprland.conf
|
||||
#
|
||||
# Session authentication agent. Copy contrib/bread-polkit.desktop to
|
||||
# ~/.config/autostart/ instead if you prefer XDG autostart.
|
||||
|
||||
exec-once = bread-polkit
|
||||
|
||||
# Optional: blur the overlay panel (namespace is bread-polkit).
|
||||
layerrule = blur, bread-polkit
|
||||
layerrule = ignorezero, bread-polkit
|
||||
300
bread-polkit/src/agent.rs
Normal file
300
bread-polkit/src/agent.rs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
//! Session-bus registration and the PolicyKit1 AuthenticationAgent.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gtk4::glib;
|
||||
use gtk4::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use zbus::zvariant::{OwnedValue, Type, Value};
|
||||
use zbus::{connection, interface, proxy, DBusError};
|
||||
|
||||
use bread_polkit::helper::{discover_transport, Transport};
|
||||
use bread_polkit::identity::{current_uid, pick_user, read_passwd, users_from_uids, UnixUser};
|
||||
use bread_polkit::session::session_id;
|
||||
|
||||
use crate::auth::{self, Outcome};
|
||||
use crate::ui::{self, Prompt};
|
||||
|
||||
pub const OBJECT_PATH: &str = "/com/breadway/PolicyKit1/AuthenticationAgent";
|
||||
|
||||
/// Reply from the GTK prompt.
|
||||
#[derive(Debug)]
|
||||
pub enum UserAction {
|
||||
Submit { username: String, password: String },
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Debug, DBusError)]
|
||||
#[zbus(prefix = "org.freedesktop.PolicyKit1.Error")]
|
||||
enum AgentError {
|
||||
#[zbus(error)]
|
||||
ZBus(zbus::Error),
|
||||
Failed(String),
|
||||
Cancelled(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Type)]
|
||||
struct Identity {
|
||||
kind: String,
|
||||
details: HashMap<String, OwnedValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Type)]
|
||||
struct Subject {
|
||||
kind: String,
|
||||
details: HashMap<String, OwnedValue>,
|
||||
}
|
||||
|
||||
#[proxy(
|
||||
interface = "org.freedesktop.PolicyKit1.Authority",
|
||||
default_service = "org.freedesktop.PolicyKit1",
|
||||
default_path = "/org/freedesktop/PolicyKit1/Authority"
|
||||
)]
|
||||
trait Authority {
|
||||
fn register_authentication_agent(
|
||||
&self,
|
||||
subject: &Subject,
|
||||
locale: &str,
|
||||
object_path: &str,
|
||||
) -> zbus::Result<()>;
|
||||
|
||||
fn unregister_authentication_agent(
|
||||
&self,
|
||||
subject: &Subject,
|
||||
object_path: &str,
|
||||
) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
struct Agent {
|
||||
transport: Transport,
|
||||
pending: Arc<Mutex<Option<mpsc::Sender<UserAction>>>>,
|
||||
}
|
||||
|
||||
#[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
|
||||
impl Agent {
|
||||
async fn begin_authentication(
|
||||
&mut self,
|
||||
action_id: String,
|
||||
message: String,
|
||||
_icon_name: String,
|
||||
_details: HashMap<String, String>,
|
||||
cookie: String,
|
||||
identities: Vec<Identity>,
|
||||
) -> Result<(), AgentError> {
|
||||
tracing::info!(%action_id, %cookie, "BeginAuthentication");
|
||||
|
||||
let users = unix_users(&identities);
|
||||
let username = pick_user(&users, current_uid())
|
||||
.map(|u| u.name.clone())
|
||||
.ok_or_else(|| AgentError::Failed("no unix-user identity".into()))?;
|
||||
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
*self.pending.lock().await = Some(tx.clone());
|
||||
|
||||
let prompt = Prompt {
|
||||
cookie: cookie.clone(),
|
||||
message: message.clone(),
|
||||
action_id: action_id.clone(),
|
||||
username: username.clone(),
|
||||
reply: tx,
|
||||
};
|
||||
invoke_ui(move || {
|
||||
if let Some(app) = running_app() {
|
||||
ui::show_prompt(&app, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
let result = self.drive_prompt(&cookie, &username, &mut rx).await;
|
||||
|
||||
*self.pending.lock().await = None;
|
||||
let cookie_close = cookie.clone();
|
||||
invoke_ui(move || ui::close_prompt(&cookie_close));
|
||||
result
|
||||
}
|
||||
|
||||
async fn cancel_authentication(&self, cookie: String) {
|
||||
tracing::info!(%cookie, "CancelAuthentication");
|
||||
if let Some(tx) = self.pending.lock().await.as_ref() {
|
||||
let _ = tx.try_send(UserAction::Cancel);
|
||||
}
|
||||
invoke_ui(move || ui::close_prompt(&cookie));
|
||||
}
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
async fn drive_prompt(
|
||||
&self,
|
||||
cookie: &str,
|
||||
default_user: &str,
|
||||
rx: &mut mpsc::Receiver<UserAction>,
|
||||
) -> Result<(), AgentError> {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
None => {
|
||||
return Err(AgentError::Cancelled("authentication prompt closed".into()));
|
||||
}
|
||||
Some(UserAction::Cancel) => {
|
||||
return Err(AgentError::Cancelled("user cancelled".into()));
|
||||
}
|
||||
Some(UserAction::Submit { username, password }) => {
|
||||
let user = if username.is_empty() {
|
||||
default_user
|
||||
} else {
|
||||
username.as_str()
|
||||
};
|
||||
match auth::authenticate(&self.transport, user, cookie, &password).await {
|
||||
Ok(Outcome::Success) => return Ok(()),
|
||||
Ok(Outcome::Failure { message }) => {
|
||||
let text = message
|
||||
.unwrap_or_else(|| auth::default_failure_message().to_string());
|
||||
let cookie = cookie.to_string();
|
||||
invoke_ui(move || ui::show_retry(&cookie, &text));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("helper: {e:#}");
|
||||
let text = e.to_string();
|
||||
let cookie = cookie.to_string();
|
||||
invoke_ui(move || ui::show_retry(&cookie, &text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_users(identities: &[Identity]) -> Vec<UnixUser> {
|
||||
let mut uids = Vec::new();
|
||||
for identity in identities {
|
||||
if identity.kind != "unix-user" {
|
||||
continue;
|
||||
}
|
||||
if let Some(uid) = uid_from_details(&identity.details) {
|
||||
uids.push(uid);
|
||||
}
|
||||
}
|
||||
users_from_uids(&uids, &read_passwd())
|
||||
}
|
||||
|
||||
fn uid_from_details(details: &HashMap<String, OwnedValue>) -> Option<u32> {
|
||||
let value = details.get("uid")?;
|
||||
u32::try_from(value).ok().or_else(|| {
|
||||
i32::try_from(value)
|
||||
.ok()
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn running_app() -> Option<gtk4::Application> {
|
||||
gtk4::gio::Application::default().and_then(|app| app.downcast::<gtk4::Application>().ok())
|
||||
}
|
||||
|
||||
/// GTK thread-default context, captured in [`spawn`] so the dbus thread
|
||||
/// can `invoke` onto the UI thread instead of its own empty context.
|
||||
static GTK_CTX: OnceLock<glib::MainContext> = OnceLock::new();
|
||||
|
||||
fn invoke_ui(f: impl FnOnce() + Send + 'static) {
|
||||
let ctx = GTK_CTX
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(glib::MainContext::default);
|
||||
ctx.invoke(f);
|
||||
}
|
||||
|
||||
fn unix_session_subject(id: &str) -> Result<Subject> {
|
||||
let value = Value::from(id.to_string());
|
||||
let owned = OwnedValue::try_from(value).context("session-id variant")?;
|
||||
let mut details = HashMap::new();
|
||||
details.insert("session-id".into(), owned);
|
||||
Ok(Subject {
|
||||
kind: "unix-session".into(),
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the system-bus agent on a background thread. Returns once the
|
||||
/// thread has been started; registration errors quit the GTK app.
|
||||
///
|
||||
/// Must be called from the GTK thread so the main context we capture is
|
||||
/// the one driving the password prompt.
|
||||
pub fn spawn() -> Result<()> {
|
||||
let _ = GTK_CTX.set(glib::MainContext::default());
|
||||
std::thread::Builder::new()
|
||||
.name("bread-polkit-dbus".into())
|
||||
.spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
invoke_ui(move || {
|
||||
eprintln!("bread-polkit: tokio runtime failed: {e}");
|
||||
if let Some(app) = running_app() {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(async move {
|
||||
if let Err(e) = run().await {
|
||||
eprintln!("bread-polkit: {e:#}");
|
||||
invoke_ui(|| {
|
||||
if let Some(app) = running_app() {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.context("spawn dbus thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run() -> Result<()> {
|
||||
let transport = discover_transport().context(
|
||||
"no polkit helper: expected /run/polkit/agent-helper.socket \
|
||||
or /usr/lib/polkit-1/polkit-agent-helper-1",
|
||||
)?;
|
||||
tracing::info!(?transport, "using polkit helper");
|
||||
|
||||
let session = session_id().context(
|
||||
"no session id (XDG_SESSION_ID / /proc/self/sessionid); \
|
||||
cannot register a session authentication agent",
|
||||
)?;
|
||||
let subject = unix_session_subject(&session)?;
|
||||
let locale = std::env::var("LANG").unwrap_or_else(|_| "C".into());
|
||||
|
||||
let agent = Agent {
|
||||
transport,
|
||||
pending: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
let connection = connection::Builder::system()?
|
||||
.serve_at(OBJECT_PATH, agent)?
|
||||
.build()
|
||||
.await
|
||||
.context("system bus")?;
|
||||
|
||||
let authority = AuthorityProxy::new(&connection)
|
||||
.await
|
||||
.context("PolicyKit1 authority proxy")?;
|
||||
authority
|
||||
.register_authentication_agent(&subject, &locale, OBJECT_PATH)
|
||||
.await
|
||||
.context("RegisterAuthenticationAgent")?;
|
||||
tracing::info!(%session, "registered as PolicyKit authentication agent");
|
||||
|
||||
std::future::pending::<()>().await;
|
||||
#[allow(unreachable_code)]
|
||||
{
|
||||
let _ = authority
|
||||
.unregister_authentication_agent(&subject, OBJECT_PATH)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
109
bread-polkit/src/auth.rs
Normal file
109
bread-polkit/src/auth.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! PAM conversation with the polkit agent helper.
|
||||
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::process::Command;
|
||||
|
||||
use bread_polkit::helper::{parse_helper_line, HelperLine, Transport};
|
||||
|
||||
/// Outcome of one helper conversation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Success,
|
||||
Failure { message: Option<String> },
|
||||
}
|
||||
|
||||
/// Handshake + PAM loop for one password attempt.
|
||||
pub async fn authenticate(
|
||||
transport: &Transport,
|
||||
username: &str,
|
||||
cookie: &str,
|
||||
password: &str,
|
||||
) -> Result<Outcome> {
|
||||
match transport {
|
||||
Transport::Socket(path) => {
|
||||
let mut stream = UnixStream::connect(path)
|
||||
.await
|
||||
.with_context(|| format!("connect {}", path.display()))?;
|
||||
stream.write_all(username.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
stream.write_all(cookie.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
let (reader, writer) = stream.into_split();
|
||||
converse(BufReader::new(reader), writer, password).await
|
||||
}
|
||||
Transport::Exec(path) => {
|
||||
let mut child = Command::new(path)
|
||||
.arg(username)
|
||||
.env("LC_ALL", "C")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.with_context(|| format!("spawn {}", path.display()))?;
|
||||
let mut stdin = child.stdin.take().context("polkit helper has no stdin")?;
|
||||
let stdout = child.stdout.take().context("polkit helper has no stdout")?;
|
||||
stdin.write_all(cookie.as_bytes()).await?;
|
||||
stdin.write_all(b"\n").await?;
|
||||
let outcome = converse(BufReader::new(stdout), stdin, password).await;
|
||||
let _ = child.wait().await;
|
||||
outcome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn converse<R, W>(mut reader: BufReader<R>, mut writer: W, password: &str) -> Result<Outcome>
|
||||
where
|
||||
R: tokio::io::AsyncRead + Unpin,
|
||||
W: tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let mut last_info: Option<String> = None;
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
return Ok(Outcome::Failure {
|
||||
message: last_info.take(),
|
||||
});
|
||||
}
|
||||
match parse_helper_line(&line) {
|
||||
HelperLine::PromptEchoOff(_) => {
|
||||
writer.write_all(password.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
HelperLine::PromptEchoOn(_) => {
|
||||
// Visible prompt (username, etc.) — we already sent the
|
||||
// identity in the handshake. An empty line is safer than
|
||||
// echoing the password.
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
HelperLine::ErrorMsg(msg) | HelperLine::TextInfo(msg) => {
|
||||
if !msg.is_empty() {
|
||||
last_info = Some(msg);
|
||||
}
|
||||
}
|
||||
HelperLine::Success => return Ok(Outcome::Success),
|
||||
HelperLine::Failure => {
|
||||
return Ok(Outcome::Failure {
|
||||
message: last_info.take(),
|
||||
});
|
||||
}
|
||||
HelperLine::Other(other) => {
|
||||
if !other.is_empty() {
|
||||
tracing::debug!("helper: {other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared default when the helper gives no `PAM_*` text on failure.
|
||||
pub fn default_failure_message() -> &'static str {
|
||||
"Authentication failed. Try again."
|
||||
}
|
||||
205
bread-polkit/src/helper.rs
Normal file
205
bread-polkit/src/helper.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
//! `polkit-agent-helper-1` transport and PAM line parser.
|
||||
//!
|
||||
//! Arch polkit 127+ talks over `/run/polkit/agent-helper.socket`. Older
|
||||
//! builds still spawn the setuid helper at
|
||||
//! `/usr/lib/polkit-1/polkit-agent-helper-1`. Prefer the socket when it
|
||||
//! exists.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// How this agent will talk to polkit's helper.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Transport {
|
||||
/// systemd socket-activated helper (polkit 127+).
|
||||
Socket(PathBuf),
|
||||
/// Legacy setuid helper binary.
|
||||
Exec(PathBuf),
|
||||
}
|
||||
|
||||
const SOCKET_CANDIDATES: &[&str] = &["/run/polkit/agent-helper.socket"];
|
||||
const HELPER_CANDIDATES: &[&str] = &[
|
||||
"/usr/lib/polkit-1/polkit-agent-helper-1",
|
||||
"/usr/libexec/polkit-1/polkit-agent-helper-1",
|
||||
];
|
||||
|
||||
/// One stdout line from the helper after the cookie handshake.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HelperLine {
|
||||
PromptEchoOff(String),
|
||||
PromptEchoOn(String),
|
||||
ErrorMsg(String),
|
||||
TextInfo(String),
|
||||
Success,
|
||||
Failure,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Pick a live transport: `BREAD_POLKIT_SOCKET` / `BREAD_POLKIT_HELPER`
|
||||
/// if set and present, otherwise the first existing well-known path.
|
||||
pub fn discover_transport() -> Option<Transport> {
|
||||
discover_transport_from(
|
||||
std::env::var_os("BREAD_POLKIT_SOCKET")
|
||||
.map(PathBuf::from)
|
||||
.as_deref(),
|
||||
std::env::var_os("BREAD_POLKIT_HELPER")
|
||||
.map(PathBuf::from)
|
||||
.as_deref(),
|
||||
SOCKET_CANDIDATES,
|
||||
HELPER_CANDIDATES,
|
||||
|p| p.exists(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Testable discovery: `exists` is injected so unit tests do not need a
|
||||
/// real `/run/polkit` socket.
|
||||
pub fn discover_transport_from(
|
||||
socket_override: Option<&Path>,
|
||||
helper_override: Option<&Path>,
|
||||
sockets: &[&str],
|
||||
helpers: &[&str],
|
||||
exists: impl Fn(&Path) -> bool,
|
||||
) -> Option<Transport> {
|
||||
if let Some(path) = socket_override {
|
||||
if exists(path) {
|
||||
return Some(Transport::Socket(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
for candidate in sockets {
|
||||
let path = Path::new(candidate);
|
||||
if exists(path) {
|
||||
return Some(Transport::Socket(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
if let Some(path) = helper_override {
|
||||
if exists(path) {
|
||||
return Some(Transport::Exec(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
for candidate in helpers {
|
||||
let path = Path::new(candidate);
|
||||
if exists(path) {
|
||||
return Some(Transport::Exec(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse one helper protocol line. Prefix match is case-sensitive and
|
||||
/// matches polkit's own `PAM_*` / `SUCCESS` / `FAILURE` tokens.
|
||||
pub fn parse_helper_line(line: &str) -> HelperLine {
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
if line == "SUCCESS" || line.starts_with("SUCCESS") {
|
||||
return HelperLine::Success;
|
||||
}
|
||||
if line == "FAILURE" || line.starts_with("FAILURE") {
|
||||
return HelperLine::Failure;
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_OFF") {
|
||||
return HelperLine::PromptEchoOff(rest.trim().to_string());
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_ON") {
|
||||
return HelperLine::PromptEchoOn(rest.trim().to_string());
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("PAM_ERROR_MSG") {
|
||||
return HelperLine::ErrorMsg(rest.trim().to_string());
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("PAM_TEXT_INFO") {
|
||||
return HelperLine::TextInfo(rest.trim().to_string());
|
||||
}
|
||||
HelperLine::Other(line.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn parse_helper_line_known_tokens() {
|
||||
assert_eq!(parse_helper_line("SUCCESS"), HelperLine::Success);
|
||||
assert_eq!(parse_helper_line("SUCCESS\n"), HelperLine::Success);
|
||||
assert_eq!(parse_helper_line("FAILURE"), HelperLine::Failure);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_PROMPT_ECHO_OFF Password:"),
|
||||
HelperLine::PromptEchoOff("Password:".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_PROMPT_ECHO_OFF"),
|
||||
HelperLine::PromptEchoOff(String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_PROMPT_ECHO_ON login:"),
|
||||
HelperLine::PromptEchoOn("login:".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_ERROR_MSG Authentication failure"),
|
||||
HelperLine::ErrorMsg("Authentication failure".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_TEXT_INFO Account locked"),
|
||||
HelperLine::TextInfo("Account locked".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("garbage"),
|
||||
HelperLine::Other("garbage".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_prefers_socket_over_exec() {
|
||||
let present: HashSet<PathBuf> = [
|
||||
"/run/polkit/agent-helper.socket",
|
||||
"/usr/lib/polkit-1/polkit-agent-helper-1",
|
||||
]
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| {
|
||||
present.contains(p)
|
||||
});
|
||||
assert_eq!(
|
||||
got,
|
||||
Some(Transport::Socket(PathBuf::from(
|
||||
"/run/polkit/agent-helper.socket"
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_falls_back_to_helper_binary() {
|
||||
let present: HashSet<PathBuf> = ["/usr/lib/polkit-1/polkit-agent-helper-1"]
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| {
|
||||
present.contains(p)
|
||||
});
|
||||
assert_eq!(
|
||||
got,
|
||||
Some(Transport::Exec(PathBuf::from(
|
||||
"/usr/lib/polkit-1/polkit-agent-helper-1"
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_override_socket_wins_when_present() {
|
||||
let override_path = Path::new("/tmp/bread-polkit-test.sock");
|
||||
let got = discover_transport_from(
|
||||
Some(override_path),
|
||||
None,
|
||||
SOCKET_CANDIDATES,
|
||||
HELPER_CANDIDATES,
|
||||
|p| p == override_path,
|
||||
);
|
||||
assert_eq!(got, Some(Transport::Socket(override_path.to_path_buf())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_none_when_nothing_exists() {
|
||||
let got =
|
||||
discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |_| false);
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
}
|
||||
128
bread-polkit/src/identity.rs
Normal file
128
bread-polkit/src/identity.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
//! Unix-user identities from a PolicyKit `BeginAuthentication` call.
|
||||
|
||||
/// A `unix-user` identity the agent can authenticate as.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnixUser {
|
||||
pub uid: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Look up `uid` in a passwd-file dump (`name:x:uid:...` lines).
|
||||
pub fn name_for_uid(uid: u32, passwd: &str) -> Option<String> {
|
||||
for line in passwd.lines() {
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let mut parts = line.split(':');
|
||||
let name = parts.next()?;
|
||||
let _pw = parts.next()?;
|
||||
let id = parts.next()?.parse::<u32>().ok()?;
|
||||
if id == uid && !name.is_empty() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve each uid to a [`UnixUser`], falling back to `uid N` when
|
||||
/// `/etc/passwd` has no name.
|
||||
pub fn users_from_uids(uids: &[u32], passwd: &str) -> Vec<UnixUser> {
|
||||
uids.iter()
|
||||
.copied()
|
||||
.map(|uid| UnixUser {
|
||||
uid,
|
||||
name: name_for_uid(uid, passwd).unwrap_or_else(|| format!("uid {uid}")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Prefer the process's own uid when it is in `users`, otherwise the first.
|
||||
pub fn pick_user<'a>(users: &'a [UnixUser], current_uid: Option<u32>) -> Option<&'a UnixUser> {
|
||||
if let Some(uid) = current_uid {
|
||||
if let Some(user) = users.iter().find(|u| u.uid == uid) {
|
||||
return Some(user);
|
||||
}
|
||||
}
|
||||
users.first()
|
||||
}
|
||||
|
||||
/// Real uid from a `/proc/self/status` dump (`Uid:\t<real> ...`).
|
||||
pub fn uid_from_status(status: &str) -> Option<u32> {
|
||||
for line in status.lines() {
|
||||
let Some(rest) = line.strip_prefix("Uid:") else {
|
||||
continue;
|
||||
};
|
||||
return rest.split_whitespace().next()?.parse().ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Current real uid, or `None` if `/proc/self/status` is unreadable.
|
||||
pub fn current_uid() -> Option<u32> {
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
uid_from_status(&status)
|
||||
}
|
||||
|
||||
/// Contents of `/etc/passwd`, or empty if unreadable.
|
||||
pub fn read_passwd() -> String {
|
||||
std::fs::read_to_string("/etc/passwd").unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PASSWD: &str = "\
|
||||
# comment
|
||||
root:x:0:0:root:/root:/bin/sh
|
||||
alice:x:1000:1000:Alice:/home/alice:/bin/zsh
|
||||
bob:x:1001:1001:Bob:/home/bob:/bin/bash
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn name_for_uid_reads_passwd_lines() {
|
||||
assert_eq!(name_for_uid(0, PASSWD).as_deref(), Some("root"));
|
||||
assert_eq!(name_for_uid(1000, PASSWD).as_deref(), Some("alice"));
|
||||
assert_eq!(name_for_uid(99, PASSWD), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn users_from_uids_falls_back_to_uid_label() {
|
||||
let users = users_from_uids(&[1000, 42], PASSWD);
|
||||
assert_eq!(
|
||||
users,
|
||||
vec![
|
||||
UnixUser {
|
||||
uid: 1000,
|
||||
name: "alice".into()
|
||||
},
|
||||
UnixUser {
|
||||
uid: 42,
|
||||
name: "uid 42".into()
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_user_prefers_current_uid() {
|
||||
let users = users_from_uids(&[0, 1000], PASSWD);
|
||||
let picked = pick_user(&users, Some(1000)).unwrap();
|
||||
assert_eq!(picked.name, "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_user_falls_back_to_first() {
|
||||
let users = users_from_uids(&[0, 1000], PASSWD);
|
||||
let picked = pick_user(&users, Some(7)).unwrap();
|
||||
assert_eq!(picked.name, "root");
|
||||
assert!(pick_user(&[], Some(1000)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uid_from_status_reads_real_uid() {
|
||||
let status = "Name:\tbread-polkit\nUid:\t1000\t1000\t1000\t1000\n";
|
||||
assert_eq!(uid_from_status(status), Some(1000));
|
||||
assert_eq!(uid_from_status("Name:\tfoo\n"), None);
|
||||
}
|
||||
}
|
||||
10
bread-polkit/src/lib.rs
Normal file
10
bread-polkit/src/lib.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
//! Non-GTK PolicyKit helper logic for `bread-polkit`.
|
||||
//!
|
||||
//! The binary (`bread-polkit`) registers as a session authentication
|
||||
//! agent and shows a themed password prompt. This library is the
|
||||
//! transport / identity / session parsing that can be unit-tested
|
||||
//! without a display.
|
||||
|
||||
pub mod helper;
|
||||
pub mod identity;
|
||||
pub mod session;
|
||||
94
bread-polkit/src/main.rs
Normal file
94
bread-polkit/src/main.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//! bread-polkit — themed PolicyKit authentication agent.
|
||||
//!
|
||||
//! Registers on the `org.freedesktop.PolicyKit1.AuthenticationAgent`
|
||||
//! interface and shows a bread-theme GTK4 password prompt. This is an
|
||||
//! agent, not a wrapper that execs `polkit-gnome`.
|
||||
//!
|
||||
//! Autostart: copy `contrib/bread-polkit.desktop` to
|
||||
//! `~/.config/autostart/`, or add `exec-once = bread-polkit` to Hyprland.
|
||||
|
||||
mod agent;
|
||||
mod auth;
|
||||
mod ui;
|
||||
|
||||
use bread_app::singleton::Acquire;
|
||||
use gtk4::prelude::*;
|
||||
|
||||
const APP_NAME: &str = "bread-polkit";
|
||||
|
||||
fn main() {
|
||||
let arg = std::env::args().nth(1);
|
||||
match arg.as_deref() {
|
||||
Some("-h") | Some("--help") => {
|
||||
print_help();
|
||||
return;
|
||||
}
|
||||
Some("-V") | Some("--version") => {
|
||||
println!("bread-polkit {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
Some(other) => {
|
||||
eprintln!("bread-polkit: unknown argument '{other}'");
|
||||
print_help();
|
||||
std::process::exit(2);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
let _guard = match bread_app::try_acquire(APP_NAME) {
|
||||
Ok(Acquire::Acquired(g)) => Some(g),
|
||||
Ok(Acquire::HeldByOther(pid)) => {
|
||||
eprintln!("bread-polkit: already running (pid {pid:?})");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("bread-polkit: singleton lock unavailable ({e}); continuing");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let app_id = bread_app::application_id(APP_NAME).expect("static app name");
|
||||
let app = gtk4::Application::builder().application_id(&app_id).build();
|
||||
|
||||
app.connect_activate(|app| {
|
||||
bread_theme::gtk::apply_shared();
|
||||
bread_theme::gtk::apply_app_css(ui::app_css);
|
||||
// No window until polkit asks; hold so GApplication stays alive.
|
||||
std::mem::forget(app.hold());
|
||||
if let Err(e) = agent::spawn() {
|
||||
eprintln!("bread-polkit: {e:#}");
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
print!(
|
||||
"\
|
||||
bread-polkit — themed PolicyKit authentication agent
|
||||
|
||||
Usage:
|
||||
bread-polkit
|
||||
bread-polkit --help
|
||||
bread-polkit --version
|
||||
|
||||
Autostart (pick one):
|
||||
cp contrib/bread-polkit.desktop ~/.config/autostart/
|
||||
exec-once = bread-polkit # Hyprland
|
||||
|
||||
The agent talks to the polkit1 AuthenticationAgent API and prompts for
|
||||
a password. It does not exec polkit-gnome. Not a bakery product; not
|
||||
on the BOS ISO lockfile.
|
||||
"
|
||||
);
|
||||
}
|
||||
49
bread-polkit/src/session.rs
Normal file
49
bread-polkit/src/session.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! Session subject for `RegisterAuthenticationAgent`.
|
||||
|
||||
/// Logind session id from `XDG_SESSION_ID`, falling back to
|
||||
/// `/proc/self/sessionid` when the kernel has one.
|
||||
pub fn session_id() -> Option<String> {
|
||||
let xdg = std::env::var("XDG_SESSION_ID").ok();
|
||||
let proc = std::fs::read_to_string("/proc/self/sessionid").ok();
|
||||
session_id_from(xdg.as_deref(), proc.as_deref())
|
||||
}
|
||||
|
||||
/// `None` when both sources are empty or the kernel reports the
|
||||
/// unsigned `-1` sentinel (`4294967295`) meaning "no session".
|
||||
pub fn session_id_from(xdg: Option<&str>, proc_sessionid: Option<&str>) -> Option<String> {
|
||||
if let Some(id) = xdg.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
return Some(id.to_string());
|
||||
}
|
||||
let raw = proc_sessionid?.trim();
|
||||
if raw.is_empty() || raw == "4294967295" {
|
||||
return None;
|
||||
}
|
||||
Some(raw.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prefers_xdg_session_id() {
|
||||
assert_eq!(session_id_from(Some("3"), Some("7")).as_deref(), Some("3"));
|
||||
assert_eq!(
|
||||
session_id_from(Some(" 3 "), Some("7")).as_deref(),
|
||||
Some("3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_proc_sessionid() {
|
||||
assert_eq!(session_id_from(Some(""), Some("7")).as_deref(), Some("7"));
|
||||
assert_eq!(session_id_from(None, Some("7\n")).as_deref(), Some("7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unset_kernel_session() {
|
||||
assert_eq!(session_id_from(None, Some("4294967295")), None);
|
||||
assert_eq!(session_id_from(Some(""), Some("")), None);
|
||||
assert_eq!(session_id_from(None, None), None);
|
||||
}
|
||||
}
|
||||
286
bread-polkit/src/ui.rs
Normal file
286
bread-polkit/src/ui.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
//! GTK4 password prompt, themed with bread-theme.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::gdk::Key;
|
||||
use gtk4::glib::{self, Propagation};
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
Align, Application, ApplicationWindow, Box as GBox, Button, Entry, EventControllerKey, Label,
|
||||
Orientation,
|
||||
};
|
||||
|
||||
use bread_theme::tokens;
|
||||
|
||||
use crate::agent::UserAction;
|
||||
|
||||
const PANEL_WIDTH: i32 = 400;
|
||||
|
||||
struct Active {
|
||||
cookie: String,
|
||||
window: ApplicationWindow,
|
||||
password: Entry,
|
||||
error: Label,
|
||||
reply: tokio::sync::mpsc::Sender<UserAction>,
|
||||
username: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ACTIVE: RefCell<Option<Active>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// App-specific rules layered on the shared bread-theme stylesheet.
|
||||
pub fn app_css() -> String {
|
||||
format!(
|
||||
".polkit-panel {{\
|
||||
background-color: @surface; color: @on-surface;\
|
||||
border-radius: {r}px; padding: {pad}px;\
|
||||
min-width: {w}px;\
|
||||
}}\n\
|
||||
.polkit-title {{ font-size: 1.4em; font-weight: bold; }}\n\
|
||||
.polkit-message {{ opacity: 0.85; }}\n\
|
||||
.polkit-identity {{ opacity: 0.7; font-size: {sec}px; }}\n\
|
||||
.polkit-error {{ color: @on-red; }}\n\
|
||||
.polkit-buttons {{ padding-top: {sm}px; }}\n",
|
||||
r = tokens::RADIUS_PRIMARY,
|
||||
pad = tokens::SPACE_XL,
|
||||
w = PANEL_WIDTH,
|
||||
sec = tokens::FONT_SIZE_SECONDARY,
|
||||
sm = tokens::SPACE_SM,
|
||||
)
|
||||
}
|
||||
|
||||
pub struct Prompt {
|
||||
pub cookie: String,
|
||||
pub message: String,
|
||||
pub action_id: String,
|
||||
pub username: String,
|
||||
pub reply: tokio::sync::mpsc::Sender<UserAction>,
|
||||
}
|
||||
|
||||
/// Show (or replace) the password overlay for this cookie.
|
||||
pub fn show_prompt(app: &Application, prompt: Prompt) {
|
||||
close_if_other_cookie(&prompt.cookie);
|
||||
|
||||
if ACTIVE.with(|a| {
|
||||
a.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.cookie == prompt.cookie)
|
||||
}) {
|
||||
present_existing(&prompt);
|
||||
return;
|
||||
}
|
||||
|
||||
let window = bread_app::gtk_popup::new_overlay_window(app, "bread-polkit");
|
||||
|
||||
let panel = GBox::new(Orientation::Vertical, tokens::SPACE_MD as i32);
|
||||
panel.add_css_class("polkit-panel");
|
||||
panel.add_css_class("card");
|
||||
panel.set_halign(Align::Center);
|
||||
panel.set_valign(Align::Center);
|
||||
panel.set_size_request(PANEL_WIDTH, -1);
|
||||
|
||||
let title = Label::new(Some("Authentication required"));
|
||||
title.add_css_class("polkit-title");
|
||||
title.add_css_class("page-title");
|
||||
title.set_halign(Align::Start);
|
||||
title.set_wrap(true);
|
||||
panel.append(&title);
|
||||
|
||||
let message = if prompt.message.trim().is_empty() {
|
||||
prompt.action_id.clone()
|
||||
} else {
|
||||
prompt.message.clone()
|
||||
};
|
||||
let msg = Label::new(Some(&message));
|
||||
msg.add_css_class("polkit-message");
|
||||
msg.set_halign(Align::Start);
|
||||
msg.set_wrap(true);
|
||||
msg.set_xalign(0.0);
|
||||
panel.append(&msg);
|
||||
|
||||
if !prompt.username.is_empty() {
|
||||
let identity = Label::new(Some(&format!("Authenticating as {}", prompt.username)));
|
||||
identity.add_css_class("polkit-identity");
|
||||
identity.add_css_class("dim-label");
|
||||
identity.set_halign(Align::Start);
|
||||
panel.append(&identity);
|
||||
}
|
||||
|
||||
let error = Label::new(None);
|
||||
error.add_css_class("polkit-error");
|
||||
error.set_halign(Align::Start);
|
||||
error.set_wrap(true);
|
||||
error.set_visible(false);
|
||||
panel.append(&error);
|
||||
|
||||
let password = Entry::builder()
|
||||
.visibility(false)
|
||||
.input_purpose(gtk4::InputPurpose::Password)
|
||||
.placeholder_text("Password")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
panel.append(&password);
|
||||
|
||||
let buttons = GBox::new(Orientation::Horizontal, tokens::SPACE_SM as i32);
|
||||
buttons.add_css_class("polkit-buttons");
|
||||
buttons.set_halign(Align::End);
|
||||
let cancel = Button::with_label("Cancel");
|
||||
cancel.add_css_class("flat");
|
||||
let confirm = Button::with_label("Authenticate");
|
||||
confirm.add_css_class("suggested-action");
|
||||
buttons.append(&cancel);
|
||||
buttons.append(&confirm);
|
||||
panel.append(&buttons);
|
||||
|
||||
window.set_child(Some(&panel));
|
||||
bread_theme::gtk::bind_window_auto_with_app_css(&window, |_| app_css());
|
||||
|
||||
let reply = prompt.reply.clone();
|
||||
let cookie = prompt.cookie.clone();
|
||||
let username = prompt.username.clone();
|
||||
|
||||
let submit = {
|
||||
let password = password.clone();
|
||||
let reply = reply.clone();
|
||||
let username = username.clone();
|
||||
Rc::new(move || {
|
||||
let secret = password.text().to_string();
|
||||
password.set_text("");
|
||||
let _ = reply.try_send(UserAction::Submit {
|
||||
username: username.clone(),
|
||||
password: secret,
|
||||
});
|
||||
})
|
||||
};
|
||||
let cancel_fn = {
|
||||
let reply = reply.clone();
|
||||
let window = window.clone();
|
||||
Rc::new(move || {
|
||||
let _ = reply.try_send(UserAction::Cancel);
|
||||
window.close();
|
||||
ACTIVE.with(|a| a.replace(None));
|
||||
})
|
||||
};
|
||||
|
||||
confirm.connect_clicked({
|
||||
let submit = submit.clone();
|
||||
move |_| submit()
|
||||
});
|
||||
password.connect_activate({
|
||||
let submit = submit.clone();
|
||||
move |_| submit()
|
||||
});
|
||||
cancel.connect_clicked({
|
||||
let cancel_fn = cancel_fn.clone();
|
||||
move |_| cancel_fn()
|
||||
});
|
||||
|
||||
let keys = EventControllerKey::new();
|
||||
keys.connect_key_pressed({
|
||||
let cancel_fn = cancel_fn.clone();
|
||||
move |_, key, _, _| {
|
||||
if key == Key::Escape {
|
||||
cancel_fn();
|
||||
Propagation::Stop
|
||||
} else {
|
||||
Propagation::Proceed
|
||||
}
|
||||
}
|
||||
});
|
||||
window.add_controller(keys);
|
||||
|
||||
bread_app::gtk_popup::close_on_outside_click(&window, &panel, {
|
||||
let cancel_fn = cancel_fn.clone();
|
||||
move || cancel_fn()
|
||||
});
|
||||
|
||||
window.connect_close_request({
|
||||
let reply = reply.clone();
|
||||
move |_| {
|
||||
let closing_ours = ACTIVE.with(|a| {
|
||||
a.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.cookie == cookie)
|
||||
});
|
||||
if closing_ours {
|
||||
let _ = reply.try_send(UserAction::Cancel);
|
||||
ACTIVE.with(|a| a.replace(None));
|
||||
}
|
||||
glib::Propagation::Proceed
|
||||
}
|
||||
});
|
||||
|
||||
ACTIVE.with(|a| {
|
||||
*a.borrow_mut() = Some(Active {
|
||||
cookie: prompt.cookie,
|
||||
window: window.clone(),
|
||||
password: password.clone(),
|
||||
error,
|
||||
reply,
|
||||
username,
|
||||
});
|
||||
});
|
||||
|
||||
window.present();
|
||||
password.grab_focus();
|
||||
}
|
||||
|
||||
fn present_existing(prompt: &Prompt) {
|
||||
ACTIVE.with(|a| {
|
||||
if let Some(active) = a.borrow_mut().as_mut() {
|
||||
active.reply = prompt.reply.clone();
|
||||
active.username = prompt.username.clone();
|
||||
active.error.set_visible(false);
|
||||
active.password.set_text("");
|
||||
active.window.present();
|
||||
active.password.grab_focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show a retry message on the open dialog for `cookie`.
|
||||
pub fn show_retry(cookie: &str, message: &str) {
|
||||
ACTIVE.with(|a| {
|
||||
let mut guard = a.borrow_mut();
|
||||
let Some(active) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if active.cookie != cookie {
|
||||
return;
|
||||
}
|
||||
active.error.set_label(message);
|
||||
active.error.set_visible(true);
|
||||
active.password.set_text("");
|
||||
active.window.present();
|
||||
active.password.grab_focus();
|
||||
});
|
||||
}
|
||||
|
||||
/// Close the dialog if it is still showing `cookie`.
|
||||
pub fn close_prompt(cookie: &str) {
|
||||
ACTIVE.with(|a| {
|
||||
let Some(active) = a.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
if active.cookie == cookie {
|
||||
active.window.close();
|
||||
} else {
|
||||
*a.borrow_mut() = Some(active);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn close_if_other_cookie(cookie: &str) {
|
||||
ACTIVE.with(|a| {
|
||||
let Some(active) = a.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
if active.cookie == cookie {
|
||||
*a.borrow_mut() = Some(active);
|
||||
} else {
|
||||
active.window.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
14
bread-screenshots/Cargo.toml
Normal file
14
bread-screenshots/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "bread-screenshots"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Shared capture plumbing for the bread ecosystem's UI screenshot tooling: layer-surface and output geometry via Hyprland IPC, capture via grim"
|
||||
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
||||
keywords = ["hyprland", "wayland", "screenshot", "grim"]
|
||||
|
||||
[dependencies]
|
||||
bread-utils = { path = "../bread-utils" }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
30
bread-screenshots/src/lib.rs
Normal file
30
bread-screenshots/src/lib.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
//! Capture primitive for the bread ecosystem's UI screenshot tooling (see
|
||||
//! `bread-capture`, the orchestrator that drives this crate's consumers).
|
||||
//!
|
||||
//! Deliberately compositor-agnostic: no Hyprland IPC, no layer/output
|
||||
//! lookup. `bread-capture` runs every target app inside an isolated,
|
||||
//! headless compositor instance of a known, fixed size (see its
|
||||
//! `isolation` module), so the caller already knows exactly what region to
|
||||
//! grab — there's nothing to query.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
const GRIM_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Capture a `w`x`h` region at `(x, y)` (compositor-global coordinates) to
|
||||
/// `out` via `grim -g`.
|
||||
pub fn capture_region(x: i32, y: i32, w: i32, h: i32, out: &Path) -> Result<()> {
|
||||
if let Some(parent) = out.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
let out_str = out.to_str().context("output path is not valid UTF-8")?;
|
||||
let geometry = format!("{x},{y} {w}x{h}");
|
||||
let result = bread_utils::proc::run("grim", &["-g", &geometry, out_str], GRIM_TIMEOUT);
|
||||
if !result.success {
|
||||
bail!("grim failed for geometry {geometry}: {}", result.stderr.trim());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,11 +1,48 @@
|
|||
# bread-theme changelog
|
||||
|
||||
## 0.7.4
|
||||
|
||||
Per-output (per-monitor) theming. Session-global `theme.css` remains the
|
||||
fallback / focused-monitor sheet; each Hyprland/GDK connector can now have
|
||||
its own palette and stylesheet. BOS still keeps bg/surface/overlay/fg
|
||||
fixed — only color1–6 come from the wallpaper.
|
||||
|
||||
On disk under `$XDG_RUNTIME_DIR/bread/` (same fallback as `shared_css_path`):
|
||||
|
||||
- `palettes/<sanitized-output>.json` — accents only (round-trips through
|
||||
`from_wal_json` / a color1–6 object; never persists pywal's light bg)
|
||||
- `themes/<sanitized-output>.css` — `stylesheet()` for that palette
|
||||
|
||||
New lib API:
|
||||
|
||||
- `themes_dir`, `palettes_dir`, `output_css_path`, `output_palette_path`,
|
||||
`sanitize_output`
|
||||
- `load_palette_for`, `write_output_palette`, `write_output_css`,
|
||||
`write_shared_css_from`
|
||||
- `palette_from_image` (isolated `wal -i`, does not touch `~/.cache/wal`),
|
||||
`generate_output`, `palette_from_json`
|
||||
- `stylesheet_resolved` — inlines `@accent` / `@on-bg` / … to hex so GTK's
|
||||
display-global `@define-color` cannot leak the wrong monitor's accent
|
||||
|
||||
GTK (`gtk` feature): `bind_window`, `bind_window_with_app_css`,
|
||||
`output_for_widget`, `bind_window_auto`, `bind_window_auto_with_app_css`.
|
||||
Widget-scoped providers at `USER - 10` so they beat `apply_shared` but
|
||||
lose to user CSS. Existing `apply_shared` / `apply_app_css` /
|
||||
`apply_css` / `apply_user_css` are unchanged.
|
||||
|
||||
CLI: `bread-theme generate-output <OUTPUT> --image <PATH> | --from-json
|
||||
<FILE> [--shared]`. Does not write `theme.css` unless `--shared`.
|
||||
|
||||
## Coordinated bump policy
|
||||
|
||||
`bread-theme` is consumed by `breadbar`, `breadbox`, and `breadpad` as a pinned
|
||||
git dependency. A breaking change to `Palette`, `css_vars`, or the `gtk` feature
|
||||
API requires all three dependents to bump their `Cargo.toml` git tag and cut a
|
||||
release together. Note the impact in this file before tagging.
|
||||
`bread-theme` is consumed by `breadbar`, `breadbox`, `breadpad`, and the other
|
||||
GTK bread apps as a pinned git dependency. A breaking change to `Palette`,
|
||||
`css_vars`, or the `gtk` feature API requires dependents to bump their
|
||||
`Cargo.toml` git tag and cut a release together. Note the impact in this file
|
||||
before tagging.
|
||||
|
||||
**0.7.4** adds per-output bind APIs (`bind_window*`, `load_palette_for`,
|
||||
`generate_output`). Apps that call those must pin `tag = "v0.7.4"`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -13,11 +13,24 @@ serde = { workspace = true }
|
|||
serde_json = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
|
||||
# Rust bindings for libadwaita (GNOME's widget library on top of GTK4) — the
|
||||
# actual source of the modern GNOME look (grouped preference rows, real
|
||||
# toggle/spin rows, view switchers), not just a CSS reskin of plain GTK4
|
||||
# widgets. `v1_7` for ToggleGroup (used for tab-row-style pickers); the
|
||||
# system library only needs to be >= that (this machine has 1.9.2).
|
||||
libadwaita = { version = "0.9", features = ["v1_7"], optional = true }
|
||||
|
||||
[features]
|
||||
# Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this).
|
||||
# bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature.
|
||||
gtk = ["dep:gtk4"]
|
||||
# Composite libadwaita-based widgets (bread_theme::adw) — separate from `gtk`
|
||||
# because libadwaita's own top-level window chrome (AdwApplicationWindow)
|
||||
# isn't compatible with gtk4-layer-shell surfaces, so the five layer-shell
|
||||
# apps (breadbar, breadbox, breadclip, breadsearch, breadpad) only want
|
||||
# plain CSS, not this. Apps with an ordinary top-level window (breadman,
|
||||
# breadhelp) want both.
|
||||
adw = ["gtk", "dep:libadwaita"]
|
||||
|
||||
# The generator CLI. It only touches the gtk-free lib API (render + write), so
|
||||
# it builds without the gtk feature and stays light.
|
||||
|
|
|
|||
78
bread-theme/src/adw.rs
Normal file
78
bread-theme/src/adw.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
//! Composite libadwaita widgets for the bread ecosystem's design system —
|
||||
//! the actual mechanism (real GNOME-style widgets, not more hand-rolled CSS)
|
||||
//! behind why bos-settings' sidebar/section/toggle rows read as more polished
|
||||
//! than the plain-GTK4 apps'. An app calls these instead of assembling boxes
|
||||
//! and labels and raw widgets from scratch each time, so spacing/sizing/
|
||||
//! grouping decisions get made once, correctly, here — not re-derived per
|
||||
//! screen.
|
||||
//!
|
||||
//! Not usable from the five `gtk4-layer-shell` apps (breadbar, breadbox,
|
||||
//! breadclip, breadsearch, breadpad): `AdwApplicationWindow`'s own chrome
|
||||
//! isn't compatible with a layer-shell surface, and these helpers assume an
|
||||
//! ordinary top-level window. Apps with a plain top-level window (breadman,
|
||||
//! breadhelp) can use the full set.
|
||||
|
||||
use libadwaita as adw;
|
||||
use adw::prelude::*;
|
||||
|
||||
/// Call once at startup, before building any widgets from this module —
|
||||
/// initializes libadwaita's style manager and forces dark mode regardless of
|
||||
/// the system GTK theme preference. bread-theme's whole design is a *fixed*
|
||||
/// dark base (only the accent tracks pywal — see `palette::FIXED_BACKGROUND`
|
||||
/// etc.) so an app respecting a light system preference here would silently
|
||||
/// break that contract the moment someone's GNOME settings say "light".
|
||||
pub fn init() {
|
||||
adw::init().expect("failed to initialize libadwaita");
|
||||
adw::StyleManager::default().set_color_scheme(adw::ColorScheme::ForceDark);
|
||||
}
|
||||
|
||||
/// A titled, optionally-described group of setting rows — the
|
||||
/// title-then-description-then-rows rhythm bos-settings already uses per
|
||||
/// section, now available to native GTK4/relm4 apps instead of a hand-rolled
|
||||
/// vbox with a bold label glued to the top.
|
||||
pub fn preferences_group(title: &str, description: Option<&str>) -> adw::PreferencesGroup {
|
||||
let group = adw::PreferencesGroup::builder().title(title).build();
|
||||
if let Some(desc) = description {
|
||||
group.set_description(Some(desc));
|
||||
}
|
||||
group
|
||||
}
|
||||
|
||||
/// A single on/off setting row with a correctly-sized, correctly-positioned
|
||||
/// switch — the direct fix for the ~1400px-wide stretched-switch bug
|
||||
/// (breadman/settings had no intrinsic width on its hand-rolled switch, so
|
||||
/// it filled the row like a progress bar).
|
||||
pub fn toggle_row(title: &str, subtitle: Option<&str>, active: bool) -> adw::SwitchRow {
|
||||
let row = adw::SwitchRow::builder().title(title).active(active).build();
|
||||
if let Some(sub) = subtitle {
|
||||
row.set_subtitle(sub);
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
/// A single numeric setting row (spin button docked to its own label,
|
||||
/// instead of stranded ~1300px away at the window's far edge).
|
||||
pub fn spin_row(title: &str, subtitle: Option<&str>, adjustment: >k4::Adjustment) -> adw::SpinRow {
|
||||
let row = adw::SpinRow::builder().title(title).adjustment(adjustment).build();
|
||||
if let Some(sub) = subtitle {
|
||||
row.set_subtitle(sub);
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
/// A general label(+subtitle) row with room for a trailing widget
|
||||
/// (`row.add_suffix(&widget)`) — for settings that don't fit switch/spin
|
||||
/// (text entries, buttons, dropdowns, a raw value display).
|
||||
pub fn action_row(title: &str, subtitle: Option<&str>) -> adw::ActionRow {
|
||||
let row = adw::ActionRow::builder().title(title).build();
|
||||
if let Some(sub) = subtitle {
|
||||
row.set_subtitle(sub);
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
/// A page of one or more `preferences_group`s, with correct margins and
|
||||
/// scroll handling — the top-level content container for a settings screen.
|
||||
pub fn preferences_page() -> adw::PreferencesPage {
|
||||
adw::PreferencesPage::new()
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@
|
|||
//! # signal every running bread GUI to recolour
|
||||
//! bread-theme path # print the stylesheet path
|
||||
//! bread-theme print # render to stdout (no write)
|
||||
//! bread-theme generate-output <OUTPUT> --image <PATH> [--shared]
|
||||
//! bread-theme generate-output <OUTPUT> --from-json <PATH> [--shared]
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
|
|
@ -25,6 +27,149 @@ fn write_and_report(verb: &str) -> ExitCode {
|
|||
}
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
eprintln!(
|
||||
"bread-theme — shared stylesheet generator\n\n\
|
||||
USAGE:\n\
|
||||
\x20 bread-theme [generate|reload|path|print]\n\
|
||||
\x20 bread-theme generate-output <OUTPUT> --image <PATH> [--shared]\n\
|
||||
\x20 bread-theme generate-output <OUTPUT> --from-json <WAL-OR-PALETTE.json> [--shared]\n\n\
|
||||
generate render the pywal palette to the shared stylesheet (default)\n\
|
||||
reload re-render and signal running bread GUIs to recolour live\n\
|
||||
path print the stylesheet path ({})\n\
|
||||
print render to stdout without writing\n\
|
||||
generate-output write palettes/<OUTPUT>.json and themes/<OUTPUT>.css\n\
|
||||
\x20 --image isolated `wal -i` (does not touch ~/.cache/wal)\n\
|
||||
\x20 --from-json wal colors.json or a color1-6 object\n\
|
||||
\x20 --shared also write the session-global theme.css",
|
||||
bread_theme::shared_css_path().display()
|
||||
);
|
||||
}
|
||||
|
||||
fn generate_output_cmd() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(2).collect();
|
||||
if args.is_empty()
|
||||
|| args
|
||||
.iter()
|
||||
.any(|a| matches!(a.as_str(), "-h" | "--help" | "help"))
|
||||
{
|
||||
print_help();
|
||||
return if args.is_empty() {
|
||||
ExitCode::FAILURE
|
||||
} else {
|
||||
ExitCode::SUCCESS
|
||||
};
|
||||
}
|
||||
|
||||
let output = args[0].as_str();
|
||||
if output.starts_with('-') {
|
||||
eprintln!("bread-theme: generate-output requires an OUTPUT name (got '{output}')");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let mut image: Option<&str> = None;
|
||||
let mut from_json: Option<&str> = None;
|
||||
let mut shared = false;
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--shared" => shared = true,
|
||||
"--image" => {
|
||||
i += 1;
|
||||
match args.get(i) {
|
||||
Some(p) => image = Some(p.as_str()),
|
||||
None => {
|
||||
eprintln!("bread-theme: --image requires a path");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
"--from-json" => {
|
||||
i += 1;
|
||||
match args.get(i) {
|
||||
Some(p) => from_json = Some(p.as_str()),
|
||||
None => {
|
||||
eprintln!("bread-theme: --from-json requires a path");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
eprintln!("bread-theme: unknown generate-output flag '{other}'");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
match (image, from_json) {
|
||||
(Some(_), Some(_)) => {
|
||||
eprintln!("bread-theme: pass only one of --image or --from-json");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
(None, None) => {
|
||||
eprintln!("bread-theme: generate-output needs --image <PATH> or --from-json <PATH>");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
(Some(path), None) => {
|
||||
match bread_theme::generate_output(output, std::path::Path::new(path)) {
|
||||
Ok(css) => finish_generate_output(output, css, shared),
|
||||
Err(e) => {
|
||||
eprintln!("bread-theme: generate-output failed: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, Some(path)) => match write_output_from_json(output, path, shared) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("bread-theme: generate-output failed: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn write_output_from_json(output: &str, json_path: &str, shared: bool) -> std::io::Result<()> {
|
||||
let json = std::fs::read_to_string(json_path)?;
|
||||
let palette = bread_theme::palette_from_json(&json).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("could not parse palette JSON: {json_path}"),
|
||||
)
|
||||
})?;
|
||||
let pal_path = bread_theme::write_output_palette(output, &palette)?;
|
||||
let css_path = bread_theme::write_output_css(output, &palette)?;
|
||||
eprintln!(
|
||||
"bread-theme: wrote {} and {}",
|
||||
pal_path.display(),
|
||||
css_path.display()
|
||||
);
|
||||
if shared {
|
||||
let shared_path = bread_theme::write_shared_css_from(&palette)?;
|
||||
eprintln!("bread-theme: wrote shared {}", shared_path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_generate_output(output: &str, css: std::path::PathBuf, shared: bool) -> ExitCode {
|
||||
eprintln!("bread-theme: wrote {}", css.display());
|
||||
if shared {
|
||||
match bread_theme::write_shared_css_from(&bread_theme::load_palette_for(output)) {
|
||||
Ok(path) => {
|
||||
eprintln!("bread-theme: wrote shared {}", path.display());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("bread-theme: failed to write shared stylesheet: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
|
||||
match cmd.as_str() {
|
||||
|
|
@ -42,20 +187,15 @@ fn main() -> ExitCode {
|
|||
// the file monitor in every running bread GUI, so they all re-read the
|
||||
// palette and recolour live — shared widgets *and* each app's own rules.
|
||||
"reload" => write_and_report("reloaded"),
|
||||
"generate-output" => generate_output_cmd(),
|
||||
"-h" | "--help" | "help" => {
|
||||
eprintln!(
|
||||
"bread-theme — shared stylesheet generator\n\n\
|
||||
USAGE:\n bread-theme [generate|reload|path|print]\n\n\
|
||||
generate render the pywal palette to the shared stylesheet (default)\n\
|
||||
reload re-render and signal running bread GUIs to recolour live\n\
|
||||
path print the stylesheet path ({})\n\
|
||||
print render to stdout without writing",
|
||||
bread_theme::shared_css_path().display()
|
||||
);
|
||||
print_help();
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
other => {
|
||||
eprintln!("bread-theme: unknown command '{other}' (try generate|reload|path|print)");
|
||||
eprintln!(
|
||||
"bread-theme: unknown command '{other}' (try generate|reload|path|print|generate-output)"
|
||||
);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
use gtk4::gdk::prelude::*;
|
||||
use gtk4::gio;
|
||||
use gtk4::glib::object::ObjectType;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::CssProvider;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::Palette;
|
||||
|
||||
/// Above APPLICATION (600) so we beat [`apply_shared`], below USER (800)
|
||||
/// so `apply_user_css` still wins.
|
||||
const BIND_PRIORITY: u32 = gtk4::STYLE_PROVIDER_PRIORITY_USER - 10;
|
||||
|
||||
thread_local! {
|
||||
static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
||||
|
|
@ -14,8 +24,7 @@ thread_local! {
|
|||
}
|
||||
|
||||
fn reload_shared() {
|
||||
let css = std::fs::read_to_string(crate::shared_css_path())
|
||||
.unwrap_or_else(|_| crate::render());
|
||||
let css = std::fs::read_to_string(crate::shared_css_path()).unwrap_or_else(|_| crate::render());
|
||||
SHARED_PROVIDER.with(|cell| apply_css(&css, cell));
|
||||
}
|
||||
|
||||
|
|
@ -114,6 +123,345 @@ pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// A filter/tag chip using the shared `.chip` stylesheet rule (an
|
||||
/// `@overlay`-filled pill, `@accent`-filled when the `active` CSS class is
|
||||
/// set) instead of a fresh literal color — this is the fix for the same
|
||||
/// component drifting to three different fills across breadclip (grey),
|
||||
/// breadpad, and breadman (both cream), none of which agreed with each
|
||||
/// other or with the shared token.
|
||||
pub fn chip(label: &str) -> gtk4::Button {
|
||||
gtk4::Button::builder()
|
||||
.label(label)
|
||||
.css_classes(["chip"])
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Toggles a chip's (or any widget's) `active` CSS class — the `.chip.active`
|
||||
/// stylesheet rule fills it with the accent instead of the neutral overlay.
|
||||
/// Wiring *when* a chip becomes active (single-select filter, multi-select
|
||||
/// tags, etc.) is genuinely per-app, so that stays the caller's job; this is
|
||||
/// just the one-line visual toggle every case needs.
|
||||
pub fn set_chip_active(chip: &impl IsA<gtk4::Widget>, active: bool) {
|
||||
if active {
|
||||
chip.add_css_class("active");
|
||||
} else {
|
||||
chip.remove_css_class("active");
|
||||
}
|
||||
}
|
||||
|
||||
/// Gdk connector for the monitor currently showing this widget, if any.
|
||||
pub fn output_for_widget(widget: &impl IsA<gtk4::Widget>) -> Option<String> {
|
||||
let widget = widget.as_ref();
|
||||
let native = widget.native()?;
|
||||
let surface = NativeExt::surface(&native)?;
|
||||
let monitor = widget.display().monitor_at_surface(&surface)?;
|
||||
monitor.connector().map(|c| c.to_string())
|
||||
}
|
||||
|
||||
struct WidgetBind {
|
||||
output: String,
|
||||
theme: CssProvider,
|
||||
app: Option<CssProvider>,
|
||||
app_build: Option<Rc<dyn Fn(&Palette) -> String>>,
|
||||
/// Keep the directory monitor + child model alive for this widget.
|
||||
_watch: Option<gio::ListModel>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static BINDS: RefCell<HashMap<usize, WidgetBind>> = RefCell::new(HashMap::new());
|
||||
static THEMES_MONITOR: RefCell<Option<gio::FileMonitor>> = const { RefCell::new(None) };
|
||||
static DESTROY_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
||||
static AUTO_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
||||
static ENTER_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
||||
}
|
||||
|
||||
fn widget_key(widget: >k4::Widget) -> usize {
|
||||
widget.as_ptr() as usize
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
fn add_widget_provider(widget: >k4::Widget, provider: &CssProvider, prio: u32) {
|
||||
widget.style_context().add_provider(provider, prio);
|
||||
}
|
||||
|
||||
/// Same `CssProvider` on the widget and its current descendants so component
|
||||
/// rules actually reach buttons/labels (a style-context provider is not
|
||||
/// inherited by children).
|
||||
fn attach_tree(widget: >k4::Widget, theme: &CssProvider, app: Option<&CssProvider>) {
|
||||
add_widget_provider(widget, theme, BIND_PRIORITY);
|
||||
if let Some(app) = app {
|
||||
add_widget_provider(widget, app, BIND_PRIORITY + 1);
|
||||
}
|
||||
let mut child = widget.first_child();
|
||||
while let Some(c) = child {
|
||||
attach_tree(&c, theme, app);
|
||||
child = c.next_sibling();
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_destroy_cleanup(widget: >k4::Widget) {
|
||||
let key = widget_key(widget);
|
||||
let inserted = DESTROY_HOOKED.with(|s| s.borrow_mut().insert(key));
|
||||
if !inserted {
|
||||
return;
|
||||
}
|
||||
widget.connect_destroy(move |w| {
|
||||
let key = widget_key(w);
|
||||
BINDS.with(|b| {
|
||||
b.borrow_mut().remove(&key);
|
||||
});
|
||||
DESTROY_HOOKED.with(|s| {
|
||||
s.borrow_mut().remove(&key);
|
||||
});
|
||||
AUTO_HOOKED.with(|s| {
|
||||
s.borrow_mut().remove(&key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn ensure_themes_watch() {
|
||||
THEMES_MONITOR.with(|cell| {
|
||||
if cell.borrow().is_some() {
|
||||
return;
|
||||
}
|
||||
let dir = crate::themes_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let monitor = gio::File::for_path(&dir)
|
||||
.monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE)
|
||||
.ok();
|
||||
if let Some(ref m) = monitor {
|
||||
m.connect_changed(move |_, file, other, _event| {
|
||||
let path = file.path().or_else(|| other.and_then(|f| f.path()));
|
||||
let Some(path) = path else {
|
||||
return;
|
||||
};
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("css") {
|
||||
return;
|
||||
}
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
return;
|
||||
};
|
||||
reload_binds_for_sanitized(stem);
|
||||
});
|
||||
}
|
||||
*cell.borrow_mut() = monitor;
|
||||
});
|
||||
}
|
||||
|
||||
fn reload_binds_for_sanitized(sanitized: &str) {
|
||||
BINDS.with(|binds| {
|
||||
for bind in binds.borrow_mut().values_mut() {
|
||||
if crate::sanitize_output(&bind.output) != sanitized {
|
||||
continue;
|
||||
}
|
||||
let palette = crate::load_palette_for(&bind.output);
|
||||
bind.theme
|
||||
.load_from_string(&crate::stylesheet_resolved(&palette));
|
||||
if let (Some(build), Some(provider)) = (&bind.app_build, &bind.app) {
|
||||
provider.load_from_string(&crate::resolve_color_names(&build(&palette), &palette));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn watch_root_children(widget: >k4::Widget) -> gio::ListModel {
|
||||
let model = widget.observe_children();
|
||||
let root = widget.downgrade();
|
||||
model.connect_items_changed(move |_, _, _, _| {
|
||||
let Some(root) = root.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let key = widget_key(&root);
|
||||
BINDS.with(|binds| {
|
||||
if let Some(bind) = binds.borrow().get(&key) {
|
||||
attach_tree(&root, &bind.theme, bind.app.as_ref());
|
||||
}
|
||||
});
|
||||
});
|
||||
model
|
||||
}
|
||||
|
||||
fn bind_window_inner(
|
||||
widget: >k4::Widget,
|
||||
output: &str,
|
||||
app_build: Option<Rc<dyn Fn(&Palette) -> String>>,
|
||||
) {
|
||||
let key = widget_key(widget);
|
||||
let palette = crate::load_palette_for(output);
|
||||
let theme_css = crate::stylesheet_resolved(&palette);
|
||||
let app_css = app_build
|
||||
.as_ref()
|
||||
.map(|build| crate::resolve_color_names(&build(&palette), &palette));
|
||||
|
||||
BINDS.with(|binds| {
|
||||
let mut map = binds.borrow_mut();
|
||||
if let Some(existing) = map.get_mut(&key) {
|
||||
existing.output = output.to_string();
|
||||
existing.theme.load_from_string(&theme_css);
|
||||
existing.app_build = app_build.clone();
|
||||
match (&app_css, existing.app.as_ref()) {
|
||||
(Some(css), Some(p)) => p.load_from_string(css),
|
||||
(Some(css), None) => {
|
||||
let p = CssProvider::new();
|
||||
p.load_from_string(css);
|
||||
add_widget_provider(widget, &p, BIND_PRIORITY + 1);
|
||||
existing.app = Some(p);
|
||||
}
|
||||
(None, Some(p)) => p.load_from_string(""),
|
||||
(None, None) => {}
|
||||
}
|
||||
attach_tree(widget, &existing.theme, existing.app.as_ref());
|
||||
return;
|
||||
}
|
||||
|
||||
let theme = CssProvider::new();
|
||||
theme.load_from_string(&theme_css);
|
||||
add_widget_provider(widget, &theme, BIND_PRIORITY);
|
||||
|
||||
let app = app_css.map(|css| {
|
||||
let p = CssProvider::new();
|
||||
p.load_from_string(&css);
|
||||
add_widget_provider(widget, &p, BIND_PRIORITY + 1);
|
||||
p
|
||||
});
|
||||
|
||||
attach_tree(widget, &theme, app.as_ref());
|
||||
|
||||
let child_model = watch_root_children(widget);
|
||||
map.insert(
|
||||
key,
|
||||
WidgetBind {
|
||||
output: output.to_string(),
|
||||
theme,
|
||||
app,
|
||||
app_build,
|
||||
_watch: Some(child_model),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
ensure_destroy_cleanup(widget);
|
||||
ensure_themes_watch();
|
||||
ensure_map_reattach(widget);
|
||||
}
|
||||
|
||||
fn ensure_map_reattach(widget: >k4::Widget) {
|
||||
// `connect_map` once per widget — re-bind already lives in BINDS.
|
||||
thread_local! {
|
||||
static MAP_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
||||
}
|
||||
let key = widget_key(widget);
|
||||
let inserted = MAP_HOOKED.with(|s| s.borrow_mut().insert(key));
|
||||
if !inserted {
|
||||
return;
|
||||
}
|
||||
widget.connect_map(|w| {
|
||||
BINDS.with(|binds| {
|
||||
if let Some(bind) = binds.borrow().get(&widget_key(w)) {
|
||||
attach_tree(w, &bind.theme, bind.app.as_ref());
|
||||
}
|
||||
});
|
||||
});
|
||||
widget.connect_destroy(move |_| {
|
||||
MAP_HOOKED.with(|s| {
|
||||
s.borrow_mut().remove(&key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Attach a widget-level `CssProvider` with
|
||||
/// `stylesheet_resolved(load_palette_for(output))` above APPLICATION so it
|
||||
/// beats [`apply_shared`] for this widget tree. User CSS still wins.
|
||||
/// Calling again on the same widget replaces the provider; it does not stack.
|
||||
pub fn bind_window(widget: &impl IsA<gtk4::Widget>, output: &str) {
|
||||
bind_window_inner(widget.as_ref(), output, None);
|
||||
}
|
||||
|
||||
/// [`bind_window`], then also apply `build(&palette)` on the same widget.
|
||||
/// App CSS may still use `@accent` etc.; those names are inlined against
|
||||
/// the same palette before loading.
|
||||
pub fn bind_window_with_app_css<F>(widget: &impl IsA<gtk4::Widget>, output: &str, build: F)
|
||||
where
|
||||
F: Fn(&Palette) -> String + 'static,
|
||||
{
|
||||
bind_window_inner(widget.as_ref(), output, Some(Rc::new(build)));
|
||||
}
|
||||
|
||||
fn attach_enter_monitor(widget: >k4::Widget, build: Option<Rc<dyn Fn(&Palette) -> String>>) {
|
||||
let Some(native) = widget.native() else {
|
||||
return;
|
||||
};
|
||||
let Some(surface) = NativeExt::surface(&native) else {
|
||||
return;
|
||||
};
|
||||
let surf_key = surface.as_ptr() as usize;
|
||||
let already = ENTER_HOOKED.with(|s| !s.borrow_mut().insert(surf_key));
|
||||
if already {
|
||||
return;
|
||||
}
|
||||
let widget = widget.clone();
|
||||
surface.connect_enter_monitor(move |_, monitor| {
|
||||
let Some(conn) = monitor.connector() else {
|
||||
return;
|
||||
};
|
||||
bind_window_inner(&widget, conn.as_str(), build.clone());
|
||||
});
|
||||
}
|
||||
|
||||
fn bind_auto(native: >k4::Native, build: Option<Rc<dyn Fn(&Palette) -> String>>) {
|
||||
let widget = native.upcast_ref::<gtk4::Widget>().clone();
|
||||
|
||||
let apply = {
|
||||
let widget = widget.clone();
|
||||
let build = build.clone();
|
||||
Rc::new(move || {
|
||||
if let Some(output) = output_for_widget(&widget) {
|
||||
bind_window_inner(&widget, &output, build.clone());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
let key = widget_key(&widget);
|
||||
let inserted = AUTO_HOOKED.with(|s| s.borrow_mut().insert(key));
|
||||
if inserted {
|
||||
widget.connect_realize({
|
||||
let apply = apply.clone();
|
||||
let widget = widget.clone();
|
||||
let build = build.clone();
|
||||
move |_| {
|
||||
apply();
|
||||
attach_enter_monitor(&widget, build.clone());
|
||||
}
|
||||
});
|
||||
widget.connect_map({
|
||||
let apply = apply.clone();
|
||||
move |_| apply()
|
||||
});
|
||||
ensure_destroy_cleanup(&widget);
|
||||
}
|
||||
|
||||
if widget.is_realized() {
|
||||
attach_enter_monitor(&widget, build);
|
||||
}
|
||||
}
|
||||
|
||||
/// Realize + `GdkSurface::enter-monitor`: rebind when the window moves
|
||||
/// outputs. If the connector is unknown, leave unbound (display fallback)
|
||||
/// rather than guessing the wrong monitor.
|
||||
pub fn bind_window_auto(window: &impl IsA<gtk4::Native>) {
|
||||
bind_auto(window.as_ref(), None);
|
||||
}
|
||||
|
||||
/// [`bind_window_auto`] plus per-output app CSS, resolved to hex.
|
||||
pub fn bind_window_auto_with_app_css<F>(window: &impl IsA<gtk4::Native>, build: F)
|
||||
where
|
||||
F: Fn(&Palette) -> String + 'static,
|
||||
{
|
||||
bind_auto(window.as_ref(), Some(Rc::new(build)));
|
||||
}
|
||||
|
||||
/// Apply a user CSS override file at USER priority. Clears the provider if the
|
||||
/// file is absent so stale overrides don't persist across SIGHUP reloads.
|
||||
pub fn apply_user_css(path: &Path, provider: &RefCell<Option<CssProvider>>) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
pub mod palette;
|
||||
#[cfg(feature = "adw")]
|
||||
pub mod adw;
|
||||
#[cfg(feature = "gtk")]
|
||||
pub mod gtk;
|
||||
mod output;
|
||||
pub mod palette;
|
||||
|
||||
pub use output::{
|
||||
generate_output, load_palette_for, output_css_path, output_palette_path, palette_from_image,
|
||||
palette_from_json, palettes_dir, sanitize_output, themes_dir, write_output_css,
|
||||
write_output_palette, write_shared_css_from,
|
||||
};
|
||||
pub use palette::{load_palette, Palette};
|
||||
|
||||
/// Design tokens from BREAD_DESIGN_SYSTEM.md.
|
||||
|
|
@ -24,6 +32,13 @@ pub mod tokens {
|
|||
pub const RADIUS_PILL: u16 = 999;
|
||||
}
|
||||
|
||||
/// CSS `font-family` list: quote the named face, leave the generic fallback
|
||||
/// unquoted. Wrapping [`tokens::FONT_FAMILY`] in one pair of quotes would
|
||||
/// make a single family named "Varela Round, sans-serif" and drop sans-serif.
|
||||
fn css_font_family() -> &'static str {
|
||||
"'Varela Round', sans-serif"
|
||||
}
|
||||
|
||||
/// Emit the `@define-color` block that all bread apps use, plus the shared
|
||||
/// font rule.
|
||||
///
|
||||
|
|
@ -39,9 +54,9 @@ pub mod tokens {
|
|||
/// one color-block implementation and it cannot drift again.
|
||||
pub fn css_vars(p: &Palette) -> String {
|
||||
format!(
|
||||
"{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n",
|
||||
"{vars}* {{ font-family: {font}; font-size: {size}px; }}\n",
|
||||
vars = define_colors(p),
|
||||
font = tokens::FONT_FAMILY,
|
||||
font = css_font_family(),
|
||||
size = tokens::FONT_SIZE_BASE,
|
||||
)
|
||||
}
|
||||
|
|
@ -51,7 +66,11 @@ pub fn luminance(hex: &str) -> f32 {
|
|||
let h = hex.trim_start_matches('#');
|
||||
let lin = |i: usize| -> f32 {
|
||||
let c = u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f32 / 255.0;
|
||||
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
if c <= 0.04045 {
|
||||
c / 12.92
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4)
|
||||
}
|
||||
|
|
@ -62,44 +81,101 @@ pub fn luminance(hex: &str) -> f32 {
|
|||
/// text readable no matter how light or dark pywal makes a given palette slot,
|
||||
/// without altering the palette colours themselves.
|
||||
pub fn ink_on(hex: &str) -> &'static str {
|
||||
if luminance(hex) > 0.179 { "#11111b" } else { "#f5f5f5" }
|
||||
if luminance(hex) > 0.179 {
|
||||
"#11111b"
|
||||
} else {
|
||||
"#f5f5f5"
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical `@define-color` block: the single naming all bread apps share.
|
||||
/// Canonical (name, value) list: the single naming all bread apps share.
|
||||
/// `surface` = color0 (darkest surface), `overlay` = color7 (muted), and
|
||||
/// `accent` = color4. Apps must use these names, not raw palette slots, so the
|
||||
/// whole ecosystem recolours together.
|
||||
///
|
||||
/// The `on-*` colours are computed ink (black/white) guaranteed to be legible on
|
||||
/// the matching background — use `@on-surface` for text on a `@surface` panel,
|
||||
/// `@on-accent` on an `@accent` button, etc. They exist because pywal can emit a
|
||||
/// the matching background — use `on-surface` for text on a `surface` panel,
|
||||
/// `on-accent` on an `accent` button, etc. They exist because pywal can emit a
|
||||
/// light value in any slot, and white text on a light surface disappears.
|
||||
///
|
||||
/// [`define_colors`] (GTK `@define-color`) and [`css_custom_properties`] (web
|
||||
/// `:root { --name: ... }`) both format this same list rather than each
|
||||
/// hand-writing their own — see `css_vars_and_stylesheet_agree_on_color_block`
|
||||
/// and `css_custom_properties_matches_define_colors_name_set` for the
|
||||
/// regression tests this exists to satisfy.
|
||||
fn color_pairs(p: &Palette) -> [(&'static str, String); 16] {
|
||||
[
|
||||
("bg", p.background.clone()),
|
||||
("fg", p.foreground.clone()),
|
||||
("surface", p.color0.clone()),
|
||||
("overlay", p.color7.clone()),
|
||||
("accent", p.color4.clone()),
|
||||
("red", p.color1.clone()),
|
||||
("green", p.color2.clone()),
|
||||
("yellow", p.color3.clone()),
|
||||
("blue", p.color4.clone()),
|
||||
("pink", p.color5.clone()),
|
||||
("teal", p.color6.clone()),
|
||||
("on-bg", ink_on(&p.background).to_string()),
|
||||
("on-surface", ink_on(&p.color0).to_string()),
|
||||
("on-accent", ink_on(&p.color4).to_string()),
|
||||
("on-red", ink_on(&p.color1).to_string()),
|
||||
("on-overlay", ink_on(&p.color7).to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
/// GTK `@define-color` block built from [`color_pairs`].
|
||||
fn define_colors(p: &Palette) -> String {
|
||||
color_pairs(p)
|
||||
.iter()
|
||||
.map(|(name, value)| format!("@define-color {name} {value};\n"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// CSS custom-properties block (`:root { --bg: ...; --on-accent: ...; }`) for
|
||||
/// web frontends (Tauri), using the exact same names as [`define_colors`] so
|
||||
/// the GTK and web outputs cannot drift apart independently — both are
|
||||
/// generated from [`color_pairs`], not two hand-written copies.
|
||||
pub fn css_custom_properties(p: &Palette) -> String {
|
||||
let vars: String = color_pairs(p)
|
||||
.iter()
|
||||
.map(|(name, value)| format!(" --{name}: {value};\n"))
|
||||
.collect();
|
||||
format!(":root {{\n{vars}}}\n")
|
||||
}
|
||||
|
||||
/// CSS custom-properties for [`tokens`] (font, spacing, radii) — the web
|
||||
/// counterpart to [`tokens`] being hand-read by GTK code, so a web frontend
|
||||
/// isn't hand-copying the same numbers into a second source of truth.
|
||||
pub fn css_tokens() -> String {
|
||||
use tokens::*;
|
||||
format!(
|
||||
"@define-color bg {bg};\n\
|
||||
@define-color fg {fg};\n\
|
||||
@define-color surface {c0};\n\
|
||||
@define-color overlay {c7};\n\
|
||||
@define-color accent {c4};\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 on-bg {on_bg};\n\
|
||||
@define-color on-surface {on_surface};\n\
|
||||
@define-color on-accent {on_accent};\n\
|
||||
@define-color on-red {on_red};\n\
|
||||
@define-color on-overlay {on_overlay};\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,
|
||||
on_bg = ink_on(&p.background),
|
||||
on_surface = ink_on(&p.color0),
|
||||
on_accent = ink_on(&p.color4),
|
||||
on_red = ink_on(&p.color1),
|
||||
on_overlay = ink_on(&p.color7),
|
||||
":root {{\n\
|
||||
\x20\x20--font-family: {font};\n\
|
||||
\x20\x20--font-size-base: {base}px;\n\
|
||||
\x20\x20--font-size-secondary: {sec}px;\n\
|
||||
\x20\x20--space-xs: {xs}px;\n\
|
||||
\x20\x20--space-sm: {sm}px;\n\
|
||||
\x20\x20--space-md: {md}px;\n\
|
||||
\x20\x20--space-lg: {lg}px;\n\
|
||||
\x20\x20--space-xl: {xl}px;\n\
|
||||
\x20\x20--radius-primary: {r1}px;\n\
|
||||
\x20\x20--radius-secondary: {r2}px;\n\
|
||||
\x20\x20--radius-tertiary: {r3}px;\n\
|
||||
\x20\x20--radius-pill: {pill}px;\n\
|
||||
}}\n",
|
||||
font = css_font_family(),
|
||||
base = FONT_SIZE_BASE,
|
||||
sec = FONT_SIZE_SECONDARY,
|
||||
xs = SPACE_XS,
|
||||
sm = SPACE_SM,
|
||||
md = SPACE_MD,
|
||||
lg = SPACE_LG,
|
||||
xl = SPACE_XL,
|
||||
r1 = RADIUS_PRIMARY,
|
||||
r2 = RADIUS_SECONDARY,
|
||||
r3 = RADIUS_TERTIARY,
|
||||
pill = RADIUS_PILL,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -113,16 +189,27 @@ pub fn stylesheet(p: &Palette) -> String {
|
|||
use tokens::*;
|
||||
format!(
|
||||
"{vars}\
|
||||
* {{ font-family: '{font}'; font-size: {base}px; }}\n\
|
||||
* {{ font-family: {font}; font-size: {base}px; }}\n\
|
||||
/* Colour is set on containers; labels inherit it, so text on any panel,\
|
||||
button, or accent is always the legible ink for that background. Bare\
|
||||
`label {{ color }}` is deliberately avoided — as a type selector it\
|
||||
would override a container's colour on its own child labels. */\n\
|
||||
window {{ background-color: @bg; color: @on-bg; }}\n\
|
||||
.dim-label, .dim {{ opacity: 0.6; font-size: {sec}px; }}\n\
|
||||
.title {{ font-size: 1.4em; font-weight: bold; }}\n\
|
||||
/* Named `.page-title`, not the more obvious `.title` - libadwaita's\
|
||||
own row/window-title widgets (AdwActionRow, AdwWindowTitle, GtkHeaderBar)\
|
||||
put a bare `title` CSS class on their internal label, so a generic\
|
||||
`.title` rule here would inflate every libadwaita row's title text\
|
||||
to 1.4em too (this is exactly what caused the settings screen's\
|
||||
~24px row-title bug). Scoping the name avoids the collision instead\
|
||||
of trying to out-specificity a first-party GTK/libadwaita class. */\n\
|
||||
.page-title {{ font-size: 1.4em; font-weight: bold; }}\n\
|
||||
.heading {{ font-weight: bold; opacity: 0.85; }}\n\
|
||||
.subtitle {{ opacity: 0.7; font-size: {sec}px; }}\n\
|
||||
/* Same libadwaita-collision reasoning as `.page-title` above - a bare\
|
||||
`.subtitle` also matches libadwaita's internal row-subtitle labels.\
|
||||
Unused by any app today, but scoped so a future caller doesn't\
|
||||
reintroduce the fight. */\n\
|
||||
.page-subtitle {{ opacity: 0.7; font-size: {sec}px; }}\n\
|
||||
button {{ background-color: @surface; color: @on-surface; border: none;\
|
||||
border-radius: {r1}px; padding: {sm}px {lg}px; }}\n\
|
||||
button:hover {{ background-color: alpha(@on-surface, 0.14); }}\n\
|
||||
|
|
@ -131,8 +218,15 @@ pub fn stylesheet(p: &Palette) -> String {
|
|||
button.flat {{ background-color: transparent; color: @on-bg; }}\n\
|
||||
button.suggested-action {{ background-color: @accent; color: @on-accent; }}\n\
|
||||
button.suggested-action:hover {{ background-color: alpha(@accent, 0.85); }}\n\
|
||||
button.destructive-action {{ background-color: @red; color: @on-red; }}\n\
|
||||
button.destructive-action:hover {{ background-color: alpha(@red, 0.85); }}\n\
|
||||
/* Deliberately NOT @red: pywal can hand `red` any hue depending on\
|
||||
the wallpaper (a blue-toned wallpaper's \"red\" slot can literally\
|
||||
render blue), which would make a destructive action indistinguishable\
|
||||
from a normal accent button - exactly backwards for a warning colour.\
|
||||
GNOME's own destructive-action is a fixed red for the same reason;\
|
||||
this is the one button style in the whole system that intentionally\
|
||||
doesn't follow the palette. */\n\
|
||||
button.destructive-action {{ background-color: #e01b24; color: #ffffff; }}\n\
|
||||
button.destructive-action:hover {{ background-color: #c01c28; }}\n\
|
||||
entry, spinbutton {{ background-color: @surface; color: @on-surface;\
|
||||
border: 1px solid @overlay; border-radius: {r2}px;\
|
||||
padding: {xs}px {sm}px; caret-color: @on-surface; }}\n\
|
||||
|
|
@ -143,7 +237,23 @@ pub fn stylesheet(p: &Palette) -> String {
|
|||
switch {{ background-color: @overlay; border-radius: {pill}px; }}\n\
|
||||
switch:checked {{ background-color: @accent; }}\n\
|
||||
switch slider {{ background-color: @on-surface; border-radius: {pill}px; }}\n\
|
||||
/* GtkScale (sliders) render with GTK's own default accent (a fixed\
|
||||
blue, independent of the app's theme) unless styled explicitly —\
|
||||
every app with a volume/brightness slider was silently showing\
|
||||
that default instead of the palette's accent until this rule\
|
||||
existed. */\n\
|
||||
scale trough {{ background-color: @overlay; border-radius: {pill}px; min-height: 6px; }}\n\
|
||||
scale trough highlight {{ background-color: @accent; border-radius: {pill}px; min-height: 6px; }}\n\
|
||||
scale slider {{ background-color: @on-bg; border-radius: {pill}px; }}\n\
|
||||
list, listbox {{ background-color: transparent; }}\n\
|
||||
/* libadwaita's AdwPreferencesGroup wraps its rows in a GtkListBox\
|
||||
carrying the `boxed-list` class, expecting a surface fill + radius\
|
||||
to read as a card. The bare-type rule above (needed so plain\
|
||||
GTK4 sidebars/lists stay transparent) was overriding that with\
|
||||
equal specificity and no fill ever won, leaving preference groups\
|
||||
as a bare bordered table instead of a card. This is scoped to the\
|
||||
class only, so it doesn't touch any non-adw list. */\n\
|
||||
list.boxed-list, listbox.boxed-list {{ background-color: @surface; border-radius: {r1}px; }}\n\
|
||||
row {{ border-radius: {r2}px; }}\n\
|
||||
row:selected, list row:selected {{ background-color: @accent; color: @on-accent; }}\n\
|
||||
.sidebar {{ background-color: @surface; color: @on-surface; }}\n\
|
||||
|
|
@ -162,7 +272,7 @@ pub fn stylesheet(p: &Palette) -> String {
|
|||
textview, .mono {{ font-family: monospace; }}\n\
|
||||
textview text {{ background-color: @surface; color: @on-surface; }}\n",
|
||||
vars = define_colors(p),
|
||||
font = FONT_FAMILY,
|
||||
font = css_font_family(),
|
||||
base = FONT_SIZE_BASE,
|
||||
sec = FONT_SIZE_SECONDARY,
|
||||
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG,
|
||||
|
|
@ -181,28 +291,33 @@ pub fn render() -> String {
|
|||
/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`,
|
||||
/// falling back to the cache dir.
|
||||
pub fn shared_css_path() -> std::path::PathBuf {
|
||||
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
if !rt.is_empty() {
|
||||
return std::path::PathBuf::from(rt).join("bread").join("theme.css");
|
||||
}
|
||||
}
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
|
||||
.join("bread")
|
||||
.join("theme.css")
|
||||
output::runtime_bread_dir().join("theme.css")
|
||||
}
|
||||
|
||||
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
|
||||
/// the path written. Used by the `bread-theme` CLI.
|
||||
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
|
||||
let path = shared_css_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
write_shared_css_from(&load_palette())
|
||||
}
|
||||
|
||||
/// `stylesheet()` with `@name` references in rule bodies replaced by hex.
|
||||
/// Longer names first (`on-surface` before `surface`, `on-bg` before `bg`)
|
||||
/// so a prefix match cannot half-replace `@on-bg`.
|
||||
pub fn stylesheet_resolved(p: &Palette) -> String {
|
||||
resolve_color_names(&stylesheet(p), p)
|
||||
}
|
||||
|
||||
/// Replace `@define-color` names (`@accent`, `@on-bg`, …) with hex values.
|
||||
/// Used by [`stylesheet_resolved`] and by GTK `bind_window` so display-global
|
||||
/// named colors cannot leak the wrong monitor's accent.
|
||||
pub(crate) fn resolve_color_names(css: &str, p: &Palette) -> String {
|
||||
let mut pairs: Vec<(&str, String)> = color_pairs(p).into_iter().collect();
|
||||
pairs.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
|
||||
let mut out = css.to_string();
|
||||
for (name, value) in pairs {
|
||||
out = out.replace(&format!("@{name}"), &value);
|
||||
}
|
||||
let tmp = path.with_extension("css.tmp");
|
||||
std::fs::write(&tmp, render())?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
Ok(path)
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
|
||||
|
|
@ -221,15 +336,24 @@ mod tests {
|
|||
#[test]
|
||||
fn css_vars_contains_all_define_color_names() {
|
||||
let css = css_vars(&Palette::default());
|
||||
for name in &["bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
|
||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||
for name in &[
|
||||
"bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay",
|
||||
] {
|
||||
assert!(
|
||||
css.contains(&format!("@define-color {name} ")),
|
||||
"missing @define-color {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_vars_contains_font_rule() {
|
||||
let css = css_vars(&Palette::default());
|
||||
assert!(css.contains("Varela Round"));
|
||||
assert!(css.contains("font-family: 'Varela Round', sans-serif;"));
|
||||
assert!(
|
||||
!css.contains("font-family: 'Varela Round, sans-serif'"),
|
||||
"named face and generic fallback must not be one quoted family"
|
||||
);
|
||||
assert!(css.contains("14px"));
|
||||
}
|
||||
|
||||
|
|
@ -242,8 +366,18 @@ mod tests {
|
|||
// 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}");
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -254,7 +388,16 @@ mod tests {
|
|||
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"] {
|
||||
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));
|
||||
}
|
||||
|
|
@ -264,13 +407,67 @@ mod tests {
|
|||
fn stylesheet_defines_canonical_colors_and_components() {
|
||||
let css = stylesheet(&Palette::default());
|
||||
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
|
||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||
assert!(
|
||||
css.contains(&format!("@define-color {name} ")),
|
||||
"missing @define-color {name}"
|
||||
);
|
||||
}
|
||||
// a representative spread of the shared component selectors
|
||||
for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".title"] {
|
||||
for sel in &[
|
||||
"button",
|
||||
"entry",
|
||||
"switch:checked",
|
||||
".card",
|
||||
".sidebar",
|
||||
"scrollbar slider",
|
||||
".page-title",
|
||||
] {
|
||||
assert!(css.contains(sel), "stylesheet missing selector: {sel}");
|
||||
}
|
||||
assert!(css.contains("Varela Round"));
|
||||
assert!(css.contains("font-family: 'Varela Round', sans-serif;"));
|
||||
assert!(
|
||||
!css.contains("font-family: 'Varela Round, sans-serif'"),
|
||||
"named face and generic fallback must not be one quoted family"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_custom_properties_matches_define_colors_name_set() {
|
||||
// Both must derive from the same color_pairs() list, so the web
|
||||
// output can't drift from the GTK one the way css_vars/stylesheet
|
||||
// used to (see css_vars_and_stylesheet_agree_on_color_block above).
|
||||
let p = Palette::default();
|
||||
let gtk = define_colors(&p);
|
||||
let web = css_custom_properties(&p);
|
||||
for (name, _) in color_pairs(&p) {
|
||||
assert!(
|
||||
gtk.contains(&format!("@define-color {name} ")),
|
||||
"gtk missing {name}"
|
||||
);
|
||||
assert!(web.contains(&format!("--{name}: ")), "web missing {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_custom_properties_is_valid_root_block() {
|
||||
let p = Palette::default();
|
||||
let css = css_custom_properties(&p);
|
||||
assert!(css.starts_with(":root {\n"));
|
||||
assert!(css.trim_end().ends_with('}'));
|
||||
assert!(css.contains(&format!("--accent: {};", p.color4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_tokens_contains_font_and_spacing_vars() {
|
||||
let css = css_tokens();
|
||||
assert!(css.contains("--font-family: 'Varela Round', sans-serif;"));
|
||||
assert!(
|
||||
!css.contains("--font-family: 'Varela Round, sans-serif'"),
|
||||
"named face and generic fallback must not be one quoted family"
|
||||
);
|
||||
assert!(css.contains("--font-size-base: 14px;"));
|
||||
assert!(css.contains("--space-md: 12px;"));
|
||||
assert!(css.contains("--radius-pill: 999px;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -297,7 +494,10 @@ mod tests {
|
|||
fn stylesheet_defines_on_colors() {
|
||||
let css = stylesheet(&Palette::default());
|
||||
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
|
||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||
assert!(
|
||||
css.contains(&format!("@define-color {name} ")),
|
||||
"missing @define-color {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -306,13 +506,58 @@ mod tests {
|
|||
// A bare `label { color: ... }` would override container colours on child
|
||||
// labels — the bug that made coloured-background text illegible.
|
||||
let css = stylesheet(&Palette::default());
|
||||
assert!(!css.contains("label { color:"), "blanket label colour rule reintroduced");
|
||||
assert!(
|
||||
!css.contains("label { color:"),
|
||||
"blanket label colour rule reintroduced"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_css_path_uses_runtime_dir() {
|
||||
let _lock = crate::output::XDG_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
|
||||
assert_eq!(shared_css_path(), std::path::PathBuf::from("/run/user/1234/bread/theme.css"));
|
||||
assert_eq!(
|
||||
shared_css_path(),
|
||||
std::path::PathBuf::from("/run/user/1234/bread/theme.css")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_resolved_inlines_color4_and_drops_named_refs_in_rules() {
|
||||
let mut p = Palette::default();
|
||||
p.color4 = "#7aa2f7".into();
|
||||
let css = stylesheet_resolved(&p);
|
||||
assert!(css.contains("#7aa2f7"), "color4 must appear as hex: {css}");
|
||||
// Rule bodies must not keep named colors — GTK display-global
|
||||
// @define-color would otherwise leak the wrong monitor's accent.
|
||||
let rules = css
|
||||
.lines()
|
||||
.filter(|l| !l.trim_start().starts_with("@define-color"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
!rules.contains("@accent"),
|
||||
"leftover @accent in rules:\n{rules}"
|
||||
);
|
||||
assert!(
|
||||
!rules.contains("@on-bg"),
|
||||
"leftover @on-bg in rules:\n{rules}"
|
||||
);
|
||||
assert!(
|
||||
!rules.contains("@on-surface"),
|
||||
"leftover @on-surface in rules:\n{rules}"
|
||||
);
|
||||
assert!(
|
||||
!rules.contains("@on-accent"),
|
||||
"leftover @on-accent in rules:\n{rules}"
|
||||
);
|
||||
// Longer names first: @on-bg must not become @on-#...
|
||||
assert!(
|
||||
!rules.contains("@on-#"),
|
||||
"half-replaced on-* name:\n{rules}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
338
bread-theme/src/output.rs
Normal file
338
bread-theme/src/output.rs
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
//! Per-output (per-monitor) palette and stylesheet paths under
|
||||
//! `$XDG_RUNTIME_DIR/bread/{palettes,themes}/`.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::palette::{from_wal_json, Palette};
|
||||
use crate::{load_palette, stylesheet};
|
||||
|
||||
/// Session-scoped `$XDG_RUNTIME_DIR/bread`, same fallback as [`crate::shared_css_path`].
|
||||
pub(crate) fn runtime_bread_dir() -> PathBuf {
|
||||
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
if !rt.is_empty() {
|
||||
return PathBuf::from(rt).join("bread");
|
||||
}
|
||||
}
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("bread")
|
||||
}
|
||||
|
||||
/// Keep `[A-Za-z0-9._-]`; replace everything else with `_`.
|
||||
pub fn sanitize_output(output: &str) -> String {
|
||||
let s: String = output
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if s.is_empty() {
|
||||
"_".into()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
pub fn themes_dir() -> PathBuf {
|
||||
runtime_bread_dir().join("themes")
|
||||
}
|
||||
|
||||
pub fn palettes_dir() -> PathBuf {
|
||||
runtime_bread_dir().join("palettes")
|
||||
}
|
||||
|
||||
pub fn output_css_path(output: &str) -> PathBuf {
|
||||
themes_dir().join(format!("{}.css", sanitize_output(output)))
|
||||
}
|
||||
|
||||
pub fn output_palette_path(output: &str) -> PathBuf {
|
||||
palettes_dir().join(format!("{}.json", sanitize_output(output)))
|
||||
}
|
||||
|
||||
fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(name) => path.with_file_name(format!("{name}.tmp")),
|
||||
None => path.with_extension("tmp"),
|
||||
};
|
||||
std::fs::write(&tmp, contents)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accents only — never persist pywal's light background/surface/overlay/fg.
|
||||
#[derive(Serialize)]
|
||||
struct StoredColors {
|
||||
color1: String,
|
||||
color2: String,
|
||||
color3: String,
|
||||
color4: String,
|
||||
color5: String,
|
||||
color6: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StoredPalette {
|
||||
colors: StoredColors,
|
||||
}
|
||||
|
||||
/// Parse on-disk JSON: wal `colors.json` shape, or a flat `{color1..color6}` object.
|
||||
/// Always forces FIXED background/foreground/color0/color7 via [`from_wal_json`].
|
||||
pub fn palette_from_json(json: &str) -> Option<Palette> {
|
||||
let value: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
if value
|
||||
.get("colors")
|
||||
.and_then(|c| c.as_object())
|
||||
.is_some_and(|o| !o.is_empty())
|
||||
{
|
||||
return from_wal_json(json);
|
||||
}
|
||||
if value.get("color1").is_some()
|
||||
|| value.get("color2").is_some()
|
||||
|| value.get("color3").is_some()
|
||||
|| value.get("color4").is_some()
|
||||
|| value.get("color5").is_some()
|
||||
|| value.get("color6").is_some()
|
||||
{
|
||||
let wrapped = serde_json::json!({ "colors": value });
|
||||
return from_wal_json(&wrapped.to_string());
|
||||
}
|
||||
from_wal_json(json)
|
||||
}
|
||||
|
||||
/// Load `palettes/<output>.json`; fall back to [`load_palette`].
|
||||
pub fn load_palette_for(output: &str) -> Palette {
|
||||
std::fs::read_to_string(output_palette_path(output))
|
||||
.ok()
|
||||
.and_then(|s| palette_from_json(&s))
|
||||
.unwrap_or_else(load_palette)
|
||||
}
|
||||
|
||||
pub fn write_output_palette(output: &str, palette: &Palette) -> std::io::Result<PathBuf> {
|
||||
let path = output_palette_path(output);
|
||||
let stored = StoredPalette {
|
||||
colors: StoredColors {
|
||||
color1: palette.color1.clone(),
|
||||
color2: palette.color2.clone(),
|
||||
color3: palette.color3.clone(),
|
||||
color4: palette.color4.clone(),
|
||||
color5: palette.color5.clone(),
|
||||
color6: palette.color6.clone(),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&stored)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
atomic_write(&path, &json)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn write_output_css(output: &str, palette: &Palette) -> std::io::Result<PathBuf> {
|
||||
let path = output_css_path(output);
|
||||
atomic_write(&path, &stylesheet(palette))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Like [`crate::write_shared_css`] but from an explicit palette.
|
||||
pub fn write_shared_css_from(palette: &Palette) -> std::io::Result<PathBuf> {
|
||||
let path = crate::shared_css_path();
|
||||
atomic_write(&path, &stylesheet(palette))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Isolated `wal -i <image> -n -q` with `XDG_CACHE_HOME` set to a unique temp
|
||||
/// dir so the user's `~/.cache/wal` is not clobbered.
|
||||
pub fn palette_from_image(path: &Path) -> std::io::Result<Palette> {
|
||||
let pid = std::process::id();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let tmp = std::env::temp_dir().join(format!("bread-theme-wal-{pid}-{nanos}"));
|
||||
std::fs::create_dir_all(&tmp)?;
|
||||
struct Rm(PathBuf);
|
||||
impl Drop for Rm {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
let _guard = Rm(tmp.clone());
|
||||
|
||||
// Classic pywal ignores XDG_CACHE_HOME and writes $HOME/.cache/wal.
|
||||
// Point HOME at the temp dir so a per-output extract cannot clobber
|
||||
// the session cache (or the other monitor's last `wal -i`).
|
||||
let status = match std::process::Command::new("wal")
|
||||
.arg("-i")
|
||||
.arg(path)
|
||||
.args(["-n", "-q"])
|
||||
.env("HOME", &tmp)
|
||||
.env("XDG_CACHE_HOME", tmp.join(".cache"))
|
||||
.status()
|
||||
{
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"wal is not installed",
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
Ok(s) => s,
|
||||
};
|
||||
if !status.success() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("wal failed with {status}"),
|
||||
));
|
||||
}
|
||||
|
||||
let json_path = [
|
||||
tmp.join(".cache").join("wal").join("colors.json"),
|
||||
tmp.join("wal").join("colors.json"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| p.is_file())
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"wal did not write colors.json under the isolated cache",
|
||||
)
|
||||
})?;
|
||||
let json = std::fs::read_to_string(&json_path)?;
|
||||
from_wal_json(&json).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"wal produced unparseable colors.json",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// [`palette_from_image`] + [`write_output_palette`] + [`write_output_css`].
|
||||
pub fn generate_output(output: &str, image: &Path) -> std::io::Result<PathBuf> {
|
||||
let palette = palette_from_image(image)?;
|
||||
write_output_palette(output, &palette)?;
|
||||
write_output_css(output, &palette)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) static XDG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::palette::{FIXED_BACKGROUND, FIXED_FOREGROUND, FIXED_OVERLAY, FIXED_SURFACE};
|
||||
|
||||
fn lock_xdg() -> std::sync::MutexGuard<'static, ()> {
|
||||
XDG_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn with_runtime_dir<T>(f: impl FnOnce(&Path) -> T) -> T {
|
||||
let _lock = lock_xdg();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"bread-theme-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let old = std::env::var("XDG_RUNTIME_DIR").ok();
|
||||
std::env::set_var("XDG_RUNTIME_DIR", &dir);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&dir)));
|
||||
match old {
|
||||
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
|
||||
None => std::env::remove_var("XDG_RUNTIME_DIR"),
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => std::panic::resume_unwind(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_output_keeps_hyprland_connectors() {
|
||||
assert_eq!(sanitize_output("HDMI-A-1"), "HDMI-A-1");
|
||||
assert_eq!(sanitize_output("eDP-1"), "eDP-1");
|
||||
assert_eq!(sanitize_output("DP-2"), "DP-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_output_replaces_unsafe_chars() {
|
||||
assert_eq!(sanitize_output("HDMI A:1"), "HDMI_A_1");
|
||||
assert_eq!(sanitize_output("foo/bar"), "foo_bar");
|
||||
assert_eq!(sanitize_output(""), "_");
|
||||
assert_eq!(sanitize_output("..ok_name-1"), "..ok_name-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_paths_use_sanitize_and_sit_under_dirs() {
|
||||
let _lock = lock_xdg();
|
||||
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
|
||||
let css = output_css_path("HDMI A:1");
|
||||
let pal = output_palette_path("HDMI A:1");
|
||||
assert_eq!(css, themes_dir().join("HDMI_A_1.css"));
|
||||
assert_eq!(pal, palettes_dir().join("HDMI_A_1.json"));
|
||||
assert!(css.starts_with(themes_dir()));
|
||||
assert!(pal.starts_with(palettes_dir()));
|
||||
assert_eq!(
|
||||
output_css_path("eDP-1"),
|
||||
PathBuf::from("/run/user/1234/bread/themes/eDP-1.css")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_palette_for_missing_file_has_fixed_bg() {
|
||||
with_runtime_dir(|_| {
|
||||
let p = load_palette_for("no-such-output");
|
||||
assert_eq!(p.background, FIXED_BACKGROUND);
|
||||
assert!(p.color4.starts_with('#'));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_output_palette_roundtrips_color4() {
|
||||
with_runtime_dir(|_| {
|
||||
let mut p = Palette::default();
|
||||
p.color4 = "#7aa2f7".into();
|
||||
p.background = "#ffffff".into();
|
||||
write_output_palette("HDMI-A-1", &p).unwrap();
|
||||
let loaded = load_palette_for("HDMI-A-1");
|
||||
assert_eq!(loaded.color4, "#7aa2f7");
|
||||
assert_eq!(loaded.background, FIXED_BACKGROUND);
|
||||
assert_eq!(loaded.foreground, FIXED_FOREGROUND);
|
||||
assert_eq!(loaded.color0, FIXED_SURFACE);
|
||||
assert_eq!(loaded.color7, FIXED_OVERLAY);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_shared_css_from_writes_shared_css_path() {
|
||||
with_runtime_dir(|rt| {
|
||||
let path = write_shared_css_from(&Palette::default()).unwrap();
|
||||
assert_eq!(path, crate::shared_css_path());
|
||||
assert_eq!(path, rt.join("bread").join("theme.css"));
|
||||
let css = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(css.contains("@define-color accent "));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_palette_for_accepts_flat_color_object() {
|
||||
with_runtime_dir(|_| {
|
||||
let path = output_palette_path("DP-1");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, r##"{"color4":"#112233","color1":"#abcdef"}"##).unwrap();
|
||||
let p = load_palette_for("DP-1");
|
||||
assert_eq!(p.color4, "#112233");
|
||||
assert_eq!(p.color1, "#abcdef");
|
||||
assert_eq!(p.background, FIXED_BACKGROUND);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -9,10 +9,10 @@ use std::path::PathBuf;
|
|||
/// 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";
|
||||
pub(crate) const FIXED_BACKGROUND: &str = "#0c0c0c";
|
||||
pub(crate) const FIXED_FOREGROUND: &str = "#e8e8e8";
|
||||
pub(crate) const FIXED_SURFACE: &str = "#1a1a1a";
|
||||
pub(crate) 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,
|
||||
|
|
@ -84,7 +84,10 @@ pub fn load_palette() -> Palette {
|
|||
pub(crate) fn from_wal_json(json: &str) -> Option<Palette> {
|
||||
let wal: WalColors = serde_json::from_str(json).ok()?;
|
||||
let c = |k: &str, fallback: &str| -> String {
|
||||
wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into())
|
||||
wal.colors
|
||||
.get(k)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback.into())
|
||||
};
|
||||
Some(Palette {
|
||||
background: FIXED_BACKGROUND.into(),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ 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 }
|
||||
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0", optional = true }
|
||||
|
||||
[features]
|
||||
# Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional
|
||||
|
|
|
|||
|
|
@ -14,10 +14,15 @@
|
|||
//!
|
||||
//! 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::emit`] and [`BreadClient::command`] are best-effort,
|
||||
//! fire-and-forget single-shot connections (mirroring `bread-emit`'s
|
||||
//! own stance) — if breadd is unreachable, the event is silently
|
||||
//! dropped, not an error the caller has to handle.
|
||||
//! - [`BreadClient::health`] / [`BreadClient::api_version`] return `None`
|
||||
//! when breadd is unreachable or the response is missing fields.
|
||||
//! Long-running daemons SHOULD log a warning in that case and MUST NOT
|
||||
//! crash. [`BreadClient::connect`] never fails just because breadd is
|
||||
//! down — do not change that.
|
||||
//! - [`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
|
||||
|
|
@ -29,6 +34,15 @@
|
|||
//! 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.
|
||||
//!
|
||||
//! `command` is the outbound half of the same story: it publishes
|
||||
//! `bread.command.<target_app>.<verb>` as an **unsourced** IPC emit
|
||||
//! (`params` is `{ event, data }` only — no `source`/`kind`). The
|
||||
//! daemon treats unsourced `bread.command.<known_app>.*` as legal so a
|
||||
//! sibling can address another app without impersonating that app's
|
||||
//! own namespace. Local refusal (eprint + return, same stance as
|
||||
//! `emit`) if `target_app` or `verb` is empty, or if `verb` contains
|
||||
//! `.` (a command verb is a single segment).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::Shutdown;
|
||||
|
|
@ -94,25 +108,104 @@ impl BreadClient {
|
|||
return;
|
||||
}
|
||||
|
||||
fire_and_forget_emit(json!({
|
||||
"event": event,
|
||||
"source": self.app_id,
|
||||
"kind": event,
|
||||
"data": data,
|
||||
}));
|
||||
}
|
||||
|
||||
/// Publish `bread.command.<target_app>.<verb>` as an unsourced IPC
|
||||
/// emit so another bread app (or a Lua workflow) can act on it.
|
||||
///
|
||||
/// Fire-and-forget: same silent-if-down stance as [`emit`]. Locally
|
||||
/// refuses (eprint + return, no socket) if `target_app` or `verb` is
|
||||
/// empty, or if `verb` contains `.` — a verb is one segment
|
||||
/// (`clear`, not `history.clear`).
|
||||
///
|
||||
/// The wire payload is `{ method: "emit", params: { event, data } }`
|
||||
/// with **no** `source`/`kind`. Do not add those: a sourced emit
|
||||
/// would have to claim the *target's* namespace (or ours), and the
|
||||
/// daemon half of this integration is specifically making unsourced
|
||||
/// `bread.command.<known_app>.*` legal.
|
||||
pub fn command(&self, target_app: &str, verb: &str, data: Value) {
|
||||
if target_app.is_empty() || verb.is_empty() || verb.contains('.') {
|
||||
eprintln!(
|
||||
"bread-client: refusing to send command to '{target_app}' with verb '{verb}' \
|
||||
(target and verb must be non-empty; verb must be a single segment)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let event = format!("bread.command.{target_app}.{verb}");
|
||||
fire_and_forget_emit(json!({
|
||||
"event": event,
|
||||
"data": data,
|
||||
}));
|
||||
}
|
||||
|
||||
/// One-shot `health` IPC request. `None` if breadd is unreachable or
|
||||
/// the response is malformed / an error.
|
||||
///
|
||||
/// Long-running daemons SHOULD log a warning when this returns
|
||||
/// `None` (or when [`api_version`] is missing) and MUST NOT crash.
|
||||
pub fn health(&self) -> Option<Value> {
|
||||
self.request("health", json!({}))
|
||||
}
|
||||
|
||||
/// `api_version` string from [`health`], or `None` if health failed
|
||||
/// or the field is absent. Same SHOULD-warn / MUST-NOT-crash rule
|
||||
/// as [`health`].
|
||||
pub fn api_version(&self) -> Option<String> {
|
||||
self.health()?
|
||||
.get("api_version")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Send a one-shot IPC request and return its `result`, or `None` on any
|
||||
/// failure — breadd unreachable, a malformed response, or an `error`
|
||||
/// field in the response. Mirrors `emit`'s graceful-degradation stance:
|
||||
/// a caller checks for `None` the same way it'd handle "daemon not
|
||||
/// installed," not via a `Result` that forces error-path plumbing for
|
||||
/// what is, for most callers (a refresh-on-connect read), an expected
|
||||
/// possibility rather than an exceptional one.
|
||||
///
|
||||
/// Unlike `emit`, this is not restricted to the client's own namespace —
|
||||
/// `method`/`params` map directly onto breadd's IPC method table (see
|
||||
/// `Documentation.md`'s "Dictionary: IPC protocol"), most of which
|
||||
/// (`state.get`, `widgets.list`, ...) are cross-namespace reads by
|
||||
/// design. Only first-party compiled code links `bread-utils`, so this
|
||||
/// carries the same trust level as `emit`'s own request construction.
|
||||
pub fn request(&self, method: &str, params: Value) -> Option<Value> {
|
||||
let request = json!({
|
||||
"id": "0",
|
||||
"method": "emit",
|
||||
"params": {
|
||||
"event": event,
|
||||
"source": self.app_id,
|
||||
"kind": event,
|
||||
"data": data,
|
||||
}
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
let Ok(line) = serde_json::to_string(&request) else {
|
||||
return;
|
||||
};
|
||||
let line = serde_json::to_string(&request).ok()?;
|
||||
|
||||
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}");
|
||||
let mut stream = UnixStream::connect(bread_shared::resolve_socket_path()).ok()?;
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_millis(200)))
|
||||
.ok()?;
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_millis(500)))
|
||||
.ok()?;
|
||||
writeln!(stream, "{line}").ok()?;
|
||||
|
||||
let mut response_line = String::new();
|
||||
BufReader::new(stream).read_line(&mut response_line).ok()?;
|
||||
if response_line.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let value: Value = serde_json::from_str(&response_line).ok()?;
|
||||
if value.get("error").is_some() {
|
||||
return None;
|
||||
}
|
||||
value.get("result").cloned()
|
||||
}
|
||||
|
||||
/// Subscribe to events matching `pattern` (glob: `*`/`**`/`?`), invoking
|
||||
|
|
@ -161,6 +254,27 @@ impl BreadClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// Fire-and-forget a single `emit` request. Shared by sourced [`BreadClient::emit`]
|
||||
/// and unsourced [`BreadClient::command`] so the write/timeout path cannot
|
||||
/// drift. Silent if the socket is missing, the write fails, or the body
|
||||
/// cannot be serialized.
|
||||
fn fire_and_forget_emit(params: Value) {
|
||||
let request = json!({
|
||||
"id": "0",
|
||||
"method": "emit",
|
||||
"params": params,
|
||||
});
|
||||
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}");
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -290,6 +404,83 @@ mod tests {
|
|||
// integration tests for the IPC-side of namespace validation.
|
||||
}
|
||||
|
||||
/// Point `HOME` + `XDG_RUNTIME_DIR` at an empty temp dir so
|
||||
/// `resolve_socket_path` cannot find a live breadd (either via
|
||||
/// `~/.config/bread/breadd.toml` or `$XDG_RUNTIME_DIR/bread/breadd.sock`).
|
||||
/// Serialized with the other env-mutating tests via `env_test_lock`.
|
||||
fn with_unreachable_daemon<T>(f: impl FnOnce() -> T) -> T {
|
||||
let _lock = crate::env_test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old_home = std::env::var("HOME").ok();
|
||||
let old_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
|
||||
unsafe {
|
||||
std::env::set_var("HOME", tmp.path());
|
||||
std::env::set_var("XDG_RUNTIME_DIR", tmp.path());
|
||||
}
|
||||
struct Restore(Option<String>, Option<String>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
match &self.0 {
|
||||
Some(v) => std::env::set_var("HOME", v),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
match &self.1 {
|
||||
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
|
||||
None => std::env::remove_var("XDG_RUNTIME_DIR"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _restore = Restore(old_home, old_xdg);
|
||||
f()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_returns_none_when_daemon_is_unreachable() {
|
||||
with_unreachable_daemon(|| {
|
||||
let client = BreadClient::connect("clip");
|
||||
assert!(client.request("widgets.list", json!(null)).is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_refuses_empty_target_without_connecting() {
|
||||
let client = BreadClient::connect("clip");
|
||||
client.command("", "clear", json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_refuses_empty_verb_without_connecting() {
|
||||
let client = BreadClient::connect("clip");
|
||||
client.command("clip", "", json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_refuses_dotted_verb_without_connecting() {
|
||||
// A verb is a single segment — `history.clear` would produce
|
||||
// `bread.command.clip.history.clear`, which is two verb segments.
|
||||
let client = BreadClient::connect("clip");
|
||||
client.command("clip", "history.clear", json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_is_a_silent_no_op_when_daemon_is_unreachable() {
|
||||
let client = BreadClient::connect("clip");
|
||||
client.command("clip", "clear", json!({ "n": 1 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_and_api_version_return_none_when_daemon_is_unreachable() {
|
||||
with_unreachable_daemon(|| {
|
||||
let client = BreadClient::connect("clip");
|
||||
assert!(client.health().is_none());
|
||||
assert!(client.api_version().is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscription_stop_joins_the_background_thread() {
|
||||
let client = BreadClient::connect("clip");
|
||||
|
|
|
|||
|
|
@ -19,12 +19,18 @@
|
|||
//! - [`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.
|
||||
//! client for breadd's IPC socket (`emit`, unsourced `command`,
|
||||
//! `health`/`api_version`, `subscribe`), for sibling `bread*` app
|
||||
//! daemons integrating with the bread automation fabric.
|
||||
//! - [`screenshot_cli`] — shared `--screenshot` / `--output` /
|
||||
//! `--width` / `--height` values, `SETTLE_DELAY` (300ms), and the
|
||||
//! "both flags or neither" validator. Next-pin helper; no clap/GTK
|
||||
//! dependency. Does not replace `bread-screenshots` or `bread-capture`.
|
||||
|
||||
pub mod atomic;
|
||||
pub mod hypr;
|
||||
pub mod proc;
|
||||
pub mod screenshot_cli;
|
||||
pub mod singleton;
|
||||
pub mod xdg;
|
||||
|
||||
|
|
|
|||
156
bread-utils/src/screenshot_cli.rs
Normal file
156
bread-utils/src/screenshot_cli.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
//! Shared `--screenshot` CLI flags and the post-`map` settle delay used by
|
||||
//! bread-capture-driven GTK apps.
|
||||
//!
|
||||
//! The same clap block (`--screenshot`, `--output`, `--width`, `--height`)
|
||||
//! plus a 300ms settle after GTK `map` is cloned across breadbar, breadbox,
|
||||
//! breadclip, breadpad, breadman, breadsearch, and breadhelp. This module
|
||||
//! is the next-pin target for that duplication — consumers this cycle still
|
||||
//! pin an older bread-utils tag and will not see it until a future release.
|
||||
//!
|
||||
//! Zero extra deps: no clap, no GTK. Apps keep (or flatten) the four `#[arg]`
|
||||
//! fields themselves and call [`validate_pair`] / [`SETTLE_DELAY`].
|
||||
//!
|
||||
//! Confirmed present in:
|
||||
//! - `breadbar/src/screenshot.rs`
|
||||
//! - `breadbox/breadbox/src/screenshot.rs`
|
||||
//! - `breadclip/breadclip/src/screenshot.rs`
|
||||
//! - `breadsearch/breadsearch/src/screenshot.rs`
|
||||
//! - `breadpad/breadpad/src/screenshot.rs`
|
||||
//! - `breadpad/breadman/src/screenshot.rs`
|
||||
//! - `breadhelp/src/screenshot.rs`
|
||||
//!
|
||||
//! Do not fold `bread-screenshots` or `bread-capture`'s `TARGETS` table into
|
||||
//! this module — those are the capture primitive and the orchestrator, not
|
||||
//! the per-app CLI flags.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Extra settle time after GTK `map` for the first frame to actually paint
|
||||
/// before grim runs. `map` fires once the surface exists, not once anything
|
||||
/// has been drawn into it.
|
||||
pub const SETTLE_DELAY: Duration = Duration::from_millis(300);
|
||||
|
||||
/// Default `--width`, matching `bread-capture --isolate-width`.
|
||||
pub const DEFAULT_WIDTH: u32 = 1920;
|
||||
|
||||
/// Default `--height`, matching `bread-capture --isolate-height`.
|
||||
pub const DEFAULT_HEIGHT: u32 = 1080;
|
||||
|
||||
/// The four `--screenshot` / `--output` / `--width` / `--height` values
|
||||
/// parsed from an app's CLI.
|
||||
///
|
||||
/// `screenshot` and `output` must both be present (a capture run) or both
|
||||
/// absent (a normal run) — see [`ScreenshotCli::validate`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScreenshotCli {
|
||||
/// Named view to capture (`--screenshot`). `None` for a normal run.
|
||||
pub screenshot: Option<String>,
|
||||
/// PNG path to write (`--output`). Required together with `screenshot`.
|
||||
pub output: Option<PathBuf>,
|
||||
/// Capture canvas width (`--width`).
|
||||
pub width: u32,
|
||||
/// Capture canvas height (`--height`).
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl Default for ScreenshotCli {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
screenshot: None,
|
||||
output: None,
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a screenshot-flag pair is invalid.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScreenshotCliError {
|
||||
/// `--screenshot` was given without `--output`.
|
||||
ScreenshotWithoutOutput,
|
||||
/// `--output` was given without `--screenshot`.
|
||||
OutputWithoutScreenshot,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ScreenshotCliError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ScreenshotWithoutOutput => write!(f, "--screenshot requires --output"),
|
||||
Self::OutputWithoutScreenshot => write!(f, "--output requires --screenshot"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ScreenshotCliError {}
|
||||
|
||||
/// Both `--screenshot` and `--output` must be present, or neither.
|
||||
pub fn validate_pair(
|
||||
screenshot: Option<&str>,
|
||||
output: Option<&Path>,
|
||||
) -> Result<(), ScreenshotCliError> {
|
||||
match (screenshot, output) {
|
||||
(Some(_), Some(_)) | (None, None) => Ok(()),
|
||||
(Some(_), None) => Err(ScreenshotCliError::ScreenshotWithoutOutput),
|
||||
(None, Some(_)) => Err(ScreenshotCliError::OutputWithoutScreenshot),
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreenshotCli {
|
||||
/// Both `screenshot` and `output` present, or neither.
|
||||
pub fn validate(&self) -> Result<(), ScreenshotCliError> {
|
||||
validate_pair(self.screenshot.as_deref(), self.output.as_deref())
|
||||
}
|
||||
|
||||
/// `true` when this is a capture run (both flags present).
|
||||
pub fn is_screenshot_run(&self) -> bool {
|
||||
self.screenshot.is_some() && self.output.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settle_delay_is_300ms() {
|
||||
assert_eq!(SETTLE_DELAY, Duration::from_millis(300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neither_flag_is_ok() {
|
||||
assert!(validate_pair(None, None).is_ok());
|
||||
assert!(ScreenshotCli::default().validate().is_ok());
|
||||
assert!(!ScreenshotCli::default().is_screenshot_run());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_flags_are_ok() {
|
||||
let cli = ScreenshotCli {
|
||||
screenshot: Some("search".into()),
|
||||
output: Some(PathBuf::from("/tmp/out.png")),
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
};
|
||||
assert!(cli.validate().is_ok());
|
||||
assert!(cli.is_screenshot_run());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_without_output_is_an_error() {
|
||||
assert_eq!(
|
||||
validate_pair(Some("search"), None),
|
||||
Err(ScreenshotCliError::ScreenshotWithoutOutput)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_without_screenshot_is_an_error() {
|
||||
let path = PathBuf::from("/tmp/out.png");
|
||||
assert_eq!(
|
||||
validate_pair(None, Some(path.as_path())),
|
||||
Err(ScreenshotCliError::OutputWithoutScreenshot)
|
||||
);
|
||||
}
|
||||
}
|
||||
30
ci/Containerfile
Normal file
30
ci/Containerfile
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Shared CI build environment for bread-ecosystem GTK4/libadwaita apps.
|
||||
#
|
||||
# Arch base: current gtk4/libadwaita/gtk4-layer-shell/graphene are all
|
||||
# available as prebuilt pacman packages, so no from-source library builds
|
||||
# are needed (unlike Fedora, where breadpad's CI used to rebuild libadwaita
|
||||
# from source on every single run and broke repeatedly on version drift).
|
||||
#
|
||||
# Base image pinned by digest, package set frozen at build time: this image
|
||||
# only changes when someone deliberately rebuilds it, not on every push.
|
||||
# Product repos that depend on this file should pin it to a commit sha
|
||||
# (see each product's ci/bread-ecosystem.rev), not track `main` — otherwise
|
||||
# an unrelated change here silently breaks every product's next release.
|
||||
#
|
||||
# EXTRA_PKGS lets a product layer on extra pacman packages (see that
|
||||
# product's ci/deps.txt) without forking this file.
|
||||
FROM archlinux@sha256:fae033b815a16f930325c2697e620362be4d2e5d739a301b10ad1fc9c8643a06
|
||||
|
||||
ARG EXTRA_PKGS=""
|
||||
|
||||
RUN pacman -Syu --noconfirm --needed \
|
||||
base-devel \
|
||||
git \
|
||||
pkgconf \
|
||||
rust \
|
||||
gtk4 \
|
||||
libadwaita \
|
||||
gtk4-layer-shell \
|
||||
graphene \
|
||||
${EXTRA_PKGS} \
|
||||
&& pacman -Scc --noconfirm
|
||||
60
ci/build.sh
Executable file
60
ci/build.sh
Executable file
|
|
@ -0,0 +1,60 @@
|
|||
#!/usr/bin/env bash
|
||||
# Shared CI build script for bread-ecosystem GTK4/libadwaita apps.
|
||||
#
|
||||
# Builds (or reuses, via docker's own layer cache) the pinned Arch image
|
||||
# from ci/Containerfile, then runs the given cargo command inside it
|
||||
# against a product repo checkout.
|
||||
#
|
||||
# Usage: ci/build.sh <product-name> <product-repo-root> <cargo-command...>
|
||||
# e.g. ci/build.sh breadpad /path/to/breadpad cargo build --release --locked
|
||||
#
|
||||
# <product-name> is used verbatim as the image tag and cache-volume name —
|
||||
# it must be passed explicitly rather than derived from <product-repo-root>'s
|
||||
# basename, because every product's CI checks out into a directory literally
|
||||
# named `src`, which would otherwise collide across every product sharing
|
||||
# this runner (same image tag, same cargo-target cache volume).
|
||||
#
|
||||
# If <product-repo-root>/ci/deps.txt exists (one pacman package per line,
|
||||
# '#' comments and blank lines ignored), those packages are installed on
|
||||
# top of the shared base image.
|
||||
#
|
||||
# Cargo's registry/git caches are shared across all products (same crates
|
||||
# regardless of which app is building); CARGO_TARGET_DIR is cached
|
||||
# per-product. Both persist in named docker volumes across runs.
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 3 ]; then
|
||||
echo "usage: build.sh <product-name> <product-repo-root> <cargo-command...>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PRODUCT="$1"
|
||||
REPO_ROOT="$(cd "$2" && pwd)"
|
||||
shift 2
|
||||
|
||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
EXTRA_PKGS=""
|
||||
if [ -f "${REPO_ROOT}/ci/deps.txt" ]; then
|
||||
EXTRA_PKGS="$(grep -vE '^\s*(#|$)' "${REPO_ROOT}/ci/deps.txt" | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
docker build \
|
||||
--build-arg "EXTRA_PKGS=${EXTRA_PKGS}" \
|
||||
-t "bread-ci:${PRODUCT}" \
|
||||
-f "${CI_DIR}/Containerfile" "${CI_DIR}"
|
||||
|
||||
docker run --rm \
|
||||
-v "${REPO_ROOT}:/workspace" \
|
||||
-v "bread-ci-cargo-registry:/root/.cargo/registry" \
|
||||
-v "bread-ci-cargo-git:/root/.cargo/git" \
|
||||
-v "bread-ci-${PRODUCT}-target:/cargo-target" \
|
||||
-w /workspace \
|
||||
-e CARGO_TARGET_DIR=/cargo-target \
|
||||
"bread-ci:${PRODUCT}" \
|
||||
bash -c '
|
||||
set -euo pipefail
|
||||
"$@"
|
||||
mkdir -p /workspace/target
|
||||
cp -a /cargo-target/. /workspace/target/
|
||||
' bash "$@"
|
||||
|
|
@ -38,18 +38,107 @@ only if:
|
|||
`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).
|
||||
A repo can be on **both** channels, **bakery only** (bread, breadbar,
|
||||
breadbox, breadcrumbs, breadpad, breadpaper, breadclip, breadmon,
|
||||
breadsearch, breadshot, breadhelp, bos-settings, bread-theme, breadcast,
|
||||
breadarr, bakery itself), **pacman only** (breadlock — installs a
|
||||
root-owned `/etc/pam.d/breadlock` PAM service file with no per-user
|
||||
equivalent, so it can never move to bakery), or **neither** (dev-only /
|
||||
not yet released). Desktop apps dropped pacman packaging; bakery-channel
|
||||
install is the supported path. `bakery` still carries a leftover
|
||||
`package.yml` / `packaging/arch/PKGBUILD` from when it was also published
|
||||
to the `[breadway]` pacman repo.
|
||||
|
||||
`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`, `beta`, and `dev`. 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.
|
||||
|
||||
There is no per-track branch anymore — every bakery-channel repo has exactly
|
||||
one long-lived branch, `main`. Tracks are driven entirely by *what you push*,
|
||||
not *which branch you push to*:
|
||||
|
||||
| Track | Index URL | Artifact root | Trigger |
|
||||
|---|---|---|---|
|
||||
| stable | `dl.breadway.dev/index.json` | `/srv/breadway-dl/<pkg>/<ver>/` | push tag `vX.Y.Z` |
|
||||
| beta | `dl.breadway.dev/beta/index.json` | `/srv/breadway-dl/beta/<pkg>/<ver>/` | push tag `vX.Y.Z-rc.N` |
|
||||
| dev | `dl.breadway.dev/dev/index.json` | `/srv/breadway-dl/dev/<pkg>/<ver>/` | push to branch `main` |
|
||||
|
||||
`scripts/gen-index.sh` takes a `TRACK` env var (default `stable`) to select
|
||||
which subtree it reads/writes — this didn't need to change. Dev/beta builds
|
||||
skip the GitHub Release upload step entirely (no release-per-commit spam) —
|
||||
`dl.breadway.dev` is their only distribution point.
|
||||
|
||||
**Why no beta/dev branches**: the old model had `dev`/`beta`/`main` as three
|
||||
separate branches, with `beta` cut from `dev` periodically and `main`
|
||||
supposed to move forward only via a `beta` merge. In practice `main` rotted
|
||||
silently in most repos — the "merge beta into main" step was a manual,
|
||||
easy-to-forget action across a dozen-plus repos with no team and no
|
||||
calendar enforcement, and it also collided with a real Forgejo Actions
|
||||
gotcha: tag-triggered workflows resolve *which version of the workflow
|
||||
YAML to run* from the repo's default branch, not the tagged commit's
|
||||
branch, so a stale `main` could silently run stale release logic even when
|
||||
the tag itself pointed at fresh code. Collapsing everything onto one
|
||||
branch removes the class of bug entirely — there's nothing left to fall
|
||||
out of sync.
|
||||
|
||||
**The full lifecycle** (see also `CONTRIBUTING.md`): day-to-day work lands
|
||||
on `feature/<name>` or `fix/<issue>` branches, merged into `main`. `main`
|
||||
publishes a fresh dev-track build on every push — this is the "test for a
|
||||
while, fix forward with another push" loop. When you want to stabilize
|
||||
before a real release, tag a release candidate directly off whatever
|
||||
commit on `main` you're happy with: `git tag vX.Y.Z-rc.1 && git push
|
||||
origin vX.Y.Z-rc.1` (both remotes). "Freezing" is just pausing pushes to
|
||||
`main` while the RC gets tested, not a branch operation — cut `-rc.2`,
|
||||
`-rc.3`, etc. for further fixes without needing to touch any branch. Once
|
||||
an RC has gone without issues, tag the real release the same way, dropping
|
||||
the `-rc.N` suffix (`vX.Y.Z`) — that's what fires `release.yml`.
|
||||
|
||||
Auto-versioning: `dev` computes its build version from the latest published
|
||||
*stable* `vX.Y.Z` tag (via `git ls-remote --tags`, filtered to exclude any
|
||||
tag containing a `-`, not `Cargo.toml` — `Cargo.toml` can drift stale
|
||||
relative to the actual last release) plus a `-dev.<timestamp>+<sha>`
|
||||
suffix. `beta` needs no computation at all — the RC tag itself
|
||||
(`X.Y.Z-rc.N`) is already valid semver and is used as the version verbatim.
|
||||
`bakery`'s semver check (`is_newer`), backed by the real `semver` crate,
|
||||
already orders these correctly with zero special-casing: a prerelease
|
||||
identifier sorts below the same version without one, and `dev` < `rc`
|
||||
alphabetically, giving `X.Y.Z-dev... < X.Y.Z-rc.N < X.Y.Z` for the same
|
||||
base version.
|
||||
|
||||
**Bakery package version honesty**: `bakery --version` is compiled from
|
||||
this repo's `[workspace.package] version` (`CARGO_PKG_VERSION`); `bakery
|
||||
list` reports the *tagged* package version from the index. Those two must
|
||||
match at tag time — set `workspace.package.version` to `X.Y.Z` *before*
|
||||
pushing `vX.Y.Z` or `vX.Y.Z-rc.N`, and never jump a git tag without that
|
||||
Cargo.toml bump. The `v0.3.1` → `v0.7.1` tag jump that left Cargo.toml at
|
||||
`0.3.1` is the bug this rule exists to prevent. Dev-track auto-versioning
|
||||
keys off the latest stable tag rather than Cargo.toml so a stale
|
||||
workspace version cannot publish a dev build that sorts *older* than
|
||||
installed bakery; that fallback is not permission to leave the workspace
|
||||
version stale.
|
||||
|
||||
Adding dev/beta to a bakery-channel repo: copy `dev-bakery.yml` /
|
||||
`rc-bakery.yml` (or `bread`'s `dev-release.yml` / `rc-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`. No branch setup needed beyond the repo's single `main`. 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
|
||||
|
|
@ -66,10 +155,11 @@ missing it; that gap is intentional and about to be moot everywhere.
|
|||
|
||||
- **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`.
|
||||
`dev-release.yml` / `rc-release.yml` / `release.yml` trio (prefer one with
|
||||
the same shape: single binary vs. binary + systemd service — compare
|
||||
against `bread`'s if there's a service to install, `breadmon`'s if not)
|
||||
and swap the repo name / binary name / `PKG_DIR`. No branch setup beyond
|
||||
the repo's single `main`.
|
||||
- **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.
|
||||
|
|
@ -82,13 +172,21 @@ missing it; that gap is intentional and about to be moot everywhere.
|
|||
|
||||
## Current state (as of this pass)
|
||||
|
||||
| Repo | bakery | pacman | notes |
|
||||
|---|---|---|---|
|
||||
| bread-ecosystem (bakery product) | yes | yes | `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 | |
|
||||
| bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | yes | complete, used as templates |
|
||||
| breadclip, breadmon, breadsearch, breadshot | yes | no | complete |
|
||||
| breadlock, breadhelp | no | yes | breadlock's `bakery.toml` was removed as orphaned; its README wrongly claimed it was a registry entry |
|
||||
| bos-settings | yes | yes | was missing both the registry entry and `release.yml`; both added |
|
||||
| bos | no | no | ISO-only via `release-iso.yml`; had an erroneous `bakery.toml` copy-pasted from bos-settings, removed |
|
||||
| breadarr | no | no | 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 |
|
||||
| Repo | bakery | pacman | tracks | notes |
|
||||
|---|---|---|---|---|
|
||||
| bread-ecosystem (bakery product) | yes | leftover `package.yml` | stable, beta, dev | bakery-channel (`curl -fsSL https://get.breadway.dev \| sh`) is the supported install; `package.yml` + `packaging/arch/PKGBUILD` remain from when bakery was also published to `[breadway]` |
|
||||
| bread-ecosystem (bread-theme product) | yes | no | stable, beta, dev | single-trunk model |
|
||||
| bread, breadbar, breadbox, breadcrumbs, breadpad, breadpaper | yes | no | stable, beta, dev | pacman packaging (PKGBUILD + `package.yml`) dropped — bakery-only, single-trunk model |
|
||||
| breadclip, breadmon, breadsearch, breadshot | yes | no | stable, beta, dev | single-trunk model |
|
||||
| breadhelp, bos-settings | yes | no | stable, beta, dev | bakery-channel desktop/settings apps; not pacman |
|
||||
| breadlock | no | yes | n/a | deliberate, permanent exception — installs a root-owned `/etc/pam.d/breadlock` PAM service file with no per-user equivalent, so it can never move to bakery |
|
||||
| bos | no | no | n/a | ISO-only via `release-iso.yml`; ships via a manual local build (`build-local.sh`), not a CI track — see its own branch note below |
|
||||
| breadcast | yes | no | stable, beta, dev | bakery product; not included in the BOS ISO |
|
||||
| breadarr | yes | no | stable, beta, dev | bakery product; homelab, not shipped on BOS |
|
||||
|
||||
`bos` doesn't follow the tracks table above (it has no `dev`/`beta`/`stable`
|
||||
publish cadence — ISO builds are deliberate and manual) but does share the
|
||||
single-`main`-branch model for the same rot-avoidance reason. It additionally
|
||||
carries a `stable` branch that CI fast-forwards to whatever commit the latest
|
||||
`vX.Y.Z` tag points at — a marker only, never merged into by hand, so it
|
||||
can't drift the way a manually-promoted branch did before.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# Maintainer: Breadway <plasticbread849@gmail.com>
|
||||
|
||||
pkgname=bakery
|
||||
# Template only — package.yml sed-replaces this from the git tag at build
|
||||
# time. 0.2.3 is not the current bakery release.
|
||||
pkgver=0.2.3
|
||||
pkgrel=1
|
||||
pkgdesc="Package manager for the bread ecosystem"
|
||||
|
|
|
|||
|
|
@ -71,3 +71,20 @@ description = "Screenshot utility for the bread ecosystem"
|
|||
name = "bos-settings"
|
||||
repo = "Breadway/bos-settings"
|
||||
description = "System settings app for Bread OS"
|
||||
|
||||
[[products]]
|
||||
name = "breadhelp"
|
||||
repo = "Breadway/breadhelp"
|
||||
description = "Onboarding and help center for Bread OS"
|
||||
|
||||
[[products]]
|
||||
name = "breadcast"
|
||||
repo = "Breadway/breadcast"
|
||||
description = "Cast your screen to any Chromecast/Google TV or DLNA renderer — daemon + GTK4 popup"
|
||||
notes = "Bakery product; not included in the BOS ISO"
|
||||
|
||||
[[products]]
|
||||
name = "breadarr"
|
||||
repo = "Breadway/breadarr"
|
||||
description = "Single-daemon Sonarr+Radarr+Prowlarr replacement — release watching, matching, grabbing, importing, and a terminal UI, no web UI"
|
||||
notes = "Homelab, not shipped on BOS"
|
||||
|
|
|
|||
|
|
@ -14,9 +14,20 @@
|
|||
#
|
||||
# scripts/doctor-channels.sh ~/Projects
|
||||
#
|
||||
# Also checks every registry product's Forgejo repo for the
|
||||
# BAKERY_MINISIGN_SEC_KEY_PATH Actions secret (via the Forgejo API) — a
|
||||
# split-out product repo silently missing this secret is exactly the gotcha
|
||||
# that bit breadcast's onboarding: its release workflows would either fail
|
||||
# the hard-fail guard (dev/rc-style workflows) or, worse, publish unsigned
|
||||
# (older release.yml-style workflows without that guard). Skipped with a
|
||||
# warning (not a failure) if ~/.config/forgejo/token doesn't exist, so this
|
||||
# still runs for anyone without API access — set SKIP_SECRETS_CHECK=1 to
|
||||
# skip it deliberately (e.g. offline).
|
||||
#
|
||||
# Exits 0 if no drift found, 1 if any repo has drift (so it's CI-friendly).
|
||||
#
|
||||
# Requires: python3 (tomllib, stdlib since 3.11)
|
||||
# Requires: python3 (tomllib, stdlib since 3.11); curl + a Forgejo token at
|
||||
# ~/.config/forgejo/token for the secrets check (soft-skipped without one).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -72,7 +83,10 @@ for dir in "${BASE_DIR}"/*/; do
|
|||
# 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
|
||||
# Prefix match, not exact: bread-ecosystem-breadcast, bread-ecosystem-onboard,
|
||||
# etc. are all worktree checkouts of this same multi-product repo, not
|
||||
# separate products the registry-membership check below applies to.
|
||||
[[ "${name}" == bread-ecosystem* ]] && continue
|
||||
|
||||
checked=$((checked + 1))
|
||||
has_bakery_toml=0
|
||||
|
|
@ -125,6 +139,55 @@ done
|
|||
|
||||
echo
|
||||
echo "checked ${checked} repos under ${BASE_DIR}"
|
||||
|
||||
# --- Secrets check ---------------------------------------------------------
|
||||
TOKEN_FILE="${HOME}/.config/forgejo/token"
|
||||
if [[ -n "${SKIP_SECRETS_CHECK:-}" ]]; then
|
||||
echo "skipping secrets check (SKIP_SECRETS_CHECK set)"
|
||||
elif [[ ! -f "${TOKEN_FILE}" ]]; then
|
||||
echo "skipping secrets check (no token at ${TOKEN_FILE})"
|
||||
else
|
||||
TOKEN="$(cat "${TOKEN_FILE}")"
|
||||
FORGEJO_API="https://git.breadway.dev/api/v1"
|
||||
|
||||
# Full "owner/repo" slugs, deduplicated (bakery + bread-theme both point
|
||||
# at Breadway/bread-ecosystem, checking it twice is wasted API calls).
|
||||
mapfile -t registry_slugs < <(python3 -c "
|
||||
import tomllib
|
||||
with open('${REGISTRY}', 'rb') as f:
|
||||
d = tomllib.load(f)
|
||||
seen = set()
|
||||
for p in d['products']:
|
||||
if p['repo'] not in seen:
|
||||
seen.add(p['repo'])
|
||||
print(p['repo'])
|
||||
")
|
||||
|
||||
secrets_missing=0
|
||||
for slug in "${registry_slugs[@]}"; do
|
||||
has_key="$(curl -s -H "Authorization: token ${TOKEN}" \
|
||||
"${FORGEJO_API}/repos/${slug}/actions/secrets" \
|
||||
| python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
secrets = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
secrets = []
|
||||
print(1 if any(s.get('name') == 'BAKERY_MINISIGN_SEC_KEY_PATH' for s in secrets) else 0)
|
||||
" 2>/dev/null || echo 0)"
|
||||
if [[ "${has_key}" != 1 ]]; then
|
||||
echo "${slug}: missing BAKERY_MINISIGN_SEC_KEY_PATH Actions secret — release CI will fail closed (or worse, publish unsigned on an older workflow shape) until it's set"
|
||||
secrets_missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${secrets_missing}" == 0 ]]; then
|
||||
echo "no missing signing secrets found across ${#registry_slugs[@]} registry repos"
|
||||
else
|
||||
drift=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${drift}" == 0 ]]; then
|
||||
echo "no channel drift found"
|
||||
else
|
||||
|
|
|
|||
|
|
@ -1,19 +1,38 @@
|
|||
#!/usr/bin/env bash
|
||||
# Generate dl.breadway.dev/index.json from:
|
||||
# - registry/bread-ecosystem.toml (product list)
|
||||
# - <DL_DIR>/<name>/bakery.toml (per-product metadata, uploaded by release.yml)
|
||||
# - <DL_DIR>/ (built binaries + sha256 files)
|
||||
# Generate dl.breadway.dev/index.json (or a track-prefixed sibling — see
|
||||
# TRACK below) from:
|
||||
# - registry/bread-ecosystem.toml (product list)
|
||||
# - <PKG_ROOT>/<name>/bakery.toml (per-product metadata, uploaded by release.yml)
|
||||
# - <PKG_ROOT>/ (built binaries + sha256 files)
|
||||
#
|
||||
# Fallback for local dev: looks for ../name/bakery.toml (sibling repo checkout).
|
||||
# Run on hestia after each product build, before the dl server is refreshed.
|
||||
#
|
||||
# TRACK selects which build track to generate an index for: "stable"
|
||||
# (default — reads/writes DL_DIR directly, byte-for-byte the same behavior
|
||||
# as before tracks existed), "beta", or "dev" (both read/write a
|
||||
# DL_DIR/<track>/ subtree, so they never collide with stable's paths). A
|
||||
# product with no release dir under the selected track's tree is skipped
|
||||
# with a warning, same as an unreleased product is today — most products
|
||||
# won't have a beta/dev build for a while after this lands.
|
||||
# Requires: jq, python3 (tomllib, stdlib since 3.11), sha256sum
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
DL_DIR="${DL_DIR:-/srv/breadway-dl}"
|
||||
DL_BASE="${DL_BASE:-https://dl.breadway.dev}"
|
||||
TRACK="${TRACK:-stable}"
|
||||
GH_BASE="https://github.com"
|
||||
OUT="${DL_DIR}/index.json"
|
||||
|
||||
if [[ "${TRACK}" == "stable" ]]; then
|
||||
PKG_ROOT="${DL_DIR}"
|
||||
URL_ROOT="${DL_BASE}"
|
||||
OUT="${DL_DIR}/index.json"
|
||||
else
|
||||
PKG_ROOT="${DL_DIR}/${TRACK}"
|
||||
URL_ROOT="${DL_BASE}/${TRACK}"
|
||||
OUT="${DL_DIR}/${TRACK}/index.json"
|
||||
fi
|
||||
|
||||
# Read the product list from the registry TOML instead of a hardcoded array.
|
||||
mapfile -t products < <(python3 -c "
|
||||
|
|
@ -30,8 +49,8 @@ build_package_json() {
|
|||
local name="$1"
|
||||
local repo="$2"
|
||||
|
||||
# Find the latest version dir under DL_DIR/<name>/
|
||||
local pkg_dir="${DL_DIR}/${name}"
|
||||
# Find the latest version dir under PKG_ROOT/<name>/
|
||||
local pkg_dir="${PKG_ROOT}/${name}"
|
||||
if [[ ! -d "${pkg_dir}" ]]; then
|
||||
echo " warning: no release dir for ${name} at ${pkg_dir}" >&2
|
||||
return 1
|
||||
|
|
@ -48,6 +67,41 @@ build_package_json() {
|
|||
local version
|
||||
version="$(basename "${version_dir}")"
|
||||
|
||||
# Locate bakery.toml. The release workflow copies it into the version dir
|
||||
# alongside the binaries (${version_dir}/bakery.toml). Fall back to a
|
||||
# sibling repo checkout for local dev use. Done before the binaries loop
|
||||
# below so license_file/desktop_file (if declared) can be excluded from
|
||||
# it by name — otherwise they'd get swept up as "binaries" with no
|
||||
# checksum, the same gotcha this loop's other exclusions guard against.
|
||||
local bakery_toml="${version_dir}/bakery.toml"
|
||||
if [[ ! -f "${bakery_toml}" ]]; then
|
||||
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
|
||||
return 1
|
||||
fi
|
||||
|
||||
local license_file_name desktop_file_name data_archive_name
|
||||
license_file_name="$(python3 -c "
|
||||
import tomllib
|
||||
with open('${bakery_toml}', 'rb') as f:
|
||||
d = tomllib.load(f)
|
||||
print(d.get('license_file', ''))
|
||||
" 2>/dev/null || true)"
|
||||
desktop_file_name="$(python3 -c "
|
||||
import tomllib
|
||||
with open('${bakery_toml}', 'rb') as f:
|
||||
d = tomllib.load(f)
|
||||
print(d.get('desktop_file', ''))
|
||||
" 2>/dev/null || true)"
|
||||
data_archive_name="$(python3 -c "
|
||||
import tomllib
|
||||
with open('${bakery_toml}', 'rb') as f:
|
||||
d = tomllib.load(f)
|
||||
print(d.get('data_archive', ''))
|
||||
" 2>/dev/null || true)"
|
||||
|
||||
# Collect all binaries in the version dir (executables only; skip metadata files).
|
||||
local binaries_json="[]"
|
||||
for bin_path in "${version_dir}"/*; do
|
||||
|
|
@ -56,6 +110,10 @@ build_package_json() {
|
|||
[[ "${bin_path}" == *.service ]] && continue
|
||||
[[ "${bin_path}" == *.css ]] && continue
|
||||
[[ "${bin_path}" == *.txt ]] && continue
|
||||
[[ "${bin_path}" == *.minisig ]] && continue
|
||||
[[ -n "${license_file_name}" && "${bin_path}" == "${version_dir}/${license_file_name}" ]] && continue
|
||||
[[ -n "${desktop_file_name}" && "${bin_path}" == "${version_dir}/${desktop_file_name}" ]] && continue
|
||||
[[ -n "${data_archive_name}" && "${bin_path}" == "${version_dir}/${data_archive_name}" ]] && continue
|
||||
[[ -f "${bin_path}" ]] || continue
|
||||
local bin_name
|
||||
bin_name="$(basename "${bin_path}")"
|
||||
|
|
@ -64,8 +122,17 @@ build_package_json() {
|
|||
if [[ -f "${sha256_path}" ]]; then
|
||||
sha256="$(awk '{print $1}' "${sha256_path}")"
|
||||
fi
|
||||
local dl_url="${DL_BASE}/${name}/${version}/${bin_name}"
|
||||
local gh_url="${GH_BASE}/${repo}/releases/download/v${version}/${bin_name}"
|
||||
local dl_url="${URL_ROOT}/${name}/${version}/${bin_name}"
|
||||
# dev/beta builds never get a real GitHub Release (see the dev/beta
|
||||
# CI workflows — that step is intentionally skipped for those
|
||||
# tracks), so github_url just mirrors dl_url rather than pointing at
|
||||
# a release asset that doesn't exist.
|
||||
local gh_url
|
||||
if [[ "${TRACK}" == "stable" ]]; then
|
||||
gh_url="${GH_BASE}/${repo}/releases/download/v${version}/${bin_name}"
|
||||
else
|
||||
gh_url="${dl_url}"
|
||||
fi
|
||||
|
||||
local entry
|
||||
entry="$(jq -n \
|
||||
|
|
@ -77,18 +144,6 @@ build_package_json() {
|
|||
binaries_json="$(jq -n --argjson arr "${binaries_json}" --argjson e "${entry}" '$arr + [$e]')"
|
||||
done
|
||||
|
||||
# Locate bakery.toml. The release workflow copies it into the version dir
|
||||
# alongside the binaries (${version_dir}/bakery.toml). Fall back to a
|
||||
# sibling repo checkout for local dev use.
|
||||
local bakery_toml="${version_dir}/bakery.toml"
|
||||
if [[ ! -f "${bakery_toml}" ]]; then
|
||||
bakery_toml="${SCRIPT_DIR}/../${name}/bakery.toml"
|
||||
fi
|
||||
if [[ ! -f "${bakery_toml}" ]]; then
|
||||
echo "ERROR: bakery.toml not found for ${name} — release.yml must copy it to \${DL_DIR}/${name}/\${VERSION}/bakery.toml" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local description system_deps optional_system_deps bread_deps services config post_install
|
||||
|
||||
description="$(python3 -c "
|
||||
|
|
@ -184,6 +239,47 @@ with open('${bakery_toml}', 'rb') as f:
|
|||
print(json.dumps(d.get('install', {}).get('post_install', [])))
|
||||
" 2>/dev/null || echo "[]")"
|
||||
|
||||
# license_file / desktop_file: plain filename fields in bakery.toml
|
||||
# (names already read above, before the binaries loop), same "artifact
|
||||
# in the version dir, sha256 computed here" pattern as config.example.
|
||||
# Empty string (not null) when unset, matching how the rest of this
|
||||
# script signals "field absent" to jq below.
|
||||
license_file="${license_file_name}"
|
||||
license_file_sha256=""
|
||||
if [[ -n "${license_file}" ]]; then
|
||||
license_path="${version_dir}/${license_file}"
|
||||
if [[ -f "${license_path}" ]]; then
|
||||
license_file_sha256="$(sha256sum "${license_path}" | awk '{print $1}')"
|
||||
else
|
||||
echo " warning: license_file '${license_file}' not found at ${license_path}" >&2
|
||||
license_file=""
|
||||
fi
|
||||
fi
|
||||
|
||||
desktop_file="${desktop_file_name}"
|
||||
desktop_file_sha256=""
|
||||
if [[ -n "${desktop_file}" ]]; then
|
||||
desktop_path="${version_dir}/${desktop_file}"
|
||||
if [[ -f "${desktop_path}" ]]; then
|
||||
desktop_file_sha256="$(sha256sum "${desktop_path}" | awk '{print $1}')"
|
||||
else
|
||||
echo " warning: desktop_file '${desktop_file}' not found at ${desktop_path}" >&2
|
||||
desktop_file=""
|
||||
fi
|
||||
fi
|
||||
|
||||
data_archive="${data_archive_name}"
|
||||
data_archive_sha256=""
|
||||
if [[ -n "${data_archive}" ]]; then
|
||||
data_archive_path="${version_dir}/${data_archive}"
|
||||
if [[ -f "${data_archive_path}" ]]; then
|
||||
data_archive_sha256="$(sha256sum "${data_archive_path}" | awk '{print $1}')"
|
||||
else
|
||||
echo " warning: data_archive '${data_archive}' not found at ${data_archive_path}" >&2
|
||||
data_archive=""
|
||||
fi
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg name "${name}" \
|
||||
--arg description "${description}" \
|
||||
|
|
@ -195,6 +291,12 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
|
|||
--argjson services "${services}" \
|
||||
--argjson config "${config}" \
|
||||
--argjson post_install "${post_install}" \
|
||||
--arg license_file "${license_file}" \
|
||||
--arg license_file_sha256 "${license_file_sha256}" \
|
||||
--arg desktop_file "${desktop_file}" \
|
||||
--arg desktop_file_sha256 "${desktop_file_sha256}" \
|
||||
--arg data_archive "${data_archive}" \
|
||||
--arg data_archive_sha256 "${data_archive_sha256}" \
|
||||
'{
|
||||
name: $name,
|
||||
description: $description,
|
||||
|
|
@ -205,7 +307,13 @@ print(json.dumps(d.get('install', {}).get('post_install', [])))
|
|||
bread_deps: $bread_deps,
|
||||
services: $services,
|
||||
config: $config,
|
||||
post_install: $post_install
|
||||
post_install: $post_install,
|
||||
license_file: (if $license_file == "" then null else $license_file end),
|
||||
license_file_sha256: (if $license_file_sha256 == "" then null else $license_file_sha256 end),
|
||||
desktop_file: (if $desktop_file == "" then null else $desktop_file end),
|
||||
desktop_file_sha256: (if $desktop_file_sha256 == "" then null else $desktop_file_sha256 end),
|
||||
data_archive: (if $data_archive == "" then null else $data_archive end),
|
||||
data_archive_sha256: (if $data_archive_sha256 == "" then null else $data_archive_sha256 end)
|
||||
}'
|
||||
}
|
||||
|
||||
|
|
@ -225,7 +333,8 @@ jq -n \
|
|||
--arg generated_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||
--argjson packages "${packages_json}" \
|
||||
'{version: $version, generated_at: $generated_at, packages: $packages}' \
|
||||
> "${OUT}"
|
||||
> "${OUT}.tmp"
|
||||
mv -f "${OUT}.tmp" "${OUT}"
|
||||
|
||||
echo "wrote ${OUT}"
|
||||
|
||||
|
|
@ -252,7 +361,7 @@ if [[ -n "${MINISIGN_SEC_KEY:-}" ]]; 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")
|
||||
sign_args=(-S -s "${MINISIGN_SEC_KEY}" -m "${OUT}" -x "${OUT}.minisig.tmp")
|
||||
if [[ -n "${MINISIGN_SEC_KEY_PASSWORD:-}" ]]; then
|
||||
MINISIGN_PASSWORD="${MINISIGN_SEC_KEY_PASSWORD}" minisign "${sign_args[@]}" </dev/null
|
||||
else
|
||||
|
|
@ -260,6 +369,7 @@ if [[ -n "${MINISIGN_SEC_KEY:-}" ]]; then
|
|||
# normally generated, since there's no human to type a passphrase).
|
||||
minisign -W "${sign_args[@]}" </dev/null
|
||||
fi
|
||||
mv -f "${OUT}.minisig.tmp" "${OUT}.minisig"
|
||||
echo "signed ${OUT} -> ${OUT}.minisig"
|
||||
else
|
||||
echo "WARNING: MINISIGN_SEC_KEY not set — index.json was NOT signed." >&2
|
||||
|
|
|
|||
77
scripts/gen-readme-products.sh
Executable file
77
scripts/gen-readme-products.sh
Executable file
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env bash
|
||||
# Rewrite the marked Products table in README.md from
|
||||
# registry/bread-ecosystem.toml (the source of truth).
|
||||
#
|
||||
# Markers (must exist in README.md):
|
||||
# <!-- gen-readme-products:start -->
|
||||
# ...generated markdown...
|
||||
# <!-- gen-readme-products:end -->
|
||||
#
|
||||
# Optional per-product `notes` in the registry is appended to the
|
||||
# description after an em-dash (used for "homelab, not BOS" / "not in ISO").
|
||||
#
|
||||
# Usage: scripts/gen-readme-products.sh
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
REGISTRY="${SCRIPT_DIR}/registry/bread-ecosystem.toml"
|
||||
README="${SCRIPT_DIR}/README.md"
|
||||
START="<!-- gen-readme-products:start -->"
|
||||
END="<!-- gen-readme-products:end -->"
|
||||
|
||||
if [[ ! -f "${REGISTRY}" ]]; then
|
||||
echo "error: registry not found at ${REGISTRY}" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "${README}" ]]; then
|
||||
echo "error: README not found at ${README}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
python3 - "${REGISTRY}" "${README}" "${START}" "${END}" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError: # pragma: no cover — 3.11+ is required
|
||||
import tomli as tomllib # type: ignore
|
||||
|
||||
registry_path, readme_path, start, end = sys.argv[1:]
|
||||
|
||||
with open(registry_path, "rb") as f:
|
||||
registry = tomllib.load(f)
|
||||
|
||||
products = registry.get("products") or []
|
||||
if not products:
|
||||
print("error: registry has no [[products]]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
lines = ["| Package | Description |", "|---------|-------------|"]
|
||||
for product in products:
|
||||
name = product["name"]
|
||||
desc = str(product.get("description") or "").replace("|", "\\|")
|
||||
notes = str(product.get("notes") or "").replace("|", "\\|")
|
||||
if notes:
|
||||
desc = f"{desc} — {notes}"
|
||||
lines.append(f"| `{name}` | {desc} |")
|
||||
table = "\n".join(lines)
|
||||
|
||||
readme = Path(readme_path)
|
||||
text = readme.read_text()
|
||||
start_at = text.find(start)
|
||||
end_at = text.find(end)
|
||||
if start_at < 0 or end_at < 0 or end_at < start_at:
|
||||
print(
|
||||
f"error: README.md is missing markers {start!r} / {end!r}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
rewritten = text[:start_at] + start + "\n\n" + table + "\n\n" + end + text[end_at + len(end):]
|
||||
if rewritten != text:
|
||||
readme.write_text(rewritten)
|
||||
print(f"updated {readme_path} ({len(products)} products)")
|
||||
else:
|
||||
print(f"{readme_path} already matches the registry ({len(products)} products)")
|
||||
PY
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
#!/bin/sh
|
||||
# Bootstrap script: downloads and installs the `bakery` binary.
|
||||
# Usage: curl https://breadway.dev/get | sh
|
||||
# Or: curl -sSfL https://breadway.dev/get | sh
|
||||
# Usage: curl -fsSL https://get.breadway.dev | sh
|
||||
set -eu
|
||||
|
||||
# Pinned minisign public key for the bakery release binary. Matches the
|
||||
|
|
@ -9,7 +8,7 @@ set -eu
|
|||
# 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="RWRh2Zr5SUinvVFCtD7S7HwGjfrye6j31Xq2mYXRdkGFDWe3yHF7W11K"
|
||||
BAKERY_MINISIGN_PUBKEY="RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8"
|
||||
|
||||
BAKERY_VERSION="${BAKERY_VERSION:-latest}"
|
||||
BIN_DIR="${BAKERY_BIN_DIR:-$HOME/.local/bin}"
|
||||
|
|
@ -20,6 +19,14 @@ die() { echo "error: $*" >&2; exit 1; }
|
|||
uname -m | grep -q x86_64 || die "bakery only supports x86_64 (got $(uname -m))"
|
||||
uname -s | grep -q Linux || die "bakery only supports Linux (got $(uname -s))"
|
||||
|
||||
# Signature verification is mandatory. Checksum-only is not sufficient —
|
||||
# the binary and its .sha256 typically come from the same server, so a
|
||||
# compromised host can serve a matching pair. Fail closed if minisign
|
||||
# isn't here rather than downloading something we refuse to trust.
|
||||
if ! command -v minisign >/dev/null 2>&1; then
|
||||
die "minisign is required to verify bakery. Install it: pacman -S minisign / apt install minisign"
|
||||
fi
|
||||
|
||||
# Build download URLs. GitHub's "latest" redirect lives at a different path from
|
||||
# versioned releases, so we handle them separately and always prefix tags with 'v'.
|
||||
if [ "${BAKERY_VERSION}" = "latest" ]; then
|
||||
|
|
@ -55,42 +62,33 @@ 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"
|
||||
sig_url_alt="${SIG_FALLBACK}"
|
||||
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"
|
||||
sig_url_alt="${SIG_URL}"
|
||||
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
|
||||
# Signature is required. A missing .minisig is a refuse-to-install, not a
|
||||
# warning — checksum-only is not a substitute.
|
||||
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
|
||||
:
|
||||
elif [ "${sig_url_alt}" != "${sig_url}" ] && fetch "${sig_url_alt}" "${TMP}.minisig" 2>/dev/null; then
|
||||
echo " signature fetched from fallback URL"
|
||||
else
|
||||
echo " warning: no .minisig published for this release yet — signature not verified" >&2
|
||||
die "could not fetch bakery-x86_64.minisig — refusing to install an unsigned binary"
|
||||
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 minisign -V -q -m "${TMP}" -x "${TMP}.minisig" -P "${BAKERY_MINISIGN_PUBKEY}"; then
|
||||
echo " signature verified (minisign)"
|
||||
else
|
||||
die "minisign signature verification FAILED — refusing to install a binary that doesn't match the pinned bakery key"
|
||||
fi
|
||||
|
||||
# Checksum is defense-in-depth only, and never enough on its own. A
|
||||
# mismatch still dies; a missing .sha256 is fine once the signature passed.
|
||||
if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; then
|
||||
expected="$(awk '{print $1}' "${TMP}.sha256")"
|
||||
actual="$(sha256sum "${TMP}" | awk '{print $1}')"
|
||||
|
|
@ -98,12 +96,6 @@ if fetch "${SHA256_URL}" "${TMP}.sha256" 2>/dev/null; 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}"
|
||||
|
|
|
|||
52
scripts/onboard-product.sh
Executable file
52
scripts/onboard-product.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/env bash
|
||||
# onboard-product.sh — register a new product in registry/bread-ecosystem.toml.
|
||||
#
|
||||
# This is the one step in bringing a product under bakery that's a genuine
|
||||
# write action; everything else (bakery.toml, CI workflows, the
|
||||
# BAKERY_MINISIGN_SEC_KEY_PATH Actions secret) is either copied from an
|
||||
# existing product repo or diagnosed by scripts/doctor-channels.sh, which
|
||||
# this script runs at the end so nothing gets missed the way breadcast's
|
||||
# missing secret did.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/onboard-product.sh <name> <owner/repo> <description>
|
||||
#
|
||||
# Example:
|
||||
# scripts/onboard-product.sh breadcast Breadway/breadcast \
|
||||
# "Cast your screen to any Chromecast/Google TV or DLNA renderer — daemon + GTK4 popup"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 3 ]]; then
|
||||
echo "usage: $0 <name> <owner/repo> <description>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
NAME="$1"
|
||||
REPO="$2"
|
||||
DESCRIPTION="$3"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
REGISTRY="${SCRIPT_DIR}/registry/bread-ecosystem.toml"
|
||||
|
||||
if python3 -c "
|
||||
import tomllib, sys
|
||||
with open('${REGISTRY}', 'rb') as f:
|
||||
d = tomllib.load(f)
|
||||
sys.exit(0 if any(p['name'] == '${NAME}' for p in d['products']) else 1)
|
||||
"; then
|
||||
echo "${NAME} is already registered in ${REGISTRY}, skipping"
|
||||
else
|
||||
cat >> "${REGISTRY}" <<EOF
|
||||
|
||||
[[products]]
|
||||
name = "${NAME}"
|
||||
repo = "${REPO}"
|
||||
description = "${DESCRIPTION}"
|
||||
EOF
|
||||
echo "added ${NAME} (${REPO}) to ${REGISTRY}"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "running doctor-channels.sh to check bakery.toml / CI workflows / signing secret are all in place for ${NAME}..."
|
||||
bash "${SCRIPT_DIR}/scripts/doctor-channels.sh" || true
|
||||
88
upgrade.md
Normal file
88
upgrade.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
**Project Overview: Bread Screenshot System**
|
||||
|
||||
### Goal
|
||||
Add a maintainable, automated system to generate high-quality screenshots/renders of **all major UI views** across the Bread ecosystem. This will dramatically speed up UI development, visual regression testing, documentation, and marketing.
|
||||
|
||||
### Scope
|
||||
|
||||
**In Scope:**
|
||||
- Automated screenshot generation for all major GUI components
|
||||
- Support for different themes (pywal accents, light/dark if added later)
|
||||
- Consistent naming and output structure
|
||||
- Easy-to-run command (`bread capture --all` or similar)
|
||||
- Integration with development workflow and CI (optional)
|
||||
|
||||
**Out of Scope (Phase 1):**
|
||||
- Video/GIF capture
|
||||
- Full automated visual diffing (can be Phase 2)
|
||||
|
||||
### Target Apps / Views
|
||||
|
||||
1. **breadbar**
|
||||
- Main bar (all placements)
|
||||
- Control panel (full + sections)
|
||||
- WiFi popover, media popover, etc.
|
||||
- Notifications
|
||||
|
||||
2. **breadman**
|
||||
- All sidebar views (All, Upcoming, Todo, Reminder, etc.)
|
||||
- Note cards in different states
|
||||
- Editor / create flow
|
||||
|
||||
3. **breadbox**
|
||||
- Main launcher view
|
||||
- Different contexts
|
||||
|
||||
4. **bos-settings**
|
||||
- All major panels
|
||||
|
||||
5. **breadpad** (capture popup)
|
||||
|
||||
6. **breadlock** (lock screen states)
|
||||
|
||||
7. **Widgets** (test module that renders many widget examples)
|
||||
|
||||
### Technical Approach (Most Idiomatic)
|
||||
|
||||
**Core Components:**
|
||||
|
||||
1. **Shared Library** (`bread-screenshots` crate in bread-ecosystem)
|
||||
- Common screenshot utilities
|
||||
- Window finding / targeting logic (using `gtk` or `grim`)
|
||||
- Theme forcing
|
||||
|
||||
2. **Per-App Screenshot Mode**
|
||||
- Add `--screenshot <view>` flag to each GTK app
|
||||
- Special runtime mode that opens the desired view and calls capture after render
|
||||
|
||||
3. **Orchestrator**
|
||||
- A small Rust binary (`bread-capture`) or bash + Rust hybrid
|
||||
- Launches each app with proper flags, waits, captures, saves
|
||||
|
||||
4. **Output Structure**
|
||||
```
|
||||
screenshots/
|
||||
├── v0.8.0/
|
||||
│ ├── breadbar-main.png
|
||||
│ ├── breadbar-control.png
|
||||
│ ├── breadman-all.png
|
||||
│ ├── breadman-todo.png
|
||||
│ └── ...
|
||||
└── latest/ (symlinks)
|
||||
```
|
||||
|
||||
### Recommended Implementation Steps
|
||||
|
||||
1. Create `bread-screenshots` crate in bread-ecosystem
|
||||
2. Add screenshot support to the most important apps first (breadman + breadbar)
|
||||
3. Build the orchestrator tool
|
||||
4. Add `bread capture` subcommand to the CLI
|
||||
5. Document usage + add to CONTRIBUTING.md
|
||||
|
||||
### Benefits
|
||||
|
||||
- Much faster UI iteration
|
||||
- Visual regression testing
|
||||
- Always up-to-date marketing/docs screenshots
|
||||
- Easier contributor onboarding for UI work
|
||||
- Professional polish for the project
|
||||
Loading…
Add table
Add a link
Reference in a new issue