Compare commits

..

No commits in common. "main" and "v0.7.2" have entirely different histories.
main ... v0.7.2

72 changed files with 512 additions and 11479 deletions

View file

@ -8,7 +8,6 @@ on:
branches: ['main'] branches: ['main']
paths: paths:
- 'bakery/**' - 'bakery/**'
- 'bread-utils/**'
- 'Cargo.toml' - 'Cargo.toml'
- 'Cargo.lock' - 'Cargo.lock'
- '.forgejo/workflows/dev-bakery.yml' - '.forgejo/workflows/dev-bakery.yml'

View file

@ -6,9 +6,6 @@ on:
jobs: jobs:
package: 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] runs-on: [self-hosted, hestia]
container: container:
image: archlinux:latest image: archlinux:latest

View file

@ -7,9 +7,11 @@ name: beta (rc) bakery
on: on:
push: push:
tags: ['v*'] tags: ['v*']
# No paths: filter. Tag pushes compare against an unrelated commit and paths:
# would skip the RC publish if bakery/** wasn't in that diff; the job - 'bakery/**'
# `if: contains -rc.` is the real gate. - 'Cargo.toml'
- 'Cargo.lock'
- '.forgejo/workflows/beta-bakery.yml'
jobs: jobs:
build: build:
@ -33,7 +35,6 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}"
mkdir -p "${PKG_DIR}" mkdir -p "${PKG_DIR}"
cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64"

View file

@ -7,9 +7,11 @@ name: beta (rc) bread-theme
on: on:
push: push:
tags: ['v*'] tags: ['v*']
# No paths: filter. Tag pushes compare against an unrelated commit and paths:
# would skip the RC publish if bread-theme/** wasn't in that diff; the - 'bread-theme/**'
# job `if: contains -rc.` is the real gate. - 'Cargo.toml'
- 'Cargo.lock'
- '.forgejo/workflows/beta-bread-theme.yml'
jobs: jobs:
build: build:
@ -30,7 +32,6 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}" PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}"
mkdir -p "${PKG_DIR}" mkdir -p "${PKG_DIR}"
cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64" cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64"

6
.gitignore vendored
View file

@ -5,9 +5,3 @@
# scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY). # scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY).
*.minisign-sec *.minisign-sec
minisign.key 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/

View file

@ -19,6 +19,3 @@ Follow [`CONTRIBUTING.md`](CONTRIBUTING.md) for any git, branch, or release work
## Don't ## Don't
- Don't embed credentials in remote URLs — SSH or a credential helper only. - 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).

View file

@ -3,15 +3,10 @@
This repo is a Cargo workspace. Bakery-channel products shipped from here This repo is a Cargo workspace. Bakery-channel products shipped from here
are `bakery` (the ecosystem package manager) and `bread-theme` (the shared are `bakery` (the ecosystem package manager) and `bread-theme` (the shared
theming crate). Shared crates that sibling apps pin — not bakery packages theming crate). Shared crates that sibling apps pin — not bakery packages
of their own — are `bread-utils`, `bread-app`, `bread-onnx`, of their own — are `bread-utils`, `bread-onnx`, `bread-screenshots`, and
`bread-screenshots`, and `bread-capture`. `bread-polkit` is an in-tree `bread-capture`. Other ecosystem products (`bread`, `breadbar`, `breadbox`,
session agent: it has `bread-polkit/bakery.toml` so it *can* be published, …) live in their own repos under `Breadway/` but follow the same workflow
but it is not in `registry/bread-ecosystem.toml` (unpublished — not on described here. The product list is `registry/bread-ecosystem.toml`.
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 ## Branches
@ -93,12 +88,10 @@ cargo build --release -p bakery
cargo test --release -p bakery cargo test --release -p bakery
``` ```
`bakery`, `bread-theme`, `bread-utils`, `bread-app`, `bread-polkit`, `bakery`, `bread-theme`, `bread-utils`, `bread-onnx`, `bread-screenshots`,
`bread-onnx`, `bread-screenshots`, and `bread-capture` are all workspace and `bread-capture` are all workspace members. Run the same commands with
members. Run the same commands with `-p bread-theme --bin bread-theme` `-p bread-theme --bin bread-theme` for that crate, or `-p bread-utils
for that crate, `-p bread-utils --features bread-client` for the IPC --features bread-client` for the IPC client.
client, or `-p bread-app --features bread-client` for the GTK bootstrap
helpers.
## CI ## CI

514
Cargo.lock generated
View file

@ -96,40 +96,6 @@ version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
@ -138,14 +104,14 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "bakery" name = "bakery"
version = "0.7.5" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-utils", "bread-utils",
"chrono", "chrono",
"clap", "clap",
"clap_complete", "clap_complete",
"dirs 5.0.1", "dirs",
"fs4", "fs4",
"hex", "hex",
"minisign-verify", "minisign-verify",
@ -154,7 +120,6 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"tempfile", "tempfile",
"toml 0.8.23",
"ureq", "ureq",
] ]
@ -185,16 +150,9 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "bread-app"
version = "0.7.5"
dependencies = [
"bread-utils",
]
[[package]] [[package]]
name = "bread-capture" name = "bread-capture"
version = "0.7.5" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-utils", "bread-utils",
@ -202,18 +160,9 @@ dependencies = [
"image", "image",
] ]
[[package]]
name = "bread-launcher"
version = "0.7.5"
dependencies = [
"bread-utils",
"gtk4",
"serde_json",
]
[[package]] [[package]]
name = "bread-onnx" name = "bread-onnx"
version = "0.7.5" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-utils", "bread-utils",
@ -226,24 +175,9 @@ dependencies = [
"ureq", "ureq",
] ]
[[package]]
name = "bread-polkit"
version = "0.7.5"
dependencies = [
"anyhow",
"bread-app",
"bread-theme",
"gtk4",
"serde",
"tokio",
"tracing",
"tracing-subscriber",
"zbus",
]
[[package]] [[package]]
name = "bread-screenshots" name = "bread-screenshots"
version = "0.7.5" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-utils", "bread-utils",
@ -252,36 +186,32 @@ dependencies = [
[[package]] [[package]]
name = "bread-shared" name = "bread-shared"
version = "0.8.0" version = "0.7.0"
source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0#cdd5de8f58e437b3fc6d9b9087eb7b3d0fd09704" source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b"
dependencies = [ dependencies = [
"dirs 6.0.0", "dirs",
"serde", "serde",
"serde_json", "serde_json",
"toml 0.8.23", "toml 0.8.23",
"uuid",
] ]
[[package]] [[package]]
name = "bread-theme" name = "bread-theme"
version = "0.7.5" version = "0.7.2"
dependencies = [ dependencies = [
"anyhow", "dirs",
"dirs 5.0.1",
"gtk4", "gtk4",
"libadwaita", "libadwaita",
"serde", "serde",
"serde_json", "serde_json",
"toml 0.8.23",
"tracing",
] ]
[[package]] [[package]]
name = "bread-utils" name = "bread-utils"
version = "0.7.5" version = "0.7.2"
dependencies = [ dependencies = [
"bread-shared", "bread-shared",
"dirs 5.0.1", "dirs",
"gtk4", "gtk4",
"gtk4-layer-shell", "gtk4-layer-shell",
"serde", "serde",
@ -308,12 +238,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cairo-rs" name = "cairo-rs"
version = "0.22.0" version = "0.22.0"
@ -425,7 +349,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -553,7 +477,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -564,7 +488,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -594,7 +518,7 @@ dependencies = [
"darling", "darling",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -604,7 +528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [ dependencies = [
"derive_builder_core", "derive_builder_core",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -623,16 +547,7 @@ version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [ dependencies = [
"dirs-sys 0.4.1", "dirs-sys",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys 0.5.0",
] ]
[[package]] [[package]]
@ -643,22 +558,10 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users 0.4.6", "redox_users",
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.6" version = "0.2.6"
@ -667,7 +570,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -682,33 +585,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -734,26 +610,6 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
@ -852,19 +708,6 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.33" version = "0.3.33"
@ -873,7 +716,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -1077,7 +920,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -1211,7 +1054,7 @@ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -1458,12 +1301,6 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libadwaita" name = "libadwaita"
version = "0.9.2" version = "0.9.2"
@ -1559,15 +1396,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]] [[package]]
name = "matrixmultiply" name = "matrixmultiply"
version = "0.3.11" version = "0.3.11"
@ -1615,17 +1443,6 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "mio"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "monostate" name = "monostate"
version = "0.1.18" version = "0.1.18"
@ -1645,7 +1462,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -1750,16 +1567,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]] [[package]]
name = "ort" name = "ort"
version = "2.0.0-rc.12" version = "2.0.0-rc.12"
@ -1802,12 +1609,6 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]] [[package]]
name = "paste" name = "paste"
version = "1.0.15" version = "1.0.15"
@ -2000,17 +1801,6 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.13.1" version = "1.13.1"
@ -2169,7 +1959,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -2185,17 +1975,6 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_repr"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "0.6.9" version = "0.6.9"
@ -2225,31 +2004,12 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.10" version = "0.3.10"
@ -2268,16 +2028,6 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "spm_precompiled" name = "spm_precompiled"
version = "0.1.4" version = "0.1.4"
@ -2325,17 +2075,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "synstructure" name = "synstructure"
version = "0.13.2" version = "0.13.2"
@ -2344,7 +2083,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -2405,7 +2144,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -2416,16 +2155,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
]
[[package]]
name = "thread_local"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [
"cfg-if",
] ]
[[package]] [[package]]
@ -2472,34 +2202,6 @@ dependencies = [
"unicode_categories", "unicode_categories",
] ]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.8.23" version = "0.8.23"
@ -2611,7 +2313,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -2623,38 +2325,12 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"once_cell",
"regex-automata",
"sharded-slab",
"thread_local",
"tracing",
"tracing-core",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@ -2742,18 +2418,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@ -2813,7 +2477,7 @@ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -2875,7 +2539,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -2886,7 +2550,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -3116,75 +2780,10 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
"synstructure", "synstructure",
] ]
[[package]]
name = "zbus"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
"async-broadcast",
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix 1.1.4",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.4",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.3",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.4",
"zvariant",
]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.54" version = "0.8.54"
@ -3202,7 +2801,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -3222,7 +2821,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
"synstructure", "synstructure",
] ]
@ -3262,7 +2861,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn",
] ]
[[package]] [[package]]
@ -3270,44 +2869,3 @@ name = "zmij"
version = "1.0.23" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zvariant"
version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.3",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.3",
"winnow 1.0.4",
]

View file

@ -1,9 +1,9 @@
[workspace] [workspace]
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit", "bread-launcher"] members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.7.5" version = "0.7.2"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Breadway <plasticbread849@gmail.com>"] authors = ["Breadway <plasticbread849@gmail.com>"]

View file

@ -106,26 +106,6 @@ 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`). `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 ## System dependencies by product
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block. `bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.
@ -145,15 +125,13 @@ Install all required deps with `sudo pacman -S <packages>`. Use `pacman -Q <pkg>
This repo is a Cargo workspace. Bakery-channel products shipped from here This repo is a Cargo workspace. Bakery-channel products shipped from here
are `bakery` and `bread-theme`; the other members are shared crates sibling 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. apps pin, not bakery packages of their own.
``` ```
bread-ecosystem/ bread-ecosystem/
├── bakery/ # package manager binary ├── bakery/ # package manager binary
├── bread-theme/ # shared pywal + fixed-dark-base theming crate ├── bread-theme/ # shared pywal + fixed-dark-base theming crate
├── bread-utils/ # shared plumbing (Hyprland IPC, singleton, XDG, BreadClient, …) ├── 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-onnx/ # shared ONNX runtime helpers
├── bread-screenshots/ # grim capture primitive used by app `--screenshot` modes ├── bread-screenshots/ # grim capture primitive used by app `--screenshot` modes
├── bread-capture/ # orchestrator that drives those `--screenshot` modes ├── bread-capture/ # orchestrator that drives those `--screenshot` modes
@ -164,41 +142,6 @@ bread-ecosystem/
└── gen-readme-products.sh # rewrites the Products table from the registry └── 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 ## Release pipeline
Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has

View file

@ -11,7 +11,6 @@ repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
anyhow = { workspace = true } anyhow = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
toml = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
ureq = { workspace = true } ureq = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }

View file

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

View file

@ -11,16 +11,8 @@ pub struct DepReport {
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> { pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> {
Ok(DepReport { Ok(DepReport {
missing: required missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(),
.iter() warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(),
.filter(|d| !dep_present(d))
.cloned()
.collect(),
warnings: optional
.iter()
.filter(|d| !dep_present(d))
.cloned()
.collect(),
}) })
} }
@ -107,24 +99,14 @@ pub fn install_hint(missing: &[String]) -> String {
/// Print a formatted doctor report for a package's system deps. /// Print a formatted doctor report for a package's system deps.
/// Returns true if all *required* deps are satisfied. /// Returns true if all *required* deps are satisfied.
pub fn report( pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool {
package_name: &str,
required: &[String],
optional: &[String],
name_width: usize,
) -> bool {
if required.is_empty() && optional.is_empty() { if required.is_empty() && optional.is_empty() {
ui::check_row(true, package_name, name_width, "no system deps required"); println!(" {}", ui::ok(&format!("{package_name}: no system deps required")));
return true; return true;
} }
match check_deps(required, optional) { match check_deps(required, optional) {
Err(e) => { Err(e) => {
ui::check_row( eprintln!(" {}", ui::fail(&format!("error running doctor for {package_name}: {e}")));
false,
package_name,
name_width,
&format!("error running doctor: {e}"),
);
false false
} }
Ok(rep) => { Ok(rep) => {
@ -141,24 +123,17 @@ pub fn report(
); );
} }
if rep.missing.is_empty() { if rep.missing.is_empty() {
ui::check_row( println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied")));
true,
package_name,
name_width,
"all required system deps satisfied",
);
true true
} else { } else {
ui::check_row(
false,
package_name,
name_width,
&format!("missing: {}", rep.missing.join(", ")),
);
eprintln!( eprintln!(
" {}", " {}",
ui::dim(&format!("install with: {}", install_hint(&rep.missing))) ui::fail(&format!(
"{package_name}: missing system deps: {}",
rep.missing.join(", ")
))
); );
eprintln!(" install with: {}", install_hint(&rep.missing));
false false
} }
} }
@ -212,14 +187,22 @@ mod tests {
#[test] #[test]
fn missing_required_dep_detected() { 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_eq!(rep.missing.len(), 1);
assert!(rep.warnings.is_empty()); assert!(rep.warnings.is_empty());
} }
#[test] #[test]
fn missing_optional_dep_becomes_warning_not_error() { 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!(rep.missing.is_empty());
assert_eq!(rep.warnings.len(), 1); assert_eq!(rep.warnings.len(), 1);
} }

View file

@ -3,7 +3,6 @@ use sha2::{Digest, Sha256};
use std::path::Path; use std::path::Path;
use crate::manifest::{fetch_binary, Binary}; use crate::manifest::{fetch_binary, Binary};
use crate::ui;
/// Download a binary, verify its SHA-256, then atomically write it into /// Download a binary, verify its SHA-256, then atomically write it into
/// place (fsynced, temp-in-same-dir-with-unique-name then rename — see /// place (fsynced, temp-in-same-dir-with-unique-name then rename — see
@ -13,16 +12,16 @@ use crate::ui;
/// bytes a second time — `verify_sha256` already confirmed `bytes` matches /// bytes a second time — `verify_sha256` already confirmed `bytes` matches
/// `binary.sha256`, so that's the value to return. /// `binary.sha256`, so that's the value to return.
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<String> { pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<String> {
ui::step("downloading", &binary.name); println!(" downloading {}", binary.name);
let bytes = fetch_binary(&binary.dl_url, &binary.github_url) let bytes = fetch_binary(&binary.dl_url, &binary.github_url)
.with_context(|| format!("downloading {}", binary.name))?; .with_context(|| format!("downloading {}", binary.name))?;
verify_sha256(&bytes, &binary.sha256) verify_sha256(&bytes, &binary.sha256)
.with_context(|| format!("checksum mismatch for {}", binary.name))?; .with_context(|| format!("checksum mismatch for {}", binary.name))?;
crate::prefix::write_bytes(dest, &bytes, 0o755) bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755))
.with_context(|| format!("placing binary at {}", dest.display()))?; .with_context(|| format!("placing binary at {}", dest.display()))?;
ui::step("placed", &dest.display().to_string()); println!(" installed {}", dest.display());
Ok(binary.sha256.clone()) Ok(binary.sha256.clone())
} }

View file

@ -7,10 +7,8 @@ use std::process::Command;
use crate::download::{fetch_and_place, verify_sha256}; use crate::download::{fetch_and_place, verify_sha256};
use crate::manifest::{fetch_binary, Package, Service}; use crate::manifest::{fetch_binary, Package, Service};
use crate::prefix::{self, Layout};
use crate::state::{InstalledPackage, State}; use crate::state::{InstalledPackage, State};
use crate::track::Track; use crate::track::Track;
use crate::ui;
/// Rejects a filename that isn't a safe single path component — no `/`, /// Rejects a filename that isn't a safe single path component — no `/`,
/// `\`, empty, `.`, or `..`. `bin.name`/`svc.unit`/`cfg.example`/`pkg.name`/ /// `\`, empty, `.`, or `..`. `bin.name`/`svc.unit`/`cfg.example`/`pkg.name`/
@ -54,7 +52,7 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool {
return false; return false;
} }
use std::io::Write; use std::io::Write;
print!("{prompt} {} ", ui::dim("[y/N]")); print!("{prompt} [y/N] ");
std::io::stdout().flush().ok(); std::io::stdout().flush().ok();
let mut buf = String::new(); let mut buf = String::new();
std::io::stdin().read_line(&mut buf).ok(); std::io::stdin().read_line(&mut buf).ok();
@ -69,27 +67,25 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool {
/// install" for the pre-overwrite backup below. /// install" for the pre-overwrite backup below.
pub fn install_package( pub fn install_package(
pkg: &Package, pkg: &Package,
layout: &Layout, bin_dir: &Path,
track: Track, track: Track,
previous: Option<&InstalledPackage>, previous: Option<&InstalledPackage>,
no_hooks: bool, no_hooks: bool,
assume_yes: bool, assume_yes: bool,
) -> Result<()> { ) -> Result<()> {
ensure_safe_component(&pkg.name, "package name")?; ensure_safe_component(&pkg.name, "package name")?;
println!("installing {}@{}", pkg.name, pkg.version);
// 1. Download and verify all binaries. On an update (not a fresh // 1. Download and verify all binaries. On an update (not a fresh
// install), back up the current binary first — best-effort, feeding // install), back up the current binary first — best-effort, feeding
// `bakery rollback` — before it's overwritten below. Backups stay // `bakery rollback` — before it's overwritten below.
// per-user under ~/.local/state even when the live binary is in
// /usr/local/bin, so a snapper snapshot of `@` plus this local copy
// is enough to roll back; no second snapshot system.
let backup_dir = previous.map(|prev| crate::state::backup_dir(&pkg.name, &prev.version)); let backup_dir = previous.map(|prev| crate::state::backup_dir(&pkg.name, &prev.version));
let mut binary_names = Vec::new(); let mut binary_names = Vec::new();
let mut binary_sha256 = HashMap::new(); let mut binary_sha256 = HashMap::new();
for bin in &pkg.binaries { for bin in &pkg.binaries {
ensure_safe_component(&bin.name, "binary name")?; ensure_safe_component(&bin.name, "binary name")?;
let install_name = strip_arch_suffix(&bin.name); let install_name = strip_arch_suffix(&bin.name);
let dest = layout.bin_dir.join(install_name); let dest = bin_dir.join(install_name);
if let Some(dir) = &backup_dir { if let Some(dir) = &backup_dir {
backup_current_binary(dir, install_name, &dest); backup_current_binary(dir, install_name, &dest);
} }
@ -98,32 +94,30 @@ pub fn install_package(
binary_sha256.insert(install_name.to_string(), sha256); binary_sha256.insert(install_name.to_string(), sha256);
} }
// 2. Scaffold config dir + download example file. Config stays // 2. Scaffold config dir + download example file.
// per-user (~/.config) regardless of prefix — it's authored content,
// not bakery-placed bits.
if let Some(cfg) = &pkg.config { if let Some(cfg) = &pkg.config {
scaffold_config(cfg, pkg)?; scaffold_config(cfg, pkg)?;
} }
// 3. Install license file, if declared. // 3. Install license file, if declared.
if let Some(license) = &pkg.license_file { if let Some(license) = &pkg.license_file {
install_license(pkg, license, layout)?; install_license(pkg, license)?;
} }
// 4. Install desktop entry, if declared. // 4. Install desktop entry, if declared.
if let Some(desktop) = &pkg.desktop_file { if let Some(desktop) = &pkg.desktop_file {
install_desktop_file(pkg, desktop, layout)?; install_desktop_file(pkg, desktop)?;
} }
// 5. Download + extract data archive, if declared. // 5. Download + extract data archive, if declared.
if let Some(archive) = &pkg.data_archive { if let Some(archive) = &pkg.data_archive {
install_data_archive(pkg, archive, layout)?; install_data_archive(pkg, archive)?;
} }
// 6. Install systemd user units. // 6. Install systemd user units.
let mut service_names = Vec::new(); let mut service_names = Vec::new();
for svc in &pkg.services { for svc in &pkg.services {
install_service(svc, layout, pkg)?; install_service(svc, bin_dir, pkg)?;
service_names.push(svc.unit.clone()); service_names.push(svc.unit.clone());
} }
@ -132,13 +126,10 @@ pub fn install_package(
// confirmation rather than running unconditionally. // confirmation rather than running unconditionally.
if !pkg.post_install.is_empty() { if !pkg.post_install.is_empty() {
if no_hooks { if no_hooks {
eprintln!( println!(
" {}", " note: skipped {} post_install hook(s) for {} (--no-hooks)",
ui::note(&format!( pkg.post_install.len(),
"skipped {} post_install hook(s) for {} (--no-hooks)", pkg.name
pkg.post_install.len(),
pkg.name
))
); );
} else if confirm( } else if confirm(
&format!( &format!(
@ -152,13 +143,7 @@ pub fn install_package(
run_hook(cmd, &pkg.name)?; run_hook(cmd, &pkg.name)?;
} }
} else { } else {
eprintln!( println!(" skipped post_install hooks for {} (declined)", pkg.name);
" {}",
ui::note(&format!(
"skipped post_install hooks for {} (declined)",
pkg.name
))
);
} }
} }
@ -178,8 +163,8 @@ pub fn install_package(
Ok(()) Ok(())
})?; })?;
println!(" {}", ui::ok(&format!("{} installed", pkg.name))); println!(" {} installed successfully", pkg.name);
warn_path_if_needed(&layout.bin_dir); warn_path_if_needed(bin_dir);
Ok(()) Ok(())
} }
@ -199,39 +184,25 @@ fn backup_current_binary(backup_dir: &Path, binary_name: &str, current_path: &Pa
} }
if let Err(e) = std::fs::create_dir_all(backup_dir) { if let Err(e) = std::fs::create_dir_all(backup_dir) {
eprintln!( eprintln!(
" {}", " warning: could not create backup dir {} ({e}) — rollback won't be available for this update",
ui::warn(&format!( backup_dir.display()
"could not create backup dir {} ({e}) — rollback won't be available for this update",
backup_dir.display()
))
); );
return; return;
} }
if let Err(e) = std::fs::copy(current_path, backup_dir.join(binary_name)) { if let Err(e) = std::fs::copy(current_path, backup_dir.join(binary_name)) {
eprintln!( eprintln!(" warning: could not back up {binary_name} before update ({e}) — rollback won't be available for this update");
" {}",
ui::warn(&format!(
"could not back up {binary_name} before update ({e}) — rollback won't be available for this update"
))
);
} }
} }
pub fn remove_package( pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: bool) -> Result<()> {
pkg_name: &str,
layout: &Layout,
assume_yes: bool,
purge: bool,
) -> Result<()> {
let installed = State::with_lock(|state| Ok(state.remove(pkg_name)))?; let installed = State::with_lock(|state| Ok(state.remove(pkg_name)))?;
let installed = match installed { let installed = match installed {
Some(p) => p, Some(p) => p,
None => { None => {
eprintln!(" {}", ui::fail(&format!("{pkg_name} is not installed"))); eprintln!("{pkg_name} is not installed");
return Ok(()); return Ok(());
} }
}; };
ui::action("Removing", pkg_name, Some(&installed.version));
// State is already committed by with_lock above — everything from here // State is already committed by with_lock above — everything from here
// is best-effort file cleanup, and must all run even if part of it fails. // is best-effort file cleanup, and must all run even if part of it fails.
@ -240,10 +211,10 @@ pub fn remove_package(
// the config/data-preserved messages below. // the config/data-preserved messages below.
let mut failures = Vec::new(); let mut failures = Vec::new();
for bin in &installed.binaries { for bin in &installed.binaries {
let path = layout.bin_dir.join(bin); let path = bin_dir.join(bin);
if path.exists() { if path.exists() {
match prefix::remove_file(&path) { match std::fs::remove_file(&path) {
Ok(()) => ui::step("removed", &path.display().to_string()), Ok(()) => println!(" removed {}", path.display()),
Err(e) => failures.push(format!("{}: {e}", path.display())), Err(e) => failures.push(format!("{}: {e}", path.display())),
} }
} }
@ -251,7 +222,7 @@ pub fn remove_package(
// Prompt for unit removal. // Prompt for unit removal.
if !installed.services.is_empty() { if !installed.services.is_empty() {
let service_dir = &layout.systemd_user_dir; let service_dir = systemd_user_dir();
for unit in &installed.services { for unit in &installed.services {
let unit_path = service_dir.join(unit); let unit_path = service_dir.join(unit);
if confirm_remove_unit(unit, assume_yes) { if confirm_remove_unit(unit, assume_yes) {
@ -259,12 +230,12 @@ pub fn remove_package(
.args(["--user", "disable", "--now", unit]) .args(["--user", "disable", "--now", unit])
.status(); .status();
if unit_path.exists() { if unit_path.exists() {
let _ = prefix::remove_file(&unit_path); std::fs::remove_file(&unit_path).ok();
} }
let _ = Command::new("systemctl") let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"]) .args(["--user", "daemon-reload"])
.status(); .status();
ui::step("removed", &format!("unit {unit}")); println!(" removed unit {unit}");
} }
} }
} }
@ -276,45 +247,34 @@ pub fn remove_package(
// surprise no flag should cause. // surprise no flag should cause.
if let Some(cfg_dir) = guess_config_dir(pkg_name) { if let Some(cfg_dir) = guess_config_dir(pkg_name) {
if cfg_dir.exists() { if cfg_dir.exists() {
ui::step("preserved", &format!("config {}", cfg_dir.display())); println!(" config preserved at {}", cfg_dir.display());
} }
} }
let share_dir = &layout.share_dir; let share_dir = dirs::data_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"));
let data_dir = share_dir.join(pkg_name); let data_dir = share_dir.join(pkg_name);
if purge { if purge {
let license_dir = share_dir.join("licenses").join(pkg_name); let license_dir = share_dir.join("licenses").join(pkg_name);
remove_purged_path(&license_dir, "license dir", true, assume_yes, &mut failures); remove_purged_path(&license_dir, "license dir", true, assume_yes, &mut failures);
let desktop_file = share_dir let desktop_file = share_dir.join("applications").join(format!("{pkg_name}.desktop"));
.join("applications") remove_purged_path(&desktop_file, "desktop entry", false, assume_yes, &mut failures);
.join(format!("{pkg_name}.desktop"));
remove_purged_path(
&desktop_file,
"desktop entry",
false,
assume_yes,
&mut failures,
);
remove_purged_path(&data_dir, "data dir", true, assume_yes, &mut failures); remove_purged_path(&data_dir, "data dir", true, assume_yes, &mut failures);
} else if data_dir.exists() { } else if data_dir.exists() {
ui::step("preserved", &format!("data {}", data_dir.display())); println!(" data preserved at {}", data_dir.display());
} }
if !failures.is_empty() { if !failures.is_empty() {
eprintln!( eprintln!(" failed to remove {} item(s):", failures.len());
" {}",
ui::fail(&format!("failed to remove {} item(s):", failures.len()))
);
for f in &failures { for f in &failures {
eprintln!(" {f}"); eprintln!(" {f}");
} }
bail!("{pkg_name} removed from state, but some files could not be deleted"); bail!("{pkg_name} removed from state, but some files could not be deleted");
} }
println!(" {}", ui::ok(&format!("{pkg_name} removed"))); println!(" {pkg_name} removed");
Ok(()) Ok(())
} }
@ -324,30 +284,21 @@ pub fn remove_package(
/// in place and prints the same "preserved at" wording the non-purge path /// in place and prints the same "preserved at" wording the non-purge path
/// already uses. Shared by `remove_package`'s three `--purge` targets /// already uses. Shared by `remove_package`'s three `--purge` targets
/// (license dir, desktop entry, data dir). /// (license dir, desktop entry, data dir).
fn remove_purged_path( fn remove_purged_path(path: &Path, label: &str, recursive: bool, assume_yes: bool, failures: &mut Vec<String>) {
path: &Path,
label: &str,
recursive: bool,
assume_yes: bool,
failures: &mut Vec<String>,
) {
if !path.exists() { if !path.exists() {
return; return;
} }
if !confirm( if !confirm(&format!(" remove {label} at {}?", path.display()), assume_yes) {
&format!(" remove {label} at {}?", path.display()), println!(" {label} preserved at {}", path.display());
assume_yes,
) {
ui::step("preserved", &format!("{label} {}", path.display()));
return; return;
} }
let result = if recursive { let result = if recursive {
prefix::remove_dir_all(path) std::fs::remove_dir_all(path)
} else { } else {
prefix::remove_file(path) std::fs::remove_file(path)
}; };
match result { match result {
Ok(()) => ui::step("removed", &path.display().to_string()), Ok(()) => println!(" removed {}", path.display()),
Err(e) => failures.push(format!("{}: {e}", path.display())), Err(e) => failures.push(format!("{}: {e}", path.display())),
} }
} }
@ -367,48 +318,36 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu
Ok(()) => { Ok(()) => {
std::fs::write(&dest, &bytes) std::fs::write(&dest, &bytes)
.with_context(|| format!("writing {}", dest.display()))?; .with_context(|| format!("writing {}", dest.display()))?;
ui::step("config", &dest.display().to_string()); println!(" installed example config at {}", dest.display());
} }
Err(e) => { Err(e) => {
eprintln!( eprintln!(
" {}", " warning: checksum mismatch for example config {example}: {e} — not installed"
ui::warn(&format!(
"checksum mismatch for example config {example}: {e} — not installed"
))
); );
ui::step("config", &dir.display().to_string()); println!(" config dir created at {}", dir.display());
} }
}, },
None => { None => {
eprintln!( eprintln!(
" {}", " warning: index.json has no sha256 for example config \
ui::warn(&format!( {example} refusing to install an unverified download"
"index.json has no sha256 for example config \
{example} refusing to install an unverified download"
))
); );
ui::step("config", &dir.display().to_string()); println!(" config dir created at {}", dir.display());
} }
}, },
Err(e) => { Err(e) => {
eprintln!( eprintln!(" warning: could not download example config {example}: {e}");
" {}", println!(" config dir created at {}", dir.display());
ui::warn(&format!("could not download example config {example}: {e}"))
);
ui::step("config", &dir.display().to_string());
} }
} }
} else { } else {
ui::step("config", &dir.display().to_string()); println!(" config dir created at {}", dir.display());
} }
} else { } else {
ui::step( println!(" config at {} already exists, skipping", dest.display());
"config",
&format!("{} already exists, skipping", dest.display()),
);
} }
} else { } else {
ui::step("config", &dir.display().to_string()); println!(" config dir created at {}", dir.display());
} }
Ok(()) Ok(())
} }
@ -427,75 +366,59 @@ fn fetch_verify_write(
label: &str, label: &str,
) -> Result<()> { ) -> Result<()> {
let Some((primary, fallback)) = pkg.artifact_urls(filename) else { let Some((primary, fallback)) = pkg.artifact_urls(filename) else {
eprintln!( eprintln!(" warning: no artifact URL to download {label} ({filename})");
" {}",
ui::warn(&format!("no artifact URL to download {label} ({filename})"))
);
return Ok(()); return Ok(());
}; };
let bytes = match fetch_binary(&primary, &fallback) { let bytes = match fetch_binary(&primary, &fallback) {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
eprintln!( eprintln!(" warning: could not download {label} {filename}: {e}");
" {}",
ui::warn(&format!("could not download {label} {filename}: {e}"))
);
return Ok(()); return Ok(());
} }
}; };
let Some(expected) = sha256 else { let Some(expected) = sha256 else {
eprintln!( eprintln!(
" {}", " warning: index.json has no sha256 for {label} {filename} — \
ui::warn(&format!( refusing to install an unverified download"
"index.json has no sha256 for {label} {filename} — \
refusing to install an unverified download"
))
); );
return Ok(()); return Ok(());
}; };
if let Err(e) = verify_sha256(&bytes, expected) { if let Err(e) = verify_sha256(&bytes, expected) {
eprintln!( eprintln!(" warning: checksum mismatch for {label} {filename}: {e} — not installed");
" {}",
ui::warn(&format!(
"checksum mismatch for {label} {filename}: {e} — not installed"
))
);
return Ok(()); return Ok(());
} }
prefix::write_bytes(dest, &bytes, 0o644) if let Some(parent) = dest.parent() {
.with_context(|| format!("writing {}", dest.display()))?; std::fs::create_dir_all(parent)?;
ui::step("installed", &format!("{label} {}", dest.display())); }
std::fs::write(dest, &bytes).with_context(|| format!("writing {}", dest.display()))?;
println!(" installed {label} at {}", dest.display());
Ok(()) Ok(())
} }
fn install_license(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> { fn install_license(pkg: &Package, filename: &str) -> Result<()> {
ensure_safe_component(filename, "license_file")?; ensure_safe_component(filename, "license_file")?;
let dest = layout let dest = dirs::data_dir()
.share_dir .unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join("licenses") .join("licenses")
.join(&pkg.name) .join(&pkg.name)
.join("LICENSE"); .join("LICENSE");
fetch_verify_write(pkg, filename, &pkg.license_file_sha256, &dest, "license") fetch_verify_write(pkg, filename, &pkg.license_file_sha256, &dest, "license")
} }
fn install_desktop_file(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> { fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> {
ensure_safe_component(filename, "desktop_file")?; ensure_safe_component(filename, "desktop_file")?;
let dest = layout let dest = dirs::data_dir()
.share_dir .unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join("applications") .join("applications")
.join(format!("{}.desktop", pkg.name)); .join(format!("{}.desktop", pkg.name));
fetch_verify_write( fetch_verify_write(pkg, filename, &pkg.desktop_file_sha256, &dest, "desktop entry")
pkg,
filename,
&pkg.desktop_file_sha256,
&dest,
"desktop entry",
)
} }
fn install_data_archive(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> { fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> {
ensure_safe_component(filename, "data_archive")?; ensure_safe_component(filename, "data_archive")?;
let data_dir = layout.share_dir.join(&pkg.name); let data_dir = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join(&pkg.name);
fetch_extract_archive(pkg, filename, &pkg.data_archive_sha256, &data_dir) fetch_extract_archive(pkg, filename, &pkg.data_archive_sha256, &data_dir)
} }
@ -504,7 +427,7 @@ fn install_data_archive(pkg: &Package, filename: &str, layout: &Layout) -> Resul
/// crate dependency — `tar` is universally present on Linux and this file /// crate dependency — `tar` is universally present on Linux and this file
/// already shells out to `systemctl` for the same "trust the base system /// already shells out to `systemctl` for the same "trust the base system
/// has this" reason. Split from `install_data_archive` (which just supplies /// has this" reason. Split from `install_data_archive` (which just supplies
/// the real `$prefix/share/<name>` destination) so tests can extract into /// the real `~/.local/share/<name>` destination) so tests can extract into
/// a tempdir instead. /// a tempdir instead.
fn fetch_extract_archive( fn fetch_extract_archive(
pkg: &Package, pkg: &Package,
@ -523,35 +446,32 @@ fn fetch_extract_archive(
.into_temp_path(); .into_temp_path();
fetch_verify_write(pkg, filename, sha256, &tmp_archive, "data archive")?; fetch_verify_write(pkg, filename, sha256, &tmp_archive, "data archive")?;
// `fetch_verify_write` treats a download/checksum/missing-sha failure if !tmp_archive.exists() {
// as a soft warning and returns `Ok` *without writing* — but the // fetch_verify_write already warned (download/checksum failure).
// `NamedTempFile` above is already created (0 bytes), so gating on
// mere `exists()` would let an empty file through to `tar tvf`, which
// then bails with a confusing "not in gzip format" error that masks
// the real cause and aborts the whole install. Gate on *non-empty*.
if std::fs::metadata(&tmp_archive)
.map(|m| m.len())
.unwrap_or(0)
== 0
{
return Ok(()); return Ok(());
} }
verify_archive_paths(&tmp_archive)?; verify_archive_paths(&tmp_archive)?;
match prefix::extract_tar_gz(&tmp_archive, dest_dir) { std::fs::create_dir_all(dest_dir)?;
Ok(()) => ui::step( let status = Command::new("tar")
"extracted", .args([
&format!("{filename}{}", dest_dir.display()), "xzf",
), &tmp_archive.to_string_lossy(),
Err(e) => { "--no-same-owner",
eprintln!( "--no-same-permissions",
" {}", "-C",
ui::warn(&format!("could not extract {filename}: {e}")) ])
); .arg(dest_dir)
} .status()
} .with_context(|| format!("running tar to extract {filename}"))?;
// `tmp_archive` (a `TempPath` guard) deletes the file when it drops here. // `tmp_archive` (a `TempPath` guard) deletes the file when it drops here.
if status.success() {
println!(" extracted {filename} to {}", dest_dir.display());
} else {
eprintln!(" warning: tar exited with {status} extracting {filename}");
}
Ok(()) Ok(())
} }
@ -613,11 +533,11 @@ fn fetch_and_verify_unit(pkg: &Package, svc: &Service) -> Result<Vec<u8>> {
Ok(bytes) Ok(bytes)
} }
fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()> { fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
ensure_safe_component(&svc.unit, "service unit")?; ensure_safe_component(&svc.unit, "service unit")?;
let service_dir = &layout.systemd_user_dir; let service_dir = systemd_user_dir();
prefix::create_dir_all(service_dir)?; std::fs::create_dir_all(&service_dir)?;
let unit_path = service_dir.join(&svc.unit); let unit_path = service_dir.join(&svc.unit);
let had_existing = unit_path.exists(); let had_existing = unit_path.exists();
@ -627,47 +547,38 @@ fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()>
// applied after the first install, unlike binaries (which always // applied after the first install, unlike binaries (which always
// re-fetch via `fetch_and_place` on every install/update). If the fetch // re-fetch via `fetch_and_place` on every install/update). If the fetch
// or checksum fails, fall back to whatever's already on disk rather than // or checksum fails, fall back to whatever's already on disk rather than
// regressing offline/flaky-network reliability. Patch ExecStart in // regressing offline/flaky-network reliability.
// memory before the write so a system-prefix install only needs one
// privileged write, not write-then-rewrite.
match fetch_and_verify_unit(pkg, svc) { match fetch_and_verify_unit(pkg, svc) {
Ok(bytes) => { Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes); std::fs::write(&unit_path, &bytes)
let patched = patch_exec_start_text(&text, &layout.bin_dir);
prefix::write_bytes(&unit_path, patched.as_bytes(), 0o644)
.with_context(|| format!("writing {}", unit_path.display()))?; .with_context(|| format!("writing {}", unit_path.display()))?;
ui::step("unit", &unit_path.display().to_string()); println!(" downloaded unit {}", unit_path.display());
} }
Err(e) => { Err(e) => {
if had_existing { if had_existing {
eprintln!( eprintln!(
" {}", " warning: could not refresh unit {} ({e}) — keeping existing copy",
ui::warn(&format!( svc.unit
"could not refresh unit {} ({e}) — keeping existing copy",
svc.unit
))
); );
patch_exec_start(&unit_path, &layout.bin_dir)?;
} else { } else {
eprintln!( eprintln!(
" {}", " warning: unit file {} not found ({e}) — skipping service setup",
ui::warn(&format!( svc.unit
"unit file {} not found ({e}) — skipping service setup",
svc.unit
))
); );
return Ok(()); return Ok(());
} }
} }
} }
patch_exec_start(&unit_path, bin_dir)?;
if !Command::new("systemctl") if !Command::new("systemctl")
.args(["--user", "daemon-reload"]) .args(["--user", "daemon-reload"])
.status() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false) .unwrap_or(false)
{ {
eprintln!(" {}", ui::warn("systemctl daemon-reload failed")); eprintln!(" warning: systemctl daemon-reload failed");
} }
if svc.enable { if svc.enable {
@ -684,9 +595,9 @@ fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()>
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false) .unwrap_or(false)
{ {
ui::step("restarted", &svc.unit); println!(" {} restarted", svc.unit);
} else { } else {
eprintln!(" {}", ui::warn(&format!("failed to restart {}", svc.unit))); eprintln!(" warning: failed to restart {}", svc.unit);
} }
} else if Command::new("systemctl") } else if Command::new("systemctl")
.args(["--user", "enable", "--now", &svc.unit]) .args(["--user", "enable", "--now", &svc.unit])
@ -694,9 +605,9 @@ fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()>
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false) .unwrap_or(false)
{ {
ui::step("enabled", &svc.unit); println!(" {} enabled and started", svc.unit);
} else { } else {
eprintln!(" {}", ui::warn(&format!("failed to enable {}", svc.unit))); eprintln!(" warning: failed to enable {}", svc.unit);
} }
} }
@ -705,12 +616,6 @@ fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()>
fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> { fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
let text = std::fs::read_to_string(unit_path)?; let text = std::fs::read_to_string(unit_path)?;
let output = patch_exec_start_text(&text, bin_dir);
prefix::write_bytes(unit_path, output.as_bytes(), 0o644)?;
Ok(())
}
fn patch_exec_start_text(text: &str, bin_dir: &Path) -> String {
let patched: String = text let patched: String = text
.lines() .lines()
.map(|line| { .map(|line| {
@ -734,21 +639,24 @@ fn patch_exec_start_text(text: &str, bin_dir: &Path) -> String {
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
if text.ends_with('\n') { // Preserve trailing newline if the original had one.
let output = if text.ends_with('\n') {
format!("{patched}\n") format!("{patched}\n")
} else { } else {
patched patched
} };
std::fs::write(unit_path, output)?;
Ok(())
} }
fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> { fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> {
ui::step("hook", cmd); println!(" running post_install hook: {cmd}");
let status = Command::new("sh") let status = Command::new("sh")
.args(["-c", cmd]) .args(["-c", cmd])
.status() .status()
.with_context(|| format!("running post_install hook for {pkg_name}"))?; .with_context(|| format!("running post_install hook for {pkg_name}"))?;
if !status.success() { if !status.success() {
eprintln!(" {}", ui::warn(&format!("hook exited with {status}"))); eprintln!(" warning: hook exited with {status}");
} }
Ok(()) Ok(())
} }
@ -757,6 +665,12 @@ fn confirm_remove_unit(unit: &str, assume_yes: bool) -> bool {
confirm(&format!(" remove systemd unit {unit}?"), assume_yes) confirm(&format!(" remove systemd unit {unit}?"), assume_yes)
} }
fn systemd_user_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("systemd/user")
}
fn guess_config_dir(pkg_name: &str) -> Option<PathBuf> { fn guess_config_dir(pkg_name: &str) -> Option<PathBuf> {
Some(dirs::config_dir()?.join(pkg_name)) Some(dirs::config_dir()?.join(pkg_name))
} }
@ -785,14 +699,11 @@ fn warn_path_if_needed(bin_dir: &Path) {
let path_var = std::env::var("PATH").unwrap_or_default(); let path_var = std::env::var("PATH").unwrap_or_default();
let bin_str = bin_dir.to_string_lossy(); let bin_str = bin_dir.to_string_lossy();
if !path_var.split(':').any(|p| p == bin_str) { if !path_var.split(':').any(|p| p == bin_str) {
eprintln!(); println!(
eprintln!( "\n note: {} is not in PATH — add to your shell profile:",
" {}", bin_str
ui::note(&format!(
"{bin_str} is not in PATH — add to your shell profile:"
))
); );
println!(" export PATH=\"{bin_str}:$PATH\""); println!(" export PATH=\"{}:$PATH\"", bin_str);
} }
} }
@ -817,7 +728,10 @@ mod tests {
if let Ok((mut stream, _)) = listener.accept() { if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 1024]; let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf); let _ = stream.read(&mut buf);
let response = format!("HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()); let response = format!(
"HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n",
body.len()
);
let _ = stream.write_all(response.as_bytes()); let _ = stream.write_all(response.as_bytes());
let _ = stream.write_all(body); let _ = stream.write_all(body);
} }
@ -834,7 +748,10 @@ mod tests {
if let Ok((mut stream, _)) = listener.accept() { if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 1024]; let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf); let _ = stream.read(&mut buf);
let response = format!("HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()); let response = format!(
"HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n",
body.len()
);
let _ = stream.write_all(response.as_bytes()); let _ = stream.write_all(response.as_bytes());
let _ = stream.write_all(&body); let _ = stream.write_all(&body);
} }
@ -880,14 +797,8 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let dest = dir.path().join("LICENSE"); let dest = dir.path().join("LICENSE");
fetch_verify_write( fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license")
&pkg, .unwrap();
"LICENSE",
&pkg.license_file_sha256.clone(),
&dest,
"license",
)
.unwrap();
assert_eq!(fs::read(&dest).unwrap(), license_bytes); assert_eq!(fs::read(&dest).unwrap(), license_bytes);
} }
@ -925,14 +836,8 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let dest = dir.path().join("LICENSE"); let dest = dir.path().join("LICENSE");
fetch_verify_write( fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license")
&pkg, .unwrap();
"LICENSE",
&pkg.license_file_sha256.clone(),
&dest,
"license",
)
.unwrap();
// Refused, not erred (matches scaffold_config's warn-and-continue // Refused, not erred (matches scaffold_config's warn-and-continue
// posture) — the file must not have been written. // posture) — the file must not have been written.
@ -980,7 +885,8 @@ mod tests {
let pkg = test_package(&base_url); let pkg = test_package(&base_url);
let dest_dir = tempdir().unwrap(); let dest_dir = tempdir().unwrap();
fetch_extract_archive(&pkg, "content.tar.gz", &Some(sha256_hex), dest_dir.path()).unwrap(); fetch_extract_archive(&pkg, "content.tar.gz", &Some(sha256_hex), dest_dir.path())
.unwrap();
let extracted = dest_dir.path().join("content/tours/onboarding.toml"); let extracted = dest_dir.path().join("content/tours/onboarding.toml");
assert_eq!(fs::read(&extracted).unwrap(), b"[[step]]\n"); assert_eq!(fs::read(&extracted).unwrap(), b"[[step]]\n");
@ -1045,34 +951,6 @@ mod tests {
assert!(err.to_string().contains("unsafe path")); assert!(err.to_string().contains("unsafe path"));
} }
#[test]
fn fetch_extract_archive_download_failure_is_non_fatal() {
// A checksum mismatch makes `fetch_verify_write` warn-and-return
// Ok without writing, leaving the (already created) NamedTempFile
// empty. The pre-extraction `tar tvf` must be skipped for an empty
// file rather than bailing, so a failed archive download degrades
// gracefully and does NOT abort the package install (regression
// for the empty-temp-file bug).
let base_url = serve_once(b"definitely not a tar.gz");
let pkg = test_package(&base_url);
let dest_dir = tempdir().unwrap();
let res = fetch_extract_archive(
&pkg,
"content.tar.gz",
&Some("0".repeat(64)), // will not match the served bytes
dest_dir.path(),
);
assert!(
res.is_ok(),
"a data-archive download/checksum failure must be non-fatal"
);
assert!(
std::fs::read_dir(dest_dir.path()).unwrap().next().is_none(),
"nothing should be extracted into dest_dir"
);
}
#[test] #[test]
fn ensure_safe_component_accepts_plain_names() { fn ensure_safe_component_accepts_plain_names() {
assert!(ensure_safe_component("breadhelp", "x").is_ok()); assert!(ensure_safe_component("breadhelp", "x").is_ok());
@ -1095,8 +973,7 @@ mod tests {
let mut pkg = test_package(&base_url); let mut pkg = test_package(&base_url);
pkg.name = "../evil".to_string(); pkg.name = "../evil".to_string();
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let layout = Layout::from_prefix(dir.path(), None); let err = install_package(&pkg, dir.path(), Track::Stable, None, true, true).unwrap_err();
let err = install_package(&pkg, &layout, Track::Stable, None, true, true).unwrap_err();
assert!(err.to_string().contains("not a safe filename")); assert!(err.to_string().contains("not a safe filename"));
} }
@ -1166,10 +1043,7 @@ mod tests {
backup_current_binary(&backup_dir, "mypkg", &current); backup_current_binary(&backup_dir, "mypkg", &current);
assert_eq!( assert_eq!(fs::read(backup_dir.join("mypkg")).unwrap(), b"old version bytes");
fs::read(backup_dir.join("mypkg")).unwrap(),
b"old version bytes"
);
} }
#[test] #[test]

View file

@ -2,7 +2,6 @@ mod doctor;
mod download; mod download;
mod install; mod install;
mod manifest; mod manifest;
mod prefix;
mod state; mod state;
mod track; mod track;
mod ui; mod ui;
@ -15,12 +14,7 @@ use std::path::{Path, PathBuf};
use track::Track; use track::Track;
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(name = "bakery", about = "Package manager for the bread ecosystem", version)]
name = "bakery",
about = "Package manager for the bread ecosystem",
version,
styles = ui::CLAP_STYLES
)]
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Cmd, command: Cmd,
@ -49,7 +43,7 @@ enum Cmd {
Remove { Remove {
package: String, package: String,
/// Also remove the license file, desktop entry, and data dir /// Also remove the license file, desktop entry, and data dir
/// ($prefix/share/<pkg>/) — config is still preserved /// (~/.local/share/<pkg>/) — config is still preserved
#[arg(long)] #[arg(long)]
purge: bool, purge: bool,
}, },
@ -69,9 +63,13 @@ enum Cmd {
installed: bool, installed: bool,
}, },
/// Show details for a package /// Show details for a package
Info { package: String }, Info {
package: String,
},
/// Search package names and descriptions /// Search package names and descriptions
Search { query: String }, Search {
query: String,
},
/// Check system dependencies for installed or requested packages /// Check system dependencies for installed or requested packages
Doctor { Doctor {
/// Package to check; omit to check all installed packages /// Package to check; omit to check all installed packages
@ -84,11 +82,15 @@ enum Cmd {
}, },
/// Roll back a package to its previously installed version, from a /// Roll back a package to its previously installed version, from a
/// local pre-update backup (not a re-download) /// local pre-update backup (not a re-download)
Rollback { package: String }, Rollback {
package: String,
},
/// Update bakery itself /// Update bakery itself
SelfUpdate, SelfUpdate,
/// Generate a shell completion script /// Generate a shell completion script
Completions { shell: clap_complete::Shell }, Completions {
shell: clap_complete::Shell,
},
/// View or switch which build track bakery follows (stable/beta/dev) /// View or switch which build track bakery follows (stable/beta/dev)
Track { Track {
#[command(subcommand)] #[command(subcommand)]
@ -105,9 +107,15 @@ enum TrackCmd {
Set { track: Track }, Set { track: Track },
} }
fn default_bin_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".local/bin")
}
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let layout = prefix::resolve(cli.bin_dir); let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir);
let no_hooks = cli.no_hooks; let no_hooks = cli.no_hooks;
let assume_yes = cli.yes; let assume_yes = cli.yes;
let dry_run = cli.dry_run; let dry_run = cli.dry_run;
@ -117,40 +125,26 @@ fn main() -> Result<()> {
Cmd::Install { packages } => { Cmd::Install { packages } => {
let index = manifest::load(true, track)?; let index = manifest::load(true, track)?;
for pkg in &packages { for pkg in &packages {
cmd_install(&index, pkg, &layout, track, no_hooks, assume_yes, dry_run)?; cmd_install(&index, pkg, &bin_dir, track, no_hooks, assume_yes, dry_run)?;
} }
Ok(()) Ok(())
} }
Cmd::Remove { package, purge } => cmd_remove(&package, &layout, assume_yes, purge), Cmd::Remove { package, purge } => cmd_remove(&package, &bin_dir, assume_yes, purge),
Cmd::Update { package, all } => cmd_update( Cmd::Update { package, all } => {
package.as_deref(), cmd_update(package.as_deref(), all, &bin_dir, track, no_hooks, assume_yes, dry_run)
all, }
&layout,
track,
no_hooks,
assume_yes,
dry_run,
),
Cmd::List { installed } => cmd_list(installed, track), Cmd::List { installed } => cmd_list(installed, track),
Cmd::Info { package } => cmd_info(&package, track), Cmd::Info { package } => cmd_info(&package, track),
Cmd::Search { query } => cmd_search(&query, track), Cmd::Search { query } => cmd_search(&query, track),
Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &layout), Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &bin_dir),
Cmd::Verify { package } => cmd_verify(package.as_deref(), &layout.bin_dir), Cmd::Verify { package } => cmd_verify(package.as_deref(), &bin_dir),
Cmd::Rollback { package } => cmd_rollback(&package, &layout.bin_dir), Cmd::Rollback { package } => cmd_rollback(&package, &bin_dir),
// Same update logic as `bakery update bakery` — this is just a // Same update logic as `bakery update bakery` — this is just a
// documented, discoverable entry point for it, since overwriting // documented, discoverable entry point for it, since overwriting
// bakery's own running binary via a normal update already works // bakery's own running binary via a normal update already works
// (rename-over-running-binary is safe on Linux) but wasn't a real // (rename-over-running-binary is safe on Linux) but wasn't a real
// first-class command. // first-class command.
Cmd::SelfUpdate => cmd_update( Cmd::SelfUpdate => cmd_update(Some("bakery"), false, &bin_dir, track, no_hooks, assume_yes, dry_run),
Some("bakery"),
false,
&layout,
track,
no_hooks,
assume_yes,
dry_run,
),
Cmd::Completions { shell } => cmd_completions(shell), Cmd::Completions { shell } => cmd_completions(shell),
Cmd::Track { action } => cmd_track(action), Cmd::Track { action } => cmd_track(action),
} }
@ -165,11 +159,11 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
let state = state::State::load()?; let state = state::State::load()?;
match action { match action {
TrackCmd::Show => { TrackCmd::Show => {
ui::heading("Track", &[&ui::style(state.track.as_str(), ui::CYAN)]); println!("current track: {}", ui::style(state.track.as_str(), ui::CYAN));
} }
TrackCmd::Set { track } => { TrackCmd::Set { track } => {
if state.track == track { if state.track == track {
println!("{}", ui::unchanged(&format!("already on track {track}"))); println!("already on track {track}");
return Ok(()); return Ok(());
} }
// Fail fast on a bad/unreachable track rather than silently // Fail fast on a bad/unreachable track rather than silently
@ -180,10 +174,10 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
state.set_track(track); state.set_track(track);
Ok(()) Ok(())
})?; })?;
ui::action("Switched", track.as_str(), None); println!(
ui::step( "switched to {} — run 'bakery update --all' to install {} builds",
"next", ui::style(track.as_str(), ui::CYAN),
&format!("bakery update --all to install {track} builds"), track
); );
} }
} }
@ -194,23 +188,14 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
fn cmd_install( fn cmd_install(
index: &manifest::Index, index: &manifest::Index,
name: &str, name: &str,
layout: &prefix::Layout, bin_dir: &std::path::Path,
track: Track, track: Track,
no_hooks: bool, no_hooks: bool,
assume_yes: bool, assume_yes: bool,
dry_run: bool, dry_run: bool,
) -> Result<()> { ) -> Result<()> {
let mut visited = HashSet::new(); let mut visited = HashSet::new();
install_with_deps( install_with_deps(index, name, bin_dir, track, no_hooks, assume_yes, dry_run, &mut visited)
index,
name,
layout,
track,
no_hooks,
assume_yes,
dry_run,
&mut visited,
)
} }
/// Recursively installs `name` and any bread_deps, skipping already-installed /// Recursively installs `name` and any bread_deps, skipping already-installed
@ -219,7 +204,7 @@ fn cmd_install(
fn install_with_deps( fn install_with_deps(
index: &manifest::Index, index: &manifest::Index,
name: &str, name: &str,
layout: &prefix::Layout, bin_dir: &std::path::Path,
track: Track, track: Track,
no_hooks: bool, no_hooks: bool,
assume_yes: bool, assume_yes: bool,
@ -238,10 +223,8 @@ fn install_with_deps(
let state = state::State::load()?; let state = state::State::load()?;
for dep in pkg.bread_deps.clone() { for dep in pkg.bread_deps.clone() {
if !state.is_installed(&dep) { if !state.is_installed(&dep) {
ui::step(if dry_run { "would need" } else { "dependency" }, &dep); println!("{} bread dependency: {dep}", if dry_run { "would install" } else { "installing" });
install_with_deps( install_with_deps(index, &dep, bin_dir, track, no_hooks, assume_yes, dry_run, visited)?;
index, &dep, layout, track, no_hooks, assume_yes, dry_run, visited,
)?;
} }
} }
@ -254,63 +237,30 @@ fn install_with_deps(
if let Some(installed) = previous { if let Some(installed) = previous {
if !is_newer(&installed.version, &pkg.version) { if !is_newer(&installed.version, &pkg.version) {
println!( println!(
" {}", "{name} already installed at {} (index has {})",
ui::unchanged(&format!( installed.version, pkg.version
"{name} already at {} (index has {})",
installed.version, pkg.version
))
); );
return Ok(()); return Ok(());
} }
} }
ui::action( println!("checking system dependencies for {name}");
if dry_run {
if previous.is_some() {
"Would update"
} else {
"Would install"
}
} else if previous.is_some() {
"Updating"
} else {
"Installing"
},
name,
Some(&pkg.version),
);
ui::step("checking", "system dependencies");
let rep = doctor::check_deps(&pkg.system_deps, &pkg.optional_system_deps)?; let rep = doctor::check_deps(&pkg.system_deps, &pkg.optional_system_deps)?;
for warn in &rep.warnings { for warn in &rep.warnings {
eprintln!( eprintln!(" note: optional dep not installed: {warn}");
" {}",
ui::note(&format!("optional dep not installed: {warn}"))
);
} }
if !rep.missing.is_empty() { if !rep.missing.is_empty() {
eprintln!( eprintln!("missing system deps for {name}: {}", rep.missing.join(", "));
" {}", eprintln!("install with: {}", doctor::install_hint(&rep.missing));
ui::fail(&format!(
"missing system deps for {name}: {}",
rep.missing.join(", ")
))
);
eprintln!(
" {}",
ui::dim(&format!(
"install with: {}",
doctor::install_hint(&rep.missing)
))
);
bail!("system deps not satisfied"); bail!("system deps not satisfied");
} }
if dry_run { if dry_run {
print_dry_run_plan(pkg); print_dry_run_plan(pkg, previous);
return Ok(()); return Ok(());
} }
install::install_package(pkg, layout, track, previous, no_hooks, assume_yes) install::install_package(pkg, bin_dir, track, previous, no_hooks, assume_yes)
} }
/// Prints what `install_with_deps`/`cmd_update` would do for `pkg` under /// Prints what `install_with_deps`/`cmd_update` would do for `pkg` under
@ -318,36 +268,35 @@ fn install_with_deps(
/// already been made — this only renders that decision, it never /// already been made — this only renders that decision, it never
/// recomputes it, so dry-run and real runs can't drift apart on "would this /// recomputes it, so dry-run and real runs can't drift apart on "would this
/// update happen at all". /// update happen at all".
fn print_dry_run_plan(pkg: &manifest::Package) { fn print_dry_run_plan(pkg: &manifest::Package, previous: Option<&state::InstalledPackage>) {
ui::kv( let verb = if previous.is_some() { "update" } else { "install" };
"binaries", println!(
&pkg.binaries " {} would {verb} {} to {}",
.iter() ui::style("dry-run:", ui::DIM),
.map(|b| b.name.as_str()) pkg.name,
.collect::<Vec<_>>() ui::style(&pkg.version, ui::BOLD)
.join(", "), );
println!(
" binaries: {}",
pkg.binaries.iter().map(|b| b.name.as_str()).collect::<Vec<_>>().join(", ")
); );
if !pkg.services.is_empty() { if !pkg.services.is_empty() {
ui::kv( println!(
"services", " services: {}",
&pkg.services pkg.services.iter().map(|s| s.unit.as_str()).collect::<Vec<_>>().join(", ")
.iter()
.map(|s| s.unit.as_str())
.collect::<Vec<_>>()
.join(", "),
); );
} }
} }
fn cmd_remove(name: &str, layout: &prefix::Layout, assume_yes: bool, purge: bool) -> Result<()> { fn cmd_remove(name: &str, bin_dir: &std::path::Path, assume_yes: bool, purge: bool) -> Result<()> {
install::remove_package(name, layout, assume_yes, purge) install::remove_package(name, bin_dir, assume_yes, purge)
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn cmd_update( fn cmd_update(
name: Option<&str>, name: Option<&str>,
all: bool, all: bool,
layout: &prefix::Layout, bin_dir: &std::path::Path,
track: Track, track: Track,
no_hooks: bool, no_hooks: bool,
assume_yes: bool, assume_yes: bool,
@ -362,16 +311,10 @@ fn cmd_update(
}; };
if targets.is_empty() { if targets.is_empty() {
println!("{}", ui::dim("no packages installed")); println!("no packages installed");
return Ok(()); return Ok(());
} }
let mut targets = targets;
targets.sort();
if all {
ui::heading("Update", &[&format!("{} packages", targets.len())]);
}
let mut any_failed = false; let mut any_failed = false;
let mut updated = 0u32; let mut updated = 0u32;
let mut unchanged = 0u32; let mut unchanged = 0u32;
@ -379,10 +322,7 @@ fn cmd_update(
let installed = match state.packages.get(pkg_name.as_str()) { let installed = match state.packages.get(pkg_name.as_str()) {
Some(p) => p, Some(p) => p,
None => { None => {
eprintln!( eprintln!("{pkg_name} is not installed, skipping");
" {}",
ui::fail(&format!("{pkg_name} is not installed, skipping"))
);
any_failed = true; any_failed = true;
continue; continue;
} }
@ -390,10 +330,7 @@ fn cmd_update(
let latest = match index.get(pkg_name) { let latest = match index.get(pkg_name) {
Some(p) => p, Some(p) => p,
None => { None => {
eprintln!( eprintln!("{pkg_name} not found in index, skipping");
" {}",
ui::fail(&format!("{pkg_name} not found in index, skipping"))
);
any_failed = true; any_failed = true;
continue; continue;
} }
@ -413,81 +350,57 @@ fn cmd_update(
// under a terminal palette that maps ANSI colors unusually. // under a terminal palette that maps ANSI colors unusually.
println!( println!(
" {}", " {}",
ui::unchanged(&format!("{pkg_name} already at {}", installed.version)) ui::unchanged(&format!("{pkg_name} is already at {}", installed.version))
); );
unchanged += 1; unchanged += 1;
continue; continue;
} }
ui::action(
if dry_run { "Would update" } else { "Updating" },
pkg_name,
Some(&latest.version),
);
if track_switch { if track_switch {
ui::step( println!(
"track", "{pkg_name} switching track {} {} {}, installing {}",
&format!( ui::style(installed.track.as_str(), ui::DIM),
"{} {} {}", ui::style("", ui::CYAN),
ui::dim(installed.track.as_str()), ui::style(track.as_str(), ui::BOLD),
ui::style("", ui::CYAN), ui::style(&latest.version, ui::BOLD)
ui::bold(track.as_str()),
),
); );
} else { } else {
ui::step( println!(
"version", "updating {pkg_name} {} {} {}",
&format!( ui::style(&installed.version, ui::DIM),
"{} {} {}", ui::style("", ui::CYAN),
ui::dim(&installed.version), ui::style(&latest.version, ui::BOLD)
ui::style("", ui::CYAN),
ui::bold(&latest.version)
),
); );
} }
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) { let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
eprintln!( eprintln!(" doctor check failed for {pkg_name}: {e}");
" {}",
ui::fail(&format!("doctor check failed for {pkg_name}: {e}"))
);
any_failed = true; any_failed = true;
continue; continue;
} }
}; };
for warn in &rep.warnings { for warn in &rep.warnings {
eprintln!( eprintln!(" note: optional dep not installed: {warn}");
" {}",
ui::note(&format!("optional dep not installed: {warn}"))
);
} }
if !rep.missing.is_empty() { if !rep.missing.is_empty() {
eprintln!( eprintln!(
" {}", " missing deps for {pkg_name}: {} — skipping update",
ui::fail(&format!( rep.missing.join(", ")
"missing deps for {pkg_name}: {} — skipping update",
rep.missing.join(", ")
))
); );
any_failed = true; any_failed = true;
continue; continue;
} }
if dry_run { if dry_run {
print_dry_run_plan(latest); print_dry_run_plan(latest, Some(installed));
updated += 1; updated += 1;
continue; continue;
} }
if let Err(e) = if let Err(e) = install::install_package(latest, bin_dir, track, Some(installed), no_hooks, assume_yes) {
install::install_package(latest, layout, track, Some(installed), no_hooks, assume_yes) eprintln!(" failed to update {pkg_name}: {e}");
{
eprintln!(
" {}",
ui::fail(&format!("failed to update {pkg_name}: {e}"))
);
any_failed = true; any_failed = true;
} else { } else {
updated += 1; updated += 1;
@ -497,14 +410,12 @@ fn cmd_update(
// Only for --all: a single named update already makes its own outcome // Only for --all: a single named update already makes its own outcome
// obvious, and "1 updated, 0 already up to date" isn't a useful takeaway. // obvious, and "1 updated, 0 already up to date" isn't a useful takeaway.
if all { if all {
let updated_s = format!("{updated} updated"); let mut parts = Vec::new();
let unchanged_s = format!("{unchanged} already current");
let mut parts: Vec<&str> = Vec::new();
if updated > 0 { if updated > 0 {
parts.push(&updated_s); parts.push(format!("{updated} updated"));
} }
parts.push(&unchanged_s); parts.push(format!("{unchanged} already up to date"));
ui::summary(&parts); println!("{}", ui::style(&parts.join(", "), ui::BOLD));
} }
if any_failed { if any_failed {
@ -516,12 +427,7 @@ fn cmd_update(
/// Whether `pkg_name` should be updated: always true on a track switch /// Whether `pkg_name` should be updated: always true on a track switch
/// (an explicit user action that must take effect regardless of version /// (an explicit user action that must take effect regardless of version
/// ordering), otherwise a real semver comparison via [`is_newer`]. /// ordering), otherwise a real semver comparison via [`is_newer`].
fn should_update( fn should_update(installed_version: &str, installed_track: Track, active_track: Track, latest_version: &str) -> bool {
installed_version: &str,
installed_track: Track,
active_track: Track,
latest_version: &str,
) -> bool {
if installed_track != active_track { if installed_track != active_track {
return true; return true;
} }
@ -535,10 +441,7 @@ fn should_update(
/// (with a warning) for any version string that isn't valid semver, rather /// (with a warning) for any version string that isn't valid semver, rather
/// than hard-erroring on packages built before this convention existed. /// than hard-erroring on packages built before this convention existed.
fn is_newer(installed: &str, latest: &str) -> bool { fn is_newer(installed: &str, latest: &str) -> bool {
match ( match (semver::Version::parse(installed), semver::Version::parse(latest)) {
semver::Version::parse(installed),
semver::Version::parse(latest),
) {
(Ok(i), Ok(l)) => l > i, (Ok(i), Ok(l)) => l > i,
_ => { _ => {
if installed != latest { if installed != latest {
@ -552,14 +455,15 @@ fn is_newer(installed: &str, latest: &str) -> bool {
} }
} }
fn catalog_row(pkg: &manifest::Package, state: &state::State) -> ui::CatalogRow { /// Prints one index entry in the shared `list`/`search` format: name,
ui::CatalogRow { /// version, description, and an `[installed <version>]` tag when applicable.
name: pkg.name.clone(), fn print_index_entry(pkg: &manifest::Package, state: &state::State) {
version: pkg.version.clone(), let tag = if state.is_installed(&pkg.name) {
installed: state.is_installed(&pkg.name), ui::style(&format!(" [installed {}]", state.packages[&pkg.name].version), ui::GREEN)
detail: pkg.description.clone(), } else {
aside: String::new(), String::new()
} };
println!(" {:<14} {:<10}{}{}", pkg.name, pkg.version, pkg.description, tag);
} }
fn cmd_list(installed_only: bool, track: Track) -> Result<()> { fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
@ -567,44 +471,24 @@ fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
if installed_only { if installed_only {
if state.packages.is_empty() { if state.packages.is_empty() {
println!("{}", ui::dim("no packages installed")); println!("no packages installed");
return Ok(()); }
for pkg in state.packages.values() {
println!(" {} {} (installed {})", pkg.name, pkg.version, pkg.installed_at);
} }
let mut pkgs: Vec<_> = state.packages.values().collect();
pkgs.sort_by(|a, b| a.name.cmp(&b.name));
ui::heading("Installed", &[&pkgs.len().to_string()]);
let rows: Vec<ui::CatalogRow> = pkgs
.iter()
.map(|pkg| ui::CatalogRow {
name: pkg.name.clone(),
version: pkg.version.clone(),
installed: true,
detail: String::new(),
aside: ui::short_date(&pkg.installed_at),
})
.collect();
ui::print_catalog(&rows);
return Ok(()); return Ok(());
} }
if !matches!(track, Track::Stable) {
println!("tracking:{}\n", ui::track_badge(track));
}
let index = manifest::load(false, track)?; let index = manifest::load(false, track)?;
let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect(); let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect();
names.sort(); names.sort();
let installed = names.iter().filter(|n| state.is_installed(n)).count(); for name in names {
let track_part = ui::track_tag(track); print_index_entry(&index.packages[name], &state);
ui::heading( }
"Packages",
&[
&format!("{} in index", names.len()),
&format!("{installed} installed"),
&track_part,
],
);
let rows: Vec<ui::CatalogRow> = names
.iter()
.map(|name| catalog_row(&index.packages[*name], &state))
.collect();
ui::print_catalog(&rows);
Ok(()) Ok(())
} }
@ -629,19 +513,13 @@ fn cmd_search(query: &str, track: Track) -> Result<()> {
names.sort(); names.sort();
if names.is_empty() { if names.is_empty() {
println!("{}", ui::dim(&format!("no packages matched '{query}'"))); println!("no packages matched '{query}'");
return Ok(()); return Ok(());
} }
ui::heading( for name in names {
"Search", print_index_entry(&index.packages[name], &state);
&[&format!("'{query}'"), &format!("{} matches", names.len())], }
);
let rows: Vec<ui::CatalogRow> = names
.iter()
.map(|name| catalog_row(&index.packages[*name], &state))
.collect();
ui::print_catalog(&rows);
Ok(()) Ok(())
} }
@ -653,60 +531,45 @@ fn cmd_info(name: &str, track: Track) -> Result<()> {
let state = state::State::load()?; let state = state::State::load()?;
let status = if let Some(inst) = state.packages.get(name) { let status = if let Some(inst) = state.packages.get(name) {
ui::style(&format!("installed {}", inst.version), ui::GREEN) ui::style(&format!("installed ({})", inst.version), ui::GREEN)
} else { } else {
ui::style("not installed", ui::DIM) ui::style("not installed", ui::DIM)
}; };
let track_part = ui::track_tag(track); println!("{}{} {}", ui::style(&pkg.name, ui::BOLD), ui::track_badge(track), pkg.version);
ui::heading(&pkg.name, &[&ui::dim(&pkg.version), &track_part]); println!(" {}", pkg.description);
println!(" {}\n", pkg.description); println!(" status: {status}");
ui::kv("status", &status); println!(
ui::kv( " binaries: {}",
"binaries", pkg.binaries
&pkg.binaries
.iter() .iter()
.map(|b| b.name.as_str()) .map(|b| b.name.as_str())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "), .join(", ")
); );
if !pkg.system_deps.is_empty() { if !pkg.system_deps.is_empty() {
ui::kv("system deps", &pkg.system_deps.join(", ")); println!(" system deps: {}", pkg.system_deps.join(", "));
} }
if !pkg.optional_system_deps.is_empty() { if !pkg.optional_system_deps.is_empty() {
ui::kv("optional", &pkg.optional_system_deps.join(", ")); println!(" optional deps: {}", pkg.optional_system_deps.join(", "));
} }
if !pkg.bread_deps.is_empty() { if !pkg.bread_deps.is_empty() {
ui::kv("bread deps", &pkg.bread_deps.join(", ")); println!(" bread deps: {}", pkg.bread_deps.join(", "));
} }
if !pkg.services.is_empty() { if !pkg.services.is_empty() {
ui::kv( println!(
"services", " services: {}",
&pkg.services pkg.services
.iter() .iter()
.map(|s| s.unit.as_str()) .map(|s| s.unit.as_str())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "), .join(", ")
); );
} }
if let Some(inst) = state.packages.get(name) {
ui::kv("installed", &ui::short_date(&inst.installed_at));
}
Ok(()) Ok(())
} }
fn report_layout(layout: &prefix::Layout) { fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Result<()> {
ui::kv(
"prefix",
&format!("{} ({})", layout.prefix.display(), layout.kind_label()),
);
ui::kv("bins", &layout.bin_dir.display().to_string());
ui::kv("share", &layout.share_dir.display().to_string());
ui::kv("units", &layout.systemd_user_dir.display().to_string());
ui::kv("state", &state::bakery_state_dir().display().to_string());
}
fn cmd_doctor(name: Option<&str>, track: Track, layout: &prefix::Layout) -> Result<()> {
let index = manifest::load(false, track)?; let index = manifest::load(false, track)?;
let state = state::State::load()?; let state = state::State::load()?;
@ -721,38 +584,18 @@ fn cmd_doctor(name: Option<&str>, track: Track, layout: &prefix::Layout) -> Resu
}; };
if targets.is_empty() { if targets.is_empty() {
ui::heading("Doctor", &["no packages"]); println!("no packages installed — nothing to check");
report_layout(layout);
println!();
println!("{}", ui::dim("no packages installed — nothing to check"));
return Ok(()); return Ok(());
} }
let mut targets = targets;
targets.sort();
ui::heading("Doctor", &[&format!("{} packages", targets.len())]);
report_layout(layout);
println!();
let name_w = ui::name_width(&targets);
let mut all_ok = true; let mut all_ok = true;
for pkg_name in &targets { for pkg_name in &targets {
if let Some(pkg) = index.get(pkg_name) { if let Some(pkg) = index.get(pkg_name) {
if !doctor::report( if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) {
pkg_name,
&pkg.system_deps,
&pkg.optional_system_deps,
name_w,
) {
all_ok = false; all_ok = false;
} }
} else { } else {
ui::check_row( eprintln!(" {pkg_name}: not found in index (removed from registry?)");
false,
pkg_name,
name_w,
"not found in index (removed from registry?)",
);
all_ok = false; all_ok = false;
} }
@ -762,7 +605,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, layout: &prefix::Layout) -> Resu
// only, not a checksum re-verification — see `bakery verify` for that. // only, not a checksum re-verification — see `bakery verify` for that.
if let Some(installed) = state.packages.get(pkg_name) { if let Some(installed) = state.packages.get(pkg_name) {
for bin in &installed.binaries { for bin in &installed.binaries {
let path = layout.bin_dir.join(bin); let path = bin_dir.join(bin);
if !path.exists() { if !path.exists() {
eprintln!( eprintln!(
" {}", " {}",
@ -778,7 +621,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, layout: &prefix::Layout) -> Resu
} }
if all_ok { if all_ok {
ui::summary(&[&ui::ok("all checks passed")]); println!("{}", ui::ok("all checks passed"));
} }
Ok(()) Ok(())
} }
@ -831,47 +674,38 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
}; };
if targets.is_empty() { if targets.is_empty() {
println!("{}", ui::dim("no packages installed — nothing to verify")); println!("no packages installed — nothing to verify");
return Ok(()); return Ok(());
} }
let mut targets = targets;
targets.sort();
ui::heading("Verify", &[&format!("{} packages", targets.len())]);
let name_w = ui::name_width(&targets);
let mut any_bad = false; let mut any_bad = false;
for pkg_name in &targets { for pkg_name in &targets {
let installed = &state.packages[pkg_name]; let installed = &state.packages[pkg_name];
if installed.binary_sha256.is_empty() { if installed.binary_sha256.is_empty() {
ui::unknown_row( println!(
pkg_name, " {} {pkg_name}: no recorded checksums (installed before 'bakery verify' support)",
name_w, ui::style("?", ui::DIM)
"no recorded checksums (installed before verify support)",
); );
continue; continue;
} }
for bin in &installed.binaries { for bin in &installed.binaries {
match verify_binary(bin_dir, bin, installed.binary_sha256.get(bin)) { match verify_binary(bin_dir, bin, installed.binary_sha256.get(bin)) {
VerifyStatus::Ok => ui::check_row(true, pkg_name, name_w, bin), VerifyStatus::Ok => println!(" {}", ui::ok(&format!("{pkg_name}: {bin}"))),
VerifyStatus::Missing => { VerifyStatus::Missing => {
ui::check_row(false, pkg_name, name_w, &format!("{bin} missing")); eprintln!(" {}", ui::fail(&format!("{pkg_name}: {bin} — MISSING")));
any_bad = true; any_bad = true;
} }
VerifyStatus::Tampered => { VerifyStatus::Tampered => {
ui::check_row( eprintln!(
false, " {}",
pkg_name, ui::fail(&format!("{pkg_name}: {bin} — TAMPERED (checksum mismatch)"))
name_w,
&format!("{bin} tampered (checksum mismatch)"),
); );
any_bad = true; any_bad = true;
} }
VerifyStatus::Unknown => { VerifyStatus::Unknown => {
ui::unknown_row( println!(
pkg_name, " {} {pkg_name}: {bin} — UNKNOWN (no recorded checksum for this binary)",
name_w, ui::style("?", ui::DIM)
&format!("{bin} no recorded checksum for this binary"),
); );
} }
} }
@ -881,7 +715,7 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
if any_bad { if any_bad {
bail!("verification failed for one or more binaries"); bail!("verification failed for one or more binaries");
} }
ui::summary(&[&ui::ok("all recorded checksums match")]); println!("{}", ui::ok("all recorded checksums match"));
Ok(()) Ok(())
} }
@ -894,25 +728,18 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
/// why rollback is backup-based rather than a network re-pin in the first /// why rollback is backup-based rather than a network re-pin in the first
/// place. Pure with respect to global state (caller supplies both dirs), so /// place. Pure with respect to global state (caller supplies both dirs), so
/// this is the piece of `bakery rollback` that's directly unit-testable. /// this is the piece of `bakery rollback` that's directly unit-testable.
fn restore_binaries( fn restore_binaries(backup_dir: &Path, binaries: &[String], bin_dir: &Path) -> Result<HashMap<String, String>> {
backup_dir: &Path,
binaries: &[String],
bin_dir: &Path,
) -> Result<HashMap<String, String>> {
let mut sha256 = HashMap::new(); let mut sha256 = HashMap::new();
for bin in binaries { for bin in binaries {
let backup_path = backup_dir.join(bin); let backup_path = backup_dir.join(bin);
if !backup_path.exists() { if !backup_path.exists() {
bail!( bail!("backup for binary '{bin}' is missing at {}", backup_path.display());
"backup for binary '{bin}' is missing at {}",
backup_path.display()
);
} }
let bytes = std::fs::read(&backup_path) let bytes = std::fs::read(&backup_path)
.with_context(|| format!("reading backup {}", backup_path.display()))?; .with_context(|| format!("reading backup {}", backup_path.display()))?;
let hash = hex::encode(Sha256::digest(&bytes)); let hash = hex::encode(Sha256::digest(&bytes));
let dest = bin_dir.join(bin); let dest = bin_dir.join(bin);
prefix::write_bytes(&dest, &bytes, 0o755) bread_utils::atomic::write_atomic_bytes(&dest, &bytes, Some(0o755))
.with_context(|| format!("restoring {}", dest.display()))?; .with_context(|| format!("restoring {}", dest.display()))?;
sha256.insert(bin.clone(), hash); sha256.insert(bin.clone(), hash);
} }
@ -940,8 +767,6 @@ fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> {
anyhow::anyhow!("no previous version recorded for {pkg_name} — nothing to roll back to") anyhow::anyhow!("no previous version recorded for {pkg_name} — nothing to roll back to")
})?; })?;
ui::action("Rolling back", pkg_name, Some(&target_version));
let backup_dir = state::backup_dir(pkg_name, &target_version); let backup_dir = state::backup_dir(pkg_name, &target_version);
if !backup_dir.exists() { if !backup_dir.exists() {
bail!( bail!(
@ -969,10 +794,7 @@ fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> {
println!( println!(
" {}", " {}",
ui::ok(&format!( ui::ok(&format!("rolled back {pkg_name} {from_version}{target_version}"))
"rolled back {pkg_name} {} → {target_version}",
ui::dim(&from_version)
))
); );
Ok(()) Ok(())
} }
@ -1016,33 +838,18 @@ mod tests {
fn should_update_true_on_track_switch_even_if_not_newer_by_semver() { fn should_update_true_on_track_switch_even_if_not_newer_by_semver() {
// "bakery track set stable && bakery update --all" from beta must // "bakery track set stable && bakery update --all" from beta must
// always take effect, even though 0.3.0 < 0.4.0-beta by strict semver. // always take effect, even though 0.3.0 < 0.4.0-beta by strict semver.
assert!(should_update( assert!(should_update("0.4.0-beta", Track::Beta, Track::Stable, "0.3.0"));
"0.4.0-beta",
Track::Beta,
Track::Stable,
"0.3.0"
));
} }
#[test] #[test]
fn should_update_false_when_same_track_and_not_newer() { fn should_update_false_when_same_track_and_not_newer() {
assert!(!should_update( assert!(!should_update("0.3.1", Track::Stable, Track::Stable, "0.3.1"));
"0.3.1",
Track::Stable,
Track::Stable,
"0.3.1"
));
assert!(!should_update("0.3.2", Track::Dev, Track::Dev, "0.3.1")); assert!(!should_update("0.3.2", Track::Dev, Track::Dev, "0.3.1"));
} }
#[test] #[test]
fn should_update_true_when_same_track_and_newer() { fn should_update_true_when_same_track_and_newer() {
assert!(should_update( assert!(should_update("0.3.1", Track::Stable, Track::Stable, "0.3.2"));
"0.3.1",
Track::Stable,
Track::Stable,
"0.3.2"
));
} }
#[test] #[test]
@ -1095,18 +902,14 @@ mod tests {
let pkg = empty_binary_package(name, "9.9.9", "http://127.0.0.1:1/unreachable"); let pkg = empty_binary_package(name, "9.9.9", "http://127.0.0.1:1/unreachable");
let mut packages = std::collections::HashMap::new(); let mut packages = std::collections::HashMap::new();
packages.insert(name.to_string(), pkg); packages.insert(name.to_string(), pkg);
let index = manifest::Index { let index = manifest::Index { version: "1".to_string(), packages };
version: "1".to_string(),
packages,
};
let bin_dir = tempdir().unwrap(); let bin_dir = tempdir().unwrap();
let layout = prefix::Layout::from_prefix(bin_dir.path(), None);
let mut visited = HashSet::new(); let mut visited = HashSet::new();
install_with_deps( install_with_deps(
&index, &index,
name, name,
&layout, bin_dir.path(),
Track::Stable, Track::Stable,
true, true,
true, true,
@ -1123,10 +926,7 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
fs::write(dir.path().join("mypkg"), b"good bytes").unwrap(); fs::write(dir.path().join("mypkg"), b"good bytes").unwrap();
let hash = hex::encode(Sha256::digest(b"good bytes")); let hash = hex::encode(Sha256::digest(b"good bytes"));
assert_eq!( assert_eq!(verify_binary(dir.path(), "mypkg", Some(&hash)), VerifyStatus::Ok);
verify_binary(dir.path(), "mypkg", Some(&hash)),
VerifyStatus::Ok
);
} }
#[test] #[test]
@ -1134,30 +934,21 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
fs::write(dir.path().join("mypkg"), b"tampered bytes").unwrap(); fs::write(dir.path().join("mypkg"), b"tampered bytes").unwrap();
let wrong_hash = "0".repeat(64); let wrong_hash = "0".repeat(64);
assert_eq!( assert_eq!(verify_binary(dir.path(), "mypkg", Some(&wrong_hash)), VerifyStatus::Tampered);
verify_binary(dir.path(), "mypkg", Some(&wrong_hash)),
VerifyStatus::Tampered
);
} }
#[test] #[test]
fn verify_binary_missing_when_file_absent() { fn verify_binary_missing_when_file_absent() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let hash = "0".repeat(64); let hash = "0".repeat(64);
assert_eq!( assert_eq!(verify_binary(dir.path(), "nope", Some(&hash)), VerifyStatus::Missing);
verify_binary(dir.path(), "nope", Some(&hash)),
VerifyStatus::Missing
);
} }
#[test] #[test]
fn verify_binary_unknown_when_no_recorded_hash() { fn verify_binary_unknown_when_no_recorded_hash() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
fs::write(dir.path().join("mypkg"), b"bytes").unwrap(); fs::write(dir.path().join("mypkg"), b"bytes").unwrap();
assert_eq!( assert_eq!(verify_binary(dir.path(), "mypkg", None), VerifyStatus::Unknown);
verify_binary(dir.path(), "mypkg", None),
VerifyStatus::Unknown
);
} }
#[test] #[test]
@ -1171,14 +962,8 @@ mod tests {
let hashes = restore_binaries(&backup_dir, &["mypkg".to_string()], &bin_dir).unwrap(); let hashes = restore_binaries(&backup_dir, &["mypkg".to_string()], &bin_dir).unwrap();
assert_eq!( assert_eq!(fs::read(bin_dir.join("mypkg")).unwrap(), b"old version bytes");
fs::read(bin_dir.join("mypkg")).unwrap(), assert_eq!(hashes["mypkg"], hex::encode(Sha256::digest(b"old version bytes")));
b"old version bytes"
);
assert_eq!(
hashes["mypkg"],
hex::encode(Sha256::digest(b"old version bytes"))
);
} }
#[test] #[test]

View file

@ -52,7 +52,8 @@ fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> {
/// exercise the verification logic with a throwaway keypair instead of the /// exercise the verification logic with a throwaway keypair instead of the
/// real production key. /// real production key.
fn verify_against_key(bytes: &[u8], sig_text: &str, pubkey_b64: &str) -> Result<()> { 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 = let signature =
Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?; Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?;
public_key public_key
@ -107,21 +108,21 @@ pub struct Package {
#[serde(default)] #[serde(default)]
pub post_install: Vec<String>, pub post_install: Vec<String>,
/// License artifact filename (e.g. "LICENSE"), installed to /// License artifact filename (e.g. "LICENSE"), installed to
/// `$prefix/share/licenses/<name>/LICENSE` (`~/.local/share/...` by /// `~/.local/share/licenses/<name>/LICENSE` — the bakery equivalent of
/// default) — the bakery equivalent of a PKGBUILD's `package()` step. /// what a PKGBUILD's `package()` does with `/usr/share/licenses`.
#[serde(default)] #[serde(default)]
pub license_file: Option<String>, pub license_file: Option<String>,
#[serde(default)] #[serde(default)]
pub license_file_sha256: Option<String>, pub license_file_sha256: Option<String>,
/// Desktop entry artifact filename (e.g. "breadhelp.desktop"), /// Desktop entry artifact filename (e.g. "breadhelp.desktop"),
/// installed to `$prefix/share/applications/<name>.desktop` so the /// installed to `~/.local/share/applications/<name>.desktop` so the
/// app shows up in any XDG-compliant launcher. /// app shows up in any XDG-compliant launcher without root.
#[serde(default)] #[serde(default)]
pub desktop_file: Option<String>, pub desktop_file: Option<String>,
#[serde(default)] #[serde(default)]
pub desktop_file_sha256: Option<String>, pub desktop_file_sha256: Option<String>,
/// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz` /// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz`
/// in the release dir, extracted to `$prefix/share/<name>/` on /// in the release dir, extracted to `~/.local/share/<name>/` on
/// install. For arbitrary data a package needs at runtime beyond a /// install. For arbitrary data a package needs at runtime beyond a
/// config example (e.g. breadhelp's guide content), where a single /// config example (e.g. breadhelp's guide content), where a single
/// downloadable file + `tar` extraction is simpler than teaching /// downloadable file + `tar` extraction is simpler than teaching
@ -182,7 +183,9 @@ pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
match read_and_verify_cache(&cache_path, &sig_cache_path, track) { match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
Ok(index) => return Ok(index), Ok(index) => return Ok(index),
Err(err) => { Err(err) => {
eprintln!(" warning: cached index.json failed verification ({err}), re-fetching…"); eprintln!(
" warning: cached index.json failed verification ({err}), re-fetching…"
);
} }
} }
} }
@ -208,12 +211,17 @@ pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
} }
} }
fn read_and_verify_cache(cache_path: &Path, sig_cache_path: &Path, track: Track) -> 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 bytes = std::fs::read(cache_path).context("reading cached index")?;
let sig_text = std::fs::read_to_string(sig_cache_path) let sig_text = std::fs::read_to_string(sig_cache_path)
.context("reading cached index.json.minisig (cache predates signing support)")?; .context("reading cached index.json.minisig (cache predates signing support)")?;
verify_index_signature(&bytes, &sig_text) verify_index_signature(&bytes, &sig_text).with_context(|| {
.with_context(|| format!("cached {track} index failed signature verification"))?; format!("cached {track} index failed signature verification")
})?;
serde_json::from_slice(&bytes).context("parsing cached index") serde_json::from_slice(&bytes).context("parsing cached index")
} }
@ -277,10 +285,8 @@ pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result<Vec<u8>> {
Ok(bytes) => Ok(bytes), Ok(bytes) => Ok(bytes),
Err(primary_err) => { Err(primary_err) => {
eprintln!( eprintln!(
" {}", " primary URL failed ({}), trying GitHub fallback…",
crate::ui::note(&format!( primary_err
"primary URL failed ({primary_err}), trying GitHub fallback…"
))
); );
fetch_bytes(fallback_url).context("both primary and GitHub fallback failed") fetch_bytes(fallback_url).context("both primary and GitHub fallback failed")
} }
@ -299,7 +305,9 @@ const CHUNK_SIZE: usize = 64 * 1024;
fn fetch_bytes(url: &str) -> Result<Vec<u8>> { fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
use std::io::{IsTerminal, Read}; use std::io::{IsTerminal, Read};
let resp = ureq::get(url).call().map_err(|e| anyhow::anyhow!("{e}"))?; let resp = ureq::get(url)
.call()
.map_err(|e| anyhow::anyhow!("{e}"))?;
let status = resp.status(); let status = resp.status();
if status != 200 { if status != 200 {
bail!("HTTP {status} from {url}"); bail!("HTTP {status} from {url}");
@ -312,11 +320,7 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
// is what makes printing partway through the download possible, without // is what makes printing partway through the download possible, without
// pulling in a progress-bar crate for what's meant to just be reassurance. // 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()); 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 let show_progress = content_length.is_some() && std::io::stderr().is_terminal();
// 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(); let mut buf = Vec::new();
let mut reader = resp.into_reader(); let mut reader = resp.into_reader();
@ -332,17 +336,27 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit"); bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit");
} }
if show_progress && last_print.elapsed() >= PROGRESS_THROTTLE { if show_progress && last_print.elapsed() >= PROGRESS_THROTTLE {
crate::ui::print_progress(buf.len() as u64, content_length.unwrap()); print_progress(buf.len() as u64, content_length.unwrap());
last_print = std::time::Instant::now(); last_print = std::time::Instant::now();
} }
} }
if show_progress { if show_progress {
crate::ui::print_progress(buf.len() as u64, content_length.unwrap()); print_progress(buf.len() as u64, content_length.unwrap());
crate::ui::finish_progress(); eprintln!();
} }
Ok(buf) Ok(buf)
} }
fn print_progress(downloaded: u64, total: u64) {
use std::io::Write;
eprint!(
"\r ⇣ {:.1}/{:.1} MB",
downloaded as f64 / 1_048_576.0,
total as f64 / 1_048_576.0
);
let _ = std::io::stderr().flush();
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -394,7 +408,10 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
fn stable_cache_path_matches_pre_track_filename() { fn stable_cache_path_matches_pre_track_filename() {
// Must stay exactly "index.json" so an existing warm cache from a // Must stay exactly "index.json" so an existing warm cache from a
// pre-track bakery binary is still used after an upgrade. // pre-track bakery binary is still used after an upgrade.
assert_eq!(cache_path(Track::Stable).file_name().unwrap(), "index.json"); assert_eq!(
cache_path(Track::Stable).file_name().unwrap(),
"index.json"
);
} }
#[test] #[test]
@ -411,22 +428,13 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
#[test] #[test]
fn stable_url_has_no_track_prefix() { fn stable_url_has_no_track_prefix() {
assert_eq!( assert_eq!(primary_url(Track::Stable), format!("{}/index.json", base_url()));
primary_url(Track::Stable),
format!("{}/index.json", base_url())
);
} }
#[test] #[test]
fn beta_and_dev_urls_are_track_prefixed() { fn beta_and_dev_urls_are_track_prefixed() {
assert_eq!( assert_eq!(primary_url(Track::Beta), format!("{}/beta/index.json", base_url()));
primary_url(Track::Beta), assert_eq!(primary_url(Track::Dev), format!("{}/dev/index.json", base_url()));
format!("{}/beta/index.json", base_url())
);
assert_eq!(
primary_url(Track::Dev),
format!("{}/dev/index.json", base_url())
);
} }
fn minimal_package_json() -> &'static str { fn minimal_package_json() -> &'static str {

View file

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

View file

@ -56,7 +56,8 @@ impl State {
pub fn save(&self) -> Result<()> { pub fn save(&self) -> Result<()> {
let path = state_path(); let path = state_path();
let text = serde_json::to_string_pretty(self)?; let text = serde_json::to_string_pretty(self)?;
bread_utils::atomic::write_atomic(&path, &text, None).context("writing installed.json") bread_utils::atomic::write_atomic(&path, &text, None)
.context("writing installed.json")
} }
/// Runs `f` against a freshly-loaded `State` while holding an exclusive /// Runs `f` against a freshly-loaded `State` while holding an exclusive
@ -113,13 +114,7 @@ fn state_base_dir() -> PathBuf {
} }
fn state_path() -> PathBuf { fn state_path() -> PathBuf {
bakery_state_dir().join("installed.json") state_base_dir().join("bakery/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 /// Local backup dir for `pkg_name`'s `version` binaries, populated by
@ -210,10 +205,7 @@ mod tests {
assert_eq!(restored.packages["bar"].version, "2.0.0"); assert_eq!(restored.packages["bar"].version, "2.0.0");
assert_eq!(restored.packages["bar"].services, ["bar.service"]); assert_eq!(restored.packages["bar"].services, ["bar.service"]);
assert_eq!(restored.packages["bar"].track, Track::Beta); assert_eq!(restored.packages["bar"].track, Track::Beta);
assert_eq!( assert_eq!(restored.packages["bar"].previous_version.as_deref(), Some("1.0.0"));
restored.packages["bar"].previous_version.as_deref(),
Some("1.0.0")
);
assert_eq!(restored.packages["bar"].binary_sha256["bar"], "abc123"); assert_eq!(restored.packages["bar"].binary_sha256["bar"], "abc123");
} }
@ -236,14 +228,6 @@ mod tests {
assert!(installed.binary_sha256.is_empty()); 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] #[test]
fn backup_dir_is_distinct_per_package_and_version() { fn backup_dir_is_distinct_per_package_and_version() {
let a = backup_dir("bakery", "0.3.1"); let a = backup_dir("bakery", "0.3.1");

View file

@ -1,6 +1,5 @@
use crate::track::Track; use crate::track::Track;
use clap::builder::styling::{AnsiColor, Effects, Styles}; use std::io::IsTerminal;
use std::io::{IsTerminal, Write};
pub const RESET: &str = "\x1b[0m"; pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m"; pub const BOLD: &str = "\x1b[1m";
@ -10,19 +9,6 @@ pub const GREEN: &str = "\x1b[32m";
pub const YELLOW: &str = "\x1b[33m"; pub const YELLOW: &str = "\x1b[33m";
pub const CYAN: &str = "\x1b[36m"; pub const CYAN: &str = "\x1b[36m";
pub const MAGENTA: &str = "\x1b[35m"; 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 /// Colors are on only when stdout is a real terminal and `NO_COLOR` isn't
/// set — the ecosystem's existing CLI (breadcrumbs) hardcodes ANSI /// set — the ecosystem's existing CLI (breadcrumbs) hardcodes ANSI
@ -32,52 +18,21 @@ pub fn colors_enabled() -> bool {
std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() 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 { pub fn style(s: &str, code: &str) -> String {
paint(s, code, colors_enabled()) if 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}") format!("{code}{s}{RESET}")
} else { } else {
s.to_string() 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 /// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the
/// common-case output is unchanged. /// common-case output is unchanged.
#[allow(dead_code)]
pub fn track_badge(track: Track) -> String { 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 { match track {
Track::Stable => String::new(), Track::Stable => String::new(),
Track::Beta => style("[beta]", YELLOW), Track::Beta => format!(" {}", style("[beta]", YELLOW)),
Track::Dev => style("[dev]", MAGENTA), Track::Dev => format!(" {}", style("[dev]", MAGENTA)),
} }
} }
@ -99,278 +54,6 @@ pub fn unchanged(s: &str) -> String {
style(&format!("· {s}"), DIM) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -394,64 +77,4 @@ mod tests {
assert!(!ok("foo").contains('·')); assert!(!ok("foo").contains('·'));
assert!(!fail("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");
}
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -50,17 +50,6 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[
("osd-volume", "osd-volume.png"), ("osd-volume", "osd-volume.png"),
("osd-brightness", "osd-brightness.png"), ("osd-brightness", "osd-brightness.png"),
("wifi-add-dialog", "wifi-add-dialog.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")]), ("breadbox", &[("launcher", "launcher.png")]),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,27 +0,0 @@
[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"] }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,48 +1,11 @@
# bread-theme changelog # 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 color16 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 color16 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 ## Coordinated bump policy
`bread-theme` is consumed by `breadbar`, `breadbox`, `breadpad`, and the other `bread-theme` is consumed by `breadbar`, `breadbox`, and `breadpad` as a pinned
GTK bread apps as a pinned git dependency. A breaking change to `Palette`, git dependency. A breaking change to `Palette`, `css_vars`, or the `gtk` feature
`css_vars`, or the `gtk` feature API requires dependents to bump their API requires all three dependents to bump their `Cargo.toml` git tag and cut a
`Cargo.toml` git tag and cut a release together. Note the impact in this file release together. Note the impact in this file before tagging.
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"`.
--- ---

View file

@ -12,11 +12,6 @@ keywords = ["theming", "pywal", "gtk4", "wayland"]
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
dirs = { 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 } gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
# Rust bindings for libadwaita (GNOME's widget library on top of GTK4) — the # Rust bindings for libadwaita (GNOME's widget library on top of GTK4) — the
# actual source of the modern GNOME look (grouped preference rows, real # actual source of the modern GNOME look (grouped preference rows, real

View file

@ -1,117 +0,0 @@
/* 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; }

View file

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

View file

@ -1,86 +0,0 @@
/* 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; }

View file

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

View file

@ -1,104 +0,0 @@
/* 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; }

View file

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

View file

@ -1,73 +0,0 @@
/* 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; }

View file

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

View file

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

View file

@ -9,10 +9,6 @@
//! # signal every running bread GUI to recolour //! # signal every running bread GUI to recolour
//! bread-theme path # print the stylesheet path //! bread-theme path # print the stylesheet path
//! bread-theme print # render to stdout (no write) //! 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; use std::process::ExitCode;
@ -29,179 +25,6 @@ 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 { fn main() -> ExitCode {
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into()); let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
match cmd.as_str() { match cmd.as_str() {
@ -219,16 +42,20 @@ fn main() -> ExitCode {
// the file monitor in every running bread GUI, so they all re-read the // 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. // palette and recolour live — shared widgets *and* each app's own rules.
"reload" => write_and_report("reloaded"), "reload" => write_and_report("reloaded"),
"generate-output" => generate_output_cmd(),
"layerrules" => layerrules_cmd(),
"-h" | "--help" | "help" => { "-h" | "--help" | "help" => {
print_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()
);
ExitCode::SUCCESS ExitCode::SUCCESS
} }
other => { other => {
eprintln!( eprintln!("bread-theme: unknown command '{other}' (try generate|reload|path|print)");
"bread-theme: unknown command '{other}' (try generate|reload|path|print|generate-output|layerrules)"
);
ExitCode::FAILURE ExitCode::FAILURE
} }
} }

View file

@ -1,22 +1,8 @@
use gtk4::gdk::prelude::*;
use gtk4::gio; use gtk4::gio;
use gtk4::glib::object::ObjectType;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::CssProvider; use gtk4::CssProvider;
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::Path; 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! { thread_local! {
static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) }; static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
@ -28,7 +14,8 @@ thread_local! {
} }
fn reload_shared() { 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)); SHARED_PROVIDER.with(|cell| apply_css(&css, cell));
} }
@ -134,10 +121,7 @@ pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) {
/// breadpad, and breadman (both cream), none of which agreed with each /// breadpad, and breadman (both cream), none of which agreed with each
/// other or with the shared token. /// other or with the shared token.
pub fn chip(label: &str) -> gtk4::Button { pub fn chip(label: &str) -> gtk4::Button {
gtk4::Button::builder() gtk4::Button::builder().label(label).css_classes(["chip"]).build()
.label(label)
.css_classes(["chip"])
.build()
} }
/// Toggles a chip's (or any widget's) `active` CSS class — the `.chip.active` /// Toggles a chip's (or any widget's) `active` CSS class — the `.chip.active`
@ -153,319 +137,6 @@ pub fn set_chip_active(chip: &impl IsA<gtk4::Widget>, active: bool) {
} }
} }
/// 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: &gtk4::Widget) -> usize {
widget.as_ptr() as usize
}
#[allow(deprecated)]
fn add_widget_provider(widget: &gtk4::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: &gtk4::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: &gtk4::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: &gtk4::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: &gtk4::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: &gtk4::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: &gtk4::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: &gtk4::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 /// 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. /// file is absent so stale overrides don't persist across SIGHUP reloads.
pub fn apply_user_css(path: &Path, provider: &RefCell<Option<CssProvider>>) { pub fn apply_user_css(path: &Path, provider: &RefCell<Option<CssProvider>>) {

View file

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

View file

@ -1,35 +1,11 @@
#[cfg(feature = "adw")] pub mod palette;
pub mod adw;
#[cfg(feature = "gtk")]
pub mod anim;
#[cfg(feature = "gtk")] #[cfg(feature = "gtk")]
pub mod gtk; pub mod gtk;
mod layerrules; #[cfg(feature = "adw")]
mod output; pub mod adw;
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}; 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. /// Design tokens from BREAD_DESIGN_SYSTEM.md.
pub mod tokens { pub mod tokens {
pub const FONT_FAMILY: &str = "Varela Round, sans-serif"; pub const FONT_FAMILY: &str = "Varela Round, sans-serif";
@ -50,13 +26,6 @@ pub mod tokens {
pub const RADIUS_PILL: u16 = 999; 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 /// Emit the `@define-color` block that all bread apps use, plus the shared
/// font rule. /// font rule.
/// ///
@ -72,9 +41,9 @@ fn css_font_family() -> &'static str {
/// one color-block implementation and it cannot drift again. /// one color-block implementation and it cannot drift again.
pub fn css_vars(p: &Palette) -> String { pub fn css_vars(p: &Palette) -> String {
format!( format!(
"{vars}* {{ font-family: {font}; font-size: {size}px; }}\n", "{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n",
vars = define_colors(p), vars = define_colors(p),
font = css_font_family(), font = tokens::FONT_FAMILY,
size = tokens::FONT_SIZE_BASE, size = tokens::FONT_SIZE_BASE,
) )
} }
@ -84,11 +53,7 @@ pub fn luminance(hex: &str) -> f32 {
let h = hex.trim_start_matches('#'); let h = hex.trim_start_matches('#');
let lin = |i: usize| -> f32 { 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; 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 { if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}; };
0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4) 0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4)
} }
@ -99,11 +64,7 @@ pub fn luminance(hex: &str) -> f32 {
/// text readable no matter how light or dark pywal makes a given palette slot, /// text readable no matter how light or dark pywal makes a given palette slot,
/// without altering the palette colours themselves. /// without altering the palette colours themselves.
pub fn ink_on(hex: &str) -> &'static str { pub fn ink_on(hex: &str) -> &'static str {
if luminance(hex) > 0.179 { if luminance(hex) > 0.179 { "#11111b" } else { "#f5f5f5" }
"#11111b"
} else {
"#f5f5f5"
}
} }
/// Canonical (name, value) list: the single naming all bread apps share. /// Canonical (name, value) list: the single naming all bread apps share.
@ -169,7 +130,7 @@ pub fn css_tokens() -> String {
use tokens::*; use tokens::*;
format!( format!(
":root {{\n\ ":root {{\n\
\x20\x20--font-family: {font};\n\ \x20\x20--font-family: '{font}';\n\
\x20\x20--font-size-base: {base}px;\n\ \x20\x20--font-size-base: {base}px;\n\
\x20\x20--font-size-secondary: {sec}px;\n\ \x20\x20--font-size-secondary: {sec}px;\n\
\x20\x20--space-xs: {xs}px;\n\ \x20\x20--space-xs: {xs}px;\n\
@ -182,18 +143,9 @@ pub fn css_tokens() -> String {
\x20\x20--radius-tertiary: {r3}px;\n\ \x20\x20--radius-tertiary: {r3}px;\n\
\x20\x20--radius-pill: {pill}px;\n\ \x20\x20--radius-pill: {pill}px;\n\
}}\n", }}\n",
font = css_font_family(), font = FONT_FAMILY, base = FONT_SIZE_BASE, sec = FONT_SIZE_SECONDARY,
base = FONT_SIZE_BASE, xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, xl = SPACE_XL,
sec = FONT_SIZE_SECONDARY, r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, r3 = RADIUS_TERTIARY, pill = RADIUS_PILL,
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,
) )
} }
@ -207,7 +159,7 @@ pub fn stylesheet(p: &Palette) -> String {
use tokens::*; use tokens::*;
format!( format!(
"{vars}\ "{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,\ /* 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\ button, or accent is always the legible ink for that background. Bare\
`label {{ color }}` is deliberately avoided as a type selector it\ `label {{ color }}` is deliberately avoided as a type selector it\
@ -290,7 +242,7 @@ pub fn stylesheet(p: &Palette) -> String {
textview, .mono {{ font-family: monospace; }}\n\ textview, .mono {{ font-family: monospace; }}\n\
textview text {{ background-color: @surface; color: @on-surface; }}\n", textview text {{ background-color: @surface; color: @on-surface; }}\n",
vars = define_colors(p), vars = define_colors(p),
font = css_font_family(), font = FONT_FAMILY,
base = FONT_SIZE_BASE, base = FONT_SIZE_BASE,
sec = FONT_SIZE_SECONDARY, sec = FONT_SIZE_SECONDARY,
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG,
@ -309,34 +261,28 @@ pub fn render() -> String {
/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`, /// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`,
/// falling back to the cache dir. /// falling back to the cache dir.
pub fn shared_css_path() -> std::path::PathBuf { pub fn shared_css_path() -> std::path::PathBuf {
output::runtime_bread_dir().join("theme.css") 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")
} }
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns /// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
/// the path written. Used by the `bread-theme` CLI. /// the path written. Used by the `bread-theme` CLI.
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> { pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
write_shared_css_from(&load_palette()) let path = shared_css_path();
} if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
/// `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);
} }
out let tmp = path.with_extension("css.tmp");
std::fs::write(&tmp, render())?;
std::fs::rename(&tmp, &path)?;
Ok(path)
} }
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`. /// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
@ -355,24 +301,15 @@ mod tests {
#[test] #[test]
fn css_vars_contains_all_define_color_names() { fn css_vars_contains_all_define_color_names() {
let css = css_vars(&Palette::default()); let css = css_vars(&Palette::default());
for name in &[ for name in &["bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
"bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay", assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
] {
assert!(
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
} }
} }
#[test] #[test]
fn css_vars_contains_font_rule() { fn css_vars_contains_font_rule() {
let css = css_vars(&Palette::default()); let css = css_vars(&Palette::default());
assert!(css.contains("font-family: 'Varela Round', sans-serif;")); assert!(css.contains("Varela Round"));
assert!(
!css.contains("font-family: 'Varela Round, sans-serif'"),
"named face and generic fallback must not be one quoted family"
);
assert!(css.contains("14px")); assert!(css.contains("14px"));
} }
@ -385,18 +322,8 @@ mod tests {
// color name — the illegible-text bug. css_vars() must now emit // color name — the illegible-text bug. css_vars() must now emit
// exactly the same color set as the full stylesheet. // exactly the same color set as the full stylesheet.
let css = css_vars(&Palette::default()); let css = css_vars(&Palette::default());
for name in &[ for name in &["accent", "on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
"accent", assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
"on-bg",
"on-surface",
"on-accent",
"on-red",
"on-overlay",
] {
assert!(
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
} }
} }
@ -407,16 +334,7 @@ mod tests {
let p = Palette::default(); let p = Palette::default();
let vars = css_vars(&p); let vars = css_vars(&p);
let sheet = stylesheet(&p); let sheet = stylesheet(&p);
for name in &[ for name in &["bg", "fg", "surface", "overlay", "accent", "on-bg", "on-surface", "on-accent"] {
"bg",
"fg",
"surface",
"overlay",
"accent",
"on-bg",
"on-surface",
"on-accent",
] {
let needle = format!("@define-color {name} "); let needle = format!("@define-color {name} ");
assert!(vars.contains(&needle) && sheet.contains(&needle)); assert!(vars.contains(&needle) && sheet.contains(&needle));
} }
@ -426,28 +344,13 @@ mod tests {
fn stylesheet_defines_canonical_colors_and_components() { fn stylesheet_defines_canonical_colors_and_components() {
let css = stylesheet(&Palette::default()); let css = stylesheet(&Palette::default());
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] { for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
assert!( assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
} }
// a representative spread of the shared component selectors // a representative spread of the shared component selectors
for sel in &[ for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".page-title"] {
"button",
"entry",
"switch:checked",
".card",
".sidebar",
"scrollbar slider",
".page-title",
] {
assert!(css.contains(sel), "stylesheet missing selector: {sel}"); assert!(css.contains(sel), "stylesheet missing selector: {sel}");
} }
assert!(css.contains("font-family: 'Varela Round', sans-serif;")); assert!(css.contains("Varela Round"));
assert!(
!css.contains("font-family: 'Varela Round, sans-serif'"),
"named face and generic fallback must not be one quoted family"
);
} }
#[test] #[test]
@ -459,10 +362,7 @@ mod tests {
let gtk = define_colors(&p); let gtk = define_colors(&p);
let web = css_custom_properties(&p); let web = css_custom_properties(&p);
for (name, _) in color_pairs(&p) { for (name, _) in color_pairs(&p) {
assert!( assert!(gtk.contains(&format!("@define-color {name} ")), "gtk missing {name}");
gtk.contains(&format!("@define-color {name} ")),
"gtk missing {name}"
);
assert!(web.contains(&format!("--{name}: ")), "web missing {name}"); assert!(web.contains(&format!("--{name}: ")), "web missing {name}");
} }
} }
@ -479,11 +379,7 @@ mod tests {
#[test] #[test]
fn css_tokens_contains_font_and_spacing_vars() { fn css_tokens_contains_font_and_spacing_vars() {
let css = css_tokens(); let css = css_tokens();
assert!(css.contains("--font-family: 'Varela Round', sans-serif;")); 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("--font-size-base: 14px;"));
assert!(css.contains("--space-md: 12px;")); assert!(css.contains("--space-md: 12px;"));
assert!(css.contains("--radius-pill: 999px;")); assert!(css.contains("--radius-pill: 999px;"));
@ -513,10 +409,7 @@ mod tests {
fn stylesheet_defines_on_colors() { fn stylesheet_defines_on_colors() {
let css = stylesheet(&Palette::default()); let css = stylesheet(&Palette::default());
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] { for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
assert!( assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
css.contains(&format!("@define-color {name} ")),
"missing @define-color {name}"
);
} }
} }
@ -525,60 +418,13 @@ mod tests {
// A bare `label { color: ... }` would override container colours on child // A bare `label { color: ... }` would override container colours on child
// labels — the bug that made coloured-background text illegible. // labels — the bug that made coloured-background text illegible.
let css = stylesheet(&Palette::default()); let css = stylesheet(&Palette::default());
assert!( assert!(!css.contains("label { color:"), "blanket label colour rule reintroduced");
!css.contains("label { color:"),
"blanket label colour rule reintroduced"
);
} }
#[test] #[test]
fn shared_css_path_uses_runtime_dir() { 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"); std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
assert_eq!( assert_eq!(shared_css_path(), std::path::PathBuf::from("/run/user/1234/bread/theme.css"));
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] #[test]

View file

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

View file

@ -9,10 +9,10 @@ use std::path::PathBuf;
/// off-hue background, and every bread GUI's panels inherit it — the app /// 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 /// stops looking like a dark BOS tool and starts looking like whatever colour
/// the wallpaper happened to be. /// the wallpaper happened to be.
pub(crate) const FIXED_BACKGROUND: &str = "#0c0c0c"; const FIXED_BACKGROUND: &str = "#0c0c0c";
pub(crate) const FIXED_FOREGROUND: &str = "#e8e8e8"; const FIXED_FOREGROUND: &str = "#e8e8e8";
pub(crate) const FIXED_SURFACE: &str = "#1a1a1a"; const FIXED_SURFACE: &str = "#1a1a1a";
pub(crate) const FIXED_OVERLAY: &str = "#d8d8d8"; const FIXED_OVERLAY: &str = "#d8d8d8";
/// Accent fallback when no pywal palette exists yet (fresh install, before /// Accent fallback when no pywal palette exists yet (fresh install, before
/// any wallpaper has been set for real) — BOS's own bread-toned accents, /// any wallpaper has been set for real) — BOS's own bread-toned accents,
@ -84,10 +84,7 @@ pub fn load_palette() -> Palette {
pub(crate) fn from_wal_json(json: &str) -> Option<Palette> { pub(crate) fn from_wal_json(json: &str) -> Option<Palette> {
let wal: WalColors = serde_json::from_str(json).ok()?; let wal: WalColors = serde_json::from_str(json).ok()?;
let c = |k: &str, fallback: &str| -> String { let c = |k: &str, fallback: &str| -> String {
wal.colors wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into())
.get(k)
.cloned()
.unwrap_or_else(|| fallback.into())
}; };
Some(Palette { Some(Palette {
background: FIXED_BACKGROUND.into(), background: FIXED_BACKGROUND.into(),

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -15,7 +15,7 @@ dirs = { workspace = true }
gtk4 = { version = "0.11", features = ["v4_12"], optional = true } gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
gtk4-layer-shell = { version = "0.8", optional = true } gtk4-layer-shell = { version = "0.8", optional = true }
toml_edit = { version = "0.22", optional = true } toml_edit = { version = "0.22", optional = true }
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0", optional = true } bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0", optional = true }
[features] [features]
# Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional # Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional

View file

@ -36,17 +36,10 @@ pub enum Socket {
/// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if /// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if
/// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're /// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're
/// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to /// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to
/// `/run/user/<uid>` (this process's real uid, or the historical /// `/run/user/1000` if unset, matching `breadmon`'s existing fallback.
/// `/run/user/1000` if that can't be read) when unset.
pub fn socket_path(kind: Socket) -> Option<PathBuf> { pub fn socket_path(kind: Socket) -> Option<PathBuf> {
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
// `XDG_RUNTIME_DIR` is normally `/run/user/<uid>`; when it's unset, let rt = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
// 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 { let file = match kind {
Socket::Request => ".socket.sock", Socket::Request => ".socket.sock",
Socket::Events => ".socket2.sock", Socket::Events => ".socket2.sock",
@ -54,21 +47,6 @@ pub fn socket_path(kind: Socket) -> Option<PathBuf> {
Some(PathBuf::from(format!("{rt}/hypr/{sig}/{file}"))) 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 /// Send `request` (e.g. `"j/activewindow"`, `"j/monitors"`) to the socket1
/// IPC socket and return the raw response body. Blocking/synchronous — this /// IPC socket and return the raw response body. Blocking/synchronous — this
/// matches every current consumer (breadbox, breadclip), which call it from /// matches every current consumer (breadbox, breadclip), which call it from
@ -244,17 +222,6 @@ mod tests {
assert!(win.fullscreen.is_fullscreen()); 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`/ // Both env-var-dependent cases share one test function: `set_var`/
// `remove_var` are process-global, and cargo runs tests in parallel // `remove_var` are process-global, and cargo runs tests in parallel
// threads by default, so two separate #[test] fns racing on the same // threads by default, so two separate #[test] fns racing on the same

View file

@ -80,9 +80,6 @@ pub fn try_acquire(app: &str) -> std::io::Result<Acquire> {
.read(true) .read(true)
.write(true) .write(true)
.create(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)?; .open(&path)?;
match file.try_lock() { match file.try_lock() {