Compare commits
106 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46b886b7bc | ||
| 4328e2171d | |||
|
|
aa326c4ecb | ||
|
|
2affeabce0 | ||
|
|
2897335016 | ||
|
|
a86c31291b | ||
|
|
6d9912af56 | ||
|
|
80e25f23b3 | ||
|
|
5b5b17fdf4 | ||
|
|
3a22b62694 | ||
|
|
0ec20714c5 | ||
|
|
94ab7216c2 | ||
|
|
eb446b091e | ||
|
|
a313eb518c | ||
|
|
470d2aa7e9 | ||
|
|
cff77d473a | ||
|
|
410f97cb28 | ||
|
|
3326207d34 | ||
|
|
801df1e85e | ||
|
|
00e8d4f28e | ||
|
|
d2c68f18b0 | ||
|
|
95e676e909 | ||
|
|
8c5e42230a | ||
|
|
6ffd689a4d | ||
|
|
96fa79c1d4 | ||
|
|
53a6c59f2d | ||
|
|
ea9758a5d5 | ||
|
|
92d3362a6b | ||
|
|
96aa6a513b | ||
|
|
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 |
97 changed files with 15903 additions and 599 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:
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -5,3 +5,9 @@
|
|||
# scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY).
|
||||
*.minisign-sec
|
||||
minisign.key
|
||||
|
||||
# Local tool caches — not build output, never belongs in the repo.
|
||||
# (breadbar already excludes graphify-out; this repo did not, and 111k lines
|
||||
# of it were swept in by a `git add -A`.)
|
||||
graphify-out/
|
||||
.grok/
|
||||
|
|
|
|||
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.
|
||||
681
Cargo.lock
generated
681
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", "bread-launcher"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.7.5"
|
||||
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"
|
||||
|
|
|
|||
110
README.md
110
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
|
||||
├── 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
|
||||
├── 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} \
|
||||
" {}",
|
||||
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
1082
bakery/src/main.rs
1082
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_path() -> PathBuf {
|
||||
dirs::state_dir()
|
||||
.unwrap_or_else(|| {
|
||||
fn state_base_dir() -> PathBuf {
|
||||
dirs::state_dir().unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~"))
|
||||
.join(".local/state")
|
||||
})
|
||||
.join("bakery/installed.json")
|
||||
}
|
||||
|
||||
fn state_path() -> PathBuf {
|
||||
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));
|
||||
}
|
||||
}
|
||||
262
bread-capture/src/main.rs
Normal file
262
bread-capture/src/main.rs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
//! 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"),
|
||||
// Theme 04/spotlight's embedded capsule (only rendered under
|
||||
// `BREAD_SHELL_THEME=spotlight` — every other theme's [bar.slots]
|
||||
// never places launcher_entry/launcher_results anywhere).
|
||||
("capsule-collapsed", "capsule-collapsed.png"),
|
||||
("capsule-expanded", "capsule-expanded.png"),
|
||||
// Phase 6c: query sections (idle "Recent"/"Apps" headers) and
|
||||
// the `=` calc mode — see breadbar's own `screenshot::KNOWN_VIEWS`
|
||||
// doc comment for why the search-state width/radius change
|
||||
// (item E) doesn't need a view of its own.
|
||||
("capsule-sections", "capsule-sections.png"),
|
||||
("capsule-calc", "capsule-calc.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 })
|
||||
}
|
||||
26
bread-launcher/Cargo.toml
Normal file
26
bread-launcher/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
name = "bread-launcher"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Headless app-launcher core (desktop-entry discovery, fuzzy matching/ranking, launch history, launching) plus an optional GTK4 results-list widget — the shared logic behind breadbox's overlay window and breadbar's embedded capsule"
|
||||
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
||||
keywords = ["launcher", "desktop-entry", "gtk4", "wayland"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
# `do_launch`/`emit_launched` publish a `bread.<app>.launched` event over
|
||||
# breadd's IPC socket after a successful spawn, fire-and-forget — this was
|
||||
# already breadbox's behaviour (`BreadClient::emit` never blocks or errors
|
||||
# the launching caller), just relocated. Not optional: launching is core,
|
||||
# headless functionality, unlike the GTK widget below.
|
||||
bread-utils = { path = "../bread-utils", features = ["bread-client"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
|
||||
|
||||
[features]
|
||||
# Enable the GTK4 results-list widget (`gtk` module): row building, fuzzy
|
||||
# filtering, match/history sorting, and keyboard-style selection movement.
|
||||
# Optional so a headless consumer of the matching/ranking/launch core (or a
|
||||
# future non-GTK host) doesn't have to pull in GTK4.
|
||||
gtk = ["dep:gtk4"]
|
||||
151
bread-launcher/src/desktop.rs
Normal file
151
bread-launcher/src/desktop.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
use std::{
|
||||
fs::{self, File},
|
||||
io::{BufRead, BufReader},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::paths::app_dirs;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DesktopEntry {
|
||||
/// Desktop file id (the `.desktop` filename, e.g. `firefox.desktop`).
|
||||
/// Empty only if the path had no file name; callers fall back to `exec`.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub exec: String,
|
||||
pub icon_name: String,
|
||||
pub icon_path: Option<PathBuf>, // resolved by caller from manifest
|
||||
pub categories: Vec<String>,
|
||||
pub wm_class: Option<String>,
|
||||
pub terminal: bool,
|
||||
}
|
||||
|
||||
pub fn strip_exec_codes(exec: &str) -> String {
|
||||
let mut out = String::with_capacity(exec.len());
|
||||
let mut chars = exec.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%' {
|
||||
match chars.peek().copied() {
|
||||
Some('%') => {
|
||||
chars.next();
|
||||
out.push('%');
|
||||
}
|
||||
Some(n) if n.is_ascii_alphabetic() => {
|
||||
chars.next();
|
||||
}
|
||||
_ => out.push(c),
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Returns `None` for entries that should not be shown (hidden, NoDisplay, non-Application type).
|
||||
pub fn parse_desktop(path: &Path) -> Option<DesktopEntry> {
|
||||
let file = File::open(path).ok()?;
|
||||
let mut in_entry = false;
|
||||
let mut name: Option<String> = None;
|
||||
let mut exec: Option<String> = None;
|
||||
let mut icon: Option<String> = None;
|
||||
let mut categories: Option<String> = None;
|
||||
let mut wm_class: Option<String> = None;
|
||||
let mut app_type: Option<String> = None;
|
||||
let mut no_display = false;
|
||||
let mut hidden = false;
|
||||
let mut terminal = false;
|
||||
|
||||
for line in BufReader::new(file).lines() {
|
||||
let Ok(raw) = line else { continue };
|
||||
let s = raw.trim();
|
||||
if s.starts_with('#') || s.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if s.starts_with('[') {
|
||||
in_entry = s == "[Desktop Entry]";
|
||||
continue;
|
||||
}
|
||||
if !in_entry {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(v) = s.strip_prefix("Name=") {
|
||||
name.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("Exec=") {
|
||||
exec.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("Icon=") {
|
||||
icon.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("Categories=") {
|
||||
categories.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("StartupWMClass=") {
|
||||
wm_class.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("Type=") {
|
||||
app_type.get_or_insert_with(|| v.to_string());
|
||||
} else if let Some(v) = s.strip_prefix("NoDisplay=") {
|
||||
no_display = v == "true";
|
||||
} else if let Some(v) = s.strip_prefix("Hidden=") {
|
||||
hidden = v == "true";
|
||||
} else if let Some(v) = s.strip_prefix("Terminal=") {
|
||||
terminal = v == "true" || v == "1";
|
||||
}
|
||||
}
|
||||
|
||||
if no_display || hidden {
|
||||
return None;
|
||||
}
|
||||
if app_type.as_deref() != Some("Application") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = name?.trim().to_string();
|
||||
let exec = strip_exec_codes(exec?.trim()).trim().to_string();
|
||||
if name.is_empty() || exec.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let icon_name = icon.unwrap_or_default().trim().to_string();
|
||||
let cats = categories
|
||||
.unwrap_or_default()
|
||||
.split(';')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let id = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(DesktopEntry {
|
||||
id,
|
||||
name,
|
||||
exec,
|
||||
icon_name,
|
||||
icon_path: None,
|
||||
categories: cats,
|
||||
wm_class: wm_class.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
|
||||
terminal,
|
||||
})
|
||||
}
|
||||
|
||||
/// Walk all configured application directories and return deduplicated entries.
|
||||
/// Entries from later directories (user-local) override those from earlier ones.
|
||||
pub fn load_all_desktop_entries() -> Vec<DesktopEntry> {
|
||||
let mut seen: std::collections::HashMap<String, DesktopEntry> = std::collections::HashMap::new();
|
||||
for dir in app_dirs() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else { continue };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
|
||||
continue;
|
||||
}
|
||||
let key = entry.file_name().to_string_lossy().into_owned();
|
||||
if let Some(app) = parse_desktop(&path) {
|
||||
seen.insert(key, app);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen.into_values().collect()
|
||||
}
|
||||
297
bread-launcher/src/gtk.rs
Normal file
297
bread-launcher/src/gtk.rs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
//! GTK4 results-list widget: the "row-building half" of what used to be
|
||||
//! breadbox's `run_ui` (`THEME_SYSTEM_PLAN.md` §3) — desktop-entry rows,
|
||||
//! fuzzy filtering, match/history sorting, and keyboard-style selection
|
||||
//! movement, packaged as [`ResultsList`] so any host window can embed it.
|
||||
//! breadbox wraps it in a full-screen overlay window today; breadbar's
|
||||
//! embedded capsule (a later phase) puts the same widget in its drawer slot.
|
||||
|
||||
use std::{cell::RefCell, path::Path, rc::Rc};
|
||||
|
||||
use gtk4::{
|
||||
gdk, gio,
|
||||
pango::EllipsizeMode,
|
||||
prelude::*,
|
||||
Align, Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation, PolicyType,
|
||||
ScrolledWindow, SelectionMode,
|
||||
};
|
||||
|
||||
use crate::desktop::DesktopEntry;
|
||||
use crate::history::LaunchHistory;
|
||||
use crate::matching::{fuzzy_matches, fuzzy_score, split_sections};
|
||||
|
||||
fn make_icon(icon_name: &str, icon_path: Option<&Path>, icon_px: i32) -> Image {
|
||||
// Try loading from resolved cached path via gio::File
|
||||
if let Some(path) = icon_path {
|
||||
let gio_file = gio::File::for_path(path);
|
||||
if let Ok(texture) = gdk::Texture::from_file(&gio_file) {
|
||||
let img = Image::new();
|
||||
img.set_paintable(Some(&texture));
|
||||
img.set_pixel_size(icon_px);
|
||||
return img;
|
||||
}
|
||||
}
|
||||
// Fall back to GTK icon theme lookup by name
|
||||
let name = if icon_name.is_empty() {
|
||||
"application-x-executable"
|
||||
} else {
|
||||
icon_name
|
||||
};
|
||||
let img = Image::from_icon_name(name);
|
||||
img.set_pixel_size(icon_px);
|
||||
img
|
||||
}
|
||||
|
||||
fn build_row(entry: &DesktopEntry, idx: u32, icon_px: i32) -> ListBoxRow {
|
||||
let row = ListBoxRow::new();
|
||||
let hbox = GBox::new(Orientation::Horizontal, 0);
|
||||
hbox.set_margin_start(6);
|
||||
hbox.set_margin_end(6);
|
||||
hbox.set_valign(Align::Center);
|
||||
|
||||
let icon = make_icon(&entry.icon_name, entry.icon_path.as_deref(), icon_px);
|
||||
hbox.append(&icon);
|
||||
|
||||
let name_lbl = Label::new(Some(&entry.name));
|
||||
name_lbl.add_css_class("app-name");
|
||||
name_lbl.set_xalign(0.0);
|
||||
name_lbl.set_hexpand(true);
|
||||
name_lbl.set_ellipsize(EllipsizeMode::End);
|
||||
hbox.append(&name_lbl);
|
||||
|
||||
if let Some(ref wm) = entry.wm_class {
|
||||
let wm_lbl = Label::new(Some(wm));
|
||||
wm_lbl.add_css_class("app-muted");
|
||||
wm_lbl.set_xalign(1.0);
|
||||
hbox.append(&wm_lbl);
|
||||
}
|
||||
|
||||
row.set_child(Some(&hbox));
|
||||
unsafe { row.set_data("entry", entry.clone()) };
|
||||
unsafe { row.set_data("initial_order", idx) };
|
||||
row
|
||||
}
|
||||
|
||||
/// A non-selectable, non-activatable "Recent"/"Apps" label row (plan phase
|
||||
/// 6c, `[launcher].sections`) — deliberately carries no `"entry"` row data,
|
||||
/// which is exactly what [`row_entry`] (and everything downstream of it:
|
||||
/// `set_query`'s filter, `select_next`/`select_prev`'s traversal) already
|
||||
/// uses to tell a header apart from a real app row.
|
||||
fn build_header_row(label: &str, idx: u32) -> ListBoxRow {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
row.set_activatable(false);
|
||||
row.add_css_class("bread-drawer-section-header");
|
||||
let lbl = Label::new(Some(label));
|
||||
lbl.add_css_class("section-header-label");
|
||||
lbl.set_xalign(0.0);
|
||||
row.set_child(Some(&lbl));
|
||||
unsafe { row.set_data("initial_order", idx) };
|
||||
row
|
||||
}
|
||||
|
||||
/// Reads the [`DesktopEntry`] a row was built from — e.g. from a
|
||||
/// `ListBox::connect_row_activated` handler, which hands back a row
|
||||
/// reference rather than going through [`ResultsList::selected_entry`].
|
||||
pub fn row_entry(row: &ListBoxRow) -> Option<DesktopEntry> {
|
||||
unsafe { row.data::<DesktopEntry>("entry").map(|p| p.as_ref().clone()) }
|
||||
}
|
||||
|
||||
/// A scrollable, filterable, rankable list of desktop-entry rows — the
|
||||
/// widget breadbox's overlay wraps today and breadbar's capsule will embed
|
||||
/// next (`THEME_SYSTEM_PLAN.md` §7). A host drives it through
|
||||
/// [`set_query`](Self::set_query) (wire to a search entry's `changed`
|
||||
/// signal), [`select_next`](Self::select_next)/[`select_prev`](Self::select_prev)
|
||||
/// (wire to arrow keys), and reads the current pick via
|
||||
/// [`selected_entry`](Self::selected_entry) — `list`/`scroller` are exposed
|
||||
/// directly for anything else a host needs (e.g. `connect_row_activated`
|
||||
/// for click-to-launch, or placing `scroller` in a slot).
|
||||
#[derive(Clone)]
|
||||
pub struct ResultsList {
|
||||
pub scroller: ScrolledWindow,
|
||||
pub list: ListBox,
|
||||
query: Rc<RefCell<String>>,
|
||||
history: Rc<RefCell<LaunchHistory>>,
|
||||
}
|
||||
|
||||
impl ResultsList {
|
||||
/// Builds one row per entry (in `entries`' given order — that order is
|
||||
/// also the fallback sort when the query is empty) and wires up sorting
|
||||
/// against `history`'s launch counts.
|
||||
///
|
||||
/// `sections` (`[launcher].sections`, plan phase 6c): when true, the
|
||||
/// idle (empty-query) view groups `entries` into "Recent"/"Apps"
|
||||
/// [`build_header_row`]s via [`split_sections`] instead of one flat
|
||||
/// list. Sections disappear the moment a query is typed — `set_query`
|
||||
/// falls back to the same flat fuzzy-ranked list either way — so this
|
||||
/// only changes the initial build order and the header rows' presence,
|
||||
/// never the (unchanged) search behaviour. `false` reproduces the
|
||||
/// exact pre-phase-6c flat list breadbox's own overlay still uses.
|
||||
pub fn new(
|
||||
entries: &[DesktopEntry],
|
||||
icon_px: i32,
|
||||
history: Rc<RefCell<LaunchHistory>>,
|
||||
sections: bool,
|
||||
) -> Self {
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(SelectionMode::Browse);
|
||||
|
||||
let mut idx = 0u32;
|
||||
if sections {
|
||||
let (recent, apps) = split_sections(entries.to_vec(), &history.borrow());
|
||||
if !recent.is_empty() {
|
||||
list.append(&build_header_row("Recent", idx));
|
||||
idx += 1;
|
||||
for entry in &recent {
|
||||
list.append(&build_row(entry, idx, icon_px));
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
if !apps.is_empty() {
|
||||
list.append(&build_header_row("Apps", idx));
|
||||
idx += 1;
|
||||
for entry in &apps {
|
||||
list.append(&build_row(entry, idx, icon_px));
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for entry in entries {
|
||||
list.append(&build_row(entry, idx, icon_px));
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let query: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
|
||||
{
|
||||
let query = Rc::clone(&query);
|
||||
let history = Rc::clone(&history);
|
||||
list.set_sort_func(move |row_a, row_b| {
|
||||
let query = query.borrow();
|
||||
if query.is_empty() {
|
||||
let oa = unsafe {
|
||||
row_a.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref())
|
||||
};
|
||||
let ob = unsafe {
|
||||
row_b.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref())
|
||||
};
|
||||
return oa.cmp(&ob).into();
|
||||
}
|
||||
// A header row carries no "entry" data — sort it after any
|
||||
// real row rather than treating the comparison as `Equal`,
|
||||
// though `set_query` also hides every header outright once
|
||||
// a query is non-empty, so this only matters for the
|
||||
// underlying (invisible) list order, never what's shown.
|
||||
match (row_entry(row_a), row_entry(row_b)) {
|
||||
(Some(ea), Some(eb)) => {
|
||||
let sa = fuzzy_score(&query, &ea);
|
||||
let sb = fuzzy_score(&query, &eb);
|
||||
let history = history.borrow();
|
||||
let ca = history.count(&ea.name);
|
||||
let cb = history.count(&eb.name);
|
||||
sa.cmp(&sb)
|
||||
.then(cb.cmp(&ca))
|
||||
.then(ea.name.to_lowercase().cmp(&eb.name.to_lowercase()))
|
||||
.into()
|
||||
}
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater.into(),
|
||||
(Some(_), None) => std::cmp::Ordering::Less.into(),
|
||||
(None, None) => std::cmp::Ordering::Equal.into(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let first_real = (0i32..)
|
||||
.map_while(|i| list.row_at_index(i))
|
||||
.find(|r| row_entry(r).is_some());
|
||||
if let Some(first) = first_real {
|
||||
list.select_row(Some(&first));
|
||||
}
|
||||
|
||||
let scroller = ScrolledWindow::new();
|
||||
scroller.set_policy(PolicyType::Never, PolicyType::Automatic);
|
||||
scroller.set_max_content_height(480);
|
||||
scroller.set_propagate_natural_height(true);
|
||||
scroller.set_child(Some(&list));
|
||||
|
||||
ResultsList { scroller, list, query, history }
|
||||
}
|
||||
|
||||
/// Re-filters (fuzzy match against name, `wm_class`, and `exec`) and
|
||||
/// re-sorts by `query`, then selects the first visible row. A header
|
||||
/// row (see [`build_header_row`]) only ever shows in the idle
|
||||
/// (empty-query) browse view — it has no name/`wm_class`/`exec` of its
|
||||
/// own to filter against.
|
||||
pub fn set_query(&self, query: &str) {
|
||||
*self.query.borrow_mut() = query.to_string();
|
||||
let mut i = 0i32;
|
||||
while let Some(row) = self.list.row_at_index(i) {
|
||||
let vis = match row_entry(&row) {
|
||||
Some(e) => {
|
||||
fuzzy_matches(query, &e.name)
|
||||
|| e.wm_class.as_deref().is_some_and(|w| fuzzy_matches(query, w))
|
||||
|| fuzzy_matches(query, &e.exec)
|
||||
}
|
||||
None => query.is_empty(),
|
||||
};
|
||||
row.set_visible(vis);
|
||||
i += 1;
|
||||
}
|
||||
self.list.invalidate_sort();
|
||||
let first_vis = (0i32..)
|
||||
.map_while(|j| self.list.row_at_index(j))
|
||||
.find(|r| r.is_visible() && row_entry(r).is_some());
|
||||
self.list.select_row(first_vis.as_ref());
|
||||
}
|
||||
|
||||
pub fn selected_entry(&self) -> Option<DesktopEntry> {
|
||||
self.list.selected_row().and_then(|r| row_entry(&r))
|
||||
}
|
||||
|
||||
/// Moves the selection to the next visible row, if any. Skips header
|
||||
/// rows even though they may be visible (the idle browse view) —
|
||||
/// `set_selectable(false)` alone doesn't stop a programmatic
|
||||
/// `select_row` call from landing on one.
|
||||
pub fn select_next(&self) {
|
||||
let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(-1);
|
||||
let mut i = cur + 1;
|
||||
loop {
|
||||
match self.list.row_at_index(i) {
|
||||
Some(r) if r.is_visible() && row_entry(&r).is_some() => {
|
||||
self.list.select_row(Some(&r));
|
||||
break;
|
||||
}
|
||||
Some(_) => i += 1,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the selection to the previous visible row, if any. See
|
||||
/// [`select_next`](Self::select_next) on skipping header rows.
|
||||
pub fn select_prev(&self) {
|
||||
let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(0);
|
||||
let mut i = cur - 1;
|
||||
loop {
|
||||
if i < 0 {
|
||||
break;
|
||||
}
|
||||
match self.list.row_at_index(i) {
|
||||
Some(r) if r.is_visible() && row_entry(&r).is_some() => {
|
||||
self.list.select_row(Some(&r));
|
||||
break;
|
||||
}
|
||||
Some(_) => i -= 1,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `entry` as launched in the shared history and persists it.
|
||||
/// Call before actually launching (matching breadbox's original
|
||||
/// increment-then-launch ordering) — history and launching are separate
|
||||
/// concerns, so this doesn't call [`crate::do_launch`] itself.
|
||||
pub fn record_launch(&self, entry: &DesktopEntry) {
|
||||
self.history.borrow_mut().increment(&entry.name);
|
||||
self.history.borrow().save();
|
||||
}
|
||||
}
|
||||
126
bread-launcher/src/history.rs
Normal file
126
bread-launcher/src/history.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
use std::{collections::HashMap, fs, path::PathBuf};
|
||||
|
||||
pub struct LaunchHistory {
|
||||
counts: HashMap<String, u32>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl LaunchHistory {
|
||||
/// `app` picks the cache subdirectory (see [`crate::cache_dir`]) the
|
||||
/// history file lives in.
|
||||
pub fn load(app: &str) -> Self {
|
||||
let path = crate::paths::cache_dir(app).join("history.json");
|
||||
let counts = fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
LaunchHistory { counts, path }
|
||||
}
|
||||
|
||||
pub fn count(&self, name: &str) -> u32 {
|
||||
self.counts.get(name).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn increment(&mut self, name: &str) {
|
||||
*self.counts.entry(name.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
/// Writes `counts` to `path` as JSON. Best-effort — a broken cache dir
|
||||
/// (missing parent, full disk, permissions) must not stop the caller
|
||||
/// from launching anything, so this never returns an error — but it now
|
||||
/// logs one on failure rather than swallowing it silently. Shared by two
|
||||
/// hosts (breadbox's overlay and breadbar's embedded capsule, both keyed
|
||||
/// under [`crate::LAUNCHER_APP`]), so a save failure here silently stops
|
||||
/// ranking history for both.
|
||||
pub fn save(&self) {
|
||||
match serde_json::to_string(&self.counts) {
|
||||
Ok(json) => {
|
||||
if let Err(err) = fs::write(&self.path, json) {
|
||||
eprintln!(
|
||||
"bread-launcher: failed to save launch history to {}: {err}",
|
||||
self.path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("bread-launcher: failed to serialize launch history: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory history with no backing file — [`save`](Self::save) fails
|
||||
/// (an empty `path` is not writable) and now logs that failure to
|
||||
/// stderr rather than swallowing it, same as any other broken-path
|
||||
/// case. Lets a test (or a future in-memory host) control counts
|
||||
/// directly instead of writing through `~/.cache/<app>/history.json`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_counts(counts: HashMap<String, u32>) -> Self {
|
||||
LaunchHistory {
|
||||
counts,
|
||||
path: PathBuf::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_history_path(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"bread-launcher-history-test-{}-{name}-{}",
|
||||
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();
|
||||
dir.join("history.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_round_trips_counts() {
|
||||
let path = temp_history_path("roundtrip");
|
||||
let mut history = LaunchHistory {
|
||||
counts: HashMap::new(),
|
||||
path: path.clone(),
|
||||
};
|
||||
history.increment("firefox.desktop");
|
||||
history.increment("firefox.desktop");
|
||||
history.increment("kitty.desktop");
|
||||
history.save();
|
||||
|
||||
let text = std::fs::read_to_string(&path).expect("save should have written the file");
|
||||
let counts: HashMap<String, u32> = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(counts.get("firefox.desktop"), Some(&2));
|
||||
assert_eq!(counts.get("kitty.desktop"), Some(&1));
|
||||
|
||||
// load() from the same path should see the same counts.
|
||||
let reloaded = LaunchHistory {
|
||||
counts: serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(),
|
||||
path: path.clone(),
|
||||
};
|
||||
assert_eq!(reloaded.count("firefox.desktop"), 2);
|
||||
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
/// `save()` on an unwritable path (e.g. the parent directory doesn't
|
||||
/// exist, or `path` is empty) must not panic — it's best-effort, called
|
||||
/// from a launcher's shutdown path where a hard failure would be worse
|
||||
/// than a lost history entry. This exercises exactly the failure branch
|
||||
/// the `eprintln!` above was added for; there is no return value to
|
||||
/// assert on, so "did not panic" is the contract under test.
|
||||
#[test]
|
||||
fn save_to_a_broken_path_does_not_panic() {
|
||||
let history = LaunchHistory::from_counts(HashMap::from([("x".to_string(), 1)]));
|
||||
history.save();
|
||||
|
||||
let history = LaunchHistory {
|
||||
counts: HashMap::new(),
|
||||
path: PathBuf::from("/nonexistent-dir/definitely-not-there/history.json"),
|
||||
};
|
||||
history.save();
|
||||
}
|
||||
}
|
||||
26
bread-launcher/src/icon.rs
Normal file
26
bread-launcher/src/icon.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use std::{fs, path::PathBuf};
|
||||
|
||||
pub struct IconCache {
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
impl IconCache {
|
||||
/// `app` picks the cache subdirectory (see [`crate::cache_dir`]) — pass
|
||||
/// the same name across a process's calls so `path_for` and
|
||||
/// `manifest_path` agree on where icons live.
|
||||
pub fn new(app: &str) -> Self {
|
||||
IconCache { dir: crate::paths::cache_dir(app).join("icons") }
|
||||
}
|
||||
|
||||
pub fn path_for(&self, icon_name: &str) -> PathBuf {
|
||||
self.dir.join(format!("{}.png", icon_name))
|
||||
}
|
||||
|
||||
pub fn manifest_path(app: &str) -> PathBuf {
|
||||
crate::paths::cache_dir(app).join("manifest.json")
|
||||
}
|
||||
|
||||
pub fn ensure_dir(&self) -> std::io::Result<()> {
|
||||
fs::create_dir_all(&self.dir)
|
||||
}
|
||||
}
|
||||
141
bread-launcher/src/launch.rs
Normal file
141
bread-launcher/src/launch.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
use std::{
|
||||
env,
|
||||
path::Path,
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
use bread_utils::bread_client::BreadClient;
|
||||
|
||||
use crate::desktop::DesktopEntry;
|
||||
|
||||
fn pick_terminal() -> String {
|
||||
if let Ok(t) = env::var("TERMINAL") {
|
||||
if !t.is_empty() {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
let path_var = env::var("PATH").unwrap_or_default();
|
||||
for t in ["foot", "kitty", "alacritty", "wezterm", "ghostty", "xterm"] {
|
||||
if path_var.split(':').any(|d| Path::new(d).join(t).exists()) {
|
||||
return t.to_string();
|
||||
}
|
||||
}
|
||||
"xterm".to_string()
|
||||
}
|
||||
|
||||
/// Spawns `entry`'s command (through a terminal if `entry.terminal` is set)
|
||||
/// and, on a successful spawn, publishes `event` via [`emit_launched`].
|
||||
///
|
||||
/// `app_id` here is the caller's **bread event-namespace id** (e.g.
|
||||
/// `"box"` for breadbox) — NOT [`crate::LAUNCHER_APP`] (`"breadbox"`).
|
||||
/// Those are two different identities that happen to look similar:
|
||||
/// `LAUNCHER_APP` only picks the shared cache/history directory (see its own
|
||||
/// doc comment), while `app_id` here is threaded straight into
|
||||
/// `BreadClient::connect(app_id)` and must be the caller's own namespace, or
|
||||
/// `BreadClient::emit`'s `validate_app_namespace` check
|
||||
/// (`event.starts_with("bread.{app_id}.")`) rejects `event` and drops it
|
||||
/// with only an eprintln — passing `LAUNCHER_APP` here by mistake is exactly
|
||||
/// that bug. breadbox passes its own `"box"` (see breadbox's `APP_ID`) so
|
||||
/// its events publish as `bread.box.*`, matching [`emit_launched`]'s doc
|
||||
/// example below.
|
||||
pub fn do_launch(entry: &DesktopEntry, app_id: &str, event: &str) {
|
||||
let cmd = entry.exec.trim();
|
||||
let spawned = if entry.terminal {
|
||||
let term = pick_terminal();
|
||||
Command::new(&term)
|
||||
.args(["-e", "bash", "-c", cmd])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
} else {
|
||||
Command::new("bash")
|
||||
.args(["-c", cmd])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
};
|
||||
if spawned.is_ok() {
|
||||
emit_launched(entry, app_id, event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes `event` under `app_id`'s bread namespace after a successful
|
||||
/// spawn — e.g. breadbox calls this with `app_id = "box"` and
|
||||
/// `event = "bread.box.launched"`, its own namespace. Fire-and-forget and
|
||||
/// non-fatal (`BreadClient::emit` never blocks or errors this caller) —
|
||||
/// breadd being absent must never affect launching itself. `app_id` must be
|
||||
/// the caller's *own* namespace id, not [`crate::LAUNCHER_APP`] — see
|
||||
/// [`do_launch`]'s doc comment for why those are different identities and
|
||||
/// what happens if they're confused.
|
||||
pub fn emit_launched(entry: &DesktopEntry, app_id: &str, event: &str) {
|
||||
let id = if entry.id.is_empty() {
|
||||
entry.exec.as_str()
|
||||
} else {
|
||||
entry.id.as_str()
|
||||
};
|
||||
BreadClient::connect(app_id).emit(
|
||||
event,
|
||||
serde_json::json!({ "id": id, "name": entry.name }),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Mirrors `bread_shared::apps::validate_app_namespace` exactly
|
||||
/// (`event.starts_with(&format!("bread.{app}."))`) without pulling in
|
||||
/// that crate here — this is the one check that decides whether
|
||||
/// [`emit_launched`]'s event actually gets published.
|
||||
fn passes_namespace_check(app_id: &str, event: &str) -> bool {
|
||||
event.starts_with(&format!("bread.{app_id}."))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documented_app_id_and_event_pair_passes_the_namespace_check() {
|
||||
// breadbox's real call site (breadbox/breadbox/src/main.rs):
|
||||
// APP_ID = "box", LAUNCHED_EVENT = "bread.box.launched".
|
||||
assert!(
|
||||
passes_namespace_check("box", "bread.box.launched"),
|
||||
"do_launch/emit_launched's own doc example must actually pass \
|
||||
BreadClient::emit's namespace check"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_app_is_not_a_valid_app_id_for_the_documented_event() {
|
||||
// The historical bug this doc fix guards against: passing
|
||||
// `LAUNCHER_APP` ("breadbox", the cache/history identity) as
|
||||
// `app_id` instead of the caller's own namespace id ("box") would
|
||||
// silently drop `bread.box.launched` — event.starts_with(
|
||||
// "bread.breadbox.") is false for "bread.box.launched".
|
||||
assert!(
|
||||
!passes_namespace_check(crate::LAUNCHER_APP, "bread.box.launched"),
|
||||
"LAUNCHER_APP must NOT satisfy the namespace check for the \
|
||||
documented event — if this ever passes, do_launch's doc comment \
|
||||
warning about confusing the two identities is wrong"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_launched_does_not_panic_when_breadd_is_unreachable() {
|
||||
// No daemon is running in a test environment — emit_launched (and
|
||||
// the BreadClient::emit it wraps) must degrade silently rather than
|
||||
// panicking or blocking, for both a valid and a namespace-violating
|
||||
// app_id.
|
||||
let entry = DesktopEntry {
|
||||
id: "firefox.desktop".to_string(),
|
||||
name: "Firefox".to_string(),
|
||||
exec: "firefox".to_string(),
|
||||
icon_name: String::new(),
|
||||
icon_path: None,
|
||||
categories: vec![],
|
||||
wm_class: None,
|
||||
terminal: false,
|
||||
};
|
||||
emit_launched(&entry, "box", "bread.box.launched");
|
||||
emit_launched(&entry, crate::LAUNCHER_APP, "bread.box.launched");
|
||||
}
|
||||
}
|
||||
63
bread-launcher/src/lib.rs
Normal file
63
bread-launcher/src/lib.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
//! Headless app-launcher core — desktop-entry discovery, fuzzy matching and
|
||||
//! ranking, launch history, and process launching — plus an optional GTK4
|
||||
//! results-list widget behind the `gtk` feature.
|
||||
//!
|
||||
//! Lives in `bread-ecosystem`, not an app repo, so breadbar (which must not
|
||||
//! depend on an app repo) can embed the same launcher logic breadbox's
|
||||
//! overlay window already wraps: one implementation, two hosts
|
||||
//! (`THEME_SYSTEM_PLAN.md` §3, §7).
|
||||
//!
|
||||
//! Every path/cache/history entry point here takes an explicit `app: &str`
|
||||
//! rather than hardcoding an app name, so more than one host can use this
|
||||
//! crate without colliding — see [`cache_dir`]/[`config_dir`]. [`LAUNCHER_APP`]
|
||||
//! is the one identity every *launcher* host (as opposed to some unrelated
|
||||
//! future consumer of `cache_dir`/`config_dir`) should actually pass — see
|
||||
//! its own doc comment for why.
|
||||
|
||||
mod desktop;
|
||||
mod history;
|
||||
mod icon;
|
||||
mod launch;
|
||||
mod matching;
|
||||
mod paths;
|
||||
mod query;
|
||||
|
||||
#[cfg(feature = "gtk")]
|
||||
pub mod gtk;
|
||||
|
||||
pub use desktop::{load_all_desktop_entries, parse_desktop, strip_exec_codes, DesktopEntry};
|
||||
pub use history::LaunchHistory;
|
||||
pub use icon::IconCache;
|
||||
pub use launch::{do_launch, emit_launched};
|
||||
pub use matching::{
|
||||
fuzzy_matches, fuzzy_score, load_sorted_entries, matches_term, priority_rank, split_sections,
|
||||
};
|
||||
pub use paths::{app_dirs, cache_dir, config_dir, home_dir};
|
||||
pub use query::{builtin_commands, eval_calc, filter_commands, parse_query, Command, ParsedQuery, QueryKind};
|
||||
|
||||
/// The launcher's one shared identity, passed to [`cache_dir`]/[`config_dir`]/
|
||||
/// [`IconCache::new`]/[`LaunchHistory::load`] by every host that embeds this
|
||||
/// crate — breadbox's overlay window AND breadbar's embedded capsule
|
||||
/// (theme 04/spotlight) alike.
|
||||
///
|
||||
/// This is deliberate, not a leftover of breadbox being first: theme 04's
|
||||
/// whole premise is that breadbar's capsule IS the launcher wearing a
|
||||
/// different shell, not a second launcher with its own history
|
||||
/// (`THEME_SYSTEM_PLAN.md` §7). If each host passed its own binary name here,
|
||||
/// the same physical launcher would rank a user's apps differently
|
||||
/// depending on which theme happened to be active — the icon cache and
|
||||
/// "most launched" ordering would silently fork in two. Sharing this
|
||||
/// constant is what keeps them one launcher.
|
||||
///
|
||||
/// Do not pass a bare `"breadbox"` string literal at a call site instead of
|
||||
/// this constant — that reads exactly like an unfixed bug (breadbar naming
|
||||
/// another app's identity) and invites a later "fix" that would quietly
|
||||
/// break the shared history this constant exists to guarantee.
|
||||
///
|
||||
/// **Not** the `app_id` for [`do_launch`]/[`emit_launched`]: those publish
|
||||
/// bread-bus events, which must be namespaced under the caller's *own*
|
||||
/// identity (breadbox's is `"box"`, not `"breadbox"`) or
|
||||
/// `BreadClient::emit`'s namespace check silently drops them. This constant
|
||||
/// is scoped to the cache/history path family only — see [`do_launch`]'s
|
||||
/// doc comment for the concrete failure mode if the two get swapped.
|
||||
pub const LAUNCHER_APP: &str = "breadbox";
|
||||
387
bread-launcher/src/matching.rs
Normal file
387
bread-launcher/src/matching.rs
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use crate::desktop::{load_all_desktop_entries, DesktopEntry};
|
||||
use crate::history::LaunchHistory;
|
||||
|
||||
// ---- Fuzzy matching (query filter) ------------------------------------------
|
||||
|
||||
/// Subsequence match used to *filter* rows as the user types: every char of
|
||||
/// `pattern`, in order, must appear somewhere in `text` (case-insensitive).
|
||||
/// Looser than [`fuzzy_score`], which ranks the rows that pass this filter.
|
||||
pub fn fuzzy_matches(pattern: &str, text: &str) -> bool {
|
||||
if pattern.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let mut chars = text.chars();
|
||||
for pc in pattern.chars() {
|
||||
let pl = pc.to_lowercase().next().unwrap_or(pc);
|
||||
if !chars
|
||||
.by_ref()
|
||||
.any(|tc| tc.to_lowercase().next().unwrap_or(tc) == pl)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Ranks how well `query` matches `entry` — lower is better. Exact match (by
|
||||
/// name or `wm_class`) sorts first, then name-prefix, then name-contains,
|
||||
/// then `wm_class`-prefix/contains, then everything else that still passed
|
||||
/// [`fuzzy_matches`] (a subsequence match with no stronger relationship).
|
||||
pub fn fuzzy_score(query: &str, entry: &DesktopEntry) -> u32 {
|
||||
let q = query.to_lowercase();
|
||||
let name = entry.name.to_lowercase();
|
||||
let wm = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
|
||||
if name == q || wm == q {
|
||||
return 0;
|
||||
}
|
||||
if name.starts_with(&q) {
|
||||
return 1;
|
||||
}
|
||||
if name.contains(&q) {
|
||||
return 2;
|
||||
}
|
||||
if wm.starts_with(&q) || wm.contains(&q) {
|
||||
return 3;
|
||||
}
|
||||
4 // subsequence match
|
||||
}
|
||||
|
||||
// ---- Priority ranking (empty-query ordering) --------------------------------
|
||||
|
||||
/// Whole-word / exact match of `term` within `field` (both lowercase). Avoids
|
||||
/// "code" matching "vscodium" while still matching "Code", "code-oss", and
|
||||
/// "Visual Studio Code".
|
||||
pub fn matches_term(field: &str, term: &str) -> bool {
|
||||
if term.is_empty() || field.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if field == term {
|
||||
return true;
|
||||
}
|
||||
let bytes = field.as_bytes();
|
||||
let tlen = term.len();
|
||||
let mut start = 0;
|
||||
while let Some(pos) = field[start..].find(term) {
|
||||
let i = start + pos;
|
||||
let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
|
||||
let after = i + tlen;
|
||||
let after_ok = after >= bytes.len() || !bytes[after].is_ascii_alphanumeric();
|
||||
if before_ok && after_ok {
|
||||
return true;
|
||||
}
|
||||
// Advance past the WHOLE match, not one byte past its start. Both `i`
|
||||
// (a match start) and `i + tlen` (its end) are guaranteed char
|
||||
// boundaries; `i + 1` is not, so a multi-byte term that failed the
|
||||
// word-boundary check left `start` inside a character and the next
|
||||
// `field[start..]` slice panicked outright. `matches_term("café", "é")`
|
||||
// reproduced it: "byte index 4 is not a char boundary". Reached via
|
||||
// priority_rank over real .desktop `Name=` values, so any non-ASCII
|
||||
// app name could crash the launcher's sort.
|
||||
start = i + tlen;
|
||||
if start >= field.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Position of `entry` in the (already-lowercased) `priority` list, matched
|
||||
/// against either its name or `wm_class`. `None` if `entry` isn't named
|
||||
/// there at all.
|
||||
pub fn priority_rank(entry: &DesktopEntry, priority_lower: &[String]) -> Option<usize> {
|
||||
let name_l = entry.name.to_lowercase();
|
||||
let wm_l = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
|
||||
priority_lower
|
||||
.iter()
|
||||
.position(|p| matches_term(&name_l, p) || matches_term(&wm_l, p))
|
||||
}
|
||||
|
||||
/// Loads every known desktop entry, resolves each one's icon path from
|
||||
/// `manifest`, and sorts them: entries named in `priority` come first (in
|
||||
/// that order), then everything else by most-launched (via `history`), then
|
||||
/// alphabetically.
|
||||
pub fn load_sorted_entries(
|
||||
manifest: &HashMap<String, PathBuf>,
|
||||
priority: &[String],
|
||||
history: &LaunchHistory,
|
||||
) -> Vec<DesktopEntry> {
|
||||
let mut entries = load_all_desktop_entries();
|
||||
|
||||
// Populate icon_path from manifest
|
||||
for entry in &mut entries {
|
||||
if let Some(path) = manifest.get(&entry.icon_name) {
|
||||
if path.exists() {
|
||||
entry.icon_path = Some(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let priority_lower: Vec<String> = priority.iter().map(|s| s.to_lowercase()).collect();
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
let ai = priority_rank(a, &priority_lower);
|
||||
let bi = priority_rank(b, &priority_lower);
|
||||
match (ai, bi) {
|
||||
(Some(i), Some(j)) => i.cmp(&j),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => {
|
||||
// Most-launched first, then alphabetical
|
||||
history
|
||||
.count(&b.name)
|
||||
.cmp(&history.count(&a.name))
|
||||
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
/// The demo's own cap (`BOS.pushRecent`'s `.slice(0, 4)`) on how many
|
||||
/// entries the "Recent" section shows.
|
||||
pub const MAX_RECENT: usize = 4;
|
||||
|
||||
/// Splits `entries` (already ordered by [`load_sorted_entries`]) into a
|
||||
/// "recent" section — the entries `history` has any launch count for, most-
|
||||
/// launched first, capped at [`MAX_RECENT`] — and an "apps" section: every
|
||||
/// other entry, in its existing relative order. No new tracking beyond
|
||||
/// `LaunchHistory`'s existing counts (THEME_SYSTEM_PLAN.md phase 6c task
|
||||
/// notes: "`LaunchHistory` already tracks counts for a recents list").
|
||||
///
|
||||
/// Only meaningful when `entries` has no priority-ranked prefix (breadbar's
|
||||
/// capsule calls [`load_sorted_entries`] with an empty `priority` list) —
|
||||
/// with a non-empty priority list, priority-ranked entries still sort first
|
||||
/// and would be treated as "apps" here even if launched often, since this
|
||||
/// function has no way to tell "sorted first because launched a lot" from
|
||||
/// "sorted first because priority-ranked" apart from the count itself.
|
||||
pub fn split_sections(
|
||||
entries: Vec<DesktopEntry>,
|
||||
history: &LaunchHistory,
|
||||
) -> (Vec<DesktopEntry>, Vec<DesktopEntry>) {
|
||||
let mut recent = Vec::new();
|
||||
let mut apps = Vec::new();
|
||||
for entry in entries {
|
||||
if recent.len() < MAX_RECENT && history.count(&entry.name) > 0 {
|
||||
recent.push(entry);
|
||||
} else {
|
||||
apps.push(entry);
|
||||
}
|
||||
}
|
||||
(recent, apps)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn multibyte_term_that_fails_word_boundary_does_not_panic() {
|
||||
// "é" occurs in "café" but is preceded by an alphanumeric, so the
|
||||
// whole-word check fails and the scan must continue — landing mid
|
||||
// character before the fix.
|
||||
assert!(!super::matches_term("café", "é"));
|
||||
assert!(!super::matches_term("naïve café", "ï"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_whole_word_still_matches() {
|
||||
assert!(super::matches_term("café bar", "café"));
|
||||
assert!(super::matches_term("día", "día"));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn entry(name: &str, wm_class: Option<&str>) -> DesktopEntry {
|
||||
DesktopEntry {
|
||||
id: format!("{name}.desktop"),
|
||||
name: name.to_string(),
|
||||
exec: "true".to_string(),
|
||||
icon_name: String::new(),
|
||||
icon_path: None,
|
||||
categories: Vec::new(),
|
||||
wm_class: wm_class.map(|s| s.to_string()),
|
||||
terminal: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- fuzzy_matches -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_empty_pattern_matches_anything() {
|
||||
assert!(fuzzy_matches("", "Firefox"));
|
||||
assert!(fuzzy_matches("", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_in_order_subsequence() {
|
||||
assert!(fuzzy_matches("ffx", "Firefox"));
|
||||
assert!(fuzzy_matches("frfx", "Firefox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_is_case_insensitive() {
|
||||
assert!(fuzzy_matches("FIREFOX", "firefox"));
|
||||
assert!(fuzzy_matches("firefox", "FireFox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_rejects_out_of_order() {
|
||||
assert!(!fuzzy_matches("xfr", "Firefox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matches_rejects_missing_chars() {
|
||||
assert!(!fuzzy_matches("firefoxx", "Firefox"));
|
||||
}
|
||||
|
||||
// ---- fuzzy_score -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn fuzzy_score_exact_name_match_is_best() {
|
||||
let e = entry("Firefox", None);
|
||||
assert_eq!(fuzzy_score("firefox", &e), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_score_exact_wm_class_match_is_best() {
|
||||
let e = entry("Firefox Web Browser", Some("firefox"));
|
||||
assert_eq!(fuzzy_score("firefox", &e), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_score_name_prefix_beats_name_contains() {
|
||||
let prefix = entry("Firefox", None);
|
||||
let contains = entry("GNU IceCat (Firefox fork)", None);
|
||||
assert_eq!(fuzzy_score("fire", &prefix), 1);
|
||||
assert_eq!(fuzzy_score("fire", &contains), 2);
|
||||
assert!(fuzzy_score("fire", &prefix) < fuzzy_score("fire", &contains));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_score_wm_class_beats_pure_subsequence() {
|
||||
let wm_hit = entry("Web Browser", Some("firefox"));
|
||||
let subseq_only = entry("Fine Iris Reflex Editor for XML", None);
|
||||
assert_eq!(fuzzy_score("fire", &wm_hit), 3);
|
||||
assert_eq!(fuzzy_score("fire", &subseq_only), 4);
|
||||
}
|
||||
|
||||
// ---- matches_term ------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn matches_term_exact_field_matches() {
|
||||
assert!(matches_term("code", "code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_term_whole_word_within_longer_field() {
|
||||
assert!(matches_term("visual studio code", "code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_term_rejects_substring_of_a_larger_word() {
|
||||
// "code" must not match inside "vscodium" — this is the whole
|
||||
// reason matches_term exists instead of a plain `contains`.
|
||||
assert!(!matches_term("vscodium", "code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_term_matches_hyphenated_variant() {
|
||||
assert!(matches_term("code-oss", "code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_term_empty_term_or_field_never_matches() {
|
||||
assert!(!matches_term("code", ""));
|
||||
assert!(!matches_term("", "code"));
|
||||
}
|
||||
|
||||
// ---- priority_rank -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn priority_rank_matches_by_name() {
|
||||
let e = entry("Firefox", None);
|
||||
let priority = vec!["firefox".to_string(), "code".to_string()];
|
||||
assert_eq!(priority_rank(&e, &priority), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_rank_matches_by_wm_class() {
|
||||
let e = entry("Web Browser", Some("firefox"));
|
||||
let priority = vec!["code".to_string(), "firefox".to_string()];
|
||||
assert_eq!(priority_rank(&e, &priority), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_rank_none_when_unlisted() {
|
||||
let e = entry("Nautilus", None);
|
||||
let priority = vec!["firefox".to_string()];
|
||||
assert_eq!(priority_rank(&e, &priority), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_rank_does_not_match_substring_of_a_word() {
|
||||
let e = entry("VSCodium", None);
|
||||
let priority = vec!["code".to_string()];
|
||||
assert_eq!(priority_rank(&e, &priority), None);
|
||||
}
|
||||
|
||||
// ---- split_sections --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn split_sections_no_history_is_all_apps() {
|
||||
let entries = vec![entry("Firefox", None), entry("GoLand", None)];
|
||||
let history = LaunchHistory::from_counts(HashMap::new());
|
||||
let (recent, apps) = split_sections(entries, &history);
|
||||
assert!(recent.is_empty());
|
||||
assert_eq!(apps.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sections_launched_entries_go_to_recent() {
|
||||
let entries = vec![
|
||||
entry("Firefox", None),
|
||||
entry("GoLand", None),
|
||||
entry("Steam", None),
|
||||
];
|
||||
let mut counts = HashMap::new();
|
||||
counts.insert("Firefox".to_string(), 5);
|
||||
let history = LaunchHistory::from_counts(counts);
|
||||
let (recent, apps) = split_sections(entries, &history);
|
||||
assert_eq!(recent.iter().map(|e| &e.name).collect::<Vec<_>>(), vec!["Firefox"]);
|
||||
assert_eq!(
|
||||
apps.iter().map(|e| &e.name).collect::<Vec<_>>(),
|
||||
vec!["GoLand", "Steam"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sections_caps_recent_at_max() {
|
||||
let entries: Vec<DesktopEntry> = (0..(MAX_RECENT + 2))
|
||||
.map(|i| entry(&format!("App{i}"), None))
|
||||
.collect();
|
||||
let counts = entries
|
||||
.iter()
|
||||
.map(|e| (e.name.clone(), 1))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let history = LaunchHistory::from_counts(counts);
|
||||
let (recent, apps) = split_sections(entries, &history);
|
||||
assert_eq!(recent.len(), MAX_RECENT);
|
||||
assert_eq!(apps.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sections_preserves_relative_order_within_each_group() {
|
||||
let mut counts = HashMap::new();
|
||||
counts.insert("A".to_string(), 1);
|
||||
counts.insert("C".to_string(), 3);
|
||||
let history = LaunchHistory::from_counts(counts);
|
||||
// load_sorted_entries would already have ordered these by count
|
||||
// desc before calling split_sections; split_sections itself just
|
||||
// partitions in whatever order it's handed, so feed it pre-sorted.
|
||||
let pre_sorted = vec![entry("C", None), entry("A", None), entry("B", None)];
|
||||
let (recent, apps) = split_sections(pre_sorted, &history);
|
||||
assert_eq!(recent.iter().map(|e| &e.name).collect::<Vec<_>>(), vec!["C", "A"]);
|
||||
assert_eq!(apps.iter().map(|e| &e.name).collect::<Vec<_>>(), vec!["B"]);
|
||||
}
|
||||
}
|
||||
50
bread-launcher/src/paths.rs
Normal file
50
bread-launcher/src/paths.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
use std::{env, path::PathBuf};
|
||||
|
||||
// ---- XDG path helpers -------------------------------------------------------
|
||||
|
||||
pub fn home_dir() -> PathBuf {
|
||||
PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into()))
|
||||
}
|
||||
|
||||
/// `$XDG_CACHE_HOME/<app>` (or `~/.cache/<app>`). `app` is the caller's own
|
||||
/// name in this scheme — e.g. breadbox passes `"breadbox"` to keep using the
|
||||
/// on-disk layout it always has; a future host picks its own.
|
||||
pub fn cache_dir(app: &str) -> PathBuf {
|
||||
env::var("XDG_CACHE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| home_dir().join(".cache"))
|
||||
.join(app)
|
||||
}
|
||||
|
||||
/// `$XDG_CONFIG_HOME/<app>` (or `~/.config/<app>`). See [`cache_dir`].
|
||||
pub fn config_dir(app: &str) -> PathBuf {
|
||||
env::var("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| home_dir().join(".config"))
|
||||
.join(app)
|
||||
}
|
||||
|
||||
/// The `applications/` directories a `.desktop` file may live in, per the
|
||||
/// XDG base-directory spec (system-wide first, user-local last so later
|
||||
/// entries can override earlier ones on lookup by filename).
|
||||
pub fn app_dirs() -> Vec<PathBuf> {
|
||||
let home = home_dir();
|
||||
let mut dirs = vec![PathBuf::from("/usr/share/applications")];
|
||||
|
||||
let xdg_data_dirs =
|
||||
env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".into());
|
||||
for d in xdg_data_dirs.split(':') {
|
||||
let p = PathBuf::from(d).join("applications");
|
||||
if p != dirs[0] {
|
||||
dirs.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
dirs.push(
|
||||
env::var("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| home.join(".local/share"))
|
||||
.join("applications"),
|
||||
);
|
||||
dirs
|
||||
}
|
||||
391
bread-launcher/src/query.rs
Normal file
391
bread-launcher/src/query.rs
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
//! Query modes (`04-spotlight.html`'s `BOS.parseQuery`/`BOS.evalCalc`/
|
||||
//! `BOS.COMMANDS`, THEME_SYSTEM_PLAN.md phase 6c): a leading `=`/`>`/`.`
|
||||
//! switches the launcher from filtering apps to evaluating an arithmetic
|
||||
//! expression, listing bread commands, or treating the rest of the query as
|
||||
//! a URL to open. Pure/headless — no GTK here — so both breadbar's embedded
|
||||
//! capsule and (per the module doc comment on `crate`) breadbox's own
|
||||
//! overlay window can adopt the same parsing/eval/filter logic. A host is
|
||||
//! expected to gate which prefixes it actually acts on against its theme's
|
||||
//! `[launcher].modes` list — this module recognizes all four kinds
|
||||
//! unconditionally and leaves that gating to the caller.
|
||||
|
||||
use crate::matching::fuzzy_matches;
|
||||
|
||||
/// Which of the four query modes a raw entry-text string names, per its
|
||||
/// leading character (mirrors `BOS.parseQuery`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum QueryKind {
|
||||
/// No recognized prefix — the ordinary app-filter query.
|
||||
Apps,
|
||||
/// Leading `=` — the rest is an arithmetic expression for [`eval_calc`].
|
||||
Calc,
|
||||
/// Leading `>` — the rest filters [`builtin_commands`].
|
||||
Cmd,
|
||||
/// Leading `.` — the rest is a URL to open.
|
||||
Url,
|
||||
}
|
||||
|
||||
/// A parsed query: which mode it names, and the text after the prefix
|
||||
/// character (empty string for a bare `=`/`>`/`.` with nothing typed yet).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedQuery {
|
||||
pub kind: QueryKind,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Splits `raw` on its leading mode character, if any. Mirrors
|
||||
/// `BOS.parseQuery` exactly: only the FIRST character is checked, and it is
|
||||
/// stripped along with any immediately-following whitespace.
|
||||
pub fn parse_query(raw: &str) -> ParsedQuery {
|
||||
let (kind, rest) = if let Some(rest) = raw.strip_prefix('=') {
|
||||
(QueryKind::Calc, rest)
|
||||
} else if let Some(rest) = raw.strip_prefix('>') {
|
||||
(QueryKind::Cmd, rest)
|
||||
} else if let Some(rest) = raw.strip_prefix('.') {
|
||||
(QueryKind::Url, rest)
|
||||
} else {
|
||||
(QueryKind::Apps, raw)
|
||||
};
|
||||
ParsedQuery {
|
||||
kind,
|
||||
value: rest.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Calc ---------------------------------------------------------------
|
||||
|
||||
/// Evaluates `expr` as a small four-function arithmetic expression
|
||||
/// (`+ - * / ( )`, decimal literals, unary minus) and formats the result
|
||||
/// the same way `BOS.evalCalc` does: rounded to 8 decimal places, an
|
||||
/// integer result prints with no trailing `.0`, a non-finite result prints
|
||||
/// "∞", an expression containing anything outside `[0-9.\s+\-*/()]`
|
||||
/// returns "bad expr", and anything else that fails to parse or evaluate
|
||||
/// (unbalanced parens, division producing NaN, trailing garbage) returns
|
||||
/// "err". `None` only for an empty/whitespace-only expression — the
|
||||
/// caller's "nothing typed after `=` yet" case, which has no result to
|
||||
/// show at all (the demo's own `if (!expr) return null;`).
|
||||
pub fn eval_calc(expr: &str) -> Option<String> {
|
||||
let expr = expr.trim();
|
||||
if expr.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !expr.chars().all(|c| c.is_ascii_digit() || " .+-*/()".contains(c)) {
|
||||
return Some("bad expr".to_string());
|
||||
}
|
||||
let mut p = CalcParser {
|
||||
bytes: expr.as_bytes(),
|
||||
pos: 0,
|
||||
};
|
||||
let result = p.parse_expr().filter(|_| p.skip_ws() == p.bytes.len());
|
||||
Some(match result {
|
||||
Some(n) if !n.is_finite() => "∞".to_string(),
|
||||
Some(n) => format_calc_result(n),
|
||||
None => "err".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Rounds to 8 decimal places and formats without a trailing `.0` for whole
|
||||
/// numbers — `String(Math.round(n * 1e8) / 1e8)` in JS, `{}` on `f64`
|
||||
/// already behaves the same way in Rust (`format!("{}", 4.0_f64)` == "4").
|
||||
fn format_calc_result(n: f64) -> String {
|
||||
let rounded = (n * 1e8).round() / 1e8;
|
||||
// Avoid printing "-0" for a result that rounds to negative zero.
|
||||
let rounded = if rounded == 0.0 { 0.0 } else { rounded };
|
||||
format!("{rounded}")
|
||||
}
|
||||
|
||||
/// Minimal recursive-descent parser: `expr := term (('+'|'-') term)*`,
|
||||
/// `term := factor (('*'|'/') factor)*`, `factor := '-' factor | number |
|
||||
/// '(' expr ')'`. Byte-indexed since the character set is already
|
||||
/// restricted to ASCII by [`eval_calc`]'s pre-check.
|
||||
struct CalcParser<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> CalcParser<'a> {
|
||||
fn skip_ws(&mut self) -> usize {
|
||||
while self.pos < self.bytes.len() && self.bytes[self.pos] == b' ' {
|
||||
self.pos += 1;
|
||||
}
|
||||
self.pos
|
||||
}
|
||||
|
||||
fn peek(&mut self) -> Option<u8> {
|
||||
self.skip_ws();
|
||||
self.bytes.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn parse_expr(&mut self) -> Option<f64> {
|
||||
let mut val = self.parse_term()?;
|
||||
loop {
|
||||
match self.peek() {
|
||||
Some(b'+') => {
|
||||
self.pos += 1;
|
||||
val += self.parse_term()?;
|
||||
}
|
||||
Some(b'-') => {
|
||||
self.pos += 1;
|
||||
val -= self.parse_term()?;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Some(val)
|
||||
}
|
||||
|
||||
fn parse_term(&mut self) -> Option<f64> {
|
||||
let mut val = self.parse_factor()?;
|
||||
loop {
|
||||
match self.peek() {
|
||||
Some(b'*') => {
|
||||
self.pos += 1;
|
||||
val *= self.parse_factor()?;
|
||||
}
|
||||
Some(b'/') => {
|
||||
self.pos += 1;
|
||||
val /= self.parse_factor()?;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Some(val)
|
||||
}
|
||||
|
||||
fn parse_factor(&mut self) -> Option<f64> {
|
||||
match self.peek()? {
|
||||
b'-' => {
|
||||
self.pos += 1;
|
||||
Some(-self.parse_factor()?)
|
||||
}
|
||||
b'+' => {
|
||||
self.pos += 1;
|
||||
self.parse_factor()
|
||||
}
|
||||
b'(' => {
|
||||
self.pos += 1;
|
||||
let val = self.parse_expr()?;
|
||||
if self.peek() == Some(b')') {
|
||||
self.pos += 1;
|
||||
Some(val)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
c if c.is_ascii_digit() || c == b'.' => self.parse_number(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_number(&mut self) -> Option<f64> {
|
||||
self.skip_ws();
|
||||
let start = self.pos;
|
||||
let mut seen_dot = false;
|
||||
let mut seen_digit = false;
|
||||
while let Some(&c) = self.bytes.get(self.pos) {
|
||||
if c.is_ascii_digit() {
|
||||
seen_digit = true;
|
||||
self.pos += 1;
|
||||
} else if c == b'.' && !seen_dot {
|
||||
seen_dot = true;
|
||||
self.pos += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !seen_digit {
|
||||
return None;
|
||||
}
|
||||
std::str::from_utf8(&self.bytes[start..self.pos])
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Commands -------------------------------------------------------------
|
||||
|
||||
/// One `>`-mode command palette entry: a display `name` and the shell
|
||||
/// command it runs (via `bash -c`, same spawn convention [`crate::do_launch`]
|
||||
/// already uses for a desktop entry's `Exec=` line).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Command {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub exec: &'static str,
|
||||
}
|
||||
|
||||
/// A small, real bread-ecosystem command palette — deliberately not the
|
||||
/// demo's placeholder set (`Lock session`/`Open settings`/`Test
|
||||
/// notification` map to nothing real in this codebase). `loginctl
|
||||
/// lock-session` and `bread reload` are both already-documented, safe,
|
||||
/// no-argument commands (see the bread CLI reference / systemd-logind).
|
||||
pub fn builtin_commands() -> &'static [Command] {
|
||||
&[
|
||||
Command {
|
||||
id: "lock",
|
||||
name: "Lock session",
|
||||
exec: "loginctl lock-session",
|
||||
},
|
||||
Command {
|
||||
id: "reload-breadd",
|
||||
name: "Reload breadd",
|
||||
exec: "bread reload",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Fuzzy-filters `commands` by `query` against each command's `name` (same
|
||||
/// subsequence match [`crate::fuzzy_matches`] uses for app rows) — an empty
|
||||
/// query matches everything, same as the app list's own empty-query case.
|
||||
pub fn filter_commands(query: &str, commands: &[Command]) -> Vec<Command> {
|
||||
commands
|
||||
.iter()
|
||||
.filter(|c| fuzzy_matches(query, c.name))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ---- parse_query -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_query_bare_text_is_apps() {
|
||||
let p = parse_query("firefox");
|
||||
assert_eq!(p.kind, QueryKind::Apps);
|
||||
assert_eq!(p.value, "firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_query_empty_is_apps() {
|
||||
let p = parse_query("");
|
||||
assert_eq!(p.kind, QueryKind::Apps);
|
||||
assert_eq!(p.value, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_query_equals_is_calc() {
|
||||
let p = parse_query("=2+2");
|
||||
assert_eq!(p.kind, QueryKind::Calc);
|
||||
assert_eq!(p.value, "2+2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_query_gt_is_cmd() {
|
||||
let p = parse_query(">lock");
|
||||
assert_eq!(p.kind, QueryKind::Cmd);
|
||||
assert_eq!(p.value, "lock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_query_dot_is_url() {
|
||||
let p = parse_query(".breadway.dev");
|
||||
assert_eq!(p.kind, QueryKind::Url);
|
||||
assert_eq!(p.value, "breadway.dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_query_strips_leading_whitespace_after_prefix() {
|
||||
let p = parse_query("= 2 + 2 ");
|
||||
assert_eq!(p.kind, QueryKind::Calc);
|
||||
assert_eq!(p.value, "2 + 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_query_bare_prefix_has_empty_value() {
|
||||
assert_eq!(parse_query("=").value, "");
|
||||
assert_eq!(parse_query(">").value, "");
|
||||
assert_eq!(parse_query(".").value, "");
|
||||
}
|
||||
|
||||
// ---- eval_calc -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn eval_calc_empty_expr_is_none() {
|
||||
assert_eq!(eval_calc(""), None);
|
||||
assert_eq!(eval_calc(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_simple_addition() {
|
||||
assert_eq!(eval_calc("2+2"), Some("4".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_precedence() {
|
||||
assert_eq!(eval_calc("2+3*4"), Some("14".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_parens() {
|
||||
assert_eq!(eval_calc("(2+3)*4"), Some("20".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_unary_minus() {
|
||||
assert_eq!(eval_calc("-5+2"), Some("-3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_decimals_round_to_8_places() {
|
||||
assert_eq!(eval_calc("0.1+0.2"), Some("0.3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_division_by_zero_is_infinity_symbol() {
|
||||
assert_eq!(eval_calc("1/0"), Some("∞".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_bad_chars_is_bad_expr() {
|
||||
assert_eq!(eval_calc("2+alert(1)"), Some("bad expr".to_string()));
|
||||
assert_eq!(eval_calc("rm -rf /"), Some("bad expr".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_unbalanced_parens_is_err() {
|
||||
assert_eq!(eval_calc("(2+3"), Some("err".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_trailing_garbage_is_err() {
|
||||
assert_eq!(eval_calc("2+3)"), Some("err".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_double_operator_is_err() {
|
||||
assert_eq!(eval_calc("2++"), Some("err".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eval_calc_whitespace_is_tolerated() {
|
||||
assert_eq!(eval_calc(" 2 + 2 "), Some("4".to_string()));
|
||||
}
|
||||
|
||||
// ---- commands --------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn builtin_commands_are_non_empty() {
|
||||
assert!(!builtin_commands().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_commands_empty_query_matches_all() {
|
||||
let all = builtin_commands();
|
||||
assert_eq!(filter_commands("", all).len(), all.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_commands_filters_by_name_subsequence() {
|
||||
let matches = filter_commands("lock", builtin_commands());
|
||||
assert!(matches.iter().any(|c| c.id == "lock"));
|
||||
assert!(!matches.iter().any(|c| c.id == "reload-breadd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_commands_no_match_is_empty() {
|
||||
assert!(filter_commands("zzzznotacommand", builtin_commands()).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
370
bread-polkit/src/agent.rs
Normal file
370
bread-polkit/src/agent.rs
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
//! 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 allowed_users: Vec<String> = users.iter().map(|u| u.name.clone()).collect();
|
||||
|
||||
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, &allowed_users, &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,
|
||||
allowed_users: &[String],
|
||||
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 }) => {
|
||||
// The username field is user-editable; only accept it when
|
||||
// it's one of the identities polkit offered (empty falls
|
||||
// back to the prefilled user). Anything else is rejected
|
||||
// and the prompt re-shown rather than starting a PAM
|
||||
// conversation for an account the request never offered.
|
||||
let Some(user) = resolve_user(default_user, allowed_users, &username) else {
|
||||
let cookie = cookie.to_string();
|
||||
invoke_ui(move || ui::show_retry(&cookie, INVALID_USER_MESSAGE));
|
||||
continue;
|
||||
};
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const INVALID_USER_MESSAGE: &str =
|
||||
"That user is not one of the identities this request offered — use the prefilled user.";
|
||||
|
||||
/// Choose the username to authenticate as from the prompt input.
|
||||
///
|
||||
/// Empty input falls back to `default`. Non-empty input must be one of
|
||||
/// `allowed` — the `unix-user` identities polkit actually offered in
|
||||
/// `BeginAuthentication` — otherwise it returns `None`. Without this, a
|
||||
/// request scoped to specific accounts (say, `root` only) could have its
|
||||
/// editable username field redirected to an arbitrary local user, kicking
|
||||
/// off a PAM conversation for an account the request never offered.
|
||||
/// (`auth::authenticate`'s PAM result still has to clear polkit's own
|
||||
/// authorization, but this closes the obvious foot-gun at the agent — the
|
||||
/// one place the identity list is actually known.)
|
||||
fn resolve_user(default: &str, allowed: &[String], input: &str) -> Option<String> {
|
||||
if input.is_empty() {
|
||||
return Some(default.to_string());
|
||||
}
|
||||
if allowed.iter().any(|u| u.as_str() == input) {
|
||||
return Some(input.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn allowed() -> Vec<String> {
|
||||
vec!["root".to_string(), "1000".to_string()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_user_falls_back_to_default_on_empty_input() {
|
||||
assert_eq!(
|
||||
resolve_user("root", &allowed(), ""),
|
||||
Some("root".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_user_accepts_an_offered_identity() {
|
||||
assert_eq!(
|
||||
resolve_user("root", &allowed(), "1000"),
|
||||
Some("1000".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_user_rejects_a_user_polkit_did_not_offer() {
|
||||
assert_eq!(resolve_user("root", &allowed(), "alice"), None);
|
||||
assert_eq!(resolve_user("root", &allowed(), "daemon"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_user_is_case_exact() {
|
||||
// Usernames are case-significant; "ROOT" is a different principle
|
||||
// than the offered "root", so it must be rejected.
|
||||
assert_eq!(resolve_user("root", &allowed(), "ROOT"), None);
|
||||
}
|
||||
}
|
||||
|
||||
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(users: &[UnixUser], current_uid: Option<u32>) -> Option<&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;
|
||||
99
bread-polkit/src/main.rs
Normal file
99
bread-polkit/src/main.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
//! 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) => {
|
||||
// Don't keep running without the single-instance lock: a second
|
||||
// copy would attempt to `serve_at` the same PolicyKit agent
|
||||
// object path on the system bus, and a password prompt held by a
|
||||
// process whose lock couldn't be taken is ambiguous state. Fail
|
||||
// fast and let a wrapper/autostart retry.
|
||||
eprintln!("bread-polkit: singleton lock unavailable ({e}); exiting");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
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"`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,30 @@ keywords = ["theming", "pywal", "gtk4", "wayland"]
|
|||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
# bread_theme::shell manifest parsing (theme.toml) — gtk-free, so `bread`
|
||||
# (daemon) and `breadcrumbs` (CLI) can validate a theme without linking GTK.
|
||||
toml = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { 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.
|
||||
|
|
|
|||
117
bread-theme/assets/shell/daylight/daylight.css
Normal file
117
bread-theme/assets/shell/daylight/daylight.css
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/* CSS template for the daylight builtin (bread-theme/src/shell/builtin.rs).
|
||||
* Same scope and substitution rules as its three siblings: only the
|
||||
* window/workspace/clock chrome the manifest's own concepts model, `{name}`
|
||||
* tokens substituted, `@name` palette references passed through untouched.
|
||||
* Declared-but-not-yet-consumed in production, same as every sibling
|
||||
* template — see `ShellTheme::css`'s doc comment for why (breadbar hand-
|
||||
* rolls its own CSS in `breadbar::theme::load_css` instead of calling this
|
||||
* method). Exercised by this crate's own tests.
|
||||
*
|
||||
* Source: bos-ui-demos/proposed/daylight.html's <style> block.
|
||||
*
|
||||
* Unlike its three siblings, this template hardcodes which of `@bg`/`@on-bg`
|
||||
* plays "paper surface" versus "ink" directly (`@on-bg` as the fill, `@bg`
|
||||
* as the text) rather than branching on `{light}` at substitution time —
|
||||
* `Tokens::substitute` only does `{name}` replacement, not conditionals, and
|
||||
* this template is Daylight's alone, so there is nothing to branch FOR here.
|
||||
* `breadbar::theme::load_css` is the one place that genuinely needs to pick
|
||||
* between the two directions at runtime (it renders all four themes from
|
||||
* one function) — see its own `panel`/`ink` locals and Tokens::light's doc
|
||||
* comment for the general mechanism this template only needs one side of.
|
||||
*/
|
||||
|
||||
window.breadbar {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
color: @bg;
|
||||
}
|
||||
window.breadbar > box > centerbox { padding: 0 14px; }
|
||||
window.breadbar button { min-height: 0; min-width: 0; }
|
||||
|
||||
/* The three detached pills (axis 3, segmented) — see
|
||||
* breadbar::theme::load_css's `.bar-segment` rule for the identical
|
||||
* production version of this block.
|
||||
*/
|
||||
.bar-segment {
|
||||
background-color: alpha(@on-bg, {bg_alpha});
|
||||
border: 1px solid alpha(@bg, 0.10);
|
||||
border-radius: {radius_bar}px;
|
||||
box-shadow: 0 2px 10px alpha(@bg, 0.13);
|
||||
}
|
||||
|
||||
.workspace-trail {
|
||||
background-image: linear-gradient(90deg, @{accent_from}, @{accent_to});
|
||||
background-color: @{accent_from};
|
||||
border-radius: {radius_sm}px;
|
||||
}
|
||||
.workspace-btn {
|
||||
background: transparent;
|
||||
opacity: 0.36;
|
||||
color: @bg;
|
||||
border-radius: {radius_sm}px;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
min-width: 28px;
|
||||
min-height: {chip_height}px;
|
||||
margin: 0;
|
||||
padding: 0 7px;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
transition: opacity 0.22s {spring_settle}, background-color 0.22s {spring_settle};
|
||||
}
|
||||
.workspace-btn:hover { opacity: 0.85; background: alpha(@bg, 0.08); }
|
||||
.workspace-btn.occupied { opacity: 0.78; }
|
||||
.workspace-btn.active { background: transparent; color: @on-accent; opacity: 1; }
|
||||
.workspace-btn.active:hover { background: transparent; }
|
||||
|
||||
.clock-plain { padding: 0 4px; }
|
||||
.clock-plain-time {
|
||||
font-size: {font_size_base}px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.date-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.48;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Warm amber equaliser — the second accent (axis 1's colour-mapping note):
|
||||
* distinct from the teal workspace-trail accent above.
|
||||
*/
|
||||
.media-eq-bar {
|
||||
background-color: @{accent2};
|
||||
}
|
||||
|
||||
/* Light-theme overrides of the notification/OSD surfaces — the demo's own
|
||||
* `.notifs`/`.note`/`.osd` block, which explicitly replaces shared.css's
|
||||
* dark defaults. `@on-bg` (paper) / `@bg` (ink), not `@bg` (glass) / `@on-bg`
|
||||
* (light ink) the way every sibling theme's identical rule reads — this is
|
||||
* the one deliberate inversion this template makes relative to its
|
||||
* siblings, and it is exactly what `{light}` conditions in the shared Rust
|
||||
* implementation (breadbar::theme::load_css). See axis 1 in the task
|
||||
* report for the full inventory this one inversion stands in for.
|
||||
*/
|
||||
window.breadbar-osd {
|
||||
background-color: alpha(@on-bg, {bg_alpha});
|
||||
color: @bg;
|
||||
border-radius: {radius_pill}px;
|
||||
border: 1px solid alpha(@bg, 0.10);
|
||||
box-shadow: 0 8px 22px alpha(@bg, 0.16);
|
||||
}
|
||||
|
||||
window.breadbar-panel {
|
||||
background-color: alpha(@on-bg, {bg_alpha});
|
||||
color: @bg;
|
||||
border-radius: {radius_card}px;
|
||||
border: 1px solid alpha(@bg, 0.10);
|
||||
}
|
||||
|
||||
window.breadbar-dismiss {
|
||||
background-color: alpha(#000000, 0.02);
|
||||
}
|
||||
|
||||
.bread-widget-slot { margin-right: {pad}px; }
|
||||
281
bread-theme/assets/shell/daylight/theme.toml
Normal file
281
bread-theme/assets/shell/daylight/theme.toml
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
# The fourth compiled-in builtin (bread-theme/src/shell/builtin.rs), demo
|
||||
# "Daylight" in bos-ui-demos/proposed/daylight.html — a bottom-anchored dock
|
||||
# split into three DETACHED pills (workspaces | clock+media | stats)
|
||||
# floating on an otherwise fully transparent bar, ink-on-paper: near-opaque
|
||||
# white surfaces, soft drop shadows (blur is OFF — see [compositor] below),
|
||||
# deep teal accent, warm amber equaliser. This is the first built-in that is
|
||||
# LIGHT, the first anchored BOTTOM, the first SEGMENTED (three pills instead
|
||||
# of one bar surface), and the first with blur deliberately disabled — see
|
||||
# the task report for what each of those four axes required.
|
||||
#
|
||||
# Values are taken from bos-ui-demos/proposed/daylight.html's <style> block;
|
||||
# where the demo is silent (satellite offsets, bar-adjacent chip sizing)
|
||||
# this file re-derives from the dock's own edge, same method every sibling
|
||||
# theme.toml already documents.
|
||||
#
|
||||
# Font (Outfit is not installed on the dev machine, same gap liquid-motion/
|
||||
# spotlight already have — renders in fallback until ttf-outfit is
|
||||
# installed) and colour (the demo's #2f6d7a teal / #c2683c amber are *flat*
|
||||
# accents — mapped to this palette's `teal` / `yellow` tokens, see [tokens]'s
|
||||
# own note — never a literal hex value, or pywal theming breaks).
|
||||
|
||||
name = "Daylight"
|
||||
id = "daylight"
|
||||
|
||||
[tokens]
|
||||
font_family = "Outfit, sans-serif"
|
||||
font_fallback = "sans-serif"
|
||||
font_size_base = 14
|
||||
# `.bar-segment`'s radius (see `bar_border = "segmented"` below) — the
|
||||
# window itself (`window.breadbar`) draws no radius at all, since it draws
|
||||
# no fill/border either. Demo: `.seg { border-radius: 14px }`.
|
||||
radius_bar = 14
|
||||
# Notification/history card radius. Demo has no explicit `.note` radius of
|
||||
# its own (its override block only touches background/border/shadow/colour
|
||||
# — see shared.css's `.note { border-radius: 12px }` default) — 14px keeps
|
||||
# it a close, deliberate match to this theme's own popover/segment radius
|
||||
# instead of inheriting an unrelated demo's 12px.
|
||||
radius_card = 14
|
||||
# Chip/workspace-pill radius. Demo: `.chip`/`.wp`/`.trail` all draw a 9px
|
||||
# pill (concentric inside the 14px segment with a 7px inset, per the demo's
|
||||
# own comment next to `.chip`).
|
||||
radius_sm = 9
|
||||
radius_pill = 999
|
||||
# Demo: `.pop { padding: 13px }`.
|
||||
pad = 13
|
||||
# `.bar-segment` fill alpha — demo: `.seg { background: rgba(255,255,255,.94) }`,
|
||||
# a near-OPAQUE paper pill, not liquid-motion/glass-workbench's translucent
|
||||
# glass (0.72). Also reused for the notification/OSD/panel surfaces below
|
||||
# (the same `bg_alpha` token every sibling theme already reuses for more
|
||||
# than just the bar).
|
||||
bg_alpha = 0.94
|
||||
# The demo defines both curves distinctly (`--spring`/`--settle`), like
|
||||
# liquid-motion — not glass-workbench/spotlight's single shared curve.
|
||||
spring = "cubic-bezier(0.22, 1.35, 0.36, 1)"
|
||||
spring_settle = "cubic-bezier(0.22, 1.2, 0.36, 1)"
|
||||
# Palette token NAMES, not hex — #2f6d7a is this palette's `teal`. Equal
|
||||
# from/to because the demo's workspace-trail fill is flat
|
||||
# (`.trail { background: var(--accent) }`), not a gradient — same reasoning
|
||||
# as glass-workbench/spotlight's own flat accents. accent_from IS read (the
|
||||
# Trail gradient's start stop, and now — this theme is the reason — its end
|
||||
# stop too, see accent_to's own doc comment in types.rs).
|
||||
accent_from = "teal"
|
||||
accent_to = "teal"
|
||||
# A SECOND accent, independent of accent_from/accent_to: the demo's media
|
||||
# equaliser bars are warm amber (`--accent2: #c2683c`), not the workspace
|
||||
# trail's deep teal — one accent value can no longer describe both, which is
|
||||
# why this key exists at all (see Tokens::accent2's doc comment). Mapped to
|
||||
# `yellow` (pywal ANSI color3): the demo's amber reads warmer/more orange
|
||||
# than a pure hue, and `yellow` is the only remaining named accent slot not
|
||||
# already claimed by `accent`/`teal`/`green`/`pink` across the four built-in
|
||||
# themes that reads plausibly warm regardless of wallpaper. Consumed by
|
||||
# breadbar::theme::load_css's `.media-eq-bar` rule.
|
||||
accent2 = "yellow"
|
||||
# Demo: `.chip { height: 26px }` / `.wp { height: 26px }` on the 40px dock —
|
||||
# the same 26px liquid-motion's own Trail-style bar already uses (see
|
||||
# `breadbar::theme::approved_chip_height`, which is what actually governs
|
||||
# this in practice — see that function's own note on why this token is
|
||||
# otherwise a restated truth, not a live input).
|
||||
chip_height = 26
|
||||
# No single demo number for bar-icon glyph size (the demo's `.gl` box is
|
||||
# 16x16, but that is the icon's *swatch*, not the glyph-drawing size other
|
||||
# themes' icon_px models) — chosen proportionally between liquid-motion's
|
||||
# 24px (44px bar) and glass-workbench's 18px (36px bar) for this theme's
|
||||
# 40px dock.
|
||||
icon_px = 20
|
||||
# THE axis-3 (segmented) knob: `window.breadbar` itself gets no fill/
|
||||
# border/radius at all (drawn fully transparent); the bar's three slot-group
|
||||
# containers each get their own `.bar-segment` pill surface instead. See
|
||||
# Tokens::bar_border's doc comment and breadbar::theme::load_css's
|
||||
# `segmented` local for exactly what this changes.
|
||||
bar_border = "segmented"
|
||||
# THE axis-1 (light) knob — see Tokens::light's doc comment for why this
|
||||
# exists and exactly what it swaps. Every dark-assuming hardcode this flag
|
||||
# fixes is listed in the task report.
|
||||
light = true
|
||||
|
||||
[bar.window]
|
||||
# THE axis-2 (bottom-anchored) knob. Full-bleed left/right (`window.
|
||||
# breadbar`'s own bounds touch both screen edges, like every sibling
|
||||
# theme's top-anchored bar), with `bottom_margin` floating it 12px off the
|
||||
# bottom edge — the demo's own `.dock { left: 0; right: 0; bottom: 12px }`.
|
||||
# The 14px horizontal inset the demo's `.dock` draws via CSS `padding`
|
||||
# (not a window margin) is reproduced the same way: see `centerbox_padding`
|
||||
# in breadbar::theme::load_css, keyed off `bar_border == "segmented"`
|
||||
# exactly like glass-workbench's flush bar already keys its own padding off
|
||||
# `bar_border == "bottom"`.
|
||||
anchors = ["bottom", "left", "right"]
|
||||
width = "fill"
|
||||
height = 40
|
||||
margin = { top = 0, left = 0, right = 0, bottom = 12 }
|
||||
exclusive = "auto"
|
||||
keyboard = "none"
|
||||
layer = "top"
|
||||
|
||||
[bar.slots]
|
||||
# Same widget-alias shape as liquid-motion (plan §11 phase 1's fixed
|
||||
# interleave points), even though the demo itself shows no extra Lua
|
||||
# widget — so a widget requesting one of these placements has a home under
|
||||
# Daylight too, the same reasoning spotlight's own `widget:left_of_stats`
|
||||
# documents for its slimmer slot set.
|
||||
left = ["workspaces", "widget:right_of_workspaces"]
|
||||
centre = ["media", "widget:left_of_clock", "clock", "widget:right_of_clock"]
|
||||
right = ["widget:left_of_stats", "volume", "wifi", "battery", "control"]
|
||||
drawer = []
|
||||
|
||||
[modules.workspaces]
|
||||
style = "trail"
|
||||
show_empty = true
|
||||
|
||||
[modules.clock]
|
||||
# Demo: one time label + one date label (`.clock`/`.date`), not liquid-
|
||||
# motion's per-digit flip — same reasoning as glass-workbench's own "plain"
|
||||
# choice.
|
||||
style = "plain"
|
||||
format = "%H:%M"
|
||||
show_date = true
|
||||
|
||||
[launcher]
|
||||
mode = "overlay"
|
||||
# Demo: `.bx { width: min(430px, 90%) }`.
|
||||
width = 430
|
||||
# NOTE — known gap, see task report: `breadbox` (the process that actually
|
||||
# reads this field) is not one of this task's two repos, and `Launcher` has
|
||||
# no "anchor from bottom" concept at all — `top` is unconditionally a
|
||||
# distance from the TOP of the screen in every existing consumer. The demo's
|
||||
# launcher rises from just above the dock (`.bx { bottom: 62px }`), which
|
||||
# this schema/consumer pairing cannot express without a breadbox-side change
|
||||
# this task cannot make. 55% is a best-effort placement (roughly the lower
|
||||
# half of the screen, not flush against either edge) documented here as a
|
||||
# stopgap, not a real fix.
|
||||
top = "55%"
|
||||
# Demo: `.bx { border-radius: 18px }`.
|
||||
radius = 18
|
||||
# Demo: `.bx .ico { width: 26px; height: 26px }`.
|
||||
icon_px = 26
|
||||
# Demo's rows all fade/slide in together with a small per-index stagger
|
||||
# (`animation-delay: min(i,10)*20ms`) — closest existing `row_anim` value is
|
||||
# glass-workbench's "stagger" (the CSS classes it reuses are already 28ms
|
||||
# apart, not 20ms, but this schema has no numeric stagger-interval knob of
|
||||
# its own to match the demo's exactly).
|
||||
row_anim = "stagger"
|
||||
# Demo: a plain 1px hairline under the search field
|
||||
# (`.bx .q { border-bottom: 1px solid rgba(26,29,34,.09) }`), not liquid-
|
||||
# motion's gradient rule.
|
||||
rule = "hairline"
|
||||
footer = "count_apps"
|
||||
sections = true
|
||||
# Demo supports all four query prefixes (bare/`=`/`>`/`.`), same as
|
||||
# spotlight — its `<script>` reuses the identical shared.js query parser.
|
||||
modes = ["apps", "calc", "cmd", "url"]
|
||||
# `.bx .r { border-radius: 10px; margin: 0 7px; padding: 9px 11px }`
|
||||
row_radius = 10
|
||||
row_inset = 7
|
||||
row_padding_v = 9
|
||||
row_padding_h = 11
|
||||
# `.bx .ico { border-radius: 8px }` (paired with icon_px = 26 above)
|
||||
icon_radius = 8
|
||||
# `.bx .q { padding: 15px 17px; font-size: 15px }`
|
||||
search_font_size = 15
|
||||
search_padding_v = 15
|
||||
search_padding_h = 17
|
||||
# Demo: `.bx { background: var(--paper) }` — fully OPAQUE white, not
|
||||
# liquid-motion/glass-workbench's translucent glass. 0.97, not a bare 1.0,
|
||||
# to read as a deliberate "near-opaque paper" choice rather than a rounding
|
||||
# accident, matching this theme's own `bg_alpha`/pill-surface language.
|
||||
panel_alpha = 0.97
|
||||
# Demo: `.bx .r.sel { background: rgba(47,109,122,.15) }` — teal at 15%.
|
||||
selection_alpha = 0.15
|
||||
|
||||
# Keyed by layer-shell namespace, matching [compositor.*] below.
|
||||
#
|
||||
# `bottom_right`, not `top_right`: THE OTHER axis-2 gap this theme exposed
|
||||
# and fixed. Every sibling theme's bar sits at the TOP, so `top_right`
|
||||
# always put breadbar-notif/breadbar-panel naturally close to the bar; a
|
||||
# bottom-anchored dock has nothing in the original three-shape anchor set
|
||||
# that keeps its satellites near it. `bottom_right` is a new fourth anchor
|
||||
# shape (bread-theme/src/shell/manifest.rs + breadbar/src/surface.rs),
|
||||
# validated the same "typo'd key is a hard error" way as its siblings.
|
||||
# `offset` is `[right, bottom]`, mirroring `top_right`'s `[right, top]`
|
||||
# convention. This bar's own bottom edge sits at margin.bottom(12) +
|
||||
# height(40) = 52px, so 64 (52 + a 12px gap, the same gap-beyond-the-bar-
|
||||
# edge relationship every sibling theme's own top_right offset already
|
||||
# uses) keeps the popup just clear of the dock.
|
||||
[surfaces."breadbar-notif"]
|
||||
anchor = "bottom_right"
|
||||
offset = [16, 64]
|
||||
# 320, not the demo's own shared.css `.notifs { width: 250px }` — every
|
||||
# sibling theme's breadbar-notif width already follows breadbar's real
|
||||
# `set_default_width(320)` live-toast constant instead of the demo markup's
|
||||
# arbitrary number (see liquid-motion/theme.toml's own note on this), and
|
||||
# this theme follows that same real-app-fidelity precedent rather than the
|
||||
# demo's number.
|
||||
width = 320
|
||||
layer = "overlay"
|
||||
|
||||
[surfaces."breadbar-osd"]
|
||||
# Demo: `.osd { bottom: 64px }` — independent of `anchor`, this already
|
||||
# matches the notif/panel offset above (both landed on 64 from the same
|
||||
# "12px clear of the dock's 52px edge" reasoning, coincidentally the same
|
||||
# number as the notif/panel `offset[1]`).
|
||||
anchor = "bottom_centre"
|
||||
offset = 64
|
||||
width = 180
|
||||
layer = "overlay"
|
||||
|
||||
[surfaces."breadbar-panel"]
|
||||
anchor = "bottom_right"
|
||||
offset = [16, 64]
|
||||
width = "auto"
|
||||
layer = "overlay"
|
||||
|
||||
[surfaces."breadbar-dismiss"]
|
||||
# THE THIRD axis-2 consequence: `fill`'s `offset` was single-value
|
||||
# (top-margin-only) before this theme — a bottom-anchored bar needs the
|
||||
# scrim's GAP at the BOTTOM instead, so `breadbar/src/surface.rs::apply`'s
|
||||
# "fill" arm now reads a `[top, bottom]` pair (falling back to `[value, 0]`
|
||||
# for every existing single-value theme, so liquid-motion/glass-workbench/
|
||||
# spotlight are unaffected). `0` here means the scrim covers all the way to
|
||||
# the true top of the screen (nothing to clear up there); `52` is this bar's
|
||||
# own edge (margin.bottom(12) + height(40)), leaving the dock's own
|
||||
# footprint click-through the way every sibling theme's single top offset
|
||||
# already leaves its own bar's footprint alone.
|
||||
anchor = "fill"
|
||||
offset = [0, 52]
|
||||
width = "fill"
|
||||
layer = "overlay"
|
||||
|
||||
# Appearance-only, never placement/workspace/focus (plan §12 Layer B
|
||||
# boundary). THE axis-4 knob: `blur = false` everywhere, and `blur_popups`
|
||||
# dropped too — the demo draws depth with `box-shadow`, not
|
||||
# `backdrop-filter`, and per the task brief this also sidesteps the
|
||||
# shadow-halo trap a blurred `ignore_alpha` surface has (fixed in breadbox
|
||||
# by removing the shadow entirely — see that fix's history) since there is
|
||||
# no blur here for a shadow to get caught inside. No `ignore_alpha`
|
||||
# anywhere below either: that key only means anything to a BLURRED surface,
|
||||
# and setting it on an unblurred one would be exactly the kind of
|
||||
# never-consumed key this project's rules forbid.
|
||||
[compositor."breadbar"]
|
||||
blur = false
|
||||
blur_popups = false
|
||||
animation = "slide bottom"
|
||||
|
||||
[compositor."breadbar-osd"]
|
||||
blur = false
|
||||
animation = "slide bottom"
|
||||
|
||||
[compositor."breadbar-notif"]
|
||||
blur = false
|
||||
animation = "slide right"
|
||||
|
||||
[compositor."breadbar-panel"]
|
||||
blur = false
|
||||
animation = "slide right"
|
||||
|
||||
[compositor."breadbar-dismiss"]
|
||||
no_anim = true
|
||||
|
||||
[compositor."breadbox"]
|
||||
blur = false
|
||||
|
||||
# No `css = "..."` overlay — compiled-in builtin, same as its three siblings.
|
||||
86
bread-theme/assets/shell/glass-workbench/glass-workbench.css
Normal file
86
bread-theme/assets/shell/glass-workbench/glass-workbench.css
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/* CSS template for the glass-workbench builtin (bread-theme/src/shell/
|
||||
* builtin.rs). Same scope and substitution rules as liquid-motion.css: only
|
||||
* the window/workspace/clock chrome the manifest's own concepts model, `{name}`
|
||||
* tokens substituted, `@name` palette references passed through untouched.
|
||||
*
|
||||
* Source: bos-ui-demos/02-glass-workbench.html's <style> block. No
|
||||
* per-digit flip (this theme has no `.clock-digit` markup at all — plain
|
||||
* style is a date label + one time label) and no gradient trail (pill style
|
||||
* never makes `.workspace-trail` visible — see breadbar's
|
||||
* `WorkspaceTrail::place`/`stretch`, which this theme's slot config simply
|
||||
* never calls).
|
||||
*/
|
||||
|
||||
window.breadbar {
|
||||
background-color: alpha(@bg, {bg_alpha});
|
||||
color: @on-bg;
|
||||
border-radius: {radius_bar}px;
|
||||
border: none;
|
||||
border-bottom: 1px solid alpha(@on-bg, 0.07);
|
||||
}
|
||||
window.breadbar > centerbox { padding: 0 {pad}px; }
|
||||
window.breadbar button { min-height: 0; min-width: 0; }
|
||||
|
||||
/* Pill workspaces: solid accent fill when active, dimmed when empty — no
|
||||
* trail overlay is ever shown (place()/stretch() are never called for this
|
||||
* style), so `.workspace-trail` itself needs no rule here.
|
||||
*/
|
||||
.workspace-btn {
|
||||
background: transparent;
|
||||
opacity: 1;
|
||||
color: alpha(@on-bg, 0.4);
|
||||
border-radius: {radius_sm}px;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
min-width: 22px;
|
||||
min-height: {chip_height}px;
|
||||
margin: 0;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.22s {spring_settle}, color 0.22s {spring_settle},
|
||||
opacity 0.22s {spring_settle};
|
||||
}
|
||||
.workspace-btn:hover { background: alpha(@on-bg, 0.08); }
|
||||
.workspace-btn.occupied { color: alpha(@on-bg, 0.8); }
|
||||
.workspace-btn:not(.occupied):not(.active) { opacity: 0.35; }
|
||||
.workspace-btn.active {
|
||||
background: @{accent_from};
|
||||
color: @on-accent;
|
||||
opacity: 1;
|
||||
}
|
||||
.workspace-btn.active:hover { background: @{accent_from}; }
|
||||
|
||||
.clock-plain { padding: 0 4px; }
|
||||
.clock-plain-time {
|
||||
font-size: {font_size_base}px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.date-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.48;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
window.breadbar-osd {
|
||||
background-color: alpha(@bg, 0.72);
|
||||
color: @on-bg;
|
||||
border-radius: {radius_pill}px;
|
||||
border: 1px solid alpha(@on-bg, 0.10);
|
||||
}
|
||||
|
||||
window.breadbar-panel {
|
||||
background-color: alpha(@bg, 0.86);
|
||||
color: @on-bg;
|
||||
border-radius: {radius_card}px;
|
||||
border: 1px solid alpha(@on-bg, 0.12);
|
||||
}
|
||||
|
||||
window.breadbar-dismiss {
|
||||
background-color: alpha(#000000, 0.02);
|
||||
}
|
||||
|
||||
.bread-widget-slot { margin-right: {pad}px; }
|
||||
198
bread-theme/assets/shell/glass-workbench/theme.toml
Normal file
198
bread-theme/assets/shell/glass-workbench/theme.toml
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# The second compiled-in builtin (bread-theme/src/shell/builtin.rs), demo 02
|
||||
# in THEME_SYSTEM_PLAN.md — "Glass Workbench": a flush edge-to-edge bar with
|
||||
# plain pill workspaces, a plain date+time clock, and cpu/ram chips instead
|
||||
# of the media widget liquid-motion carries. Values are taken from
|
||||
# bos-ui-demos/02-glass-workbench.html's <style> block; where the demo is
|
||||
# silent on a number (bar-adjacent chip sizing, satellite offsets) this file
|
||||
# scales liquid-motion's own numbers down by the same 36/44 bar-height ratio
|
||||
# rather than inventing something unrelated to either source.
|
||||
#
|
||||
# Unlike liquid-motion, this builtin has no "as the code exists today" tether
|
||||
# — it is new, so demo fidelity is the only source of truth. See Phase 5's
|
||||
# task notes for the two exceptions: font (IBM Plex Sans is not installed on
|
||||
# the dev machine, so this renders in fallback until the user installs
|
||||
# ttf-ibm-plex — this now matters for real: breadbox's launcher panel
|
||||
# consumes font_family/font_fallback below, see [tokens]'s own note) and
|
||||
# colour (the demo's #7a9a88 sage is a *flat* accent, so it maps to the
|
||||
# palette's `green` token for both accent_from and accent_to — never a
|
||||
# literal hex value, or pywal theming breaks).
|
||||
|
||||
name = "Glass Workbench"
|
||||
id = "glass-workbench"
|
||||
|
||||
[tokens]
|
||||
# font_family/font_fallback/font_size_base are consumed by breadbox's
|
||||
# launcher panel only (breadbox::main::build_css's entry.search/row rules —
|
||||
# see Tokens::font_family's doc comment for the exact scope); breadbar's
|
||||
# bar/chip/clock text still comes from bread_theme::stylesheet()'s own
|
||||
# hardcoded FONT_FAMILY constant, unaffected by this key.
|
||||
font_family = "IBM Plex Sans"
|
||||
font_fallback = "Inter, Noto Sans, sans-serif"
|
||||
font_size_base = 13
|
||||
radius_bar = 0
|
||||
radius_card = 10
|
||||
radius_sm = 6
|
||||
radius_pill = 999
|
||||
pad = 12
|
||||
bg_alpha = 0.72
|
||||
# The demo defines exactly one easing curve (`--spring: cubic-bezier(.22,
|
||||
# 1.2, .36, 1)`), not liquid-motion's overshoot/settle pair — there's no
|
||||
# digit-flip or trail-stretch here to want a bounce for. Both `spring` and
|
||||
# `spring_settle` point at the demo's one curve rather than inventing a
|
||||
# second value the demo never specifies.
|
||||
spring = "cubic-bezier(0.22, 1.2, 0.36, 1)"
|
||||
spring_settle = "cubic-bezier(0.22, 1.2, 0.36, 1)"
|
||||
# Palette token NAMES, not hex — #7a9a88 is this palette's `green`. Equal
|
||||
# from/to because the demo's accent is flat, not a gradient. accent_from IS
|
||||
# read (Pill-style workspace fill, theme.rs); accent_to is declared-but-
|
||||
# not-yet-consumed regardless of style — nothing reads it outside the
|
||||
# breadbar Trail gradient this theme doesn't use, and even Trail hardcodes
|
||||
# its own literal gradient rather than substituting it. See
|
||||
# Tokens::accent_to's doc comment.
|
||||
accent_from = "green"
|
||||
accent_to = "green"
|
||||
# Demo: `.ws button { height: 20px }`. Liquid-motion's chip_height (32) is
|
||||
# tied to its 44px island; 20px is the demo's own number for this bar's
|
||||
# workspace pills, and is reused for the bar's other small chips (cpu/ram/
|
||||
# wifi/battery/control) so they all sit at the same height on the thinner
|
||||
# 36px flush bar.
|
||||
chip_height = 22
|
||||
# No demo number for bar-icon size; scaled from liquid-motion's 24px by the
|
||||
# same 36/44 bar-height ratio liquid-motion itself uses for its 44px bar.
|
||||
icon_px = 18
|
||||
# Flush edge-to-edge bar: a full border would draw a stray line along the
|
||||
# top/side screen edges a floating island doesn't have to worry about. See
|
||||
# Tokens::bar_border's doc comment.
|
||||
bar_border = "bottom"
|
||||
|
||||
[bar.window]
|
||||
anchors = ["top", "left", "right"]
|
||||
width = "fill"
|
||||
height = 36
|
||||
margin = { top = 0, left = 0, right = 0 }
|
||||
exclusive = "auto"
|
||||
keyboard = "none"
|
||||
layer = "top"
|
||||
|
||||
[bar.slots]
|
||||
# No media module — demo 02 has no media widget at all (plan §1 table).
|
||||
# breadbar must tolerate the omission: `media_widget` is still built and
|
||||
# registered under the "media" module name (main.rs), it just never appears
|
||||
# in any of this theme's slot lists, so it's never appended anywhere.
|
||||
left = ["workspaces"]
|
||||
centre = ["clock"]
|
||||
right = ["cpu", "ram", "wifi", "battery", "control"]
|
||||
drawer = []
|
||||
|
||||
[modules.workspaces]
|
||||
style = "pill"
|
||||
show_empty = true
|
||||
|
||||
[modules.clock]
|
||||
style = "plain"
|
||||
format = "%H:%M"
|
||||
show_date = true
|
||||
|
||||
[launcher]
|
||||
# Glass Workbench — dense and technical, the deliberate opposite of Liquid
|
||||
# Motion's soft/roomy numbers: a tight panel radius, snug row insets/padding,
|
||||
# small 22px icons, a flat (no section headers) list, and a denser accent
|
||||
# selection fill. Spec: bos-ui-demos/proposed/glass-workbench.html's `.bx`
|
||||
# block. breadbox reads every key below now; see main.rs's build_css.
|
||||
mode = "overlay"
|
||||
width = 560
|
||||
top = "64px"
|
||||
radius = 10
|
||||
icon_px = 22
|
||||
row_anim = "stagger"
|
||||
rule = "hairline"
|
||||
footer = "count_results"
|
||||
sections = false
|
||||
modes = ["apps"]
|
||||
# `.bx .r { border-radius: 6px; margin: 0 6px; padding: 8px 10px }`
|
||||
row_radius = 6
|
||||
row_inset = 6
|
||||
row_padding_v = 8
|
||||
row_padding_h = 10
|
||||
# `.bx .ico { border-radius: 5px }` (paired with icon_px = 22 above)
|
||||
icon_radius = 5
|
||||
# `.bx .q { padding: 12px 15px; font-size: 14px }`
|
||||
search_font_size = 14
|
||||
search_padding_v = 12
|
||||
search_padding_h = 15
|
||||
# `.bx .r.sel { background: rgba(accent, .28) }`
|
||||
# Panel opacity, separate from tokens.bg_alpha (the bar). Matches the
|
||||
# approved reference: demo `.bx.gw{background:rgba(16,20,18,.95)}`. breadbox hardcoded 0.60, which washed the
|
||||
# panel out over a bright wallpaper.
|
||||
panel_alpha = 0.95
|
||||
selection_alpha = 0.28
|
||||
|
||||
# Same four satellite namespaces as liquid-motion, keyed identically so the
|
||||
# two manifests validate against the same [compositor.*] keyspace. Offsets
|
||||
# are liquid-motion's own numbers re-derived from this bar's actual edge
|
||||
# instead of the island's: liquid-motion's bar bottom edge sits at
|
||||
# margin.top(12) + height(44) = 56px, and its breadbar-notif/-panel top
|
||||
# offset (64) is that plus an 8px gap, while breadbar-dismiss (56) sits
|
||||
# flush against it with no gap. This bar's edge is margin.top(0) +
|
||||
# height(36) = 36px, so the same two relationships give 44 (36 + 8) and 36.
|
||||
[surfaces."breadbar-notif"]
|
||||
anchor = "top_right"
|
||||
offset = [16, 44]
|
||||
width = 320
|
||||
layer = "overlay"
|
||||
|
||||
[surfaces."breadbar-osd"]
|
||||
# Independent of bar height (an OSD pill near the bottom of the screen) —
|
||||
# unchanged from liquid-motion.
|
||||
anchor = "bottom_centre"
|
||||
offset = 80
|
||||
width = 180
|
||||
layer = "overlay"
|
||||
|
||||
[surfaces."breadbar-panel"]
|
||||
anchor = "top_right"
|
||||
offset = [16, 44]
|
||||
width = "auto"
|
||||
layer = "overlay"
|
||||
|
||||
[surfaces."breadbar-dismiss"]
|
||||
anchor = "fill"
|
||||
offset = 36
|
||||
width = "fill"
|
||||
layer = "overlay"
|
||||
|
||||
# Appearance-only, never placement/workspace/focus (plan §12 Layer B
|
||||
# boundary) — identical to liquid-motion's rules. Blur *strength* is global
|
||||
# (plan §9 known limit), so this theme cannot independently match the demo's
|
||||
# per-surface blur radii (20px bar / 16px launcher / 22px popovers / 6px
|
||||
# window borders); only on/off, ignore_alpha and slide direction are
|
||||
# per-namespace knobs this system actually has.
|
||||
[compositor."breadbar"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
blur_popups = true
|
||||
animation = "slide top"
|
||||
|
||||
[compositor."breadbar-osd"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
animation = "slide bottom"
|
||||
|
||||
[compositor."breadbar-notif"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
animation = "slide right"
|
||||
|
||||
[compositor."breadbar-panel"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
animation = "slide right"
|
||||
|
||||
[compositor."breadbar-dismiss"]
|
||||
no_anim = true
|
||||
|
||||
[compositor."breadbox"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
|
||||
# No `css = "..."` overlay — compiled-in builtin, same as liquid-motion.
|
||||
104
bread-theme/assets/shell/liquid-motion/liquid-motion.css
Normal file
104
bread-theme/assets/shell/liquid-motion/liquid-motion.css
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/* CSS template for the liquid-motion builtin (bread-theme/src/shell/builtin.rs).
|
||||
*
|
||||
* Curly-brace placeholders like {radius_bar} or {spring} are substituted
|
||||
* from [tokens] by ShellTheme::css / Tokens::substitute; @-prefixed palette
|
||||
* references (@accent, @on-bg, ...) pass through untouched, exactly like
|
||||
* `bread_theme::stylesheet()` — GTK's own @define-color mechanism resolves
|
||||
* them when the CSS provider is attached to a display/output.
|
||||
*
|
||||
* Scope: this is the window/workspace/clock chrome the manifest's own
|
||||
* concepts (bar.window, modules.workspaces, modules.clock) actually model —
|
||||
* not a byte-for-byte copy of breadbar/src/theme.rs::load_css's full ~250
|
||||
* lines (notification cards, wifi popover, control panel, media widget, ...).
|
||||
* Those stay hand-written in breadbar for now; migrating them behind this
|
||||
* same token-substitution mechanism is Phase 2/3 work (plan §6), once
|
||||
* breadbar actually consumes ShellTheme and can verify pixel parity itself
|
||||
* via --screenshot. Phase 1's job is the mechanism, exercised end-to-end
|
||||
* here with a representative slice, not the full port.
|
||||
*/
|
||||
|
||||
@keyframes row-in {
|
||||
from { opacity: 0; margin-top: 8px; }
|
||||
to { opacity: 1; margin-top: 0; }
|
||||
}
|
||||
@keyframes digit-flip {
|
||||
from { opacity: 0; margin-top: 7px; }
|
||||
to { opacity: 1; margin-top: 0; }
|
||||
}
|
||||
|
||||
window.breadbar {
|
||||
background-color: alpha(@bg, {bg_alpha});
|
||||
color: @on-bg;
|
||||
border-radius: {radius_bar}px;
|
||||
border: 1px solid alpha(@on-bg, 0.08);
|
||||
}
|
||||
window.breadbar > centerbox { padding: 0 8px 0 6px; }
|
||||
window.breadbar button { min-height: 0; min-width: 0; }
|
||||
|
||||
.workspace-trail {
|
||||
background-image: linear-gradient(90deg, @{accent_from}, @{accent_to});
|
||||
background-color: @{accent_from};
|
||||
border-radius: {radius_card}px;
|
||||
}
|
||||
.workspace-btn {
|
||||
background: transparent;
|
||||
opacity: 0.36;
|
||||
color: @on-bg;
|
||||
border-radius: {radius_card}px;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
min-width: 28px;
|
||||
min-height: {chip_height}px;
|
||||
margin: 0;
|
||||
padding: 0 7px;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
transition: opacity 0.22s {spring_settle}, background-color 0.22s {spring_settle};
|
||||
}
|
||||
.workspace-btn:hover { opacity: 0.85; background: alpha(@on-bg, 0.08); }
|
||||
.workspace-btn.occupied { opacity: 0.78; }
|
||||
.workspace-btn.active { background: transparent; color: @on-accent; opacity: 1; }
|
||||
.workspace-btn.active:hover { background: transparent; }
|
||||
.workspace-btn.ws-in { animation: row-in 0.32s {spring_settle} both; }
|
||||
|
||||
.clock-box { padding: 0 4px; }
|
||||
.clock-label {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.04em;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.clock-digit {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.04em;
|
||||
min-width: 15px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.clock-colon { min-width: 10px; opacity: 0.7; }
|
||||
.clock-digit.flip { animation: digit-flip 0.45s {spring} both; }
|
||||
|
||||
window.breadbar-osd {
|
||||
background-color: alpha(@bg, 0.70);
|
||||
color: @on-bg;
|
||||
border-radius: {radius_pill}px;
|
||||
border: 1px solid alpha(@on-bg, 0.10);
|
||||
}
|
||||
|
||||
window.breadbar-panel {
|
||||
background-color: alpha(@bg, {bg_alpha});
|
||||
color: @on-bg;
|
||||
border-radius: 14px;
|
||||
border: 1px solid alpha(@on-bg, 0.12);
|
||||
}
|
||||
|
||||
window.breadbar-dismiss {
|
||||
background-color: alpha(#000000, 0.02);
|
||||
}
|
||||
|
||||
.bread-widget-slot { margin-right: {pad}px; }
|
||||
204
bread-theme/assets/shell/liquid-motion/theme.toml
Normal file
204
bread-theme/assets/shell/liquid-motion/theme.toml
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# The compiled-in builtin theme (bread-theme/src/shell/builtin.rs). The bar
|
||||
# side (workspaces/clock/chips/satellites) describes breadbar AS IT EXISTS
|
||||
# TODAY, not the 01-liquid-motion.html demo — where the two disagree (bar
|
||||
# side margin, the two easing curves), this file follows the current Rust
|
||||
# source, since Phase 2's acceptance test is pixel-identical rendering
|
||||
# against today's bar. The `[launcher]` table is the one exception: it now
|
||||
# follows bos-ui-demos/proposed/liquid-motion.html's `.bx` block (the
|
||||
# launcher-core redesign's spec) rather than breadbox's old, pre-redesign
|
||||
# hardcoded CSS — see that section's own note.
|
||||
#
|
||||
# Sources: breadbar/src/main.rs:16-22 (BAR_* / CHIP_HEIGHT / ICON_PX consts),
|
||||
# breadbar/src/theme.rs (load_css's radius/pad/spring locals and the actual
|
||||
# CSS selectors), breadbar/src/{panel,osd}.rs and
|
||||
# breadbar/src/notifications/{popup,history}.rs (satellite window anchors,
|
||||
# margins, namespaces), bos-ui-demos/proposed/liquid-motion.html (launcher),
|
||||
# ~/.config/hypr/scripts/ui/rules.lua (compositor rules).
|
||||
|
||||
name = "Liquid Motion"
|
||||
id = "liquid-motion"
|
||||
|
||||
[tokens]
|
||||
# font_family/font_fallback are now consumed, but only by breadbox's launcher
|
||||
# panel (breadbox::main::build_css's entry.search/row font-family) — see
|
||||
# Tokens::font_family's doc comment for the exact scope. breadbar's bar/chip/
|
||||
# clock text still comes from bread_theme::stylesheet()'s own hardcoded
|
||||
# FONT_FAMILY constant, unaffected by this key. font_size_base is likewise
|
||||
# now consumed for the launcher's row font-size only.
|
||||
font_family = "Outfit, sans-serif"
|
||||
font_fallback = "sans-serif"
|
||||
font_size_base = 14
|
||||
radius_bar = 16
|
||||
radius_card = 12
|
||||
radius_sm = 9
|
||||
# Not in the plan's §4 schema list, but a named local in theme.rs::load_css
|
||||
# alongside radius_bar/radius_card/radius_sm (`radius_pill = "999px"`) — the
|
||||
# OSD pill's corner radius.
|
||||
radius_pill = 999
|
||||
pad = 12
|
||||
bg_alpha = 0.72
|
||||
# Two curves theme.rs actually uses, not one: `spring` is the overshoot/bounce
|
||||
# curve (clock flips, pop-ins, the workspace caret draw); `spring_settle` is
|
||||
# the flatter curve used for hovers and workspace/stat-pair transitions. The
|
||||
# plan's §4 example names only `spring`.
|
||||
spring = "cubic-bezier(0.22, 1.35, 0.36, 1)"
|
||||
spring_settle = "cubic-bezier(0.22, 1.2, 0.36, 1)"
|
||||
# "accent" flows through as @accent (the workspace-trail gradient's start);
|
||||
# these are palette token NAMES, not hex - pywal still drives colour.
|
||||
# Declared-but-not-yet-consumed (accent_to only): breadbar's Trail-style CSS
|
||||
# hardcodes the literal gradient `linear-gradient(90deg, @accent, @teal)`
|
||||
# instead of substituting these two tokens (it never calls
|
||||
# ShellTheme::css(), the only thing that does the substitution) — this
|
||||
# theme's "teal" is what the demo intends, not what actually renders if a
|
||||
# future theme changed it. See Tokens::accent_to's doc comment.
|
||||
accent_from = "accent"
|
||||
accent_to = "teal"
|
||||
# Not in the plan's §4 schema list, but breadbar::CHIP_HEIGHT / ::ICON_PX
|
||||
# today.
|
||||
chip_height = 26
|
||||
icon_px = 24
|
||||
|
||||
[bar.window]
|
||||
anchors = ["top", "left", "right"]
|
||||
width = "fill"
|
||||
height = 44
|
||||
# No `bottom` set here (or by either sibling builtin) — it's accepted by the
|
||||
# schema but breadbar never applies it (no set_margin(Edge::Bottom, ...)
|
||||
# call exists). See Margin::bottom's doc comment before relying on it.
|
||||
margin = { top = 12, left = 16, right = 16 }
|
||||
exclusive = "auto"
|
||||
# breadbar never calls gtk4-layer-shell's set_keyboard_mode today, which
|
||||
# defaults to KeyboardMode::None — spelled out explicitly here rather than
|
||||
# left to omit-means-default, since "the shell must never fail to start
|
||||
# because a theme file is malformed" cuts both ways: an explicit builtin
|
||||
# value can't silently drift if the crate's own Default ever changes.
|
||||
keyboard = "none"
|
||||
layer = "top"
|
||||
|
||||
[bar.slots]
|
||||
# The `widget:*` entries reproduce breadbar's pre-Phase-3b fixed Lua-widget
|
||||
# interleave exactly (main.rs's now-removed widget_right_of_workspaces /
|
||||
# widget_left_of_clock / widget_right_of_clock / widget_left_of_stats), so
|
||||
# this manifest still renders pixel-identically to today's bar. `tray`
|
||||
# deliberately has NO slot entry anywhere — it lives in the control-panel
|
||||
# popover, not the bar, and is keyed directly by breadbar regardless of
|
||||
# `[bar.slots]`.
|
||||
left = ["workspaces", "widget:right_of_workspaces"]
|
||||
centre = ["media", "widget:left_of_clock", "clock", "widget:right_of_clock"]
|
||||
right = ["widget:left_of_stats", "volume", "wifi", "battery", "control"]
|
||||
drawer = []
|
||||
|
||||
[modules.workspaces]
|
||||
style = "trail"
|
||||
show_empty = true
|
||||
|
||||
[modules.clock]
|
||||
style = "flip"
|
||||
# Declared-but-not-yet-consumed under style = "flip": bar::clock::time()
|
||||
# hardcodes a 24h HH:MM layout regardless of `format`; only style = "plain"
|
||||
# ever calls formatted(&format). Set here to state the layout this clock
|
||||
# actually draws, not because anything reads it. Same for show_date — only
|
||||
# ever attached to the Plain style's box.
|
||||
format = "%H:%M"
|
||||
show_date = false
|
||||
|
||||
[launcher]
|
||||
# Liquid Motion / Glass Workbench redesign (bos-ui-demos/proposed/
|
||||
# liquid-motion.html's `.bx` block is the spec): soft and roomy — a big
|
||||
# panel radius, generous row insets/padding, 28px icons, section headers,
|
||||
# and a translucent (not solid) accent selection fill. breadbox reads every
|
||||
# key below now; see main.rs's build_css for the consuming CSS.
|
||||
mode = "overlay"
|
||||
width = 600
|
||||
top = "120px"
|
||||
radius = 20
|
||||
icon_px = 28
|
||||
row_anim = "flip"
|
||||
rule = "gradient"
|
||||
footer = "count_apps"
|
||||
sections = true
|
||||
modes = ["apps"]
|
||||
# `.bx .r { border-radius: 12px; margin: 0 8px; padding: 10px 12px }`
|
||||
row_radius = 12
|
||||
row_inset = 8
|
||||
row_padding_v = 10
|
||||
row_padding_h = 12
|
||||
# `.bx .ico { border-radius: 9px }` (paired with icon_px = 28 above)
|
||||
icon_radius = 9
|
||||
# `.bx .q { padding: 16px 18px; font-size: 16px }`
|
||||
search_font_size = 16
|
||||
search_padding_v = 16
|
||||
search_padding_h = 18
|
||||
# `.bx .r.sel { background: rgba(accent, .22) }`
|
||||
# Panel opacity, separate from tokens.bg_alpha (the bar). Matches the
|
||||
# approved reference: demo `.bx.lm{background:rgba(21,14,23,.93)}`. breadbox hardcoded 0.60, which washed the
|
||||
# panel out over a bright wallpaper.
|
||||
panel_alpha = 0.93
|
||||
selection_alpha = 0.22
|
||||
|
||||
# Keyed by layer-shell namespace, matching [compositor.*] below, so the two
|
||||
# tables share one keyspace and can be validated against each other.
|
||||
# breadbar-notif's popup toast uses set_default_width(320) (its history
|
||||
# sibling on the same namespace uses 360 — 320 is the live-toast surface, the
|
||||
# one this positions). breadbar-panel sets no window width at all: its
|
||||
# popovers size from their own CSS (.control-panel-inner / .wifi-popover-inner
|
||||
# min-width), which is why its width is "auto" rather than a number.
|
||||
[surfaces."breadbar-notif"]
|
||||
anchor = "top_right"
|
||||
offset = [16, 64]
|
||||
width = 320
|
||||
layer = "overlay"
|
||||
|
||||
[surfaces."breadbar-osd"]
|
||||
anchor = "bottom_centre"
|
||||
offset = 80
|
||||
width = 180
|
||||
layer = "overlay"
|
||||
|
||||
[surfaces."breadbar-panel"]
|
||||
anchor = "top_right"
|
||||
offset = [16, 64]
|
||||
width = "auto"
|
||||
layer = "overlay"
|
||||
|
||||
[surfaces."breadbar-dismiss"]
|
||||
# Anchored to all four edges (a fullscreen click-away scrim) with only a top
|
||||
# margin, so it starts below the bar rather than covering it.
|
||||
anchor = "fill"
|
||||
offset = 56
|
||||
width = "fill"
|
||||
layer = "overlay"
|
||||
|
||||
# Faithful to scripts/ui/rules.lua's five breadbar namespaces + breadbox.
|
||||
[compositor."breadbar"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
blur_popups = true
|
||||
animation = "slide top"
|
||||
|
||||
[compositor."breadbar-osd"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
animation = "slide bottom"
|
||||
|
||||
[compositor."breadbar-notif"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
animation = "slide right"
|
||||
|
||||
[compositor."breadbar-panel"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
animation = "slide right"
|
||||
|
||||
[compositor."breadbar-dismiss"]
|
||||
no_anim = true
|
||||
|
||||
[compositor."breadbox"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
|
||||
# No `css = "..."` overlay: the builtin is compiled in via `include_str!`
|
||||
# and has no on-disk directory to resolve a relative overlay path against.
|
||||
# `extra.css` is for user/system themes, which do have one — see
|
||||
# `resolve_theme` in mod.rs.
|
||||
73
bread-theme/assets/shell/spotlight/spotlight.css
Normal file
73
bread-theme/assets/shell/spotlight/spotlight.css
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/* CSS template for the spotlight builtin (bread-theme/src/shell/builtin.rs).
|
||||
* Same scope and substitution rules as liquid-motion.css/glass-workbench.css:
|
||||
* only the window/workspace/clock chrome the manifest's own concepts model.
|
||||
*
|
||||
* NOTE (unlike its two siblings): breadbar does not actually call
|
||||
* `ShellTheme::css()` today — its live stylesheet is the hand-written,
|
||||
* token-driven `breadbar::theme::load_css()`, which is where the capsule's
|
||||
* real chrome (dots, `.launcher-entry`, `.bread-drawer`/results rows) lives.
|
||||
* This file exists so the manifest's own template mechanism stays exercised
|
||||
* for all three builtins equally (and so a future `bread-theme` CLI/test
|
||||
* that DOES call `.css()` sees a real spotlight template) — see
|
||||
* `ShellTheme::css`'s own doc comment for why that gap between "what the
|
||||
* manifest models" and "what breadbar actually renders" is pre-existing,
|
||||
* not new to this file.
|
||||
*
|
||||
* Source: bos-ui-demos/04-spotlight.html's <style> block. No trail overlay
|
||||
* (dots never call `WorkspaceTrail::place`/`stretch`, same as pill) and no
|
||||
* per-digit flip (`style = "none"` — no clock label markup at all).
|
||||
*/
|
||||
|
||||
window.breadbar {
|
||||
background-color: alpha(@bg, {bg_alpha});
|
||||
color: @on-bg;
|
||||
border-radius: {radius_bar}px;
|
||||
border: 1px solid alpha(@on-bg, 0.08);
|
||||
}
|
||||
window.breadbar > box > centerbox { padding: 0 {pad}px; }
|
||||
window.breadbar button { min-height: 0; min-width: 0; }
|
||||
|
||||
/* Dots: colour/opacity/radius only — width is per-instance data
|
||||
* (`modules.workspaces.dot_widths`), set directly via `set_size_request` in
|
||||
* Rust (`bar::workspaces::make_dot_button`), since GTK CSS has no
|
||||
* per-instance variable width the way the demo's `[data-n]` attribute
|
||||
* selectors do.
|
||||
*/
|
||||
.workspace-dot {
|
||||
background-color: alpha(@on-bg, 0.35);
|
||||
border-radius: {radius_pill}px;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
min-height: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: background-color 0.25s {spring_settle}, opacity 0.25s {spring_settle};
|
||||
}
|
||||
.workspace-dot:hover { background-color: alpha(@on-bg, 0.55); }
|
||||
.workspace-dot:not(.occupied):not(.active) { opacity: 0.35; }
|
||||
.workspace-dot.active {
|
||||
background-color: @{accent_from};
|
||||
opacity: 1;
|
||||
}
|
||||
.workspace-dot.active:hover { background-color: @{accent_from}; }
|
||||
|
||||
window.breadbar-osd {
|
||||
background-color: alpha(@bg, 0.72);
|
||||
color: @on-bg;
|
||||
border-radius: {radius_pill}px;
|
||||
border: 1px solid alpha(@on-bg, 0.10);
|
||||
}
|
||||
|
||||
window.breadbar-panel {
|
||||
background-color: alpha(@bg, 0.86);
|
||||
color: @on-bg;
|
||||
border-radius: {radius_card}px;
|
||||
border: 1px solid alpha(@on-bg, 0.12);
|
||||
}
|
||||
|
||||
window.breadbar-dismiss {
|
||||
background-color: alpha(#000000, 0.02);
|
||||
}
|
||||
|
||||
.bread-widget-slot { margin-right: {pad}px; }
|
||||
213
bread-theme/assets/shell/spotlight/theme.toml
Normal file
213
bread-theme/assets/shell/spotlight/theme.toml
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# The third compiled-in builtin (bread-theme/src/shell/builtin.rs), demo 04
|
||||
# in THEME_SYSTEM_PLAN.md — "Spotlight": breadbar's bar IS the launcher, a
|
||||
# centred capsule that expands into results (plan §7). Values are taken from
|
||||
# bos-ui-demos/04-spotlight.html's <style> block; where the demo is silent
|
||||
# (satellite offsets, bar-adjacent chip sizing) this file re-derives from the
|
||||
# capsule's own top edge the same way glass-workbench re-derives from its
|
||||
# flush bar's edge — see that file's header comment for the method.
|
||||
#
|
||||
# Two exceptions, same shape as glass-workbench's: font (Outfit is not
|
||||
# installed on the dev machine, so this renders in fallback until the user
|
||||
# installs ttf-outfit — same gap liquid-motion already has) and colour (the
|
||||
# demo's #e87898 pink is a *flat* accent — `--accent` doubling as both the
|
||||
# capsule's dot fill and its only gradient stop — so it maps to the
|
||||
# palette's `pink` token for both accent_from and accent_to, never a literal
|
||||
# hex value).
|
||||
#
|
||||
# Phase 6c (this pass): query modes (`=` calc, `>` command, `.` url) and
|
||||
# result sections ("recent"/"apps") are now implemented — `modes` lists all
|
||||
# four and `sections` is `true`. The demo's `.searching .capsule` state
|
||||
# (480px -> 520px width, 22px -> 20px radius) is modeled via two new
|
||||
# `[launcher]` keys, `search_width`/`search_radius`, read only by
|
||||
# `LauncherMode::Embedded` hosts (breadbar's capsule; unread by breadbox's
|
||||
# overlay window, which stays `mode = "overlay"`).
|
||||
|
||||
name = "Spotlight"
|
||||
id = "spotlight"
|
||||
|
||||
[tokens]
|
||||
# Declared-but-not-yet-consumed: see liquid-motion/theme.toml's identical
|
||||
# note next to these three keys — neither breadbar nor breadbox reads them.
|
||||
font_family = "Outfit"
|
||||
font_fallback = "Varela Round, sans-serif"
|
||||
font_size_base = 13
|
||||
radius_bar = 22
|
||||
radius_card = 12
|
||||
radius_sm = 8
|
||||
radius_pill = 999
|
||||
pad = 14
|
||||
# Demo: `background: color-mix(in oklab, var(--glass) 82%, transparent)`.
|
||||
bg_alpha = 0.82
|
||||
# The demo's one curve (`--spring: cubic-bezier(.22,1.35,.36,1)`) — the same
|
||||
# overshoot liquid-motion calls `spring`. No separate flatter "settle" curve
|
||||
# is defined in 04's stylesheet, so both accessors point at it, same
|
||||
# reasoning as glass-workbench's single-curve tokens.
|
||||
spring = "cubic-bezier(0.22, 1.35, 0.36, 1)"
|
||||
spring_settle = "cubic-bezier(0.22, 1.35, 0.36, 1)"
|
||||
# Palette token NAMES, not hex — #e87898 is this palette's `pink`. Equal
|
||||
# from/to because the demo's accent here is flat, not liquid-motion's
|
||||
# accent→teal gradient. accent_from IS read (the dots' active fill,
|
||||
# theme.rs); accent_to is declared-but-not-yet-consumed, same as every
|
||||
# other theme's — see Tokens::accent_to's doc comment.
|
||||
accent_from = "pink"
|
||||
accent_to = "pink"
|
||||
# Demo: `.barrow { height: 36px }` with no separate workspace-chip height —
|
||||
# the dots (`.dots button`) are sized entirely by `dot_widths` below, not by
|
||||
# chip_height, but this token still feeds any other chip-shaped element
|
||||
# (e.g. a future result-row affordance) at the bar's own row height.
|
||||
chip_height = 22
|
||||
# Demo: `.ico { width: 30px; height: 30px }` for a result row's icon
|
||||
# swatch — the icon glyph itself sits inside that box with implicit
|
||||
# padding, so the icon_px passed to bread-launcher's `ResultsList` is sized
|
||||
# a little under the swatch box, same margin glass-workbench leaves between
|
||||
# its 18px icon_px and larger chip_height.
|
||||
icon_px = 22
|
||||
# Floating centred capsule, full rounded border on every edge — same
|
||||
# reasoning as liquid-motion's island, not glass-workbench's flush bar.
|
||||
bar_border = "full"
|
||||
|
||||
[bar.window]
|
||||
# Anchored top ONLY: gtk4-layer-shell centres a surface anchored to
|
||||
# neither left nor right (THEME_SYSTEM_PLAN.md §7) — this is what makes the
|
||||
# capsule float centred instead of spanning the screen width.
|
||||
anchors = ["top"]
|
||||
width = 480
|
||||
height = 36
|
||||
margin = { top = 16 }
|
||||
# No exclusive zone: tiled clients run full-height behind the capsule,
|
||||
# unlike Island/Edge's reserved strip — the capsule floats over content.
|
||||
exclusive = "none"
|
||||
# Hands keyboard focus over only while `launcher_entry` holds it (plan §7)
|
||||
# — Island/Edge themes keep "none", unchanged.
|
||||
keyboard = "on_demand"
|
||||
layer = "top"
|
||||
|
||||
[bar.slots]
|
||||
left = ["workspaces"]
|
||||
centre = ["launcher_entry"]
|
||||
# `widget:left_of_stats` (Phase 6c decision, see task notes): a Lua widget
|
||||
# that requests this same placement (`bread-shared`'s `WidgetPlacement::
|
||||
# LeftOfStats`, e.g. a real user's `git-branch-widget.lua`) would otherwise
|
||||
# be silently undeliverable under spotlight — no `[bar.slots]` entry named
|
||||
# any `widget:*` key at all, so `reconcile_widgets` drops it every time
|
||||
# (quietly, since the Phase 6b fix, but still dropped). Deliberately just
|
||||
# this one alias, not all five liquid-motion carries: spotlight has no
|
||||
# clock module to anchor `left_of_clock`/`right_of_clock` against and no
|
||||
# workspace-adjacent affordance next to its compact dots, so adding those
|
||||
# would clutter a launcher-focused capsule with slots nothing here asks
|
||||
# for. `left_of_stats` sits right where `battery` already does, matching
|
||||
# liquid-motion's own ordering (`widget:left_of_stats` before its module).
|
||||
right = ["widget:left_of_stats", "battery"]
|
||||
drawer = ["launcher_results"]
|
||||
|
||||
[modules.workspaces]
|
||||
style = "dots"
|
||||
show_empty = true
|
||||
# 0/1/2/3-or-more open windows → dot width in px
|
||||
# (`04-spotlight.html`'s `.dots button[data-n="N"]` rules).
|
||||
dot_widths = [8, 13, 17, 22]
|
||||
|
||||
[modules.clock]
|
||||
# No clock label at all — `launcher_entry`'s placeholder IS the clock
|
||||
# (`04-spotlight.html`: `q.placeholder = t` in `clock()`, replaced by the
|
||||
# search prompt only once focused/open).
|
||||
style = "none"
|
||||
# Declared-but-not-yet-consumed under style = "none": no clock module is
|
||||
# built at all, so there's no format/date to apply this to — see
|
||||
# ClockModule::format's doc comment. Kept for the same "state actual design
|
||||
# intent" reason as liquid-motion's Flip-style note.
|
||||
format = "%H:%M"
|
||||
show_date = false
|
||||
placeholder_clock = true
|
||||
|
||||
[launcher]
|
||||
mode = "embedded"
|
||||
# Unread by an embedded launcher (breadbar's launcher_entry/launcher_results
|
||||
# read [bar.window].width and this table's radius/icon_px instead of
|
||||
# width/top — those two fields exist only for LauncherMode::Overlay, which
|
||||
# breadbox's own window still uses). Kept at the bar's own width/22px radius
|
||||
# for consistency with a reader who expects [launcher] to describe the
|
||||
# launcher's shape regardless of mode.
|
||||
width = 480
|
||||
top = "16px"
|
||||
radius = 22
|
||||
icon_px = 22
|
||||
row_anim = "none"
|
||||
rule = "none"
|
||||
footer = "count_apps"
|
||||
# Phase 6c: "recent" / "apps" headers in the idle (empty-query) drawer view
|
||||
# — `bread_launcher::split_sections` groups the same already-loaded/sorted
|
||||
# entries `LaunchHistory`'s counts already rank, no new tracking needed.
|
||||
sections = true
|
||||
# `04-spotlight.html`'s three prefixes: `=` calc, `>` command, `.` url —
|
||||
# `bread_launcher::parse_query`/`eval_calc`/`filter_commands` implement the
|
||||
# pure logic; breadbar's capsule wiring (main.rs) gates on this list so an
|
||||
# unlisted prefix character just falls through to a literal "apps" query.
|
||||
modes = ["apps", "calc", "cmd", "url"]
|
||||
# `04-spotlight.html`: `.searching .capsule { width: 520px; border-radius:
|
||||
# 20px }` vs the idle 480px/22px above — animated via
|
||||
# `bread_theme::anim::spring_to` (width) and a `.searching` CSS class
|
||||
# (radius) in breadbar's capsule wiring.
|
||||
search_width = 520
|
||||
search_radius = 20
|
||||
|
||||
# Same five satellite namespaces as liquid-motion/glass-workbench, keyed
|
||||
# identically. This bar's edge sits at margin.top(16) + height(36) = 52px —
|
||||
# re-deriving glass-workbench's own edge+8/edge relationship (44 = 36+8,
|
||||
# 36 = 36) against 52 gives 60 (52+8) and 52.
|
||||
[surfaces."breadbar-notif"]
|
||||
anchor = "top_right"
|
||||
offset = [16, 60]
|
||||
width = 320
|
||||
layer = "overlay"
|
||||
|
||||
[surfaces."breadbar-osd"]
|
||||
# Independent of bar height — unchanged from liquid-motion/glass-workbench.
|
||||
anchor = "bottom_centre"
|
||||
offset = 80
|
||||
width = 180
|
||||
layer = "overlay"
|
||||
|
||||
[surfaces."breadbar-panel"]
|
||||
anchor = "top_right"
|
||||
offset = [16, 60]
|
||||
width = "auto"
|
||||
layer = "overlay"
|
||||
|
||||
[surfaces."breadbar-dismiss"]
|
||||
anchor = "fill"
|
||||
offset = 52
|
||||
width = "fill"
|
||||
layer = "overlay"
|
||||
|
||||
# Appearance-only, identical in shape to liquid-motion/glass-workbench's
|
||||
# rules — blur strength is still global (plan §9 known limit).
|
||||
[compositor."breadbar"]
|
||||
blur = true
|
||||
ignore_alpha = 0.18
|
||||
blur_popups = true
|
||||
animation = "slide top"
|
||||
|
||||
[compositor."breadbar-osd"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
animation = "slide bottom"
|
||||
|
||||
[compositor."breadbar-notif"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
animation = "slide right"
|
||||
|
||||
[compositor."breadbar-panel"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
animation = "slide right"
|
||||
|
||||
[compositor."breadbar-dismiss"]
|
||||
no_anim = true
|
||||
|
||||
[compositor."breadbox"]
|
||||
blur = true
|
||||
ignore_alpha = 0.2
|
||||
|
||||
# No `css = "..."` overlay — compiled-in builtin, same as its two siblings.
|
||||
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()
|
||||
}
|
||||
142
bread-theme/src/anim.rs
Normal file
142
bread-theme/src/anim.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
//! `anim::spring_to` — THEME_SYSTEM_PLAN.md §7/§8: GTK4 has no CSS
|
||||
//! width/height transition on a widget or a layer-shell surface (unlike the
|
||||
//! demos' `transition: width .45s var(--spring)` /
|
||||
//! `transition: max-height .4s var(--spring)`), so a size change that wants
|
||||
//! to animate has to interpolate a plain integer over the frame clock
|
||||
//! instead and re-apply it (`set_size_request`, `set_default_width`, ...)
|
||||
//! every frame.
|
||||
//!
|
||||
//! This is the same house technique `breadbar::bar::workspaces::WorkspaceTrail`
|
||||
//! already uses for the workspace trail's stretch/snap (a local, un-exported
|
||||
//! `ease`/`ease_overshoot` pair driven by `add_tick_callback` and
|
||||
//! `Instant::elapsed`) — lifted here, generalized to a plain `i32 -> i32`
|
||||
//! interpolation with a caller-supplied frame callback, so theme 04's
|
||||
//! capsule-drawer expand/collapse (and any future popover that wants the
|
||||
//! same effect) doesn't have to reimplement it.
|
||||
|
||||
use gtk4::glib::ControlFlow;
|
||||
use gtk4::prelude::*;
|
||||
|
||||
/// Approximates `cubic-bezier(0.22, 1.35, 0.36, 1)` — the overshoot/"spring"
|
||||
/// curve every builtin theme's `tokens.spring` names (`Tokens::spring`'s own
|
||||
/// default). Not a literal bezier solve (same approximation
|
||||
/// `bar::workspaces::ease_overshoot` uses) — exact enough that the eye can't
|
||||
/// tell it apart from the real curve at animation speeds.
|
||||
fn spring_ease(t: f64) -> f64 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let c = 1.35;
|
||||
let t1 = t - 1.0;
|
||||
1.0 + t1 * t1 * ((c + 1.0) * t1 + c)
|
||||
}
|
||||
|
||||
/// One frame's worth of `spring_to`'s interpolation math — split out from
|
||||
/// the tick callback below purely so it has a name a unit test can call
|
||||
/// directly instead of re-deriving the same arithmetic.
|
||||
///
|
||||
/// `spring_ease` is a backOut curve that legitimately overshoots past 1.0
|
||||
/// (peaks ~1.065) partway through the run — fine for a growing animation
|
||||
/// (`to` > `from`), but on a *shrink* (`from` > `to`, e.g. a drawer
|
||||
/// collapsing to 0) that overshoot drives the raw interpolated value BELOW
|
||||
/// `to`, which can go negative for a size request and trip GTK's
|
||||
/// `height >= -1` assertion. Clamping here, once, protects every caller
|
||||
/// automatically instead of relying on each call site to remember
|
||||
/// `h.max(0)` — this already bit breadbar once (see
|
||||
/// `breadbar::main::animate_drawer_height`'s own clamp, now
|
||||
/// redundant-but-harmless defense in depth on top of this).
|
||||
fn frame_value(from: i32, to: i32, t: f64) -> i32 {
|
||||
let eased = spring_ease(t);
|
||||
let value = from as f64 + (to - from) as f64 * eased;
|
||||
let lo = from.min(to) as f64;
|
||||
let hi = from.max(to) as f64;
|
||||
value.clamp(lo, hi).round() as i32
|
||||
}
|
||||
|
||||
/// Interpolates from `from` to `to` over `duration_ms`, calling `on_frame`
|
||||
/// with each intermediate value (and, on the final tick, the exact `to` —
|
||||
/// never an off-by-rounding near-miss) via `widget`'s frame clock.
|
||||
///
|
||||
/// Returns the [`gtk4::TickCallbackId`] so a caller that might need to
|
||||
/// interrupt an in-flight run (e.g. the drawer re-opening before its close
|
||||
/// animation finished) can `.remove()` it early; a run left to finish on its
|
||||
/// own needs no cleanup — the callback self-terminates by returning
|
||||
/// [`ControlFlow::Break`] once `duration_ms` has elapsed, same as
|
||||
/// `WorkspaceTrail`'s own tick callbacks.
|
||||
pub fn spring_to(
|
||||
widget: &impl IsA<gtk4::Widget>,
|
||||
from: i32,
|
||||
to: i32,
|
||||
duration_ms: f64,
|
||||
on_frame: impl FnMut(i32) + 'static,
|
||||
) -> gtk4::TickCallbackId {
|
||||
let started = std::time::Instant::now();
|
||||
// `add_tick_callback` requires `Fn`, not `FnMut` — the caller's frame
|
||||
// closure almost always needs to mutate captured state (a widget's size
|
||||
// request, an `Rc<Cell<..>>` flag), so it's boxed behind a `RefCell`
|
||||
// here rather than pushing `Cell`/`RefCell` plumbing onto every call
|
||||
// site (every current and future caller wants `FnMut`, none want `Fn`).
|
||||
let on_frame = std::cell::RefCell::new(on_frame);
|
||||
widget.add_tick_callback(move |_, _| {
|
||||
let elapsed = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut on_frame = on_frame.borrow_mut();
|
||||
if elapsed >= duration_ms {
|
||||
on_frame(to);
|
||||
return ControlFlow::Break;
|
||||
}
|
||||
on_frame(frame_value(from, to, elapsed / duration_ms));
|
||||
ControlFlow::Continue
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Samples [`frame_value`] — the exact function `spring_to`'s tick
|
||||
/// callback calls every frame — across the full `t` timeline at fine
|
||||
/// granularity, bypassing the real frame clock (which needs a running
|
||||
/// main loop the test environment doesn't have).
|
||||
fn sample_all_frames(from: i32, to: i32) -> Vec<i32> {
|
||||
let steps = 2000;
|
||||
(0..=steps)
|
||||
.map(|i| frame_value(from, to, i as f64 / steps as f64))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spring_ease_overshoots_past_one_for_a_backout_curve() {
|
||||
// Sanity check on the premise: without a clamp, a shrink would
|
||||
// legitimately go negative around this point in the curve.
|
||||
assert!(spring_ease(0.8) > 1.0, "expected overshoot past 1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_never_emits_a_value_outside_the_from_to_range() {
|
||||
// from > to (a drawer collapsing to 0) is exactly the case the
|
||||
// unclamped overshoot could drive negative.
|
||||
for value in sample_all_frames(480, 0) {
|
||||
assert!(
|
||||
(0..=480).contains(&value),
|
||||
"shrink emitted {value}, outside [0, 480]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grow_never_emits_a_value_outside_the_from_to_range() {
|
||||
for value in sample_all_frames(0, 480) {
|
||||
assert!(
|
||||
(0..=480).contains(&value),
|
||||
"grow emitted {value}, outside [0, 480]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_span_never_clamps_outside_the_single_point() {
|
||||
// from == to: lo == hi == that point for every t, including past
|
||||
// the overshoot peak.
|
||||
for value in sample_all_frames(120, 120) {
|
||||
assert_eq!(value, 120);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,10 @@
|
|||
//! # 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]
|
||||
//! bread-theme layerrules # write the active theme's [compositor] table
|
||||
//! # to ~/.config/hypr/layerrules.json (plan §9)
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
|
|
@ -25,6 +29,179 @@ fn write_and_report(verb: &str) -> ExitCode {
|
|||
}
|
||||
}
|
||||
|
||||
fn print_help_to(mut w: impl std::io::Write) {
|
||||
let _ = write!(
|
||||
&mut w,
|
||||
"bread-theme — shared stylesheet generator\n\n\
|
||||
USAGE:\n\
|
||||
\x20 bread-theme [generate|reload|path|print|layerrules]\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\n\
|
||||
layerrules write the active shell theme's [compositor] table to\n\
|
||||
\x20 {} —\n\
|
||||
\x20 scripts/ui/rules.lua reads it for per-namespace blur/\n\
|
||||
\x20 animation, falling back to its hardcoded rules if this\n\
|
||||
\x20 is missing or malformed",
|
||||
bread_theme::shared_css_path().display(),
|
||||
bread_theme::layerrules_path().display()
|
||||
);
|
||||
}
|
||||
|
||||
/// Usage/help text. Explicitly requested help (`--help`/`-h`) goes to
|
||||
/// **stdout** so it can be piped/grepped; the same text on an error path
|
||||
/// (e.g. `generate-output` with no args) goes to stderr via
|
||||
/// [`print_help_err`].
|
||||
fn print_help() {
|
||||
print_help_to(std::io::stdout());
|
||||
}
|
||||
|
||||
fn print_help_err() {
|
||||
print_help_to(std::io::stderr());
|
||||
}
|
||||
|
||||
fn generate_output_cmd() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(2).collect();
|
||||
if args.iter().any(|a| matches!(a.as_str(), "-h" | "--help" | "help")) {
|
||||
print_help();
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
if args.is_empty() {
|
||||
// Missing arguments is an error, not a help request — usage goes to
|
||||
// stderr.
|
||||
print_help_err();
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
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 layerrules_cmd() -> ExitCode {
|
||||
match bread_theme::write_layerrules_active() {
|
||||
Ok(path) => {
|
||||
eprintln!("bread-theme: wrote {}", path.display());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("bread-theme: failed to write layer rules: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
|
||||
match cmd.as_str() {
|
||||
|
|
@ -42,20 +219,16 @@ 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(),
|
||||
"layerrules" => layerrules_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|layerrules)"
|
||||
);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,22 @@
|
|||
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;
|
||||
|
||||
/// Per-widget app-CSS builder: given the resolved palette for the widget's
|
||||
/// monitor, produce the app stylesheet to layer on top of the theme CSS.
|
||||
type AppCssBuilder = Rc<dyn Fn(&Palette) -> String>;
|
||||
|
||||
/// 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 +28,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 +127,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<AppCssBuilder>,
|
||||
/// 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<AppCssBuilder>,
|
||||
) {
|
||||
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<AppCssBuilder>) {
|
||||
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<AppCssBuilder>) {
|
||||
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>>) {
|
||||
|
|
|
|||
211
bread-theme/src/layerrules.rs
Normal file
211
bread-theme/src/layerrules.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
//! Generates `~/.config/hypr/layerrules.json` from the active shell theme's
|
||||
//! `[compositor]` table (`THEME_SYSTEM_PLAN.md` §9). `scripts/ui/rules.lua`
|
||||
//! reads this file and emits `hl.layer_rule` calls from it, keeping its own
|
||||
//! hardcoded rules as a pcall-guarded fallback for when this file is missing
|
||||
//! or malformed — so generation here never has to be perfect, only present.
|
||||
//!
|
||||
//! Scope (plan §9's appearance/placement boundary): a theme's `[compositor]`
|
||||
//! table owns per-namespace *appearance* only — blur, ignore_alpha,
|
||||
//! blur_popups, animation, no_anim (the [`crate::shell::LayerRule`] field
|
||||
//! set). It never owns placement, workspace-assignment, or focus rules —
|
||||
//! those stay Lua-side user policy (`rules.lua`'s `hl.window_rule` calls)
|
||||
//! that this generator does not touch.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::shell::ShellTheme;
|
||||
|
||||
fn config_home() -> PathBuf {
|
||||
// XDG spec: `XDG_CONFIG_HOME` is only honored when it's an *absolute*
|
||||
// path; a relative value must be ignored (matches `bread_utils::xdg`).
|
||||
if let Ok(v) = std::env::var("XDG_CONFIG_HOME") {
|
||||
let p = PathBuf::from(&v);
|
||||
if p.is_absolute() {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".config")
|
||||
}
|
||||
|
||||
/// `~/.config/hypr/layerrules.json` (or under `$XDG_CONFIG_HOME` if set) —
|
||||
/// alongside `binds.json`, `settings.json`, `monitors.json`, and
|
||||
/// `autostart.json`, the established flat-JSON-under-`hypr/` convention
|
||||
/// those files already use (see `~/.config/hypr/scripts/input/binds.lua` for
|
||||
/// the read side of that pattern, which `scripts/ui/rules.lua` now mirrors).
|
||||
pub fn layerrules_path() -> PathBuf {
|
||||
config_home().join("hypr").join("layerrules.json")
|
||||
}
|
||||
|
||||
/// Render `theme`'s `[compositor]` table as the JSON object
|
||||
/// `scripts/ui/rules.lua` expects: keyed by layer-shell namespace (e.g.
|
||||
/// `"breadbar"`, `"breadbox"`), each value the namespace's
|
||||
/// [`crate::shell::LayerRule`] fields. `compositor_rules()` returns a
|
||||
/// `BTreeMap`, so namespace order is stable (alphabetical) across runs and a
|
||||
/// rewritten file diffs cleanly.
|
||||
pub fn layerrules_json(theme: &ShellTheme) -> String {
|
||||
serde_json::to_string_pretty(theme.compositor_rules())
|
||||
.expect("LayerRule serialization is infallible (no maps/floats that can fail)")
|
||||
}
|
||||
|
||||
/// [`layerrules_json`] + atomic write (tmp + rename), the same durability
|
||||
/// [`crate::write_shared_css_from`] uses so a reload can never observe a
|
||||
/// half-written file.
|
||||
pub fn write_layerrules(theme: &ShellTheme) -> std::io::Result<PathBuf> {
|
||||
let path = layerrules_path();
|
||||
let json = layerrules_json(theme);
|
||||
crate::output::atomic_write(&path, &json)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// [`write_layerrules`] from the active theme ([`crate::shell::load`], which
|
||||
/// never fails — a broken active theme falls back to the builtin). Used by
|
||||
/// the `bread-theme layerrules` CLI subcommand.
|
||||
pub fn write_layerrules_active() -> std::io::Result<PathBuf> {
|
||||
write_layerrules(&crate::shell::load())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
fn lock_xdg() -> std::sync::MutexGuard<'static, ()> {
|
||||
// Shared with `shell::tests::isolated_xdg`, which also mutates
|
||||
// XDG_CONFIG_HOME — must be the *same* lock, not a look-alike one,
|
||||
// or the two modules' parallel tests race each other's env var
|
||||
// reads (see `crate::test_support`'s doc comment).
|
||||
crate::test_support::XDG_CONFIG_HOME_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn with_config_home<T>(f: impl FnOnce(&Path) -> T) -> T {
|
||||
let _lock = lock_xdg();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"bread-theme-layerrules-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_CONFIG_HOME").ok();
|
||||
std::env::set_var("XDG_CONFIG_HOME", &dir);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&dir)));
|
||||
match old {
|
||||
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
|
||||
None => std::env::remove_var("XDG_CONFIG_HOME"),
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => std::panic::resume_unwind(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layerrules_path_sits_under_config_hypr() {
|
||||
with_config_home(|dir| {
|
||||
assert_eq!(layerrules_path(), dir.join("hypr").join("layerrules.json"));
|
||||
});
|
||||
}
|
||||
|
||||
/// True if any file under `dir` has a name containing `.tmp.` (a
|
||||
/// leftover from `output::atomic_write`'s pid-suffixed temp files).
|
||||
fn has_leftover_tmp(dir: &Path) -> bool {
|
||||
fn walk(p: &Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(p) else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if walk(&path) {
|
||||
return true;
|
||||
}
|
||||
} else if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.contains(".tmp."))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
walk(dir)
|
||||
}
|
||||
|
||||
/// `load_named("liquid-motion")` resolves through discovery (user dir,
|
||||
/// then system dir, then the compiled-in builtin) — isolate
|
||||
/// `XDG_CONFIG_HOME` to an empty dir so this can't pick up a real
|
||||
/// `~/.config/bread/themes/liquid-motion/theme.toml` override and land
|
||||
/// on a different `[compositor]` table than the builtin's.
|
||||
fn builtin_theme() -> ShellTheme {
|
||||
with_config_home(|_| crate::shell::load_named("liquid-motion").unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layerrules_json_covers_all_six_builtin_namespaces() {
|
||||
let theme = builtin_theme();
|
||||
let json = layerrules_json(&theme);
|
||||
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
let obj = value.as_object().expect("top-level object");
|
||||
for ns in [
|
||||
"breadbar",
|
||||
"breadbar-osd",
|
||||
"breadbar-notif",
|
||||
"breadbar-panel",
|
||||
"breadbar-dismiss",
|
||||
"breadbox",
|
||||
] {
|
||||
assert!(obj.contains_key(ns), "missing namespace {ns} in JSON");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layerrules_json_shape_matches_breadbar_rule() {
|
||||
let theme = builtin_theme();
|
||||
let json = layerrules_json(&theme);
|
||||
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
let bar = &value["breadbar"];
|
||||
assert_eq!(bar["blur"], true);
|
||||
assert_eq!(bar["ignore_alpha"], 0.2);
|
||||
assert_eq!(bar["blur_popups"], true);
|
||||
assert_eq!(bar["animation"], "slide top");
|
||||
// no_anim is a plain bool (not Option), so it's always present, even
|
||||
// when false — unlike ignore_alpha/animation which are omitted.
|
||||
assert_eq!(bar["no_anim"], false);
|
||||
|
||||
let dismiss = &value["breadbar-dismiss"];
|
||||
assert_eq!(dismiss["no_anim"], true);
|
||||
// ignore_alpha/animation are unset for breadbar-dismiss, so the
|
||||
// skip_serializing_if omits them entirely rather than writing null.
|
||||
assert!(dismiss.get("ignore_alpha").is_none());
|
||||
assert!(dismiss.get("animation").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_layerrules_active_writes_atomically_and_is_reloadable() {
|
||||
with_config_home(|dir| {
|
||||
let path = write_layerrules_active().unwrap();
|
||||
assert_eq!(path, dir.join("hypr").join("layerrules.json"));
|
||||
assert!(path.is_file());
|
||||
// No leftover .tmp file after the atomic rename.
|
||||
assert!(!has_leftover_tmp(dir));
|
||||
|
||||
let contents = std::fs::read_to_string(&path).unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&contents).unwrap();
|
||||
assert!(value.as_object().unwrap().contains_key("breadbox"));
|
||||
|
||||
// Rewriting (theme switch, pywal hook, etc.) must not fail or
|
||||
// leave a stale temp file behind.
|
||||
let path2 = write_layerrules_active().unwrap();
|
||||
assert_eq!(path, path2);
|
||||
assert!(!has_leftover_tmp(dir));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,35 @@
|
|||
pub mod palette;
|
||||
#[cfg(feature = "adw")]
|
||||
pub mod adw;
|
||||
#[cfg(feature = "gtk")]
|
||||
pub mod anim;
|
||||
#[cfg(feature = "gtk")]
|
||||
pub mod gtk;
|
||||
mod layerrules;
|
||||
mod output;
|
||||
pub mod palette;
|
||||
pub mod shell;
|
||||
|
||||
pub use layerrules::{layerrules_json, layerrules_path, write_layerrules, write_layerrules_active};
|
||||
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};
|
||||
|
||||
/// Env-var locks shared by any test module that mutates process-global
|
||||
/// state (`std::env::set_var`) — `cargo test` runs a crate's tests in
|
||||
/// parallel by default, so every module touching the *same* env var must
|
||||
/// serialize through the *same* lock or their mutations race each other's
|
||||
/// reads. `bread_theme::output`'s own `XDG_ENV_LOCK` guards `XDG_RUNTIME_DIR`
|
||||
/// specifically and stays where it is; `XDG_CONFIG_HOME_LOCK` here is the
|
||||
/// one shared by `shell::tests` and `layerrules::tests`, which both point
|
||||
/// `XDG_CONFIG_HOME` at an isolated temp dir.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
pub(crate) static XDG_CONFIG_HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
}
|
||||
|
||||
/// Design tokens from BREAD_DESIGN_SYSTEM.md.
|
||||
pub mod tokens {
|
||||
pub const FONT_FAMILY: &str = "Varela Round, sans-serif";
|
||||
|
|
@ -24,6 +50,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 +72,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 +84,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 +99,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 +207,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 +236,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 +255,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 +290,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 +309,34 @@ 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();
|
||||
// Longest name first, so `@on-bg` is replaced before `@bg` can match its tail.
|
||||
pairs.sort_by_key(|(name, _)| std::cmp::Reverse(name.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 +355,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 +385,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 +407,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 +426,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 +513,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 +525,60 @@ 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 p = Palette {
|
||||
color4: "#7aa2f7".into(),
|
||||
..Default::default()
|
||||
};
|
||||
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]
|
||||
|
|
|
|||
344
bread-theme/src/output.rs
Normal file
344
bread-theme/src/output.rs
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
//! 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 {
|
||||
// XDG spec: `XDG_RUNTIME_DIR` is only honored when set to a non-empty,
|
||||
// *absolute* path; a relative value must be ignored.
|
||||
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
let p = PathBuf::from(&rt);
|
||||
if !rt.is_empty() && p.is_absolute() {
|
||||
return p.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)))
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
// Pad the temp name with the pid (matching `bread_utils::atomic`) so two
|
||||
// concurrent writers for the same target can't race on one shared `.tmp`
|
||||
// file — e.g. two `bread-theme generate-output` runs writing the same
|
||||
// `themes/<output>.css` at once.
|
||||
let tmp = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(name) => path.with_file_name(format!(".{name}.tmp.{}", std::process::id())),
|
||||
None => path.with_extension(format!("tmp.{}", std::process::id())),
|
||||
};
|
||||
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::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 p = Palette {
|
||||
color4: "#7aa2f7".into(),
|
||||
background: "#ffffff".into(),
|
||||
..Default::default()
|
||||
};
|
||||
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(),
|
||||
|
|
|
|||
113
bread-theme/src/shell/builtin.rs
Normal file
113
bread-theme/src/shell/builtin.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
//! The compiled-in themes (plan §11 phase 1/5: "**One** built-in manifest
|
||||
//! (`liquid-motion`) describing the bar as it exists today", extended in
|
||||
//! Phase 5 with `glass-workbench`, demo 02). Every file here is plain data,
|
||||
//! not Rust — each `theme.toml` is the manifest text a user override would
|
||||
//! otherwise supply, and each `<id>.css` is the CSS template `ShellTheme::css`
|
||||
//! substitutes tokens into (see that method's doc comment for why this
|
||||
//! template is a representative subset of `breadbar::theme::load_css`'s
|
||||
//! full stylesheet rather than a byte-for-byte copy of it).
|
||||
//!
|
||||
//! All are read with `include_str!` so a broken build can't ship without
|
||||
//! them, and so [`super`] never touches the filesystem for a builtin — it
|
||||
//! must work identically whether or not `$XDG_CONFIG_HOME` exists at all.
|
||||
|
||||
pub const LIQUID_MOTION_ID: &str = "liquid-motion";
|
||||
|
||||
const LIQUID_MOTION_TOML: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/assets/shell/liquid-motion/theme.toml"
|
||||
));
|
||||
|
||||
const LIQUID_MOTION_CSS: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/assets/shell/liquid-motion/liquid-motion.css"
|
||||
));
|
||||
|
||||
pub const GLASS_WORKBENCH_ID: &str = "glass-workbench";
|
||||
|
||||
const GLASS_WORKBENCH_TOML: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/assets/shell/glass-workbench/theme.toml"
|
||||
));
|
||||
|
||||
const GLASS_WORKBENCH_CSS: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/assets/shell/glass-workbench/glass-workbench.css"
|
||||
));
|
||||
|
||||
pub const SPOTLIGHT_ID: &str = "spotlight";
|
||||
|
||||
const SPOTLIGHT_TOML: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/assets/shell/spotlight/theme.toml"
|
||||
));
|
||||
|
||||
const SPOTLIGHT_CSS: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/assets/shell/spotlight/spotlight.css"
|
||||
));
|
||||
|
||||
pub const DAYLIGHT_ID: &str = "daylight";
|
||||
|
||||
const DAYLIGHT_TOML: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/assets/shell/daylight/theme.toml"
|
||||
));
|
||||
|
||||
const DAYLIGHT_CSS: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/assets/shell/daylight/daylight.css"
|
||||
));
|
||||
|
||||
/// One compiled-in theme's identity plus its two `include_str!`ed assets.
|
||||
/// `id`/`name` are also duplicated inside `toml`'s own `id =`/`name =`
|
||||
/// fields — kept here too so [`all`]/[`find`] can list/look up a builtin
|
||||
/// without parsing TOML first (`super::list`'s builtin fallback entry, and
|
||||
/// `super::find_source`'s existence check, both run before any manifest
|
||||
/// parsing happens).
|
||||
pub struct BuiltinTheme {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub toml: &'static str,
|
||||
pub css: &'static str,
|
||||
}
|
||||
|
||||
/// Every compiled-in theme, in the order [`super::list`] should present
|
||||
/// them. Adding a third builtin is one entry here plus its two asset files
|
||||
/// — nothing else in `mod.rs` names a specific builtin id except the always-
|
||||
/// -safe fallback ([`LIQUID_MOTION_ID`], deliberately still hardcoded at
|
||||
/// its one call site in `super::resolve_builtin` — see that function's doc
|
||||
/// comment for why that one reference must NOT become "whichever builtin is
|
||||
/// listed first").
|
||||
pub const ALL: &[BuiltinTheme] = &[
|
||||
BuiltinTheme {
|
||||
id: LIQUID_MOTION_ID,
|
||||
name: "Liquid Motion",
|
||||
toml: LIQUID_MOTION_TOML,
|
||||
css: LIQUID_MOTION_CSS,
|
||||
},
|
||||
BuiltinTheme {
|
||||
id: GLASS_WORKBENCH_ID,
|
||||
name: "Glass Workbench",
|
||||
toml: GLASS_WORKBENCH_TOML,
|
||||
css: GLASS_WORKBENCH_CSS,
|
||||
},
|
||||
BuiltinTheme {
|
||||
id: SPOTLIGHT_ID,
|
||||
name: "Spotlight",
|
||||
toml: SPOTLIGHT_TOML,
|
||||
css: SPOTLIGHT_CSS,
|
||||
},
|
||||
BuiltinTheme {
|
||||
id: DAYLIGHT_ID,
|
||||
name: "Daylight",
|
||||
toml: DAYLIGHT_TOML,
|
||||
css: DAYLIGHT_CSS,
|
||||
},
|
||||
];
|
||||
|
||||
/// Looks up a compiled-in theme by id — `None` means "not a builtin",
|
||||
/// exactly like a miss in the user/system theme directories.
|
||||
pub fn find(id: &str) -> Option<&'static BuiltinTheme> {
|
||||
ALL.iter().find(|t| t.id == id)
|
||||
}
|
||||
318
bread-theme/src/shell/hotreload.rs
Normal file
318
bread-theme/src/shell/hotreload.rs
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
//! `watch()` — only compiled under the `gtk` feature, since
|
||||
//! `gio::FileMonitor` is a gtk4 dependency and the rest of `shell` is
|
||||
//! deliberately gtk-free (`bread`/`breadcrumbs` link this crate without the
|
||||
//! `gtk` feature at all).
|
||||
|
||||
use gtk4::gio;
|
||||
use gtk4::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// The handle [`watch`] returns. Keep it alive — dropping it disarms both
|
||||
/// the config-file watch and whichever theme directory is currently armed;
|
||||
/// there is nothing else to call on it.
|
||||
///
|
||||
/// Two independent [`gio::FileMonitor`]s live behind this, not one:
|
||||
/// - a fixed watch on `~/.config/bread/` (or `$XDG_CONFIG_HOME/bread/`)
|
||||
/// for `shell.toml` itself, so a change to the *active* theme id is
|
||||
/// noticed at all;
|
||||
/// - a swappable watch on whichever theme directory is currently active,
|
||||
/// re-armed onto the new directory whenever the config watch observes
|
||||
/// `active` changing (see [`watch`]'s doc comment for what this does and
|
||||
/// does not cover).
|
||||
pub struct ThemeWatch {
|
||||
_config_monitor: Option<gio::FileMonitor>,
|
||||
// `Rc<RefCell<..>>` (rather than a plain field) because the config
|
||||
// watch's own callback needs to replace this monitor in place when the
|
||||
// active theme changes, while this struct is what keeps it alive for
|
||||
// the caller.
|
||||
_theme_monitor: Rc<RefCell<Option<gio::FileMonitor>>>,
|
||||
}
|
||||
|
||||
/// Best-effort: `monitor_directory` on `dir` can fail (e.g.
|
||||
/// `fs.inotify.max_user_watches` exhausted) — that must never be fatal, per
|
||||
/// this crate's "the shell must never fail to start because a theme file is
|
||||
/// malformed" stance (`shell` module doc). Logs once per call site and
|
||||
/// returns `None` rather than panicking; the caller simply runs without a
|
||||
/// live watch for whatever this was meant to cover, same as
|
||||
/// `bread_theme::gtk::watch_theme_file`'s existing `.ok()?` stance for the
|
||||
/// shared-stylesheet watch.
|
||||
fn monitor_dir(dir: &std::path::Path, what: &str) -> Option<gio::FileMonitor> {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
match gio::File::for_path(dir).monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE) {
|
||||
Ok(m) => Some(m),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"bread-theme: could not create a file monitor for {what} ({}); \
|
||||
continuing without live reload for it",
|
||||
err
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_dir(id: &str) -> PathBuf {
|
||||
super::user_theme_path(id)
|
||||
.parent()
|
||||
.expect("user_theme_path always has a parent")
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
/// (Re-)arms `theme_monitor` on `id`'s own directory, replacing whatever was
|
||||
/// armed before (dropping the old [`gio::FileMonitor`] disarms it). Returns
|
||||
/// the id actually armed, so the caller can remember it for the "did the
|
||||
/// active id change" comparison in [`watch`]'s config-watch callback.
|
||||
fn arm_theme_watch(theme_monitor: &Rc<RefCell<Option<gio::FileMonitor>>>, id: &str, f: &Rc<dyn Fn(super::ShellTheme)>) {
|
||||
let dir = theme_dir(id);
|
||||
let monitor = monitor_dir(&dir, &format!("theme '{id}'s directory ({})", dir.display()));
|
||||
if let Some(m) = &monitor {
|
||||
let f = f.clone();
|
||||
m.connect_changed(move |_, _file, _other, _event| {
|
||||
f(super::load());
|
||||
});
|
||||
}
|
||||
*theme_monitor.borrow_mut() = monitor;
|
||||
}
|
||||
|
||||
/// Fires `f` with a freshly-[`super::load`]ed [`super::ShellTheme`] whenever
|
||||
/// the active theme's own directory changes on disk, AND re-arms itself onto
|
||||
/// a new theme's directory when `~/.config/bread/shell.toml`'s `active` key
|
||||
/// changes — so switching themes while a host is already running (e.g.
|
||||
/// bos-settings rewriting `active =`) picks up live edits to the *new*
|
||||
/// theme without a restart, not just edits to whichever theme happened to
|
||||
/// be active when `watch()` was first called.
|
||||
///
|
||||
/// Watches directories, not the files themselves, for the same reason
|
||||
/// `bread_theme::gtk::watch_theme_file` does (see that function's doc
|
||||
/// comment): an editor or `bread-theme` doing an atomic write-tmp-then-
|
||||
/// rename replaces the inode, and a monitor on the file itself dies after
|
||||
/// the first replace (inotify reports `DELETE_SELF` and never re-arms).
|
||||
///
|
||||
/// # What this does NOT cover
|
||||
///
|
||||
/// `$BREAD_SHELL_THEME` is a **single-process override for testing**, not
|
||||
/// the primary theme selector (`shell.toml`'s `active` key is — see the
|
||||
/// `shell` module doc's "Discovery" section) — precisely because an env var
|
||||
/// set for one process is invisible to any other, it cannot coordinate two
|
||||
/// already-running hosts (breadbar/breadbox) onto the same theme, and this
|
||||
/// watch cannot observe it changing either: it's read once per call to
|
||||
/// [`super::active_theme_id`] (i.e. once per `load()`/re-arm here), and
|
||||
/// there is no filesystem event to watch for a process's own environment
|
||||
/// changing out from under it. A host that wants to pick up a new
|
||||
/// `$BREAD_SHELL_THEME` value has to restart, same as before this fix.
|
||||
pub fn watch<F: Fn(super::ShellTheme) + 'static>(f: F) -> ThemeWatch {
|
||||
let f: Rc<dyn Fn(super::ShellTheme)> = Rc::new(f);
|
||||
|
||||
let watched_id = Rc::new(RefCell::new(super::active_theme_id()));
|
||||
let theme_monitor: Rc<RefCell<Option<gio::FileMonitor>>> = Rc::new(RefCell::new(None));
|
||||
arm_theme_watch(&theme_monitor, &watched_id.borrow(), &f);
|
||||
|
||||
let config_dir = super::config_home().join("bread");
|
||||
let config_monitor = monitor_dir(
|
||||
&config_dir,
|
||||
&format!("the shell config directory ({})", config_dir.display()),
|
||||
);
|
||||
if let Some(cm) = &config_monitor {
|
||||
let theme_monitor = theme_monitor.clone();
|
||||
let watched_id = watched_id.clone();
|
||||
let f = f.clone();
|
||||
cm.connect_changed(move |_, file, other, _event| {
|
||||
// Only `shell.toml` changing can move `active` — ignore any
|
||||
// other file (e.g. a theme directory that happens to also live
|
||||
// under this same parent) so this doesn't re-check on every
|
||||
// unrelated write in ~/.config/bread/.
|
||||
let is_shell_toml = |f: &gio::File| {
|
||||
f.basename().and_then(|b| b.to_str().map(str::to_string)) == Some("shell.toml".to_string())
|
||||
};
|
||||
if !is_shell_toml(file) && !other.is_some_and(is_shell_toml) {
|
||||
return;
|
||||
}
|
||||
let new_id = super::active_theme_id();
|
||||
if new_id == *watched_id.borrow() {
|
||||
// shell.toml changed but `active` didn't (or moved to the
|
||||
// same value) — the already-armed theme watch still covers
|
||||
// whatever changed.
|
||||
return;
|
||||
}
|
||||
*watched_id.borrow_mut() = new_id.clone();
|
||||
arm_theme_watch(&theme_monitor, &new_id, &f);
|
||||
f(super::load());
|
||||
});
|
||||
}
|
||||
|
||||
ThemeWatch {
|
||||
_config_monitor: config_monitor,
|
||||
_theme_monitor: theme_monitor,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Isolated `$XDG_CONFIG_HOME`, mirroring `shell::tests::isolated_xdg`
|
||||
/// (can't reuse it directly — it's private to the parent module's own
|
||||
/// `tests` submodule — but shares the same lock so the two test files
|
||||
/// never race the same env vars).
|
||||
struct EnvGuard {
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
dir: PathBuf,
|
||||
old_xdg: Option<String>,
|
||||
old_theme_var: Option<String>,
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.old_xdg {
|
||||
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
|
||||
None => std::env::remove_var("XDG_CONFIG_HOME"),
|
||||
}
|
||||
match &self.old_theme_var {
|
||||
Some(v) => std::env::set_var("BREAD_SHELL_THEME", v),
|
||||
None => std::env::remove_var("BREAD_SHELL_THEME"),
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn isolated_xdg() -> EnvGuard {
|
||||
let lock = crate::test_support::XDG_CONFIG_HOME_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"bread-theme-hotreload-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_xdg = std::env::var("XDG_CONFIG_HOME").ok();
|
||||
let old_theme_var = std::env::var("BREAD_SHELL_THEME").ok();
|
||||
std::env::set_var("XDG_CONFIG_HOME", &dir);
|
||||
std::env::remove_var("BREAD_SHELL_THEME");
|
||||
EnvGuard {
|
||||
_lock: lock,
|
||||
dir,
|
||||
old_xdg,
|
||||
old_theme_var,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_theme(xdg: &EnvGuard, id: &str, toml_body: &str) {
|
||||
let dir = xdg.dir.join("bread/themes").join(id);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("theme.toml"), toml_body).unwrap();
|
||||
}
|
||||
|
||||
fn write_shell_toml(xdg: &EnvGuard, active: &str) {
|
||||
let dir = xdg.dir.join("bread");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("shell.toml"), format!("active = \"{active}\"\n")).unwrap();
|
||||
}
|
||||
|
||||
/// Pumps the default `glib::MainContext` (where `gio::FileMonitor`
|
||||
/// dispatches its `connect_changed` signal) until `done()` returns
|
||||
/// true or `timeout` elapses. Real inotify events go through the OS,
|
||||
/// so this polls rather than blocking on a single iteration.
|
||||
fn pump_until(timeout: std::time::Duration, mut done: impl FnMut() -> bool) {
|
||||
let start = std::time::Instant::now();
|
||||
let ctx = gtk4::glib::MainContext::default();
|
||||
while !done() && start.elapsed() < timeout {
|
||||
while ctx.iteration(false) {}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_arms_a_monitor_for_the_currently_active_theme() {
|
||||
let xdg = isolated_xdg();
|
||||
write_theme(&xdg, "liquid-motion", "id = \"liquid-motion\"\n");
|
||||
let watch = watch(|_| {});
|
||||
assert!(
|
||||
watch._theme_monitor.borrow().is_some(),
|
||||
"watch() should arm a monitor for the active theme's directory"
|
||||
);
|
||||
assert!(
|
||||
watch._config_monitor.is_some(),
|
||||
"watch() should arm a monitor for the shell config directory too"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_the_active_theme_directory_fires_the_callback() {
|
||||
let xdg = isolated_xdg();
|
||||
write_theme(&xdg, "custom", "id = \"custom\"\n[tokens]\npad = 1\n");
|
||||
write_shell_toml(&xdg, "custom");
|
||||
|
||||
let calls = Rc::new(RefCell::new(0));
|
||||
let calls2 = calls.clone();
|
||||
let _watch = watch(move |_| *calls2.borrow_mut() += 1);
|
||||
|
||||
write_theme(&xdg, "custom", "id = \"custom\"\n[tokens]\npad = 2\n");
|
||||
pump_until(std::time::Duration::from_secs(3), || *calls.borrow() > 0);
|
||||
|
||||
assert!(
|
||||
*calls.borrow() > 0,
|
||||
"editing the active theme's own directory should fire the watch callback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_the_active_theme_re_arms_onto_the_new_directory() {
|
||||
// This is the coordination gap the fix closes: before it, `watch()`
|
||||
// resolved the active theme's directory once and pinned a monitor
|
||||
// there forever, so a `shell.toml` `active` switch left the OLD
|
||||
// theme's edits observed and the NEW theme's edits invisible until
|
||||
// a restart.
|
||||
let xdg = isolated_xdg();
|
||||
write_theme(&xdg, "theme-a", "id = \"theme-a\"\n[tokens]\npad = 1\n");
|
||||
write_theme(&xdg, "theme-b", "id = \"theme-b\"\n[tokens]\npad = 1\n");
|
||||
write_shell_toml(&xdg, "theme-a");
|
||||
|
||||
let seen_ids = Rc::new(RefCell::new(Vec::<String>::new()));
|
||||
let seen_ids2 = seen_ids.clone();
|
||||
let _watch = watch(move |theme| seen_ids2.borrow_mut().push(theme.id().to_string()));
|
||||
|
||||
// Switch active to theme-b.
|
||||
write_shell_toml(&xdg, "theme-b");
|
||||
pump_until(std::time::Duration::from_secs(3), || {
|
||||
seen_ids.borrow().iter().any(|id| id == "theme-b")
|
||||
});
|
||||
assert!(
|
||||
seen_ids.borrow().iter().any(|id| id == "theme-b"),
|
||||
"switching shell.toml's active id should fire the callback with the new theme: {:?}",
|
||||
seen_ids.borrow()
|
||||
);
|
||||
|
||||
seen_ids.borrow_mut().clear();
|
||||
|
||||
// Now edit theme-b's own directory — the re-armed watch must see it.
|
||||
write_theme(&xdg, "theme-b", "id = \"theme-b\"\n[tokens]\npad = 2\n");
|
||||
pump_until(std::time::Duration::from_secs(3), || !seen_ids.borrow().is_empty());
|
||||
assert!(
|
||||
!seen_ids.borrow().is_empty(),
|
||||
"after switching, editing the NEW active theme's directory must fire the callback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_dir_degrades_gracefully_instead_of_panicking() {
|
||||
// A directory that cannot possibly be created (its parent is a
|
||||
// plain file, not a directory) — `create_dir_all` fails, and this
|
||||
// must not panic either way `monitor_directory` then behaves.
|
||||
let xdg = isolated_xdg();
|
||||
let blocker = xdg.dir.join("not-a-directory");
|
||||
std::fs::write(&blocker, b"x").unwrap();
|
||||
let impossible = blocker.join("child").join("grandchild");
|
||||
|
||||
// Must not panic; may or may not return a monitor depending on
|
||||
// glib's own behavior for a directory that doesn't exist yet, but
|
||||
// this call itself is infallible from the caller's point of view.
|
||||
let _ = monitor_dir(&impossible, "test");
|
||||
}
|
||||
}
|
||||
601
bread-theme/src/shell/manifest.rs
Normal file
601
bread-theme/src/shell/manifest.rs
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
//! `theme.toml` deserialization, validation, and resolution into
|
||||
//! [`crate::shell::ShellTheme`].
|
||||
//!
|
||||
//! Two layers on purpose: `Raw*` types mirror the TOML shape exactly (every
|
||||
//! field optional, `deny_unknown_fields` everywhere so a typo'd key is a
|
||||
//! hard error naming that key rather than a silent no-op) and know nothing
|
||||
//! about defaults; [`RawManifest::resolve`] is the one place defaults get
|
||||
//! filled and string enums get validated, producing the fully-resolved
|
||||
//! types in `types.rs`.
|
||||
|
||||
use anyhow::{anyhow, bail, Context};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use super::types::*;
|
||||
|
||||
/// Module names a slot entry may reference without recompiling anything —
|
||||
/// plan §2 tier 1/2 (declarative slots) plus the `widget:*` escape hatch
|
||||
/// (tier 3, validated separately since its suffix is open-ended). This is
|
||||
/// intentionally the set the *current* theme and the plan's own schema
|
||||
/// example use; Phase 3 (breadbar's module registry) is the place a new
|
||||
/// built-in module name gets added for real.
|
||||
const KNOWN_MODULES: &[&str] = &[
|
||||
"workspaces",
|
||||
"media",
|
||||
"clock",
|
||||
"volume",
|
||||
"wifi",
|
||||
"battery",
|
||||
"control",
|
||||
"launcher_entry",
|
||||
"launcher_results",
|
||||
// `02-glass-workbench` (plan §11 phase 5): plain right-side stat chips,
|
||||
// reusing the same `AppInput::StatsUpdate` data the control panel's
|
||||
// sys-grid already receives — see breadbar's `bar::slots` module docs.
|
||||
"cpu",
|
||||
"ram",
|
||||
];
|
||||
|
||||
pub(super) fn validate_module_name(theme_id: &str, slot: &str, module: &str) -> anyhow::Result<()> {
|
||||
if module.starts_with("widget:") || KNOWN_MODULES.contains(&module) {
|
||||
return Ok(());
|
||||
}
|
||||
bail!(
|
||||
"theme '{theme_id}': slot \"{slot}\" references unknown module \"{module}\" \
|
||||
(known modules: {}, or widget:<lua-module-name>)",
|
||||
KNOWN_MODULES.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn validate_slots(raw: &RawManifest, theme_id: &str) -> anyhow::Result<()> {
|
||||
let Some(bar) = &raw.bar else { return Ok(()) };
|
||||
let Some(slots) = &bar.slots else {
|
||||
return Ok(());
|
||||
};
|
||||
for (slot_name, list) in [
|
||||
("left", &slots.left),
|
||||
("centre", &slots.centre),
|
||||
("right", &slots.right),
|
||||
("drawer", &slots.drawer),
|
||||
] {
|
||||
for module in list {
|
||||
validate_module_name(theme_id, slot_name, module)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct RawManifest {
|
||||
pub(super) name: Option<String>,
|
||||
pub(super) id: Option<String>,
|
||||
/// Present only so `extends` deserializes as a *known* field (otherwise
|
||||
/// `deny_unknown_fields` would reject every theme that sets it). The
|
||||
/// value itself is read straight off the raw `toml::Value` in
|
||||
/// `mod.rs::resolve_theme` — before this struct exists — since the
|
||||
/// merge has to happen ahead of (and separately from) deserialization.
|
||||
#[allow(dead_code)]
|
||||
pub(super) extends: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) tokens: HashMap<String, toml::Value>,
|
||||
pub(super) bar: Option<RawBar>,
|
||||
pub(super) modules: Option<RawModules>,
|
||||
pub(super) launcher: Option<RawLauncher>,
|
||||
pub(super) surfaces: Option<HashMap<String, RawSurface>>,
|
||||
pub(super) compositor: Option<HashMap<String, RawLayerRule>>,
|
||||
/// Overlay CSS path, resolved relative to the theme file's own
|
||||
/// directory, appended last by `ShellTheme::css`. Declared-but-not-yet-
|
||||
/// consumed in production: `ShellTheme::css` (the only reader of this
|
||||
/// field's resolved `extra_css`) is not called by breadbar or breadbox
|
||||
/// today — see that method's doc comment. Setting this key is currently
|
||||
/// a no-op for a real running shell (it IS exercised by this crate's own
|
||||
/// tests). (Schema note: plan §4
|
||||
/// shows `css = "extra.css"` textually after the `[compositor]` table
|
||||
/// with no table header of its own between them, which in real TOML
|
||||
/// would nest it *inside* `[compositor]`. Treated here as a top-level
|
||||
/// field per §5's `css()` doc — see this crate's implementation notes.)
|
||||
pub(super) css: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct RawBar {
|
||||
pub(super) window: Option<RawWindow>,
|
||||
pub(super) slots: Option<RawSlots>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct RawWindow {
|
||||
pub(super) anchors: Option<Vec<String>>,
|
||||
pub(super) width: Option<RawSize>,
|
||||
pub(super) height: Option<i64>,
|
||||
pub(super) margin: Option<RawMargin>,
|
||||
pub(super) exclusive: Option<RawExclusive>,
|
||||
pub(super) keyboard: Option<String>,
|
||||
pub(super) layer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub(super) enum RawSize {
|
||||
Named(String),
|
||||
Px(i64),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub(super) enum RawExclusive {
|
||||
Named(String),
|
||||
Px(i64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub(super) struct RawMargin {
|
||||
pub(super) top: i64,
|
||||
pub(super) left: i64,
|
||||
pub(super) right: i64,
|
||||
/// See [`super::types::Margin::bottom`] — accepted and resolved, but
|
||||
/// breadbar never applies it.
|
||||
pub(super) bottom: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub(super) struct RawSlots {
|
||||
pub(super) left: Vec<String>,
|
||||
pub(super) centre: Vec<String>,
|
||||
pub(super) right: Vec<String>,
|
||||
pub(super) drawer: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct RawModules {
|
||||
pub(super) workspaces: Option<RawWorkspacesModule>,
|
||||
pub(super) clock: Option<RawClockModule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct RawWorkspacesModule {
|
||||
pub(super) style: Option<String>,
|
||||
pub(super) show_empty: Option<bool>,
|
||||
/// `[6, 10, 14, 18]`-shaped — see [`DotWidths`]. A length other than 4
|
||||
/// is a hard error (validated in [`resolve_modules`]) rather than
|
||||
/// silently truncated/padded, matching this file's "typo'd key is a
|
||||
/// hard error" policy for enum-ish values.
|
||||
pub(super) dot_widths: Option<Vec<i64>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct RawClockModule {
|
||||
pub(super) style: Option<String>,
|
||||
pub(super) format: Option<String>,
|
||||
pub(super) show_date: Option<bool>,
|
||||
pub(super) placeholder_clock: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct RawLauncher {
|
||||
pub(super) mode: Option<String>,
|
||||
pub(super) width: Option<i64>,
|
||||
pub(super) top: Option<String>,
|
||||
pub(super) radius: Option<i64>,
|
||||
pub(super) icon_px: Option<i64>,
|
||||
pub(super) row_anim: Option<String>,
|
||||
pub(super) rule: Option<String>,
|
||||
pub(super) footer: Option<String>,
|
||||
pub(super) sections: Option<bool>,
|
||||
pub(super) modes: Option<Vec<String>>,
|
||||
pub(super) search_width: Option<i64>,
|
||||
pub(super) search_radius: Option<i64>,
|
||||
pub(super) row_radius: Option<i64>,
|
||||
pub(super) row_inset: Option<i64>,
|
||||
pub(super) row_padding_v: Option<i64>,
|
||||
pub(super) row_padding_h: Option<i64>,
|
||||
pub(super) icon_radius: Option<i64>,
|
||||
pub(super) search_font_size: Option<i64>,
|
||||
pub(super) search_padding_v: Option<i64>,
|
||||
pub(super) search_padding_h: Option<i64>,
|
||||
pub(super) panel_alpha: Option<f64>,
|
||||
pub(super) selection_alpha: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct RawSurface {
|
||||
pub(super) anchor: Option<String>,
|
||||
pub(super) offset: Option<RawOffset>,
|
||||
pub(super) width: Option<RawSurfaceWidth>,
|
||||
pub(super) layer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub(super) enum RawOffset {
|
||||
Single(f64),
|
||||
Pair([f64; 2]),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub(super) enum RawSurfaceWidth {
|
||||
Named(String),
|
||||
Px(i64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub(super) struct RawLayerRule {
|
||||
pub(super) blur: Option<bool>,
|
||||
pub(super) ignore_alpha: Option<f64>,
|
||||
pub(super) blur_popups: Option<bool>,
|
||||
pub(super) animation: Option<String>,
|
||||
pub(super) no_anim: Option<bool>,
|
||||
}
|
||||
|
||||
fn token_value(v: &toml::Value) -> anyhow::Result<TokenValue> {
|
||||
match v {
|
||||
toml::Value::String(s) => Ok(TokenValue::Str(s.clone())),
|
||||
toml::Value::Integer(i) => Ok(TokenValue::Int(*i)),
|
||||
toml::Value::Float(f) => Ok(TokenValue::Float(*f)),
|
||||
toml::Value::Boolean(b) => Ok(TokenValue::Bool(*b)),
|
||||
other => Err(anyhow!("must be a string, number, or bool, got {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_window(theme_id: &str, w: &RawWindow) -> anyhow::Result<WindowSpec> {
|
||||
let default = WindowSpec::default();
|
||||
|
||||
let anchors = match &w.anchors {
|
||||
Some(list) => {
|
||||
for a in list {
|
||||
if !matches!(a.as_str(), "top" | "bottom" | "left" | "right") {
|
||||
bail!(
|
||||
"theme '{theme_id}': bar.window.anchors contains unknown anchor \"{a}\" \
|
||||
(expected top|bottom|left|right)"
|
||||
);
|
||||
}
|
||||
}
|
||||
list.clone()
|
||||
}
|
||||
None => default.anchors,
|
||||
};
|
||||
|
||||
let width = match &w.width {
|
||||
Some(RawSize::Named(s)) if s == "fill" => Width::Fill,
|
||||
Some(RawSize::Named(other)) => bail!(
|
||||
"theme '{theme_id}': bar.window.width = \"{other}\" is not \"fill\" \
|
||||
(use a bare number for a fixed width)"
|
||||
),
|
||||
Some(RawSize::Px(n)) => Width::Px(*n as i32),
|
||||
None => default.width,
|
||||
};
|
||||
|
||||
let height = w.height.map(|h| h as i32).unwrap_or(default.height);
|
||||
|
||||
let margin = w
|
||||
.margin
|
||||
.as_ref()
|
||||
.map(|m| Margin {
|
||||
top: m.top as i32,
|
||||
left: m.left as i32,
|
||||
right: m.right as i32,
|
||||
bottom: m.bottom as i32,
|
||||
})
|
||||
.unwrap_or(default.margin);
|
||||
|
||||
let exclusive = match &w.exclusive {
|
||||
Some(RawExclusive::Named(s)) if s == "auto" => Exclusive::Auto,
|
||||
Some(RawExclusive::Named(s)) if s == "none" => Exclusive::None,
|
||||
Some(RawExclusive::Named(other)) => bail!(
|
||||
"theme '{theme_id}': bar.window.exclusive = \"{other}\" is not \"auto\" or \"none\" \
|
||||
(use a bare number for a fixed exclusive zone)"
|
||||
),
|
||||
Some(RawExclusive::Px(n)) => Exclusive::Px(*n as i32),
|
||||
None => default.exclusive,
|
||||
};
|
||||
|
||||
let keyboard = match w.keyboard.as_deref() {
|
||||
None => default.keyboard,
|
||||
Some("none") => Keyboard::None,
|
||||
Some("on_demand") => Keyboard::OnDemand,
|
||||
Some("exclusive") => Keyboard::Exclusive,
|
||||
Some(other) => bail!(
|
||||
"theme '{theme_id}': bar.window.keyboard = \"{other}\" is not none|on_demand|exclusive"
|
||||
),
|
||||
};
|
||||
|
||||
let layer = match w.layer.as_deref() {
|
||||
None => default.layer,
|
||||
Some("top") => "top".to_string(),
|
||||
Some("overlay") => "overlay".to_string(),
|
||||
Some(other) => {
|
||||
bail!("theme '{theme_id}': bar.window.layer = \"{other}\" is not top|overlay")
|
||||
}
|
||||
};
|
||||
|
||||
Ok(WindowSpec {
|
||||
anchors,
|
||||
width,
|
||||
height,
|
||||
margin,
|
||||
exclusive,
|
||||
keyboard,
|
||||
layer,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_modules(theme_id: &str, m: Option<&RawModules>) -> anyhow::Result<Modules> {
|
||||
let ws = m.and_then(|m| m.workspaces.as_ref());
|
||||
let style = match ws.and_then(|w| w.style.as_deref()) {
|
||||
None => WorkspaceStyle::Trail,
|
||||
Some("trail") => WorkspaceStyle::Trail,
|
||||
Some("pill") => WorkspaceStyle::Pill,
|
||||
Some("dots") => WorkspaceStyle::Dots,
|
||||
Some(other) => bail!(
|
||||
"theme '{theme_id}': modules.workspaces.style = \"{other}\" is not trail|pill|dots"
|
||||
),
|
||||
};
|
||||
let show_empty = ws.and_then(|w| w.show_empty).unwrap_or(true);
|
||||
let dot_widths = match ws.and_then(|w| w.dot_widths.as_ref()) {
|
||||
None => DEFAULT_DOT_WIDTHS,
|
||||
Some(v) if v.len() == 4 => [v[0] as i32, v[1] as i32, v[2] as i32, v[3] as i32],
|
||||
Some(v) => bail!(
|
||||
"theme '{theme_id}': modules.workspaces.dot_widths has {} entries, expected 4 \
|
||||
(0/1/2/3-or-more open windows)",
|
||||
v.len()
|
||||
),
|
||||
};
|
||||
|
||||
let ck = m.and_then(|m| m.clock.as_ref());
|
||||
let cstyle = match ck.and_then(|c| c.style.as_deref()) {
|
||||
None => ClockStyle::Flip,
|
||||
Some("flip") => ClockStyle::Flip,
|
||||
Some("plain") => ClockStyle::Plain,
|
||||
Some("none") => ClockStyle::None,
|
||||
Some(other) => {
|
||||
bail!("theme '{theme_id}': modules.clock.style = \"{other}\" is not flip|plain|none")
|
||||
}
|
||||
};
|
||||
let format = ck
|
||||
.and_then(|c| c.format.clone())
|
||||
.unwrap_or_else(|| "%H:%M".to_string());
|
||||
let show_date = ck.and_then(|c| c.show_date).unwrap_or(false);
|
||||
let placeholder_clock = ck.and_then(|c| c.placeholder_clock).unwrap_or(false);
|
||||
|
||||
Ok(Modules {
|
||||
workspaces: WorkspacesModule {
|
||||
style,
|
||||
show_empty,
|
||||
dot_widths,
|
||||
},
|
||||
clock: ClockModule {
|
||||
style: cstyle,
|
||||
format,
|
||||
show_date,
|
||||
placeholder_clock,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_launcher(theme_id: &str, l: Option<&RawLauncher>) -> anyhow::Result<Launcher> {
|
||||
let mode = match l.and_then(|l| l.mode.as_deref()) {
|
||||
None => LauncherMode::Overlay,
|
||||
Some("overlay") => LauncherMode::Overlay,
|
||||
Some("embedded") => LauncherMode::Embedded,
|
||||
Some(other) => {
|
||||
bail!("theme '{theme_id}': launcher.mode = \"{other}\" is not overlay|embedded")
|
||||
}
|
||||
};
|
||||
let width = l.and_then(|l| l.width).unwrap_or(540) as i32;
|
||||
let radius = l.and_then(|l| l.radius).unwrap_or(20) as i32;
|
||||
Ok(Launcher {
|
||||
mode,
|
||||
width,
|
||||
top: l
|
||||
.and_then(|l| l.top.clone())
|
||||
.unwrap_or_else(|| "16%".to_string()),
|
||||
radius,
|
||||
icon_px: l.and_then(|l| l.icon_px).unwrap_or(36) as i32,
|
||||
row_anim: l
|
||||
.and_then(|l| l.row_anim.clone())
|
||||
.unwrap_or_else(|| "flip".to_string()),
|
||||
rule: l
|
||||
.and_then(|l| l.rule.clone())
|
||||
.unwrap_or_else(|| "gradient".to_string()),
|
||||
footer: l
|
||||
.and_then(|l| l.footer.clone())
|
||||
.unwrap_or_else(|| "count_apps".to_string()),
|
||||
sections: l.and_then(|l| l.sections).unwrap_or(false),
|
||||
modes: l
|
||||
.and_then(|l| l.modes.clone())
|
||||
.unwrap_or_else(|| vec!["apps".to_string()]),
|
||||
// Default to the idle value — a theme that never sets these gets
|
||||
// "no change while searching", not a hardcoded widen/shrink it
|
||||
// never asked for (see the field docs on `Launcher`).
|
||||
search_width: l.and_then(|l| l.search_width).map(|v| v as i32).unwrap_or(width),
|
||||
search_radius: l.and_then(|l| l.search_radius).map(|v| v as i32).unwrap_or(radius),
|
||||
// Defaults below reproduce breadbox's pre-redesign hardcoded CSS
|
||||
// literals (`row { padding: 8px 12px; border-radius: 6px; }`,
|
||||
// `listbox { padding: 4px; }`, no icon swatch, a solid — not
|
||||
// alpha-blended — selection fill), so a theme that omits this whole
|
||||
// block of new keys (spotlight does; its capsule doesn't read them
|
||||
// at all) renders the same as before this change, not a jump to
|
||||
// either built-in's specific numbers.
|
||||
row_radius: l.and_then(|l| l.row_radius).unwrap_or(6) as i32,
|
||||
row_inset: l.and_then(|l| l.row_inset).unwrap_or(4) as i32,
|
||||
row_padding_v: l.and_then(|l| l.row_padding_v).unwrap_or(8) as i32,
|
||||
row_padding_h: l.and_then(|l| l.row_padding_h).unwrap_or(12) as i32,
|
||||
icon_radius: l.and_then(|l| l.icon_radius).unwrap_or(0) as i32,
|
||||
search_font_size: l.and_then(|l| l.search_font_size).unwrap_or(16) as i32,
|
||||
search_padding_v: l.and_then(|l| l.search_padding_v).unwrap_or(12) as i32,
|
||||
search_padding_h: l.and_then(|l| l.search_padding_h).unwrap_or(16) as i32,
|
||||
// 0.93 default, not breadbox's historical 0.60: a launcher panel needs
|
||||
// near-opacity to stay legible over a bright wallpaper.
|
||||
panel_alpha: l.and_then(|l| l.panel_alpha).unwrap_or(0.93),
|
||||
selection_alpha: l.and_then(|l| l.selection_alpha).unwrap_or(0.24),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_surfaces(
|
||||
theme_id: &str,
|
||||
raw: Option<&HashMap<String, RawSurface>>,
|
||||
) -> anyhow::Result<BTreeMap<String, Surface>> {
|
||||
let mut out = BTreeMap::new();
|
||||
let Some(raw) = raw else { return Ok(out) };
|
||||
for (namespace, s) in raw {
|
||||
let anchor = match s.anchor.as_deref() {
|
||||
Some(a @ ("top_right" | "bottom_right" | "bottom_centre" | "fill")) => a.to_string(),
|
||||
Some(other) => bail!(
|
||||
"theme '{theme_id}': surfaces.{namespace}.anchor = \"{other}\" is not \
|
||||
top_right|bottom_right|bottom_centre|fill (the only shapes breadbar's \
|
||||
satellite windows implement — see breadbar/src/surface.rs)"
|
||||
),
|
||||
None => bail!(
|
||||
"theme '{theme_id}': surfaces.{namespace} has no anchor set \
|
||||
(expected one of top_right|bottom_centre|fill)"
|
||||
),
|
||||
};
|
||||
let offset = match &s.offset {
|
||||
None => vec![],
|
||||
Some(RawOffset::Single(v)) => vec![*v],
|
||||
Some(RawOffset::Pair(v)) => v.to_vec(),
|
||||
};
|
||||
let width = match &s.width {
|
||||
None => SurfaceWidth::Auto,
|
||||
Some(RawSurfaceWidth::Named(n)) if n == "fill" => SurfaceWidth::Fill,
|
||||
Some(RawSurfaceWidth::Named(n)) if n == "auto" => SurfaceWidth::Auto,
|
||||
Some(RawSurfaceWidth::Named(other)) => bail!(
|
||||
"theme '{theme_id}': surfaces.{namespace}.width = \"{other}\" is not \"fill\" or \"auto\" \
|
||||
(use a bare number for a fixed width)"
|
||||
),
|
||||
Some(RawSurfaceWidth::Px(n)) => SurfaceWidth::Px(*n as i32),
|
||||
};
|
||||
let layer = match s.layer.as_deref() {
|
||||
None | Some("overlay") => "overlay".to_string(),
|
||||
Some("top") => "top".to_string(),
|
||||
Some(other) => bail!(
|
||||
"theme '{theme_id}': surfaces.{namespace}.layer = \"{other}\" is not top|overlay"
|
||||
),
|
||||
};
|
||||
out.insert(
|
||||
namespace.clone(),
|
||||
Surface {
|
||||
anchor,
|
||||
offset,
|
||||
width,
|
||||
layer,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn resolve_compositor(raw: Option<&HashMap<String, RawLayerRule>>) -> BTreeMap<String, LayerRule> {
|
||||
let mut out = BTreeMap::new();
|
||||
let Some(raw) = raw else { return out };
|
||||
for (namespace, r) in raw {
|
||||
out.insert(
|
||||
namespace.clone(),
|
||||
LayerRule {
|
||||
blur: r.blur.unwrap_or(false),
|
||||
ignore_alpha: r.ignore_alpha,
|
||||
blur_popups: r.blur_popups.unwrap_or(false),
|
||||
animation: r.animation.clone(),
|
||||
no_anim: r.no_anim.unwrap_or(false),
|
||||
},
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl RawManifest {
|
||||
/// Fill every default and validate every enum-ish string, producing a
|
||||
/// fully-resolved [`super::ShellTheme`]. `requested_id` is the id this
|
||||
/// manifest was looked up under (used as the id/name fallback when the
|
||||
/// TOML omits `id`/`name`); `css_template` and `extra_css` are threaded
|
||||
/// in by the discovery/extends logic in `mod.rs` since neither is a
|
||||
/// plain TOML field (extra_css is *read from* a TOML field, `css`, but
|
||||
/// resolving that path against the theme's own directory happens in the
|
||||
/// caller, which is the only place that still has the directory handy).
|
||||
pub(super) fn resolve(
|
||||
&self,
|
||||
requested_id: &str,
|
||||
css_template: String,
|
||||
extra_css: Option<String>,
|
||||
) -> anyhow::Result<super::ShellTheme> {
|
||||
let id = self.id.clone().unwrap_or_else(|| requested_id.to_string());
|
||||
let name = self.name.clone().unwrap_or_else(|| id.clone());
|
||||
|
||||
let mut tokens_map = BTreeMap::new();
|
||||
for (k, v) in &self.tokens {
|
||||
let tv = token_value(v).with_context(|| format!("theme '{id}': tokens.{k}"))?;
|
||||
tokens_map.insert(k.clone(), tv);
|
||||
}
|
||||
let tokens = Tokens::from_map(tokens_map);
|
||||
|
||||
let window = match self.bar.as_ref().and_then(|b| b.window.as_ref()) {
|
||||
Some(w) => resolve_window(&id, w)?,
|
||||
None => WindowSpec::default(),
|
||||
};
|
||||
|
||||
let slots = self
|
||||
.bar
|
||||
.as_ref()
|
||||
.and_then(|b| b.slots.as_ref())
|
||||
.map(|s| Slots {
|
||||
left: s.left.clone(),
|
||||
centre: s.centre.clone(),
|
||||
right: s.right.clone(),
|
||||
drawer: s.drawer.clone(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let modules = resolve_modules(&id, self.modules.as_ref())?;
|
||||
let launcher = resolve_launcher(&id, self.launcher.as_ref())?;
|
||||
let surfaces = resolve_surfaces(&id, self.surfaces.as_ref())?;
|
||||
let compositor = resolve_compositor(self.compositor.as_ref());
|
||||
|
||||
Ok(super::ShellTheme {
|
||||
name,
|
||||
id,
|
||||
tokens,
|
||||
window,
|
||||
slots,
|
||||
modules,
|
||||
launcher,
|
||||
surfaces,
|
||||
compositor,
|
||||
css_template,
|
||||
extra_css,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-merge `over` onto `base`: tables merge key-by-key recursively;
|
||||
/// anything else (scalars, arrays — including slot lists) is a full
|
||||
/// replacement. This is `extends`'s one-level merge (plan §4/§11):
|
||||
/// `mod.rs` calls this exactly once per `load_named`, with the base's own
|
||||
/// `extends` key already stripped by the caller so a chain can't go deeper
|
||||
/// than one level.
|
||||
pub(super) fn merge_values(base: toml::Value, over: toml::Value) -> toml::Value {
|
||||
match (base, over) {
|
||||
(toml::Value::Table(mut base_t), toml::Value::Table(over_t)) => {
|
||||
for (k, v) in over_t {
|
||||
let merged = match base_t.remove(&k) {
|
||||
Some(existing) => merge_values(existing, v),
|
||||
None => v,
|
||||
};
|
||||
base_t.insert(k, merged);
|
||||
}
|
||||
toml::Value::Table(base_t)
|
||||
}
|
||||
(_, over) => over,
|
||||
}
|
||||
}
|
||||
1459
bread-theme/src/shell/mod.rs
Normal file
1459
bread-theme/src/shell/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
590
bread-theme/src/shell/types.rs
Normal file
590
bread-theme/src/shell/types.rs
Normal file
|
|
@ -0,0 +1,590 @@
|
|||
//! Fully-resolved shell theme types — see `bread-theme/src/shell/mod.rs` for
|
||||
//! the module overview and `manifest.rs` for how these are built from TOML.
|
||||
//!
|
||||
//! Every type here has all defaults filled in; there is no further "is this
|
||||
//! set" branching once a `ShellTheme` exists. That resolution work happens
|
||||
//! once, in `manifest.rs`, so consumers (breadbar, breadbox, bos-settings)
|
||||
//! never have to know the manifest format at all.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Workspace strip rendering. Phase 1 ships only `Trail` (what breadbar draws
|
||||
/// today); `Pill`/`Dots` exist now so 02/04 (plan §11 phases 5-6) are additive.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WorkspaceStyle {
|
||||
Trail,
|
||||
Pill,
|
||||
Dots,
|
||||
}
|
||||
|
||||
/// Clock rendering. Phase 1 ships only `Flip` (today's per-digit flip clock).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClockStyle {
|
||||
Flip,
|
||||
Plain,
|
||||
None,
|
||||
}
|
||||
|
||||
/// Dot pill widths (px) for 0/1/2/3-or-more open windows, `style = "dots"`
|
||||
/// (theme 04/spotlight). Index 3 covers "3 or more" — the demo's own dots
|
||||
/// never grow past that fourth width. Unused by `Trail`/`Pill`.
|
||||
pub type DotWidths = [i32; 4];
|
||||
|
||||
/// The demo's own numbers (`04-spotlight.html`'s `.dots button[data-n="N"]`
|
||||
/// rules) — the default a theme gets if it sets `style = "dots"` but omits
|
||||
/// `dot_widths`.
|
||||
pub const DEFAULT_DOT_WIDTHS: DotWidths = [6, 10, 14, 18];
|
||||
|
||||
/// How the launcher attaches to the shell. Phase 1 ships only `Overlay`
|
||||
/// (breadbox's own window); `Embedded` is theme 04's bar-drawer launcher.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LauncherMode {
|
||||
Overlay,
|
||||
Embedded,
|
||||
}
|
||||
|
||||
/// `gtk4_layer_shell::KeyboardMode` mirror, kept independent of the `gtk`
|
||||
/// feature so the manifest types stay usable without GTK linked in (bread,
|
||||
/// breadcrumbs). Values map 1:1 onto `KeyboardMode::{None,Exclusive,OnDemand}`
|
||||
/// (verified against gtk4-layer-shell 0.8.1's `src/auto/enums.rs`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Keyboard {
|
||||
None,
|
||||
OnDemand,
|
||||
Exclusive,
|
||||
}
|
||||
|
||||
/// `bar.window.width` / a surface's `width`: `"fill"` spans the anchored
|
||||
/// edges, a bare number is a fixed/centred/hug width.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Width {
|
||||
Fill,
|
||||
Px(i32),
|
||||
}
|
||||
|
||||
/// `bar.window.exclusive`: `"auto"` reserves `height + margin.top`, `"none"`
|
||||
/// reserves nothing (theme 04's capsule), or a literal pixel override.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Exclusive {
|
||||
Auto,
|
||||
None,
|
||||
Px(i32),
|
||||
}
|
||||
|
||||
/// A satellite surface's width: unlike the bar window, a satellite can also
|
||||
/// be `Auto` — sized by its own content/CSS with no `set_default_width` call
|
||||
/// at all. `breadbar-panel` is exactly this today (popover content decides
|
||||
/// its width via `.control-panel-inner`/`.wifi-popover-inner` min-width, not
|
||||
/// the layer-shell window).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SurfaceWidth {
|
||||
Fill,
|
||||
Auto,
|
||||
Px(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct Margin {
|
||||
pub top: i32,
|
||||
pub left: i32,
|
||||
pub right: i32,
|
||||
/// Declared-but-not-yet-consumed: parsed and carried all the way
|
||||
/// through resolution, but breadbar only calls
|
||||
/// `gtk4_layer_shell::LayerShell::set_margin` for
|
||||
/// `Edge::{Top,Left,Right}` (`breadbar/src/main.rs`) — there is no
|
||||
/// `Edge::Bottom` call anywhere in that crate. All three built-in
|
||||
/// themes happen to omit `margin.bottom` (defaulting to 0, this
|
||||
/// struct's own `Default`), which is exactly why the gap has stayed
|
||||
/// invisible: a theme author who *does* set it would see no effect.
|
||||
pub bottom: i32,
|
||||
}
|
||||
|
||||
/// `bar.window` — plan §2: window shape is data, not a closed layout enum.
|
||||
/// Island/Edge/Capsule are three *values* of this struct, not three code
|
||||
/// paths.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct WindowSpec {
|
||||
pub anchors: Vec<String>,
|
||||
pub width: Width,
|
||||
pub height: i32,
|
||||
pub margin: Margin,
|
||||
pub exclusive: Exclusive,
|
||||
pub keyboard: Keyboard,
|
||||
pub layer: String,
|
||||
}
|
||||
|
||||
impl Default for WindowSpec {
|
||||
/// Generic baseline for a theme that omits `[bar.window]` entirely —
|
||||
/// deliberately the plan §4 schema example's numbers, not necessarily
|
||||
/// any particular shipped theme's. `liquid-motion` sets every field
|
||||
/// explicitly, so it never falls through to this.
|
||||
fn default() -> Self {
|
||||
WindowSpec {
|
||||
anchors: vec!["top".into(), "left".into(), "right".into()],
|
||||
width: Width::Fill,
|
||||
height: 44,
|
||||
margin: Margin {
|
||||
top: 12,
|
||||
left: 14,
|
||||
right: 14,
|
||||
bottom: 0,
|
||||
},
|
||||
exclusive: Exclusive::Auto,
|
||||
keyboard: Keyboard::None,
|
||||
layer: "top".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `bar.slots` — plan §2: structure is slots, not layout code. `drawer` is
|
||||
/// the only thing Capsule/theme-04 adds over Island, and it's just an
|
||||
/// (empty, for now) slot list, not a code path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Slots {
|
||||
pub left: Vec<String>,
|
||||
pub centre: Vec<String>,
|
||||
pub right: Vec<String>,
|
||||
pub drawer: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspacesModule {
|
||||
pub style: WorkspaceStyle,
|
||||
pub show_empty: bool,
|
||||
/// `style = "dots"` only — see [`DotWidths`]. Trail/Pill ignore this.
|
||||
pub dot_widths: DotWidths,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClockModule {
|
||||
pub style: ClockStyle,
|
||||
/// Consumed only by `ClockStyle::Plain` (`breadbar::bar::clock`'s
|
||||
/// `formatted()`, called from `main.rs` only in the `Plain` arm) —
|
||||
/// `Flip` and `None` never read it; `Flip`'s own `time()` hardcodes a
|
||||
/// 24h `HH:MM` layout regardless of this field. All three built-in
|
||||
/// themes set `format = "%H:%M"` (which happens to match `Flip`'s
|
||||
/// hardcoded layout, masking the gap for two of the three styles); see
|
||||
/// each `theme.toml`'s own note next to it for which styles actually
|
||||
/// honour this.
|
||||
pub format: String,
|
||||
/// Consumed only by `ClockStyle::Plain`, same scoping as
|
||||
/// [`Self::format`] — `date_lbl` is only ever attached under the
|
||||
/// `Plain` arm's box, so this value can never be observed under
|
||||
/// `Flip`/`None`. Currently harmless in practice (every built-in theme
|
||||
/// that isn't `Plain` sets this `false`, so there's nothing to ignore),
|
||||
/// but the same "declared, scoped to one style" caveat as `format`
|
||||
/// applies.
|
||||
pub show_date: bool,
|
||||
/// `style = "none"` + this `true`: no module renders a clock label of
|
||||
/// its own — `launcher_entry`'s placeholder text becomes the time
|
||||
/// instead (theme 04/spotlight: the capsule's entry IS the clock until
|
||||
/// focused, per `04-spotlight.html`'s `q.placeholder = t`). Meaningless
|
||||
/// for `Flip`/`Plain`, which already show a time some other way.
|
||||
pub placeholder_clock: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Modules {
|
||||
pub workspaces: WorkspacesModule,
|
||||
pub clock: ClockModule,
|
||||
}
|
||||
|
||||
// No `Eq`: `selection_alpha` is an `f64`, which doesn't implement it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Launcher {
|
||||
pub mode: LauncherMode,
|
||||
pub width: i32,
|
||||
pub top: String,
|
||||
pub radius: i32,
|
||||
pub icon_px: i32,
|
||||
pub row_anim: String,
|
||||
pub rule: String,
|
||||
/// Selects the footer label's noun — `"count_apps"` renders
|
||||
/// `"{n} applications"`, anything else (`"count_results"` included)
|
||||
/// renders `"{n} results"`. Consumed by `breadbox::main`'s footer label
|
||||
/// (appended below `ResultsList::scroller`, updated on every
|
||||
/// `set_query` call alongside the visible-row count).
|
||||
pub footer: String,
|
||||
/// Consumed by breadbar's embedded capsule (`breadbar::main::run_ui`,
|
||||
/// passed straight to `ResultsList::new`) for every theme, and by
|
||||
/// breadbox's own overlay (`breadbox::main::run_ui`) as of the
|
||||
/// liquid-motion/glass-workbench redesign — `true` groups the idle
|
||||
/// (empty-query) view into "Recent"/"Apps" headers via
|
||||
/// `bread_launcher::gtk::split_sections`, `false` reproduces the flat
|
||||
/// list. breadbox no longer hardcodes `false` at its `ResultsList::new`
|
||||
/// call site; it reads this field.
|
||||
pub sections: bool,
|
||||
pub modes: Vec<String>,
|
||||
/// `LauncherMode::Embedded` only (theme 04/spotlight, plan §7 phase 6c):
|
||||
/// the capsule's own width while a search is in progress
|
||||
/// (`04-spotlight.html`: `.searching .capsule { width: 520px }` vs the
|
||||
/// idle 480px). Defaults to `width` (no widen) for a theme that omits
|
||||
/// it, so an `Overlay`-mode theme — which never reads this field at all
|
||||
/// — and an `Embedded` theme that just doesn't want the widen both fall
|
||||
/// back to "no change" rather than a hardcoded magic number.
|
||||
pub search_width: i32,
|
||||
/// `LauncherMode::Embedded` only: `border-radius` while searching
|
||||
/// (`04-spotlight.html`: `.searching .capsule { border-radius: 20px }`
|
||||
/// vs the collapsed 22px `radius`). Same default-to-`radius` fallback
|
||||
/// reasoning as `search_width`.
|
||||
pub search_radius: i32,
|
||||
/// Result row `border-radius` (px) — `breadbox::main::build_css`'s
|
||||
/// `row { border-radius: ... }`. Liquid Motion's soft 12px vs Glass
|
||||
/// Workbench's dense 6px is the clearest single signal that the two
|
||||
/// themes are different instruments, not one launcher recoloured.
|
||||
pub row_radius: i32,
|
||||
/// Result row horizontal inset (px) from the panel's edge —
|
||||
/// `row { margin: 0 {row_inset}px; }`. Mirrors the demos' `.bx .r`
|
||||
/// `margin: 0 Npx` rule (liquid-motion 8px, glass-workbench 6px).
|
||||
pub row_inset: i32,
|
||||
/// Result row vertical padding (px) — `row { padding: {row_padding_v}px
|
||||
/// {row_padding_h}px; }`'s first component.
|
||||
pub row_padding_v: i32,
|
||||
/// Result row horizontal padding (px) — same rule's second component.
|
||||
pub row_padding_h: i32,
|
||||
/// Row icon `border-radius` (px) — `breadbox::main::build_css`'s
|
||||
/// `image { border-radius: ...; }`, paired with `icon_px` for the
|
||||
/// swatch's size. Liquid Motion's rounder 9px vs Glass Workbench's
|
||||
/// tighter 5px.
|
||||
pub icon_radius: i32,
|
||||
/// Search entry font-size (px) — distinct from `tokens.font_size_base`
|
||||
/// (which sizes the result rows): both demos give the search field a
|
||||
/// larger face than its rows (liquid-motion 16 vs its rows' 14,
|
||||
/// glass-workbench 14 vs its rows' 13).
|
||||
pub search_font_size: i32,
|
||||
/// Search entry vertical padding (px).
|
||||
pub search_padding_v: i32,
|
||||
/// Search entry horizontal padding (px).
|
||||
pub search_padding_h: i32,
|
||||
/// Opacity of the launcher PANEL itself, distinct from `tokens.bg_alpha`
|
||||
/// (which governs the thin bar). A bar can be very translucent and stay
|
||||
/// readable because it is 36-44px tall over a small slice of wallpaper; a
|
||||
/// full launcher panel at the same alpha washes out badly over a bright
|
||||
/// wallpaper and its text becomes hard to read. The approved reference
|
||||
/// uses 0.95 (glass-workbench) and 0.93 (liquid-motion); breadbox
|
||||
/// previously hardcoded 0.60, which is what made it look washed out.
|
||||
pub panel_alpha: f64,
|
||||
/// Selected/hovered row background: `alpha(@accent, selection_alpha)`.
|
||||
/// Liquid Motion's softer 0.22 vs Glass Workbench's denser 0.28 — see
|
||||
/// each demo's `.bx .r.sel` rule.
|
||||
pub selection_alpha: f64,
|
||||
}
|
||||
|
||||
/// A satellite surface, keyed by layer-shell namespace in `[surfaces.*]` —
|
||||
/// deliberately the same keyspace as `[compositor.*]` (see module docs)
|
||||
/// rather than a role name, so the two tables can be validated against each
|
||||
/// other and a namespace's positioning and compositor treatment live under
|
||||
/// one lookup.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Surface {
|
||||
/// One of `top_right`/`bottom_right`/`bottom_centre`/`fill` — validated
|
||||
/// in `manifest.rs::resolve_surfaces` against the shapes
|
||||
/// `breadbar/src/surface.rs::apply` actually implements, the same
|
||||
/// "typo'd key is a hard error" policy this field's siblings
|
||||
/// (`width`, `layer`) already got. Required, not defaulted: a surface
|
||||
/// entry with no anchor at all is as much a hard `theme.toml` error as
|
||||
/// an unrecognized one.
|
||||
///
|
||||
/// `bottom_right` (daylight, plan §11 phase 7) added alongside the
|
||||
/// original three: every built-in theme before it anchored its bar to
|
||||
/// the TOP, so `breadbar-notif`/`breadbar-panel` popping up from
|
||||
/// `top_right` always sat naturally close to the bar. A bottom-anchored
|
||||
/// bar has no shape in the original three that keeps those satellites
|
||||
/// near it — `top_right` would put them at the opposite corner of the
|
||||
/// screen from the dock they visually belong to. `offset` is
|
||||
/// `[right, bottom]` for this anchor, the same two-element convention
|
||||
/// `top_right`'s `[right, top]` already uses.
|
||||
pub anchor: String,
|
||||
pub offset: Vec<f64>,
|
||||
pub width: SurfaceWidth,
|
||||
pub layer: String,
|
||||
}
|
||||
|
||||
/// One `[compositor.*]` entry — plan §9: the per-namespace layer-shell rule
|
||||
/// an app ships as its default and a theme may override. Mirrors the field
|
||||
/// set `hl.layer_rule` actually accepts in `scripts/ui/rules.lua` (blur,
|
||||
/// ignore_alpha, blur_popups, animation, no_anim) — that Lua API isn't in
|
||||
/// hyprland-api.lua's type annotations, so this field set is evidenced by
|
||||
/// working usage, not documentation (plan §12 risk 3).
|
||||
///
|
||||
/// Also `Serialize`: this is the per-namespace shape written to
|
||||
/// `~/.config/hypr/layerrules.json` by `bread_theme::layerrules` (plan §9
|
||||
/// step 3-4), which `scripts/ui/rules.lua` parses back into `hl.layer_rule`
|
||||
/// calls. `Option::None` fields are omitted rather than emitted as `null` —
|
||||
/// the Lua JSON reader treats a missing key and a `null` value identically
|
||||
/// (assigning `nil` into a table key is a no-op), so either encoding is
|
||||
/// correct, but omitting keeps the file legible for hand inspection.
|
||||
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize)]
|
||||
pub struct LayerRule {
|
||||
pub blur: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ignore_alpha: Option<f64>,
|
||||
pub blur_popups: bool,
|
||||
/// Passed through verbatim to `hl.layer_rule`'s `animation` field
|
||||
/// (`"slide top"`, `"slide bottom"`, …) — kept as a plain string rather
|
||||
/// than a closed enum since `hl.layer_rule`'s own field set is only
|
||||
/// evidenced by working usage in `rules.lua`, not documented (plan §12
|
||||
/// risk 3); a closed Rust enum here would need updating in lockstep
|
||||
/// with Hyprland additions this crate has no way to know about.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub animation: Option<String>,
|
||||
pub no_anim: bool,
|
||||
}
|
||||
|
||||
/// A raw TOML scalar carried through to [`Tokens`] for `{name}` substitution
|
||||
/// in [`crate::shell::ShellTheme::css`]. Kept untyped (rather than forcing
|
||||
/// every token into a `String`) so `css()` can format a number without a
|
||||
/// theme author having to quote it, while `bg_alpha = 0.72` etc. still round
|
||||
/// -trips as a real float for any future non-string consumer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TokenValue {
|
||||
Str(String),
|
||||
Int(i64),
|
||||
Float(f64),
|
||||
Bool(bool),
|
||||
}
|
||||
|
||||
impl TokenValue {
|
||||
/// Textual form used both for `{name}` substitution in CSS and for the
|
||||
/// typed accessors' fallback formatting.
|
||||
pub fn as_css(&self) -> String {
|
||||
match self {
|
||||
TokenValue::Str(s) => s.clone(),
|
||||
TokenValue::Int(i) => i.to_string(),
|
||||
TokenValue::Float(f) => {
|
||||
if f.fract() == 0.0 {
|
||||
format!("{f:.0}")
|
||||
} else {
|
||||
f.to_string()
|
||||
}
|
||||
}
|
||||
TokenValue::Bool(b) => b.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[tokens]`, resolved. Deliberately an open bag (`BTreeMap`), not a fixed
|
||||
/// struct: the schema (plan §4) names eleven fields, but a theme may define
|
||||
/// arbitrary extra keys purely for `{name}` substitution in `extra.css`
|
||||
/// (`radius_pill`, `chip_height`, `icon_px`, `spring_settle` below are all
|
||||
/// exactly this — real values `theme.rs::load_css` uses today that the plan
|
||||
/// text's `[tokens]` example didn't list). The named accessors below give
|
||||
/// the documented fields typed access with sensible defaults; [`Tokens::get`]
|
||||
/// and [`Tokens::substitute`] cover everything else.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Tokens {
|
||||
pub(crate) map: BTreeMap<String, TokenValue>,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
pub fn from_map(map: BTreeMap<String, TokenValue>) -> Self {
|
||||
Tokens { map }
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&TokenValue> {
|
||||
self.map.get(key)
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> impl Iterator<Item = &str> {
|
||||
self.map.keys().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
fn str_or(&self, key: &str, default: &str) -> String {
|
||||
match self.map.get(key) {
|
||||
Some(v) => v.as_css(),
|
||||
None => default.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn int_or(&self, key: &str, default: i64) -> i64 {
|
||||
match self.map.get(key) {
|
||||
Some(TokenValue::Int(i)) => *i,
|
||||
Some(TokenValue::Float(f)) => *f as i64,
|
||||
Some(TokenValue::Str(s)) => s.parse().unwrap_or(default),
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
fn float_or(&self, key: &str, default: f64) -> f64 {
|
||||
match self.map.get(key) {
|
||||
Some(TokenValue::Float(f)) => *f,
|
||||
Some(TokenValue::Int(i)) => *i as f64,
|
||||
Some(TokenValue::Str(s)) => s.parse().unwrap_or(default),
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Same fallback shape as [`Self::str_or`]/[`Self::int_or`]/
|
||||
/// [`Self::float_or`] for a `TokenValue::Bool`. First consumer: `light`
|
||||
/// below (theme 05/daylight, plan §11 phase 7) — every existing token is
|
||||
/// a string/number, this is the first bool-shaped one, hence the new
|
||||
/// helper rather than reusing one of the three above.
|
||||
fn bool_or(&self, key: &str, default: bool) -> bool {
|
||||
match self.map.get(key) {
|
||||
Some(TokenValue::Bool(b)) => *b,
|
||||
Some(TokenValue::Str(s)) => s.parse().unwrap_or(default),
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumed by `breadbox::main::build_css` for the launcher panel's
|
||||
/// `font-family` (the `entry.search`/`row` rule) as of the
|
||||
/// liquid-motion/glass-workbench redesign — combined with
|
||||
/// [`Self::font_fallback`] into a CSS font stack. Still NOT consumed by
|
||||
/// `breadbar` (bar/chip/clock text) or by [`crate::stylesheet`]'s
|
||||
/// ecosystem-wide base rule, which still hardcodes
|
||||
/// `crate::tokens::FONT_FAMILY` for every non-launcher widget — this is
|
||||
/// a scoped, launcher-only wire-up, not the full "per-shell-theme font
|
||||
/// everywhere" replacement a `stylesheet()` owner would need to decide
|
||||
/// on separately.
|
||||
pub fn font_family(&self) -> String {
|
||||
self.str_or("font_family", crate::tokens::FONT_FAMILY)
|
||||
}
|
||||
/// See [`Self::font_family`] — same scoped (launcher-only) consumption,
|
||||
/// appended after `font_family` in the CSS font stack breadbox builds.
|
||||
pub fn font_fallback(&self) -> String {
|
||||
self.str_or("font_fallback", "sans-serif")
|
||||
}
|
||||
/// Consumed by `breadbox::main::build_css` for the result rows'
|
||||
/// `font-size` (the search entry uses `[launcher].search_font_size`
|
||||
/// instead — both demos give the search field a larger face than its
|
||||
/// rows). Still not read by `breadbar`.
|
||||
pub fn font_size_base(&self) -> i64 {
|
||||
self.int_or("font_size_base", crate::tokens::FONT_SIZE_BASE as i64)
|
||||
}
|
||||
pub fn radius_bar(&self) -> i64 {
|
||||
self.int_or("radius_bar", crate::tokens::RADIUS_PRIMARY as i64)
|
||||
}
|
||||
pub fn radius_card(&self) -> i64 {
|
||||
self.int_or("radius_card", crate::tokens::RADIUS_PRIMARY as i64)
|
||||
}
|
||||
pub fn radius_sm(&self) -> i64 {
|
||||
self.int_or("radius_sm", crate::tokens::RADIUS_SECONDARY as i64)
|
||||
}
|
||||
/// Not in the plan §4 schema list, but a named local in
|
||||
/// `theme.rs::load_css` (`radius_pill = "999px"`) alongside the three
|
||||
/// siblings that are. See the [`Tokens`] doc comment.
|
||||
pub fn radius_pill(&self) -> i64 {
|
||||
self.int_or("radius_pill", crate::tokens::RADIUS_PILL as i64)
|
||||
}
|
||||
pub fn pad(&self) -> i64 {
|
||||
self.int_or("pad", crate::tokens::SPACE_MD as i64)
|
||||
}
|
||||
pub fn bg_alpha(&self) -> f64 {
|
||||
self.float_or("bg_alpha", 0.72)
|
||||
}
|
||||
/// The overshoot/bounce curve (`0.22, 1.35, 0.36, 1`) — clock flips,
|
||||
/// pop-ins, the workspace caret draw.
|
||||
pub fn spring(&self) -> String {
|
||||
self.str_or("spring", "cubic-bezier(0.22, 1.35, 0.36, 1)")
|
||||
}
|
||||
/// The settle curve (`0.22, 1.2, 0.36, 1`) — hovers, workspace-btn
|
||||
/// opacity/background transitions, OSD/notification slide-ins. Not in
|
||||
/// the plan §4 schema (which names only `spring`), but `theme.rs` uses
|
||||
/// it just as pervasively as the overshoot curve. See [`Tokens`] doc.
|
||||
pub fn spring_settle(&self) -> String {
|
||||
self.str_or("spring_settle", "cubic-bezier(0.22, 1.2, 0.36, 1)")
|
||||
}
|
||||
pub fn accent_from(&self) -> String {
|
||||
self.str_or("accent_from", "accent")
|
||||
}
|
||||
/// Was declared-but-not-yet-consumed in production through theme 04
|
||||
/// (spotlight): breadbar's hand-rolled Trail CSS hardcoded the literal
|
||||
/// gradient `linear-gradient(90deg, @accent, @teal)` instead of
|
||||
/// substituting `accent_from`/`accent_to`, silently correct only because
|
||||
/// liquid-motion's own `accent_from`/`accent_to` happened to be
|
||||
/// `"accent"`/`"teal"` — the exact two names the hardcode already spelled
|
||||
/// out. Theme 05 (daylight, plan §11 phase 7) is the first Trail-style
|
||||
/// theme to set a *different* pair (`accent_from = accent_to = "teal"`,
|
||||
/// a flat fill, not a gradient), which is what finally forced
|
||||
/// `breadbar::theme::load_css`'s Trail branch to read this method for
|
||||
/// real instead of the two literal names — see that function's own note
|
||||
/// next to `.workspace-trail`.
|
||||
pub fn accent_to(&self) -> String {
|
||||
let from = self.accent_from();
|
||||
self.str_or("accent_to", &from)
|
||||
}
|
||||
/// A second, independent accent for chrome that shouldn't track the
|
||||
/// primary `accent_from`/`accent_to` pair — daylight (plan §11 phase 7)
|
||||
/// is the first theme that needs one: its media-widget equaliser bars
|
||||
/// are warm amber while the workspace trail/active-fill accent is deep
|
||||
/// teal, so one `accent_from` value can no longer describe both.
|
||||
/// Defaults to `accent_from` itself, which reproduces every earlier
|
||||
/// theme's actual rendering byte-for-byte (liquid-motion/glass-
|
||||
/// workbench/spotlight all paint their equaliser with the same accent
|
||||
/// as everything else, and none of them sets this key). Consumed by
|
||||
/// `breadbar::theme::load_css`'s `.media-eq-bar` rule.
|
||||
pub fn accent2(&self) -> String {
|
||||
let from = self.accent_from();
|
||||
self.str_or("accent2", &from)
|
||||
}
|
||||
/// Whether this theme's surfaces are painted ink-on-paper (near-opaque
|
||||
/// LIGHT fills with dark ink) rather than the glass-on-dark look every
|
||||
/// earlier theme assumed — daylight (plan §11 phase 7) is the first
|
||||
/// theme to set this `true`. Defaults `false`, reproducing every
|
||||
/// existing theme's rendering unchanged.
|
||||
///
|
||||
/// Exists because the palette's `bg`/`surface`/`overlay`/`fg` slots are
|
||||
/// NEVER pywal-derived (`bread_theme::palette`'s `FIXED_*` constants —
|
||||
/// deliberately, so a bright wallpaper can't turn the whole UI an
|
||||
/// unreadable light colour by accident) — only the six accent slots
|
||||
/// vary. That means there is no palette token whose value is a light
|
||||
/// "paper" surface for a theme to reference by name; `@bg` is always
|
||||
/// dark, `@on-bg` is always its computed-legible near-white ink.
|
||||
/// `breadbar::theme::load_css` reads this flag to swap which of those
|
||||
/// two *fixed, anti-correlated* tokens plays "surface fill" versus
|
||||
/// "ink" for every translucent card/panel/hover-wash in the stylesheet
|
||||
/// (`@on-bg` as the near-white fill, `@bg` as the near-black ink,
|
||||
/// otherwise the reverse) — see that function's own `panel`/`ink`
|
||||
/// locals. This works only because `@bg`/`@on-bg` are pinned opposite
|
||||
/// constants by construction; it is not a general "pick any light
|
||||
/// surface colour" mechanism, and this doc comment is the canonical
|
||||
/// place that fact is written down (see the task report for the full
|
||||
/// reasoning: the palette schema has no dedicated light-surface token).
|
||||
pub fn light(&self) -> bool {
|
||||
self.bool_or("light", false)
|
||||
}
|
||||
/// Workspace-pill / chip height. Not in the plan §4 schema, but
|
||||
/// `breadbar::CHIP_HEIGHT` (32) today. See [`Tokens`] doc.
|
||||
pub fn chip_height(&self) -> i64 {
|
||||
self.int_or("chip_height", 32)
|
||||
}
|
||||
/// Not in the plan §4 schema, but `breadbar::ICON_PX` (24) today. See
|
||||
/// [`Tokens`] doc.
|
||||
pub fn icon_px(&self) -> i64 {
|
||||
self.int_or("icon_px", 24)
|
||||
}
|
||||
/// Not in the plan §4 schema. `"full"` (default, liquid-motion's island)
|
||||
/// draws a border on all four edges; `"bottom"` (glass-workbench's flush
|
||||
/// edge-to-edge bar, plan §1) draws only the bottom hairline the demo's
|
||||
/// `.bar { border-bottom: 1px solid #ffffff12 }` calls for — a floating
|
||||
/// island's full border would otherwise render as a stray top/side line
|
||||
/// flush against the screen edge. `"segmented"` (daylight, plan §11
|
||||
/// phase 7) is a third value: `window.breadbar` itself gets NO fill,
|
||||
/// border, or radius at all (fully transparent), and the bar's three
|
||||
/// slot-group containers each carry their own pill surface instead (see
|
||||
/// `breadbar::theme::load_css`'s `segmented`/`.bar-segment` locals) —
|
||||
/// this is what lets one bar surface look like three detached floating
|
||||
/// pills rather than one continuous strip. See [`Tokens`] doc; consumed
|
||||
/// by `breadbar::theme::load_css`, not by [`crate::shell::ShellTheme::css`].
|
||||
pub fn bar_border(&self) -> String {
|
||||
self.str_or("bar_border", "full")
|
||||
}
|
||||
|
||||
/// Replace every `{name}` occurrence in `template` with that token's
|
||||
/// [`TokenValue::as_css`] form. Longest names are substituted first
|
||||
/// (mirrors [`crate::resolve_color_names`]) so `{radius}` cannot
|
||||
/// half-consume `{radius_bar}` if a theme happens to define both.
|
||||
/// `@name` palette references are untouched — this only ever looks at
|
||||
/// `{...}` tokens.
|
||||
pub fn substitute(&self, template: &str) -> String {
|
||||
let mut keys: Vec<&String> = self.map.keys().collect();
|
||||
keys.sort_by_key(|k| std::cmp::Reverse(k.len()));
|
||||
let mut out = template.to_string();
|
||||
for k in keys {
|
||||
let value = self.map[k].as_css();
|
||||
out = out.replace(&format!("{{{k}}}"), &value);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
let request = json!({
|
||||
"id": "0",
|
||||
"method": "emit",
|
||||
"params": {
|
||||
fire_and_forget_emit(json!({
|
||||
"event": event,
|
||||
"source": self.app_id,
|
||||
"kind": event,
|
||||
"data": data,
|
||||
}));
|
||||
}
|
||||
});
|
||||
let Ok(line) = serde_json::to_string(&request) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(mut stream) = UnixStream::connect(bread_shared::resolve_socket_path()) else {
|
||||
/// 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 _ = stream.set_write_timeout(Some(Duration::from_millis(200)));
|
||||
let _ = writeln!(stream, "{line}");
|
||||
}
|
||||
|
||||
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": method,
|
||||
"params": params,
|
||||
});
|
||||
let line = serde_json::to_string(&request).ok()?;
|
||||
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -36,10 +36,17 @@ pub enum Socket {
|
|||
/// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if
|
||||
/// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're
|
||||
/// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to
|
||||
/// `/run/user/1000` if unset, matching `breadmon`'s existing fallback.
|
||||
/// `/run/user/<uid>` (this process's real uid, or the historical
|
||||
/// `/run/user/1000` if that can't be read) when unset.
|
||||
pub fn socket_path(kind: Socket) -> Option<PathBuf> {
|
||||
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
||||
let rt = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
|
||||
// `XDG_RUNTIME_DIR` is normally `/run/user/<uid>`; when it's unset,
|
||||
// reconstruct that from the process's real uid instead of assuming uid
|
||||
// 1000, so the socket path is right for any account.
|
||||
let rt = env::var("XDG_RUNTIME_DIR")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(fallback_runtime_dir);
|
||||
let file = match kind {
|
||||
Socket::Request => ".socket.sock",
|
||||
Socket::Events => ".socket2.sock",
|
||||
|
|
@ -47,6 +54,21 @@ pub fn socket_path(kind: Socket) -> Option<PathBuf> {
|
|||
Some(PathBuf::from(format!("{rt}/hypr/{sig}/{file}")))
|
||||
}
|
||||
|
||||
/// `XDG_RUNTIME_DIR`'s conventional `/run/user/<uid>` value, derived from
|
||||
/// this process's real uid (`Uid:` line in `/proc/self/status`). Falls back
|
||||
/// to the historical `/run/user/1000` if that can't be read.
|
||||
fn fallback_runtime_dir() -> String {
|
||||
let status = std::fs::read_to_string("/proc/self/status").unwrap_or_default();
|
||||
for line in status.lines() {
|
||||
if let Some(rest) = line.strip_prefix("Uid:") {
|
||||
if let Some(uid) = rest.split_whitespace().next() {
|
||||
return format!("/run/user/{uid}");
|
||||
}
|
||||
}
|
||||
}
|
||||
"/run/user/1000".to_string()
|
||||
}
|
||||
|
||||
/// Send `request` (e.g. `"j/activewindow"`, `"j/monitors"`) to the socket1
|
||||
/// IPC socket and return the raw response body. Blocking/synchronous — this
|
||||
/// matches every current consumer (breadbox, breadclip), which call it from
|
||||
|
|
@ -222,6 +244,17 @@ mod tests {
|
|||
assert!(win.fullscreen.is_fullscreen());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_runtime_dir_is_user_uid_shaped() {
|
||||
let dir = fallback_runtime_dir();
|
||||
assert!(dir.starts_with("/run/user/"), "got {dir}");
|
||||
let uid = dir.trim_start_matches("/run/user/");
|
||||
assert!(
|
||||
!uid.is_empty() && uid.chars().all(|c| c.is_ascii_digit()),
|
||||
"fallback uid is not numeric: {uid}"
|
||||
);
|
||||
}
|
||||
|
||||
// Both env-var-dependent cases share one test function: `set_var`/
|
||||
// `remove_var` are process-global, and cargo runs tests in parallel
|
||||
// threads by default, so two separate #[test] fns racing on the same
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +80,9 @@ pub fn try_acquire(app: &str) -> std::io::Result<Acquire> {
|
|||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
// Never truncate on open: an existing lock file may be held by a live
|
||||
// instance. We only clear it (`set_len(0)`) *after* winning the lock.
|
||||
.truncate(false)
|
||||
.open(&path)?;
|
||||
|
||||
match file.try_lock() {
|
||||
|
|
|
|||
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:
|
||||
# Generate dl.breadway.dev/index.json (or a track-prefixed sibling — see
|
||||
# TRACK below) 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)
|
||||
# - <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