Compare commits
10 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
347f356b1d | ||
|
|
f5a47490f7 | ||
|
|
4f8859527d | ||
|
|
e2c6452e4f | ||
|
|
a9754d90ed | ||
|
|
fcba376038 | ||
|
|
11c0e844e5 | ||
|
|
c296d26408 | ||
|
|
b20e4bcec1 | ||
|
|
3f1caa99f5 |
42 changed files with 4861 additions and 508 deletions
|
|
@ -8,6 +8,7 @@ on:
|
|||
branches: ['main']
|
||||
paths:
|
||||
- 'bakery/**'
|
||||
- 'bread-utils/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- '.forgejo/workflows/dev-bakery.yml'
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ on:
|
|||
|
||||
jobs:
|
||||
package:
|
||||
# PKGBUILD pkgver cannot contain `-`; skip RC tags the same way
|
||||
# release-bakery.yml does.
|
||||
if: ${{ !contains(github.ref_name, '-rc.') }}
|
||||
runs-on: [self-hosted, hestia]
|
||||
container:
|
||||
image: archlinux:latest
|
||||
|
|
|
|||
|
|
@ -7,11 +7,9 @@ name: beta (rc) bakery
|
|||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
paths:
|
||||
- 'bakery/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- '.forgejo/workflows/beta-bakery.yml'
|
||||
# No paths: filter. Tag pushes compare against an unrelated commit and
|
||||
# would skip the RC publish if bakery/** wasn't in that diff; the job
|
||||
# `if: contains -rc.` is the real gate.
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -35,6 +33,7 @@ jobs:
|
|||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64"
|
||||
|
|
|
|||
|
|
@ -7,11 +7,9 @@ name: beta (rc) bread-theme
|
|||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
paths:
|
||||
- 'bread-theme/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- '.forgejo/workflows/beta-bread-theme.yml'
|
||||
# No paths: filter. Tag pushes compare against an unrelated commit and
|
||||
# would skip the RC publish if bread-theme/** wasn't in that diff; the
|
||||
# job `if: contains -rc.` is the real gate.
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -32,6 +30,7 @@ jobs:
|
|||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64"
|
||||
|
|
|
|||
|
|
@ -19,3 +19,6 @@ Follow [`CONTRIBUTING.md`](CONTRIBUTING.md) for any git, branch, or release work
|
|||
|
||||
## Don't
|
||||
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||
- Don't flip bakery's default install prefix. System prefix (`/usr/local` via
|
||||
`/etc/bakery/config.toml` or `BAKERY_PREFIX`) is for BOS; hermes and
|
||||
`get.sh` stay on `~/.local`. See [`bakery/README.md`](bakery/README.md).
|
||||
|
|
|
|||
|
|
@ -3,10 +3,15 @@
|
|||
This repo is a Cargo workspace. Bakery-channel products shipped from here
|
||||
are `bakery` (the ecosystem package manager) and `bread-theme` (the shared
|
||||
theming crate). Shared crates that sibling apps pin — not bakery packages
|
||||
of their own — are `bread-utils`, `bread-onnx`, `bread-screenshots`, and
|
||||
`bread-capture`. 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`.
|
||||
of their own — are `bread-utils`, `bread-app`, `bread-onnx`,
|
||||
`bread-screenshots`, and `bread-capture`. `bread-polkit` is an in-tree
|
||||
session agent: it has `bread-polkit/bakery.toml` so it *can* be published,
|
||||
but it is not in `registry/bread-ecosystem.toml` (unpublished — not on
|
||||
the bakery index, not on the BOS ISO). Other ecosystem products
|
||||
(`bread`, `breadbar`, `breadbox`, …) live in their own repos under
|
||||
`Breadway/` but follow the same workflow described here. The product list
|
||||
is `registry/bread-ecosystem.toml`. New GTK tools should depend on
|
||||
`bread-app` instead of copying another app's bootstrap.
|
||||
|
||||
## Branches
|
||||
|
||||
|
|
@ -88,10 +93,12 @@ cargo build --release -p bakery
|
|||
cargo test --release -p bakery
|
||||
```
|
||||
|
||||
`bakery`, `bread-theme`, `bread-utils`, `bread-onnx`, `bread-screenshots`,
|
||||
and `bread-capture` are all workspace members. Run the same commands with
|
||||
`-p bread-theme --bin bread-theme` for that crate, or `-p bread-utils
|
||||
--features bread-client` for the IPC client.
|
||||
`bakery`, `bread-theme`, `bread-utils`, `bread-app`, `bread-polkit`,
|
||||
`bread-onnx`, `bread-screenshots`, and `bread-capture` are all workspace
|
||||
members. Run the same commands with `-p bread-theme --bin bread-theme`
|
||||
for that crate, `-p bread-utils --features bread-client` for the IPC
|
||||
client, or `-p bread-app --features bread-client` for the GTK bootstrap
|
||||
helpers.
|
||||
|
||||
## CI
|
||||
|
||||
|
|
|
|||
502
Cargo.lock
generated
502
Cargo.lock
generated
|
|
@ -96,6 +96,40 @@ version = "1.0.103"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
|
|
@ -104,14 +138,14 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
|||
|
||||
[[package]]
|
||||
name = "bakery"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bread-utils",
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"dirs",
|
||||
"dirs 5.0.1",
|
||||
"fs4",
|
||||
"hex",
|
||||
"minisign-verify",
|
||||
|
|
@ -120,6 +154,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"toml 0.8.23",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
|
|
@ -150,9 +185,16 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-app"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"bread-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-capture"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bread-utils",
|
||||
|
|
@ -162,7 +204,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "bread-onnx"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bread-utils",
|
||||
|
|
@ -175,9 +217,24 @@ dependencies = [
|
|||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-polkit"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bread-app",
|
||||
"bread-theme",
|
||||
"gtk4",
|
||||
"serde",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-screenshots"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bread-utils",
|
||||
|
|
@ -186,20 +243,21 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "bread-shared"
|
||||
version = "0.7.0"
|
||||
source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b"
|
||||
version = "0.8.0"
|
||||
source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0#cdd5de8f58e437b3fc6d9b9087eb7b3d0fd09704"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml 0.8.23",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-theme"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"dirs 5.0.1",
|
||||
"gtk4",
|
||||
"libadwaita",
|
||||
"serde",
|
||||
|
|
@ -208,10 +266,10 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "bread-utils"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"bread-shared",
|
||||
"dirs",
|
||||
"dirs 5.0.1",
|
||||
"gtk4",
|
||||
"gtk4-layer-shell",
|
||||
"serde",
|
||||
|
|
@ -238,6 +296,12 @@ version = "0.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.22.0"
|
||||
|
|
@ -349,7 +413,7 @@ dependencies = [
|
|||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -477,7 +541,7 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -488,7 +552,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
|||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -518,7 +582,7 @@ dependencies = [
|
|||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -528,7 +592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||
dependencies = [
|
||||
"derive_builder_core",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -547,7 +611,16 @@ version = "5.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[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]]
|
||||
|
|
@ -558,10 +631,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
|||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"redox_users 0.4.6",
|
||||
"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]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
|
|
@ -570,7 +655,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -585,6 +670,33 @@ version = "1.0.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
|
|
@ -610,6 +722,26 @@ dependencies = [
|
|||
"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]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
|
|
@ -708,6 +840,19 @@ version = "0.3.33"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.33"
|
||||
|
|
@ -716,7 +861,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -920,7 +1065,7 @@ dependencies = [
|
|||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1054,7 +1199,7 @@ dependencies = [
|
|||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1301,6 +1446,12 @@ version = "3.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libadwaita"
|
||||
version = "0.9.2"
|
||||
|
|
@ -1396,6 +1547,15 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.11"
|
||||
|
|
@ -1443,6 +1603,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "monostate"
|
||||
version = "0.1.18"
|
||||
|
|
@ -1462,7 +1633,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1567,6 +1738,16 @@ version = "0.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ort"
|
||||
version = "2.0.0-rc.12"
|
||||
|
|
@ -1609,6 +1790,12 @@ dependencies = [
|
|||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
|
|
@ -1801,6 +1988,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
|
|
@ -1959,7 +2157,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1975,6 +2173,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
|
|
@ -2004,12 +2213,31 @@ dependencies = [
|
|||
"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]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
|
|
@ -2028,6 +2256,16 @@ version = "1.15.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "spm_precompiled"
|
||||
version = "0.1.4"
|
||||
|
|
@ -2075,6 +2313,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
|
|
@ -2083,7 +2332,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2144,7 +2393,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2155,7 +2404,16 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2202,6 +2460,34 @@ dependencies = [
|
|||
"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]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
|
|
@ -2313,7 +2599,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2325,12 +2611,38 @@ dependencies = [
|
|||
"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]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
|
@ -2418,6 +2730,18 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
|
|
@ -2477,7 +2801,7 @@ dependencies = [
|
|||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
|
|
@ -2539,7 +2863,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2550,7 +2874,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2780,10 +3104,75 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
"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]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.54"
|
||||
|
|
@ -2801,7 +3190,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2821,7 +3210,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
|
|
@ -2861,7 +3250,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2869,3 +3258,44 @@ name = "zmij"
|
|||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
[workspace]
|
||||
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture"]
|
||||
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Breadway <plasticbread849@gmail.com>"]
|
||||
|
|
|
|||
59
README.md
59
README.md
|
|
@ -106,6 +106,26 @@ bakery remove <pkg> # remove a package (data files are never deleted)
|
|||
|
||||
`bakery install` runs `doctor` first and bails with a clear message if any system dependency is missing. Binaries land in `~/.local/bin` (override with `BAKERY_BIN_DIR`).
|
||||
|
||||
## System prefix (BOS)
|
||||
|
||||
Default install root is `~/.local`. BOS sets a system prefix so bakery-managed
|
||||
desktop apps live on the `@` root subvolume and ride along with
|
||||
snapper/grub-btrfs snapshots:
|
||||
|
||||
```toml
|
||||
# /etc/bakery/config.toml
|
||||
prefix = "/usr/local"
|
||||
```
|
||||
|
||||
`BAKERY_PREFIX` overrides the config file. A non-home prefix installs bins to
|
||||
`$prefix/bin`, share/data/desktop/licenses to `$prefix/share/...`, and systemd
|
||||
user units to `/usr/lib/systemd/user`. Per-user state (`installed.json`,
|
||||
update backups) stays in `~/.local/state/bakery`. Writes that need root use
|
||||
`sudo -n`, then `pkexec`. `bakery doctor` prints the active prefix.
|
||||
|
||||
Hermes and `get.sh` are unchanged — they keep the user-local default. See
|
||||
[`bakery/README.md`](bakery/README.md).
|
||||
|
||||
## System dependencies by product
|
||||
|
||||
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.
|
||||
|
|
@ -125,13 +145,15 @@ 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
|
||||
are `bakery` and `bread-theme`; the other members are shared crates sibling
|
||||
apps pin, not bakery packages of their own.
|
||||
apps pin, or in-tree tools that are not bakery packages of their own.
|
||||
|
||||
```
|
||||
bread-ecosystem/
|
||||
├── bakery/ # package manager binary
|
||||
├── bread-theme/ # shared pywal + fixed-dark-base theming crate
|
||||
├── bread-utils/ # shared plumbing (Hyprland IPC, singleton, XDG, BreadClient, …)
|
||||
├── bread-app/ # GTK bootstrap new tools should use (app id, singleton, overlay, command listen)
|
||||
├── bread-polkit/ # themed PolicyKit agent (bakery.toml present; unpublished)
|
||||
├── bread-onnx/ # shared ONNX runtime helpers
|
||||
├── bread-screenshots/ # grim capture primitive used by app `--screenshot` modes
|
||||
├── bread-capture/ # orchestrator that drives those `--screenshot` modes
|
||||
|
|
@ -142,6 +164,41 @@ bread-ecosystem/
|
|||
└── gen-readme-products.sh # rewrites the Products table from the registry
|
||||
```
|
||||
|
||||
### New GTK tools
|
||||
|
||||
Do not copy another app's `main.rs`. Depend on `bread-app`:
|
||||
|
||||
- `bread_app::application_id` / `try_acquire` / `toggle_or_kill` for the
|
||||
`com.breadway.*` application id and single-instance lock
|
||||
- feature `gtk` re-exports `bread_utils::gtk_popup` (layer-shell overlay)
|
||||
- feature `bread-client` for `listen_commands` on `bread.command.<app>.**`
|
||||
|
||||
See the `bread-app` crate docs. Existing apps are not migrated in this
|
||||
tree; `bread-polkit` is the first in-tree consumer.
|
||||
|
||||
### bread-polkit
|
||||
|
||||
A session PolicyKit authentication agent (password prompt, cancel,
|
||||
identity). Not a wrapper around `polkit-gnome`. `bread-polkit/bakery.toml`
|
||||
exists so it can be published via bakery; it is not in
|
||||
`registry/bread-ecosystem.toml` and is therefore unpublished — not on the
|
||||
bakery index and not on the BOS ISO lockfile.
|
||||
|
||||
```sh
|
||||
cargo run -p bread-polkit
|
||||
```
|
||||
|
||||
Autostart — pick one:
|
||||
|
||||
```sh
|
||||
cp bread-polkit/contrib/bread-polkit.desktop ~/.config/autostart/
|
||||
```
|
||||
|
||||
```
|
||||
# hyprland.conf
|
||||
exec-once = bread-polkit
|
||||
```
|
||||
|
||||
## Release pipeline
|
||||
|
||||
Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
|||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
ureq = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
|
|
|||
30
bakery/README.md
Normal file
30
bakery/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# bakery
|
||||
|
||||
Package manager for the bread ecosystem. Usage lives in the
|
||||
[repo README](../README.md).
|
||||
|
||||
## Install prefix
|
||||
|
||||
Default root is `~/.local` (bins in `~/.local/bin`, data in
|
||||
`~/.local/share`). That is the hermes / `get.sh` path and must stay the
|
||||
default.
|
||||
|
||||
BOS sets a system prefix so bakery-managed desktop apps live on the `@`
|
||||
root subvolume and are included in snapper/grub-btrfs snapshots:
|
||||
|
||||
```toml
|
||||
# /etc/bakery/config.toml
|
||||
prefix = "/usr/local"
|
||||
```
|
||||
|
||||
`BAKERY_PREFIX` overrides the config file. A non-home prefix installs:
|
||||
|
||||
| Thing | Path |
|
||||
|-------|------|
|
||||
| bins | `$prefix/bin` |
|
||||
| share / desktop / licenses / data | `$prefix/share/...` |
|
||||
| systemd user units | `/usr/lib/systemd/user` |
|
||||
|
||||
Per-user state (`installed.json` and pre-update backups) stays in
|
||||
`~/.local/state/bakery`. Writes that need root use `sudo -n`, then
|
||||
`pkexec`. `bakery doctor` prints the active prefix.
|
||||
|
|
@ -11,8 +11,16 @@ pub struct DepReport {
|
|||
|
||||
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> {
|
||||
Ok(DepReport {
|
||||
missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(),
|
||||
warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(),
|
||||
missing: required
|
||||
.iter()
|
||||
.filter(|d| !dep_present(d))
|
||||
.cloned()
|
||||
.collect(),
|
||||
warnings: optional
|
||||
.iter()
|
||||
.filter(|d| !dep_present(d))
|
||||
.cloned()
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -99,14 +107,24 @@ pub fn install_hint(missing: &[String]) -> String {
|
|||
|
||||
/// Print a formatted doctor report for a package's system deps.
|
||||
/// Returns true if all *required* deps are satisfied.
|
||||
pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool {
|
||||
pub fn report(
|
||||
package_name: &str,
|
||||
required: &[String],
|
||||
optional: &[String],
|
||||
name_width: usize,
|
||||
) -> bool {
|
||||
if required.is_empty() && optional.is_empty() {
|
||||
println!(" {}", ui::ok(&format!("{package_name}: no system deps required")));
|
||||
ui::check_row(true, package_name, name_width, "no system deps required");
|
||||
return true;
|
||||
}
|
||||
match check_deps(required, optional) {
|
||||
Err(e) => {
|
||||
eprintln!(" {}", ui::fail(&format!("error running doctor for {package_name}: {e}")));
|
||||
ui::check_row(
|
||||
false,
|
||||
package_name,
|
||||
name_width,
|
||||
&format!("error running doctor: {e}"),
|
||||
);
|
||||
false
|
||||
}
|
||||
Ok(rep) => {
|
||||
|
|
@ -123,17 +141,24 @@ pub fn report(package_name: &str, required: &[String], optional: &[String]) -> b
|
|||
);
|
||||
}
|
||||
if rep.missing.is_empty() {
|
||||
println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied")));
|
||||
ui::check_row(
|
||||
true,
|
||||
package_name,
|
||||
name_width,
|
||||
"all required system deps satisfied",
|
||||
);
|
||||
true
|
||||
} else {
|
||||
ui::check_row(
|
||||
false,
|
||||
package_name,
|
||||
name_width,
|
||||
&format!("missing: {}", rep.missing.join(", ")),
|
||||
);
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::fail(&format!(
|
||||
"{package_name}: missing system deps: {}",
|
||||
rep.missing.join(", ")
|
||||
))
|
||||
ui::dim(&format!("install with: {}", install_hint(&rep.missing)))
|
||||
);
|
||||
eprintln!(" install with: {}", install_hint(&rep.missing));
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
@ -187,22 +212,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn missing_required_dep_detected() {
|
||||
let rep = check_deps(
|
||||
&["this-package-does-not-exist-xyzzy42".to_string()],
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let rep = check_deps(&["this-package-does-not-exist-xyzzy42".to_string()], &[]).unwrap();
|
||||
assert_eq!(rep.missing.len(), 1);
|
||||
assert!(rep.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_optional_dep_becomes_warning_not_error() {
|
||||
let rep = check_deps(
|
||||
&[],
|
||||
&["this-package-does-not-exist-xyzzy42".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
let rep = check_deps(&[], &["this-package-does-not-exist-xyzzy42".to_string()]).unwrap();
|
||||
assert!(rep.missing.is_empty());
|
||||
assert_eq!(rep.warnings.len(), 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use sha2::{Digest, Sha256};
|
|||
use std::path::Path;
|
||||
|
||||
use crate::manifest::{fetch_binary, Binary};
|
||||
use crate::ui;
|
||||
|
||||
/// Download a binary, verify its SHA-256, then atomically write it into
|
||||
/// place (fsynced, temp-in-same-dir-with-unique-name then rename — see
|
||||
|
|
@ -12,16 +13,16 @@ use crate::manifest::{fetch_binary, Binary};
|
|||
/// bytes a second time — `verify_sha256` already confirmed `bytes` matches
|
||||
/// `binary.sha256`, so that's the value to return.
|
||||
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<String> {
|
||||
println!(" downloading {}…", binary.name);
|
||||
ui::step("downloading", &binary.name);
|
||||
let bytes = fetch_binary(&binary.dl_url, &binary.github_url)
|
||||
.with_context(|| format!("downloading {}", binary.name))?;
|
||||
|
||||
verify_sha256(&bytes, &binary.sha256)
|
||||
.with_context(|| format!("checksum mismatch for {}", binary.name))?;
|
||||
|
||||
bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755))
|
||||
crate::prefix::write_bytes(dest, &bytes, 0o755)
|
||||
.with_context(|| format!("placing binary at {}", dest.display()))?;
|
||||
println!(" installed {}", dest.display());
|
||||
ui::step("placed", &dest.display().to_string());
|
||||
Ok(binary.sha256.clone())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ use std::process::Command;
|
|||
|
||||
use crate::download::{fetch_and_place, verify_sha256};
|
||||
use crate::manifest::{fetch_binary, Package, Service};
|
||||
use crate::prefix::{self, Layout};
|
||||
use crate::state::{InstalledPackage, State};
|
||||
use crate::track::Track;
|
||||
use crate::ui;
|
||||
|
||||
/// Rejects a filename that isn't a safe single path component — no `/`,
|
||||
/// `\`, empty, `.`, or `..`. `bin.name`/`svc.unit`/`cfg.example`/`pkg.name`/
|
||||
|
|
@ -52,7 +54,7 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool {
|
|||
return false;
|
||||
}
|
||||
use std::io::Write;
|
||||
print!("{prompt} [y/N] ");
|
||||
print!("{prompt} {} ", ui::dim("[y/N]"));
|
||||
std::io::stdout().flush().ok();
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_line(&mut buf).ok();
|
||||
|
|
@ -67,25 +69,27 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool {
|
|||
/// install" for the pre-overwrite backup below.
|
||||
pub fn install_package(
|
||||
pkg: &Package,
|
||||
bin_dir: &Path,
|
||||
layout: &Layout,
|
||||
track: Track,
|
||||
previous: Option<&InstalledPackage>,
|
||||
no_hooks: bool,
|
||||
assume_yes: bool,
|
||||
) -> Result<()> {
|
||||
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
|
||||
// install), back up the current binary first — best-effort, feeding
|
||||
// `bakery rollback` — before it's overwritten below.
|
||||
// `bakery rollback` — before it's overwritten below. Backups stay
|
||||
// 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 mut binary_names = Vec::new();
|
||||
let mut binary_sha256 = HashMap::new();
|
||||
for bin in &pkg.binaries {
|
||||
ensure_safe_component(&bin.name, "binary name")?;
|
||||
let install_name = strip_arch_suffix(&bin.name);
|
||||
let dest = bin_dir.join(install_name);
|
||||
let dest = layout.bin_dir.join(install_name);
|
||||
if let Some(dir) = &backup_dir {
|
||||
backup_current_binary(dir, install_name, &dest);
|
||||
}
|
||||
|
|
@ -94,30 +98,32 @@ pub fn install_package(
|
|||
binary_sha256.insert(install_name.to_string(), sha256);
|
||||
}
|
||||
|
||||
// 2. Scaffold config dir + download example file.
|
||||
// 2. Scaffold config dir + download example file. Config stays
|
||||
// per-user (~/.config) regardless of prefix — it's authored content,
|
||||
// not bakery-placed bits.
|
||||
if let Some(cfg) = &pkg.config {
|
||||
scaffold_config(cfg, pkg)?;
|
||||
}
|
||||
|
||||
// 3. Install license file, if declared.
|
||||
if let Some(license) = &pkg.license_file {
|
||||
install_license(pkg, license)?;
|
||||
install_license(pkg, license, layout)?;
|
||||
}
|
||||
|
||||
// 4. Install desktop entry, if declared.
|
||||
if let Some(desktop) = &pkg.desktop_file {
|
||||
install_desktop_file(pkg, desktop)?;
|
||||
install_desktop_file(pkg, desktop, layout)?;
|
||||
}
|
||||
|
||||
// 5. Download + extract data archive, if declared.
|
||||
if let Some(archive) = &pkg.data_archive {
|
||||
install_data_archive(pkg, archive)?;
|
||||
install_data_archive(pkg, archive, layout)?;
|
||||
}
|
||||
|
||||
// 6. Install systemd user units.
|
||||
let mut service_names = Vec::new();
|
||||
for svc in &pkg.services {
|
||||
install_service(svc, bin_dir, pkg)?;
|
||||
install_service(svc, layout, pkg)?;
|
||||
service_names.push(svc.unit.clone());
|
||||
}
|
||||
|
||||
|
|
@ -126,10 +132,13 @@ pub fn install_package(
|
|||
// confirmation rather than running unconditionally.
|
||||
if !pkg.post_install.is_empty() {
|
||||
if no_hooks {
|
||||
println!(
|
||||
" note: skipped {} post_install hook(s) for {} (--no-hooks)",
|
||||
pkg.post_install.len(),
|
||||
pkg.name
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::note(&format!(
|
||||
"skipped {} post_install hook(s) for {} (--no-hooks)",
|
||||
pkg.post_install.len(),
|
||||
pkg.name
|
||||
))
|
||||
);
|
||||
} else if confirm(
|
||||
&format!(
|
||||
|
|
@ -143,7 +152,13 @@ pub fn install_package(
|
|||
run_hook(cmd, &pkg.name)?;
|
||||
}
|
||||
} else {
|
||||
println!(" skipped post_install hooks for {} (declined)", pkg.name);
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::note(&format!(
|
||||
"skipped post_install hooks for {} (declined)",
|
||||
pkg.name
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,8 +178,8 @@ pub fn install_package(
|
|||
Ok(())
|
||||
})?;
|
||||
|
||||
println!(" {} installed successfully", pkg.name);
|
||||
warn_path_if_needed(bin_dir);
|
||||
println!(" {}", ui::ok(&format!("{} installed", pkg.name)));
|
||||
warn_path_if_needed(&layout.bin_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -184,25 +199,39 @@ fn backup_current_binary(backup_dir: &Path, binary_name: &str, current_path: &Pa
|
|||
}
|
||||
if let Err(e) = std::fs::create_dir_all(backup_dir) {
|
||||
eprintln!(
|
||||
" warning: could not create backup dir {} ({e}) — rollback won't be available for this update",
|
||||
backup_dir.display()
|
||||
" {}",
|
||||
ui::warn(&format!(
|
||||
"could not create backup dir {} ({e}) — rollback won't be available for this update",
|
||||
backup_dir.display()
|
||||
))
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::copy(current_path, backup_dir.join(binary_name)) {
|
||||
eprintln!(" warning: could not back up {binary_name} before update ({e}) — rollback won't be available for this update");
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::warn(&format!(
|
||||
"could not back up {binary_name} before update ({e}) — rollback won't be available for this update"
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: bool) -> Result<()> {
|
||||
pub fn remove_package(
|
||||
pkg_name: &str,
|
||||
layout: &Layout,
|
||||
assume_yes: bool,
|
||||
purge: bool,
|
||||
) -> Result<()> {
|
||||
let installed = State::with_lock(|state| Ok(state.remove(pkg_name)))?;
|
||||
let installed = match installed {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("{pkg_name} is not installed");
|
||||
eprintln!(" {}", ui::fail(&format!("{pkg_name} is not installed")));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
ui::action("Removing", pkg_name, Some(&installed.version));
|
||||
// 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.
|
||||
|
||||
|
|
@ -211,10 +240,10 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
|
|||
// the config/data-preserved messages below.
|
||||
let mut failures = Vec::new();
|
||||
for bin in &installed.binaries {
|
||||
let path = bin_dir.join(bin);
|
||||
let path = layout.bin_dir.join(bin);
|
||||
if path.exists() {
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => println!(" removed {}", path.display()),
|
||||
match prefix::remove_file(&path) {
|
||||
Ok(()) => ui::step("removed", &path.display().to_string()),
|
||||
Err(e) => failures.push(format!("{}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
|
@ -222,7 +251,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
|
|||
|
||||
// Prompt for unit removal.
|
||||
if !installed.services.is_empty() {
|
||||
let service_dir = systemd_user_dir();
|
||||
let service_dir = &layout.systemd_user_dir;
|
||||
for unit in &installed.services {
|
||||
let unit_path = service_dir.join(unit);
|
||||
if confirm_remove_unit(unit, assume_yes) {
|
||||
|
|
@ -230,12 +259,12 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
|
|||
.args(["--user", "disable", "--now", unit])
|
||||
.status();
|
||||
if unit_path.exists() {
|
||||
std::fs::remove_file(&unit_path).ok();
|
||||
let _ = prefix::remove_file(&unit_path);
|
||||
}
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "daemon-reload"])
|
||||
.status();
|
||||
println!(" removed unit {unit}");
|
||||
ui::step("removed", &format!("unit {unit}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -247,34 +276,45 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
|
|||
// surprise no flag should cause.
|
||||
if let Some(cfg_dir) = guess_config_dir(pkg_name) {
|
||||
if cfg_dir.exists() {
|
||||
println!(" config preserved at {}", cfg_dir.display());
|
||||
ui::step("preserved", &format!("config {}", cfg_dir.display()));
|
||||
}
|
||||
}
|
||||
|
||||
let share_dir = dirs::data_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"));
|
||||
let share_dir = &layout.share_dir;
|
||||
let data_dir = share_dir.join(pkg_name);
|
||||
|
||||
if purge {
|
||||
let license_dir = share_dir.join("licenses").join(pkg_name);
|
||||
remove_purged_path(&license_dir, "license dir", true, assume_yes, &mut failures);
|
||||
|
||||
let desktop_file = share_dir.join("applications").join(format!("{pkg_name}.desktop"));
|
||||
remove_purged_path(&desktop_file, "desktop entry", false, assume_yes, &mut failures);
|
||||
let desktop_file = share_dir
|
||||
.join("applications")
|
||||
.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);
|
||||
} else if data_dir.exists() {
|
||||
println!(" data preserved at {}", data_dir.display());
|
||||
ui::step("preserved", &format!("data {}", data_dir.display()));
|
||||
}
|
||||
|
||||
if !failures.is_empty() {
|
||||
eprintln!(" failed to remove {} item(s):", failures.len());
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::fail(&format!("failed to remove {} item(s):", failures.len()))
|
||||
);
|
||||
for f in &failures {
|
||||
eprintln!(" {f}");
|
||||
}
|
||||
bail!("{pkg_name} removed from state, but some files could not be deleted");
|
||||
}
|
||||
|
||||
println!(" {pkg_name} removed");
|
||||
println!(" {}", ui::ok(&format!("{pkg_name} removed")));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -284,21 +324,30 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
|
|||
/// in place and prints the same "preserved at" wording the non-purge path
|
||||
/// already uses. Shared by `remove_package`'s three `--purge` targets
|
||||
/// (license dir, desktop entry, data dir).
|
||||
fn remove_purged_path(path: &Path, label: &str, recursive: bool, assume_yes: bool, failures: &mut Vec<String>) {
|
||||
fn remove_purged_path(
|
||||
path: &Path,
|
||||
label: &str,
|
||||
recursive: bool,
|
||||
assume_yes: bool,
|
||||
failures: &mut Vec<String>,
|
||||
) {
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
if !confirm(&format!(" remove {label} at {}?", path.display()), assume_yes) {
|
||||
println!(" {label} preserved at {}", path.display());
|
||||
if !confirm(
|
||||
&format!(" remove {label} at {}?", path.display()),
|
||||
assume_yes,
|
||||
) {
|
||||
ui::step("preserved", &format!("{label} {}", path.display()));
|
||||
return;
|
||||
}
|
||||
let result = if recursive {
|
||||
std::fs::remove_dir_all(path)
|
||||
prefix::remove_dir_all(path)
|
||||
} else {
|
||||
std::fs::remove_file(path)
|
||||
prefix::remove_file(path)
|
||||
};
|
||||
match result {
|
||||
Ok(()) => println!(" removed {}", path.display()),
|
||||
Ok(()) => ui::step("removed", &path.display().to_string()),
|
||||
Err(e) => failures.push(format!("{}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
|
@ -318,36 +367,48 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu
|
|||
Ok(()) => {
|
||||
std::fs::write(&dest, &bytes)
|
||||
.with_context(|| format!("writing {}", dest.display()))?;
|
||||
println!(" installed example config at {}", dest.display());
|
||||
ui::step("config", &dest.display().to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" warning: checksum mismatch for example config {example}: {e} — not installed"
|
||||
" {}",
|
||||
ui::warn(&format!(
|
||||
"checksum mismatch for example config {example}: {e} — not installed"
|
||||
))
|
||||
);
|
||||
println!(" config dir created at {}", dir.display());
|
||||
ui::step("config", &dir.display().to_string());
|
||||
}
|
||||
},
|
||||
None => {
|
||||
eprintln!(
|
||||
" warning: index.json has no sha256 for example config \
|
||||
{example} — refusing to install an unverified download"
|
||||
" {}",
|
||||
ui::warn(&format!(
|
||||
"index.json has no sha256 for example config \
|
||||
{example} — refusing to install an unverified download"
|
||||
))
|
||||
);
|
||||
println!(" config dir created at {}", dir.display());
|
||||
ui::step("config", &dir.display().to_string());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!(" warning: could not download example config {example}: {e}");
|
||||
println!(" config dir created at {}", dir.display());
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::warn(&format!("could not download example config {example}: {e}"))
|
||||
);
|
||||
ui::step("config", &dir.display().to_string());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" config dir created at {}", dir.display());
|
||||
ui::step("config", &dir.display().to_string());
|
||||
}
|
||||
} else {
|
||||
println!(" config at {} already exists, skipping", dest.display());
|
||||
ui::step(
|
||||
"config",
|
||||
&format!("{} already exists, skipping", dest.display()),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
println!(" config dir created at {}", dir.display());
|
||||
ui::step("config", &dir.display().to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -366,59 +427,75 @@ fn fetch_verify_write(
|
|||
label: &str,
|
||||
) -> Result<()> {
|
||||
let Some((primary, fallback)) = pkg.artifact_urls(filename) else {
|
||||
eprintln!(" warning: no artifact URL to download {label} ({filename})");
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::warn(&format!("no artifact URL to download {label} ({filename})"))
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
let bytes = match fetch_binary(&primary, &fallback) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!(" warning: could not download {label} {filename}: {e}");
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::warn(&format!("could not download {label} {filename}: {e}"))
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let Some(expected) = sha256 else {
|
||||
eprintln!(
|
||||
" warning: index.json has no sha256 for {label} {filename} — \
|
||||
refusing to install an unverified download"
|
||||
" {}",
|
||||
ui::warn(&format!(
|
||||
"index.json has no sha256 for {label} {filename} — \
|
||||
refusing to install an unverified download"
|
||||
))
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
if let Err(e) = verify_sha256(&bytes, expected) {
|
||||
eprintln!(" warning: checksum mismatch for {label} {filename}: {e} — not installed");
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::warn(&format!(
|
||||
"checksum mismatch for {label} {filename}: {e} — not installed"
|
||||
))
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(dest, &bytes).with_context(|| format!("writing {}", dest.display()))?;
|
||||
println!(" installed {label} at {}", dest.display());
|
||||
prefix::write_bytes(dest, &bytes, 0o644)
|
||||
.with_context(|| format!("writing {}", dest.display()))?;
|
||||
ui::step("installed", &format!("{label} {}", dest.display()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_license(pkg: &Package, filename: &str) -> Result<()> {
|
||||
fn install_license(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> {
|
||||
ensure_safe_component(filename, "license_file")?;
|
||||
let dest = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
let dest = layout
|
||||
.share_dir
|
||||
.join("licenses")
|
||||
.join(&pkg.name)
|
||||
.join("LICENSE");
|
||||
fetch_verify_write(pkg, filename, &pkg.license_file_sha256, &dest, "license")
|
||||
}
|
||||
|
||||
fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> {
|
||||
fn install_desktop_file(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> {
|
||||
ensure_safe_component(filename, "desktop_file")?;
|
||||
let dest = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
let dest = layout
|
||||
.share_dir
|
||||
.join("applications")
|
||||
.join(format!("{}.desktop", pkg.name));
|
||||
fetch_verify_write(pkg, filename, &pkg.desktop_file_sha256, &dest, "desktop entry")
|
||||
fetch_verify_write(
|
||||
pkg,
|
||||
filename,
|
||||
&pkg.desktop_file_sha256,
|
||||
&dest,
|
||||
"desktop entry",
|
||||
)
|
||||
}
|
||||
|
||||
fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> {
|
||||
fn install_data_archive(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> {
|
||||
ensure_safe_component(filename, "data_archive")?;
|
||||
let data_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join(&pkg.name);
|
||||
let data_dir = layout.share_dir.join(&pkg.name);
|
||||
fetch_extract_archive(pkg, filename, &pkg.data_archive_sha256, &data_dir)
|
||||
}
|
||||
|
||||
|
|
@ -427,7 +504,7 @@ fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> {
|
|||
/// crate dependency — `tar` is universally present on Linux and this file
|
||||
/// already shells out to `systemctl` for the same "trust the base system
|
||||
/// has this" reason. Split from `install_data_archive` (which just supplies
|
||||
/// the real `~/.local/share/<name>` destination) so tests can extract into
|
||||
/// the real `$prefix/share/<name>` destination) so tests can extract into
|
||||
/// a tempdir instead.
|
||||
fn fetch_extract_archive(
|
||||
pkg: &Package,
|
||||
|
|
@ -453,25 +530,19 @@ fn fetch_extract_archive(
|
|||
|
||||
verify_archive_paths(&tmp_archive)?;
|
||||
|
||||
std::fs::create_dir_all(dest_dir)?;
|
||||
let status = Command::new("tar")
|
||||
.args([
|
||||
"xzf",
|
||||
&tmp_archive.to_string_lossy(),
|
||||
"--no-same-owner",
|
||||
"--no-same-permissions",
|
||||
"-C",
|
||||
])
|
||||
.arg(dest_dir)
|
||||
.status()
|
||||
.with_context(|| format!("running tar to extract {filename}"))?;
|
||||
// `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}");
|
||||
match prefix::extract_tar_gz(&tmp_archive, dest_dir) {
|
||||
Ok(()) => ui::step(
|
||||
"extracted",
|
||||
&format!("{filename} → {}", dest_dir.display()),
|
||||
),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::warn(&format!("could not extract {filename}: {e}"))
|
||||
);
|
||||
}
|
||||
}
|
||||
// `tmp_archive` (a `TempPath` guard) deletes the file when it drops here.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -533,11 +604,11 @@ fn fetch_and_verify_unit(pkg: &Package, svc: &Service) -> Result<Vec<u8>> {
|
|||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
|
||||
fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()> {
|
||||
ensure_safe_component(&svc.unit, "service unit")?;
|
||||
|
||||
let service_dir = systemd_user_dir();
|
||||
std::fs::create_dir_all(&service_dir)?;
|
||||
let service_dir = &layout.systemd_user_dir;
|
||||
prefix::create_dir_all(service_dir)?;
|
||||
|
||||
let unit_path = service_dir.join(&svc.unit);
|
||||
let had_existing = unit_path.exists();
|
||||
|
|
@ -547,38 +618,47 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
|
|||
// applied after the first install, unlike binaries (which always
|
||||
// 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
|
||||
// regressing offline/flaky-network reliability.
|
||||
// regressing offline/flaky-network reliability. Patch ExecStart in
|
||||
// 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) {
|
||||
Ok(bytes) => {
|
||||
std::fs::write(&unit_path, &bytes)
|
||||
let text = String::from_utf8_lossy(&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()))?;
|
||||
println!(" downloaded unit {}", unit_path.display());
|
||||
ui::step("unit", &unit_path.display().to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
if had_existing {
|
||||
eprintln!(
|
||||
" warning: could not refresh unit {} ({e}) — keeping existing copy",
|
||||
svc.unit
|
||||
" {}",
|
||||
ui::warn(&format!(
|
||||
"could not refresh unit {} ({e}) — keeping existing copy",
|
||||
svc.unit
|
||||
))
|
||||
);
|
||||
patch_exec_start(&unit_path, &layout.bin_dir)?;
|
||||
} else {
|
||||
eprintln!(
|
||||
" warning: unit file {} not found ({e}) — skipping service setup",
|
||||
svc.unit
|
||||
" {}",
|
||||
ui::warn(&format!(
|
||||
"unit file {} not found ({e}) — skipping service setup",
|
||||
svc.unit
|
||||
))
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
patch_exec_start(&unit_path, bin_dir)?;
|
||||
|
||||
if !Command::new("systemctl")
|
||||
.args(["--user", "daemon-reload"])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
eprintln!(" warning: systemctl daemon-reload failed");
|
||||
eprintln!(" {}", ui::warn("systemctl daemon-reload failed"));
|
||||
}
|
||||
|
||||
if svc.enable {
|
||||
|
|
@ -595,9 +675,9 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
|
|||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
println!(" {} restarted", svc.unit);
|
||||
ui::step("restarted", &svc.unit);
|
||||
} else {
|
||||
eprintln!(" warning: failed to restart {}", svc.unit);
|
||||
eprintln!(" {}", ui::warn(&format!("failed to restart {}", svc.unit)));
|
||||
}
|
||||
} else if Command::new("systemctl")
|
||||
.args(["--user", "enable", "--now", &svc.unit])
|
||||
|
|
@ -605,9 +685,9 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
|
|||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
println!(" {} enabled and started", svc.unit);
|
||||
ui::step("enabled", &svc.unit);
|
||||
} else {
|
||||
eprintln!(" warning: failed to enable {}", svc.unit);
|
||||
eprintln!(" {}", ui::warn(&format!("failed to enable {}", svc.unit)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -616,6 +696,12 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
|
|||
|
||||
fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
|
||||
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
|
||||
.lines()
|
||||
.map(|line| {
|
||||
|
|
@ -639,24 +725,21 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
|
|||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
// Preserve trailing newline if the original had one.
|
||||
let output = if text.ends_with('\n') {
|
||||
if text.ends_with('\n') {
|
||||
format!("{patched}\n")
|
||||
} else {
|
||||
patched
|
||||
};
|
||||
std::fs::write(unit_path, output)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> {
|
||||
println!(" running post_install hook: {cmd}");
|
||||
ui::step("hook", cmd);
|
||||
let status = Command::new("sh")
|
||||
.args(["-c", cmd])
|
||||
.status()
|
||||
.with_context(|| format!("running post_install hook for {pkg_name}"))?;
|
||||
if !status.success() {
|
||||
eprintln!(" warning: hook exited with {status}");
|
||||
eprintln!(" {}", ui::warn(&format!("hook exited with {status}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -665,12 +748,6 @@ fn confirm_remove_unit(unit: &str, assume_yes: bool) -> bool {
|
|||
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> {
|
||||
Some(dirs::config_dir()?.join(pkg_name))
|
||||
}
|
||||
|
|
@ -699,11 +776,14 @@ fn warn_path_if_needed(bin_dir: &Path) {
|
|||
let path_var = std::env::var("PATH").unwrap_or_default();
|
||||
let bin_str = bin_dir.to_string_lossy();
|
||||
if !path_var.split(':').any(|p| p == bin_str) {
|
||||
println!(
|
||||
"\n note: {} is not in PATH — add to your shell profile:",
|
||||
bin_str
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::note(&format!(
|
||||
"{bin_str} is not in PATH — add to your shell profile:"
|
||||
))
|
||||
);
|
||||
println!(" export PATH=\"{}:$PATH\"", bin_str);
|
||||
println!(" export PATH=\"{bin_str}:$PATH\"");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -728,10 +808,7 @@ mod tests {
|
|||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
let mut buf = [0u8; 1024];
|
||||
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(body);
|
||||
}
|
||||
|
|
@ -748,10 +825,7 @@ mod tests {
|
|||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
let mut buf = [0u8; 1024];
|
||||
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(&body);
|
||||
}
|
||||
|
|
@ -797,8 +871,14 @@ mod tests {
|
|||
|
||||
let dir = tempdir().unwrap();
|
||||
let dest = dir.path().join("LICENSE");
|
||||
fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license")
|
||||
.unwrap();
|
||||
fetch_verify_write(
|
||||
&pkg,
|
||||
"LICENSE",
|
||||
&pkg.license_file_sha256.clone(),
|
||||
&dest,
|
||||
"license",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fs::read(&dest).unwrap(), license_bytes);
|
||||
}
|
||||
|
|
@ -836,8 +916,14 @@ mod tests {
|
|||
|
||||
let dir = tempdir().unwrap();
|
||||
let dest = dir.path().join("LICENSE");
|
||||
fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license")
|
||||
.unwrap();
|
||||
fetch_verify_write(
|
||||
&pkg,
|
||||
"LICENSE",
|
||||
&pkg.license_file_sha256.clone(),
|
||||
&dest,
|
||||
"license",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Refused, not erred (matches scaffold_config's warn-and-continue
|
||||
// posture) — the file must not have been written.
|
||||
|
|
@ -885,8 +971,7 @@ mod tests {
|
|||
let pkg = test_package(&base_url);
|
||||
|
||||
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");
|
||||
assert_eq!(fs::read(&extracted).unwrap(), b"[[step]]\n");
|
||||
|
|
@ -973,7 +1058,8 @@ mod tests {
|
|||
let mut pkg = test_package(&base_url);
|
||||
pkg.name = "../evil".to_string();
|
||||
let dir = tempdir().unwrap();
|
||||
let err = install_package(&pkg, dir.path(), Track::Stable, None, true, true).unwrap_err();
|
||||
let layout = Layout::from_prefix(dir.path(), None);
|
||||
let err = install_package(&pkg, &layout, Track::Stable, None, true, true).unwrap_err();
|
||||
assert!(err.to_string().contains("not a safe filename"));
|
||||
}
|
||||
|
||||
|
|
@ -1043,7 +1129,10 @@ mod tests {
|
|||
|
||||
backup_current_binary(&backup_dir, "mypkg", ¤t);
|
||||
|
||||
assert_eq!(fs::read(backup_dir.join("mypkg")).unwrap(), b"old version bytes");
|
||||
assert_eq!(
|
||||
fs::read(backup_dir.join("mypkg")).unwrap(),
|
||||
b"old version bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ mod doctor;
|
|||
mod download;
|
||||
mod install;
|
||||
mod manifest;
|
||||
mod prefix;
|
||||
mod state;
|
||||
mod track;
|
||||
mod ui;
|
||||
|
|
@ -14,7 +15,12 @@ use std::path::{Path, PathBuf};
|
|||
use track::Track;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "bakery", about = "Package manager for the bread ecosystem", version)]
|
||||
#[command(
|
||||
name = "bakery",
|
||||
about = "Package manager for the bread ecosystem",
|
||||
version,
|
||||
styles = ui::CLAP_STYLES
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Cmd,
|
||||
|
|
@ -43,7 +49,7 @@ enum Cmd {
|
|||
Remove {
|
||||
package: String,
|
||||
/// Also remove the license file, desktop entry, and data dir
|
||||
/// (~/.local/share/<pkg>/) — config is still preserved
|
||||
/// ($prefix/share/<pkg>/) — config is still preserved
|
||||
#[arg(long)]
|
||||
purge: bool,
|
||||
},
|
||||
|
|
@ -63,13 +69,9 @@ enum Cmd {
|
|||
installed: bool,
|
||||
},
|
||||
/// Show details for a package
|
||||
Info {
|
||||
package: String,
|
||||
},
|
||||
Info { package: String },
|
||||
/// Search package names and descriptions
|
||||
Search {
|
||||
query: String,
|
||||
},
|
||||
Search { query: String },
|
||||
/// Check system dependencies for installed or requested packages
|
||||
Doctor {
|
||||
/// Package to check; omit to check all installed packages
|
||||
|
|
@ -82,15 +84,11 @@ enum Cmd {
|
|||
},
|
||||
/// Roll back a package to its previously installed version, from a
|
||||
/// local pre-update backup (not a re-download)
|
||||
Rollback {
|
||||
package: String,
|
||||
},
|
||||
Rollback { package: String },
|
||||
/// Update bakery itself
|
||||
SelfUpdate,
|
||||
/// 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)
|
||||
Track {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -107,15 +105,9 @@ enum TrackCmd {
|
|||
Set { track: Track },
|
||||
}
|
||||
|
||||
fn default_bin_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~"))
|
||||
.join(".local/bin")
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir);
|
||||
let layout = prefix::resolve(cli.bin_dir);
|
||||
let no_hooks = cli.no_hooks;
|
||||
let assume_yes = cli.yes;
|
||||
let dry_run = cli.dry_run;
|
||||
|
|
@ -125,26 +117,40 @@ fn main() -> Result<()> {
|
|||
Cmd::Install { packages } => {
|
||||
let index = manifest::load(true, track)?;
|
||||
for pkg in &packages {
|
||||
cmd_install(&index, pkg, &bin_dir, track, no_hooks, assume_yes, dry_run)?;
|
||||
cmd_install(&index, pkg, &layout, track, no_hooks, assume_yes, dry_run)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Remove { package, purge } => cmd_remove(&package, &bin_dir, assume_yes, purge),
|
||||
Cmd::Update { package, all } => {
|
||||
cmd_update(package.as_deref(), all, &bin_dir, track, no_hooks, assume_yes, dry_run)
|
||||
}
|
||||
Cmd::Remove { package, purge } => cmd_remove(&package, &layout, assume_yes, purge),
|
||||
Cmd::Update { package, all } => cmd_update(
|
||||
package.as_deref(),
|
||||
all,
|
||||
&layout,
|
||||
track,
|
||||
no_hooks,
|
||||
assume_yes,
|
||||
dry_run,
|
||||
),
|
||||
Cmd::List { installed } => cmd_list(installed, track),
|
||||
Cmd::Info { package } => cmd_info(&package, track),
|
||||
Cmd::Search { query } => cmd_search(&query, track),
|
||||
Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &bin_dir),
|
||||
Cmd::Verify { package } => cmd_verify(package.as_deref(), &bin_dir),
|
||||
Cmd::Rollback { package } => cmd_rollback(&package, &bin_dir),
|
||||
Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &layout),
|
||||
Cmd::Verify { package } => cmd_verify(package.as_deref(), &layout.bin_dir),
|
||||
Cmd::Rollback { package } => cmd_rollback(&package, &layout.bin_dir),
|
||||
// Same update logic as `bakery update bakery` — this is just a
|
||||
// documented, discoverable entry point for it, since overwriting
|
||||
// bakery's own running binary via a normal update already works
|
||||
// (rename-over-running-binary is safe on Linux) but wasn't a real
|
||||
// first-class command.
|
||||
Cmd::SelfUpdate => cmd_update(Some("bakery"), false, &bin_dir, track, no_hooks, assume_yes, dry_run),
|
||||
Cmd::SelfUpdate => cmd_update(
|
||||
Some("bakery"),
|
||||
false,
|
||||
&layout,
|
||||
track,
|
||||
no_hooks,
|
||||
assume_yes,
|
||||
dry_run,
|
||||
),
|
||||
Cmd::Completions { shell } => cmd_completions(shell),
|
||||
Cmd::Track { action } => cmd_track(action),
|
||||
}
|
||||
|
|
@ -159,11 +165,11 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
|
|||
let state = state::State::load()?;
|
||||
match action {
|
||||
TrackCmd::Show => {
|
||||
println!("current track: {}", ui::style(state.track.as_str(), ui::CYAN));
|
||||
ui::heading("Track", &[&ui::style(state.track.as_str(), ui::CYAN)]);
|
||||
}
|
||||
TrackCmd::Set { track } => {
|
||||
if state.track == track {
|
||||
println!("already on track {track}");
|
||||
println!("{}", ui::unchanged(&format!("already on track {track}")));
|
||||
return Ok(());
|
||||
}
|
||||
// Fail fast on a bad/unreachable track rather than silently
|
||||
|
|
@ -174,10 +180,10 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
|
|||
state.set_track(track);
|
||||
Ok(())
|
||||
})?;
|
||||
println!(
|
||||
"switched to {} — run 'bakery update --all' to install {} builds",
|
||||
ui::style(track.as_str(), ui::CYAN),
|
||||
track
|
||||
ui::action("Switched", track.as_str(), None);
|
||||
ui::step(
|
||||
"next",
|
||||
&format!("bakery update --all to install {track} builds"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -188,14 +194,23 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
|
|||
fn cmd_install(
|
||||
index: &manifest::Index,
|
||||
name: &str,
|
||||
bin_dir: &std::path::Path,
|
||||
layout: &prefix::Layout,
|
||||
track: Track,
|
||||
no_hooks: bool,
|
||||
assume_yes: bool,
|
||||
dry_run: bool,
|
||||
) -> Result<()> {
|
||||
let mut visited = HashSet::new();
|
||||
install_with_deps(index, name, bin_dir, track, no_hooks, assume_yes, dry_run, &mut visited)
|
||||
install_with_deps(
|
||||
index,
|
||||
name,
|
||||
layout,
|
||||
track,
|
||||
no_hooks,
|
||||
assume_yes,
|
||||
dry_run,
|
||||
&mut visited,
|
||||
)
|
||||
}
|
||||
|
||||
/// Recursively installs `name` and any bread_deps, skipping already-installed
|
||||
|
|
@ -204,7 +219,7 @@ fn cmd_install(
|
|||
fn install_with_deps(
|
||||
index: &manifest::Index,
|
||||
name: &str,
|
||||
bin_dir: &std::path::Path,
|
||||
layout: &prefix::Layout,
|
||||
track: Track,
|
||||
no_hooks: bool,
|
||||
assume_yes: bool,
|
||||
|
|
@ -223,8 +238,10 @@ fn install_with_deps(
|
|||
let state = state::State::load()?;
|
||||
for dep in pkg.bread_deps.clone() {
|
||||
if !state.is_installed(&dep) {
|
||||
println!("{} bread dependency: {dep}", if dry_run { "would install" } else { "installing" });
|
||||
install_with_deps(index, &dep, bin_dir, track, no_hooks, assume_yes, dry_run, visited)?;
|
||||
ui::step(if dry_run { "would need" } else { "dependency" }, &dep);
|
||||
install_with_deps(
|
||||
index, &dep, layout, track, no_hooks, assume_yes, dry_run, visited,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -237,30 +254,63 @@ fn install_with_deps(
|
|||
if let Some(installed) = previous {
|
||||
if !is_newer(&installed.version, &pkg.version) {
|
||||
println!(
|
||||
"{name} already installed at {} (index has {})",
|
||||
installed.version, pkg.version
|
||||
" {}",
|
||||
ui::unchanged(&format!(
|
||||
"{name} already at {} (index has {})",
|
||||
installed.version, pkg.version
|
||||
))
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
println!("checking system dependencies for {name}…");
|
||||
ui::action(
|
||||
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)?;
|
||||
for warn in &rep.warnings {
|
||||
eprintln!(" note: optional dep not installed: {warn}");
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::note(&format!("optional dep not installed: {warn}"))
|
||||
);
|
||||
}
|
||||
if !rep.missing.is_empty() {
|
||||
eprintln!("missing system deps for {name}: {}", rep.missing.join(", "));
|
||||
eprintln!("install with: {}", doctor::install_hint(&rep.missing));
|
||||
eprintln!(
|
||||
" {}",
|
||||
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");
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
print_dry_run_plan(pkg, previous);
|
||||
print_dry_run_plan(pkg);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
install::install_package(pkg, bin_dir, track, previous, no_hooks, assume_yes)
|
||||
install::install_package(pkg, layout, track, previous, no_hooks, assume_yes)
|
||||
}
|
||||
|
||||
/// Prints what `install_with_deps`/`cmd_update` would do for `pkg` under
|
||||
|
|
@ -268,35 +318,36 @@ fn install_with_deps(
|
|||
/// 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
|
||||
/// update happen at all".
|
||||
fn print_dry_run_plan(pkg: &manifest::Package, previous: Option<&state::InstalledPackage>) {
|
||||
let verb = if previous.is_some() { "update" } else { "install" };
|
||||
println!(
|
||||
" {} would {verb} {} to {}",
|
||||
ui::style("dry-run:", ui::DIM),
|
||||
pkg.name,
|
||||
ui::style(&pkg.version, ui::BOLD)
|
||||
);
|
||||
println!(
|
||||
" binaries: {}",
|
||||
pkg.binaries.iter().map(|b| b.name.as_str()).collect::<Vec<_>>().join(", ")
|
||||
fn print_dry_run_plan(pkg: &manifest::Package) {
|
||||
ui::kv(
|
||||
"binaries",
|
||||
&pkg.binaries
|
||||
.iter()
|
||||
.map(|b| b.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
);
|
||||
if !pkg.services.is_empty() {
|
||||
println!(
|
||||
" services: {}",
|
||||
pkg.services.iter().map(|s| s.unit.as_str()).collect::<Vec<_>>().join(", ")
|
||||
ui::kv(
|
||||
"services",
|
||||
&pkg.services
|
||||
.iter()
|
||||
.map(|s| s.unit.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_remove(name: &str, bin_dir: &std::path::Path, assume_yes: bool, purge: bool) -> Result<()> {
|
||||
install::remove_package(name, bin_dir, assume_yes, purge)
|
||||
fn cmd_remove(name: &str, layout: &prefix::Layout, assume_yes: bool, purge: bool) -> Result<()> {
|
||||
install::remove_package(name, layout, assume_yes, purge)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn cmd_update(
|
||||
name: Option<&str>,
|
||||
all: bool,
|
||||
bin_dir: &std::path::Path,
|
||||
layout: &prefix::Layout,
|
||||
track: Track,
|
||||
no_hooks: bool,
|
||||
assume_yes: bool,
|
||||
|
|
@ -311,10 +362,16 @@ fn cmd_update(
|
|||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
println!("no packages installed");
|
||||
println!("{}", ui::dim("no packages installed"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut targets = targets;
|
||||
targets.sort();
|
||||
if all {
|
||||
ui::heading("Update", &[&format!("{} packages", targets.len())]);
|
||||
}
|
||||
|
||||
let mut any_failed = false;
|
||||
let mut updated = 0u32;
|
||||
let mut unchanged = 0u32;
|
||||
|
|
@ -322,7 +379,10 @@ fn cmd_update(
|
|||
let installed = match state.packages.get(pkg_name.as_str()) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("{pkg_name} is not installed, skipping");
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::fail(&format!("{pkg_name} is not installed, skipping"))
|
||||
);
|
||||
any_failed = true;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -330,7 +390,10 @@ fn cmd_update(
|
|||
let latest = match index.get(pkg_name) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("{pkg_name} not found in index, skipping");
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::fail(&format!("{pkg_name} not found in index, skipping"))
|
||||
);
|
||||
any_failed = true;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -350,57 +413,81 @@ fn cmd_update(
|
|||
// under a terminal palette that maps ANSI colors unusually.
|
||||
println!(
|
||||
" {}",
|
||||
ui::unchanged(&format!("{pkg_name} is already at {}", installed.version))
|
||||
ui::unchanged(&format!("{pkg_name} already at {}", installed.version))
|
||||
);
|
||||
unchanged += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
ui::action(
|
||||
if dry_run { "Would update" } else { "Updating" },
|
||||
pkg_name,
|
||||
Some(&latest.version),
|
||||
);
|
||||
if track_switch {
|
||||
println!(
|
||||
"{pkg_name} switching track {} {} {}, installing {}",
|
||||
ui::style(installed.track.as_str(), ui::DIM),
|
||||
ui::style("→", ui::CYAN),
|
||||
ui::style(track.as_str(), ui::BOLD),
|
||||
ui::style(&latest.version, ui::BOLD)
|
||||
ui::step(
|
||||
"track",
|
||||
&format!(
|
||||
"{} {} {}",
|
||||
ui::dim(installed.track.as_str()),
|
||||
ui::style("→", ui::CYAN),
|
||||
ui::bold(track.as_str()),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"updating {pkg_name} {} {} {}",
|
||||
ui::style(&installed.version, ui::DIM),
|
||||
ui::style("→", ui::CYAN),
|
||||
ui::style(&latest.version, ui::BOLD)
|
||||
ui::step(
|
||||
"version",
|
||||
&format!(
|
||||
"{} {} {}",
|
||||
ui::dim(&installed.version),
|
||||
ui::style("→", ui::CYAN),
|
||||
ui::bold(&latest.version)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!(" doctor check failed for {pkg_name}: {e}");
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::fail(&format!("doctor check failed for {pkg_name}: {e}"))
|
||||
);
|
||||
any_failed = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for warn in &rep.warnings {
|
||||
eprintln!(" note: optional dep not installed: {warn}");
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::note(&format!("optional dep not installed: {warn}"))
|
||||
);
|
||||
}
|
||||
if !rep.missing.is_empty() {
|
||||
eprintln!(
|
||||
" missing deps for {pkg_name}: {} — skipping update",
|
||||
rep.missing.join(", ")
|
||||
" {}",
|
||||
ui::fail(&format!(
|
||||
"missing deps for {pkg_name}: {} — skipping update",
|
||||
rep.missing.join(", ")
|
||||
))
|
||||
);
|
||||
any_failed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
print_dry_run_plan(latest, Some(installed));
|
||||
print_dry_run_plan(latest);
|
||||
updated += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = install::install_package(latest, bin_dir, track, Some(installed), no_hooks, assume_yes) {
|
||||
eprintln!(" failed to update {pkg_name}: {e}");
|
||||
if let Err(e) =
|
||||
install::install_package(latest, layout, track, Some(installed), no_hooks, assume_yes)
|
||||
{
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::fail(&format!("failed to update {pkg_name}: {e}"))
|
||||
);
|
||||
any_failed = true;
|
||||
} else {
|
||||
updated += 1;
|
||||
|
|
@ -410,12 +497,14 @@ fn cmd_update(
|
|||
// 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.
|
||||
if all {
|
||||
let mut parts = Vec::new();
|
||||
let updated_s = format!("{updated} updated");
|
||||
let unchanged_s = format!("{unchanged} already current");
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
if updated > 0 {
|
||||
parts.push(format!("{updated} updated"));
|
||||
parts.push(&updated_s);
|
||||
}
|
||||
parts.push(format!("{unchanged} already up to date"));
|
||||
println!("{}", ui::style(&parts.join(", "), ui::BOLD));
|
||||
parts.push(&unchanged_s);
|
||||
ui::summary(&parts);
|
||||
}
|
||||
|
||||
if any_failed {
|
||||
|
|
@ -427,7 +516,12 @@ fn cmd_update(
|
|||
/// Whether `pkg_name` should be updated: always true on a track switch
|
||||
/// (an explicit user action that must take effect regardless of version
|
||||
/// ordering), otherwise a real semver comparison via [`is_newer`].
|
||||
fn should_update(installed_version: &str, installed_track: Track, active_track: Track, latest_version: &str) -> bool {
|
||||
fn should_update(
|
||||
installed_version: &str,
|
||||
installed_track: Track,
|
||||
active_track: Track,
|
||||
latest_version: &str,
|
||||
) -> bool {
|
||||
if installed_track != active_track {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -441,7 +535,10 @@ fn should_update(installed_version: &str, installed_track: Track, active_track:
|
|||
/// (with a warning) for any version string that isn't valid semver, rather
|
||||
/// than hard-erroring on packages built before this convention existed.
|
||||
fn is_newer(installed: &str, latest: &str) -> bool {
|
||||
match (semver::Version::parse(installed), semver::Version::parse(latest)) {
|
||||
match (
|
||||
semver::Version::parse(installed),
|
||||
semver::Version::parse(latest),
|
||||
) {
|
||||
(Ok(i), Ok(l)) => l > i,
|
||||
_ => {
|
||||
if installed != latest {
|
||||
|
|
@ -455,15 +552,14 @@ fn is_newer(installed: &str, latest: &str) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Prints one index entry in the shared `list`/`search` format: name,
|
||||
/// version, description, and an `[installed <version>]` tag when applicable.
|
||||
fn print_index_entry(pkg: &manifest::Package, state: &state::State) {
|
||||
let tag = if state.is_installed(&pkg.name) {
|
||||
ui::style(&format!(" [installed {}]", state.packages[&pkg.name].version), ui::GREEN)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
println!(" {:<14} {:<10} — {}{}", pkg.name, pkg.version, pkg.description, tag);
|
||||
fn catalog_row(pkg: &manifest::Package, state: &state::State) -> ui::CatalogRow {
|
||||
ui::CatalogRow {
|
||||
name: pkg.name.clone(),
|
||||
version: pkg.version.clone(),
|
||||
installed: state.is_installed(&pkg.name),
|
||||
detail: pkg.description.clone(),
|
||||
aside: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
|
||||
|
|
@ -471,24 +567,44 @@ fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
|
|||
|
||||
if installed_only {
|
||||
if state.packages.is_empty() {
|
||||
println!("no packages installed");
|
||||
}
|
||||
for pkg in state.packages.values() {
|
||||
println!(" {} {} (installed {})", pkg.name, pkg.version, pkg.installed_at);
|
||||
println!("{}", ui::dim("no packages installed"));
|
||||
return Ok(());
|
||||
}
|
||||
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(());
|
||||
}
|
||||
|
||||
if !matches!(track, Track::Stable) {
|
||||
println!("tracking:{}\n", ui::track_badge(track));
|
||||
}
|
||||
|
||||
let index = manifest::load(false, track)?;
|
||||
let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
print_index_entry(&index.packages[name], &state);
|
||||
}
|
||||
let installed = names.iter().filter(|n| state.is_installed(n)).count();
|
||||
let track_part = ui::track_tag(track);
|
||||
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(())
|
||||
}
|
||||
|
||||
|
|
@ -513,13 +629,19 @@ fn cmd_search(query: &str, track: Track) -> Result<()> {
|
|||
names.sort();
|
||||
|
||||
if names.is_empty() {
|
||||
println!("no packages matched '{query}'");
|
||||
println!("{}", ui::dim(&format!("no packages matched '{query}'")));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for name in names {
|
||||
print_index_entry(&index.packages[name], &state);
|
||||
}
|
||||
ui::heading(
|
||||
"Search",
|
||||
&[&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(())
|
||||
}
|
||||
|
||||
|
|
@ -531,45 +653,60 @@ fn cmd_info(name: &str, track: Track) -> Result<()> {
|
|||
|
||||
let state = state::State::load()?;
|
||||
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 {
|
||||
ui::style("not installed", ui::DIM)
|
||||
};
|
||||
|
||||
println!("{}{} {}", ui::style(&pkg.name, ui::BOLD), ui::track_badge(track), pkg.version);
|
||||
println!(" {}", pkg.description);
|
||||
println!(" status: {status}");
|
||||
println!(
|
||||
" binaries: {}",
|
||||
pkg.binaries
|
||||
let track_part = ui::track_tag(track);
|
||||
ui::heading(&pkg.name, &[&ui::dim(&pkg.version), &track_part]);
|
||||
println!(" {}\n", pkg.description);
|
||||
ui::kv("status", &status);
|
||||
ui::kv(
|
||||
"binaries",
|
||||
&pkg.binaries
|
||||
.iter()
|
||||
.map(|b| b.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
.join(", "),
|
||||
);
|
||||
if !pkg.system_deps.is_empty() {
|
||||
println!(" system deps: {}", pkg.system_deps.join(", "));
|
||||
ui::kv("system deps", &pkg.system_deps.join(", "));
|
||||
}
|
||||
if !pkg.optional_system_deps.is_empty() {
|
||||
println!(" optional deps: {}", pkg.optional_system_deps.join(", "));
|
||||
ui::kv("optional", &pkg.optional_system_deps.join(", "));
|
||||
}
|
||||
if !pkg.bread_deps.is_empty() {
|
||||
println!(" bread deps: {}", pkg.bread_deps.join(", "));
|
||||
ui::kv("bread deps", &pkg.bread_deps.join(", "));
|
||||
}
|
||||
if !pkg.services.is_empty() {
|
||||
println!(
|
||||
" services: {}",
|
||||
pkg.services
|
||||
ui::kv(
|
||||
"services",
|
||||
&pkg.services
|
||||
.iter()
|
||||
.map(|s| s.unit.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
.join(", "),
|
||||
);
|
||||
}
|
||||
if let Some(inst) = state.packages.get(name) {
|
||||
ui::kv("installed", &ui::short_date(&inst.installed_at));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Result<()> {
|
||||
fn report_layout(layout: &prefix::Layout) {
|
||||
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 state = state::State::load()?;
|
||||
|
||||
|
|
@ -584,18 +721,38 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re
|
|||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
println!("no packages installed — nothing to check");
|
||||
ui::heading("Doctor", &["no packages"]);
|
||||
report_layout(layout);
|
||||
println!();
|
||||
println!("{}", ui::dim("no packages installed — nothing to check"));
|
||||
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;
|
||||
for pkg_name in &targets {
|
||||
if let Some(pkg) = index.get(pkg_name) {
|
||||
if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) {
|
||||
if !doctor::report(
|
||||
pkg_name,
|
||||
&pkg.system_deps,
|
||||
&pkg.optional_system_deps,
|
||||
name_w,
|
||||
) {
|
||||
all_ok = false;
|
||||
}
|
||||
} else {
|
||||
eprintln!(" {pkg_name}: not found in index (removed from registry?)");
|
||||
ui::check_row(
|
||||
false,
|
||||
pkg_name,
|
||||
name_w,
|
||||
"not found in index (removed from registry?)",
|
||||
);
|
||||
all_ok = false;
|
||||
}
|
||||
|
||||
|
|
@ -605,7 +762,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re
|
|||
// only, not a checksum re-verification — see `bakery verify` for that.
|
||||
if let Some(installed) = state.packages.get(pkg_name) {
|
||||
for bin in &installed.binaries {
|
||||
let path = bin_dir.join(bin);
|
||||
let path = layout.bin_dir.join(bin);
|
||||
if !path.exists() {
|
||||
eprintln!(
|
||||
" {}",
|
||||
|
|
@ -621,7 +778,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re
|
|||
}
|
||||
|
||||
if all_ok {
|
||||
println!("{}", ui::ok("all checks passed"));
|
||||
ui::summary(&[&ui::ok("all checks passed")]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -674,38 +831,47 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
|||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
println!("no packages installed — nothing to verify");
|
||||
println!("{}", ui::dim("no packages installed — nothing to verify"));
|
||||
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;
|
||||
for pkg_name in &targets {
|
||||
let installed = &state.packages[pkg_name];
|
||||
if installed.binary_sha256.is_empty() {
|
||||
println!(
|
||||
" {} {pkg_name}: no recorded checksums (installed before 'bakery verify' support)",
|
||||
ui::style("?", ui::DIM)
|
||||
ui::unknown_row(
|
||||
pkg_name,
|
||||
name_w,
|
||||
"no recorded checksums (installed before verify support)",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for bin in &installed.binaries {
|
||||
match verify_binary(bin_dir, bin, installed.binary_sha256.get(bin)) {
|
||||
VerifyStatus::Ok => println!(" {}", ui::ok(&format!("{pkg_name}: {bin}"))),
|
||||
VerifyStatus::Ok => ui::check_row(true, pkg_name, name_w, bin),
|
||||
VerifyStatus::Missing => {
|
||||
eprintln!(" {}", ui::fail(&format!("{pkg_name}: {bin} — MISSING")));
|
||||
ui::check_row(false, pkg_name, name_w, &format!("{bin} missing"));
|
||||
any_bad = true;
|
||||
}
|
||||
VerifyStatus::Tampered => {
|
||||
eprintln!(
|
||||
" {}",
|
||||
ui::fail(&format!("{pkg_name}: {bin} — TAMPERED (checksum mismatch)"))
|
||||
ui::check_row(
|
||||
false,
|
||||
pkg_name,
|
||||
name_w,
|
||||
&format!("{bin} tampered (checksum mismatch)"),
|
||||
);
|
||||
any_bad = true;
|
||||
}
|
||||
VerifyStatus::Unknown => {
|
||||
println!(
|
||||
" {} {pkg_name}: {bin} — UNKNOWN (no recorded checksum for this binary)",
|
||||
ui::style("?", ui::DIM)
|
||||
ui::unknown_row(
|
||||
pkg_name,
|
||||
name_w,
|
||||
&format!("{bin} no recorded checksum for this binary"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -715,7 +881,7 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
|||
if any_bad {
|
||||
bail!("verification failed for one or more binaries");
|
||||
}
|
||||
println!("{}", ui::ok("all recorded checksums match"));
|
||||
ui::summary(&[&ui::ok("all recorded checksums match")]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -728,18 +894,25 @@ 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
|
||||
/// place. Pure with respect to global state (caller supplies both dirs), so
|
||||
/// this is the piece of `bakery rollback` that's directly unit-testable.
|
||||
fn restore_binaries(backup_dir: &Path, binaries: &[String], bin_dir: &Path) -> Result<HashMap<String, String>> {
|
||||
fn restore_binaries(
|
||||
backup_dir: &Path,
|
||||
binaries: &[String],
|
||||
bin_dir: &Path,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let mut sha256 = HashMap::new();
|
||||
for bin in binaries {
|
||||
let backup_path = backup_dir.join(bin);
|
||||
if !backup_path.exists() {
|
||||
bail!("backup for binary '{bin}' is missing at {}", backup_path.display());
|
||||
bail!(
|
||||
"backup for binary '{bin}' is missing at {}",
|
||||
backup_path.display()
|
||||
);
|
||||
}
|
||||
let bytes = std::fs::read(&backup_path)
|
||||
.with_context(|| format!("reading backup {}", backup_path.display()))?;
|
||||
let hash = hex::encode(Sha256::digest(&bytes));
|
||||
let dest = bin_dir.join(bin);
|
||||
bread_utils::atomic::write_atomic_bytes(&dest, &bytes, Some(0o755))
|
||||
prefix::write_bytes(&dest, &bytes, 0o755)
|
||||
.with_context(|| format!("restoring {}", dest.display()))?;
|
||||
sha256.insert(bin.clone(), hash);
|
||||
}
|
||||
|
|
@ -767,6 +940,8 @@ 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")
|
||||
})?;
|
||||
|
||||
ui::action("Rolling back", pkg_name, Some(&target_version));
|
||||
|
||||
let backup_dir = state::backup_dir(pkg_name, &target_version);
|
||||
if !backup_dir.exists() {
|
||||
bail!(
|
||||
|
|
@ -794,7 +969,10 @@ fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
|||
|
||||
println!(
|
||||
" {}",
|
||||
ui::ok(&format!("rolled back {pkg_name} {from_version} → {target_version}"))
|
||||
ui::ok(&format!(
|
||||
"rolled back {pkg_name} {} → {target_version}",
|
||||
ui::dim(&from_version)
|
||||
))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -838,18 +1016,33 @@ mod tests {
|
|||
fn should_update_true_on_track_switch_even_if_not_newer_by_semver() {
|
||||
// "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.
|
||||
assert!(should_update("0.4.0-beta", Track::Beta, Track::Stable, "0.3.0"));
|
||||
assert!(should_update(
|
||||
"0.4.0-beta",
|
||||
Track::Beta,
|
||||
Track::Stable,
|
||||
"0.3.0"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_update_false_when_same_track_and_not_newer() {
|
||||
assert!(!should_update("0.3.1", Track::Stable, Track::Stable, "0.3.1"));
|
||||
assert!(!should_update(
|
||||
"0.3.1",
|
||||
Track::Stable,
|
||||
Track::Stable,
|
||||
"0.3.1"
|
||||
));
|
||||
assert!(!should_update("0.3.2", Track::Dev, Track::Dev, "0.3.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_update_true_when_same_track_and_newer() {
|
||||
assert!(should_update("0.3.1", Track::Stable, Track::Stable, "0.3.2"));
|
||||
assert!(should_update(
|
||||
"0.3.1",
|
||||
Track::Stable,
|
||||
Track::Stable,
|
||||
"0.3.2"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -902,14 +1095,18 @@ mod tests {
|
|||
let pkg = empty_binary_package(name, "9.9.9", "http://127.0.0.1:1/unreachable");
|
||||
let mut packages = std::collections::HashMap::new();
|
||||
packages.insert(name.to_string(), pkg);
|
||||
let index = manifest::Index { version: "1".to_string(), packages };
|
||||
let index = manifest::Index {
|
||||
version: "1".to_string(),
|
||||
packages,
|
||||
};
|
||||
|
||||
let bin_dir = tempdir().unwrap();
|
||||
let layout = prefix::Layout::from_prefix(bin_dir.path(), None);
|
||||
let mut visited = HashSet::new();
|
||||
install_with_deps(
|
||||
&index,
|
||||
name,
|
||||
bin_dir.path(),
|
||||
&layout,
|
||||
Track::Stable,
|
||||
true,
|
||||
true,
|
||||
|
|
@ -926,7 +1123,10 @@ mod tests {
|
|||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join("mypkg"), b"good bytes").unwrap();
|
||||
let hash = hex::encode(Sha256::digest(b"good bytes"));
|
||||
assert_eq!(verify_binary(dir.path(), "mypkg", Some(&hash)), VerifyStatus::Ok);
|
||||
assert_eq!(
|
||||
verify_binary(dir.path(), "mypkg", Some(&hash)),
|
||||
VerifyStatus::Ok
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -934,21 +1134,30 @@ mod tests {
|
|||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join("mypkg"), b"tampered bytes").unwrap();
|
||||
let wrong_hash = "0".repeat(64);
|
||||
assert_eq!(verify_binary(dir.path(), "mypkg", Some(&wrong_hash)), VerifyStatus::Tampered);
|
||||
assert_eq!(
|
||||
verify_binary(dir.path(), "mypkg", Some(&wrong_hash)),
|
||||
VerifyStatus::Tampered
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_binary_missing_when_file_absent() {
|
||||
let dir = tempdir().unwrap();
|
||||
let hash = "0".repeat(64);
|
||||
assert_eq!(verify_binary(dir.path(), "nope", Some(&hash)), VerifyStatus::Missing);
|
||||
assert_eq!(
|
||||
verify_binary(dir.path(), "nope", Some(&hash)),
|
||||
VerifyStatus::Missing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_binary_unknown_when_no_recorded_hash() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join("mypkg"), b"bytes").unwrap();
|
||||
assert_eq!(verify_binary(dir.path(), "mypkg", None), VerifyStatus::Unknown);
|
||||
assert_eq!(
|
||||
verify_binary(dir.path(), "mypkg", None),
|
||||
VerifyStatus::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -962,8 +1171,14 @@ mod tests {
|
|||
|
||||
let hashes = restore_binaries(&backup_dir, &["mypkg".to_string()], &bin_dir).unwrap();
|
||||
|
||||
assert_eq!(fs::read(bin_dir.join("mypkg")).unwrap(), b"old version bytes");
|
||||
assert_eq!(hashes["mypkg"], hex::encode(Sha256::digest(b"old version bytes")));
|
||||
assert_eq!(
|
||||
fs::read(bin_dir.join("mypkg")).unwrap(),
|
||||
b"old version bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
hashes["mypkg"],
|
||||
hex::encode(Sha256::digest(b"old version bytes"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -52,8 +52,7 @@ fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> {
|
|||
/// exercise the verification logic with a throwaway keypair instead of the
|
||||
/// real production key.
|
||||
fn verify_against_key(bytes: &[u8], sig_text: &str, pubkey_b64: &str) -> Result<()> {
|
||||
let public_key =
|
||||
PublicKey::from_base64(pubkey_b64).context("public key is malformed")?;
|
||||
let public_key = PublicKey::from_base64(pubkey_b64).context("public key is malformed")?;
|
||||
let signature =
|
||||
Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?;
|
||||
public_key
|
||||
|
|
@ -108,21 +107,21 @@ pub struct Package {
|
|||
#[serde(default)]
|
||||
pub post_install: Vec<String>,
|
||||
/// License artifact filename (e.g. "LICENSE"), installed to
|
||||
/// `~/.local/share/licenses/<name>/LICENSE` — the bakery equivalent of
|
||||
/// what a PKGBUILD's `package()` does with `/usr/share/licenses`.
|
||||
/// `$prefix/share/licenses/<name>/LICENSE` (`~/.local/share/...` by
|
||||
/// default) — the bakery equivalent of a PKGBUILD's `package()` step.
|
||||
#[serde(default)]
|
||||
pub license_file: Option<String>,
|
||||
#[serde(default)]
|
||||
pub license_file_sha256: Option<String>,
|
||||
/// Desktop entry artifact filename (e.g. "breadhelp.desktop"),
|
||||
/// installed to `~/.local/share/applications/<name>.desktop` so the
|
||||
/// app shows up in any XDG-compliant launcher without root.
|
||||
/// installed to `$prefix/share/applications/<name>.desktop` so the
|
||||
/// app shows up in any XDG-compliant launcher.
|
||||
#[serde(default)]
|
||||
pub desktop_file: Option<String>,
|
||||
#[serde(default)]
|
||||
pub desktop_file_sha256: Option<String>,
|
||||
/// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz`
|
||||
/// in the release dir, extracted to `~/.local/share/<name>/` on
|
||||
/// in the release dir, extracted to `$prefix/share/<name>/` on
|
||||
/// install. For arbitrary data a package needs at runtime beyond a
|
||||
/// config example (e.g. breadhelp's guide content), where a single
|
||||
/// downloadable file + `tar` extraction is simpler than teaching
|
||||
|
|
@ -183,9 +182,7 @@ pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
|
|||
match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
|
||||
Ok(index) => return Ok(index),
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
" warning: cached index.json failed verification ({err}), re-fetching…"
|
||||
);
|
||||
eprintln!(" warning: cached index.json failed verification ({err}), re-fetching…");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -211,17 +208,12 @@ 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 sig_text = std::fs::read_to_string(sig_cache_path)
|
||||
.context("reading cached index.json.minisig (cache predates signing support)")?;
|
||||
verify_index_signature(&bytes, &sig_text).with_context(|| {
|
||||
format!("cached {track} index failed signature verification")
|
||||
})?;
|
||||
verify_index_signature(&bytes, &sig_text)
|
||||
.with_context(|| format!("cached {track} index failed signature verification"))?;
|
||||
serde_json::from_slice(&bytes).context("parsing cached index")
|
||||
}
|
||||
|
||||
|
|
@ -285,8 +277,10 @@ pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result<Vec<u8>> {
|
|||
Ok(bytes) => Ok(bytes),
|
||||
Err(primary_err) => {
|
||||
eprintln!(
|
||||
" primary URL failed ({}), trying GitHub fallback…",
|
||||
primary_err
|
||||
" {}",
|
||||
crate::ui::note(&format!(
|
||||
"primary URL failed ({primary_err}), trying GitHub fallback…"
|
||||
))
|
||||
);
|
||||
fetch_bytes(fallback_url).context("both primary and GitHub fallback failed")
|
||||
}
|
||||
|
|
@ -305,9 +299,7 @@ const CHUNK_SIZE: usize = 64 * 1024;
|
|||
|
||||
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
||||
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();
|
||||
if status != 200 {
|
||||
bail!("HTTP {status} from {url}");
|
||||
|
|
@ -320,7 +312,11 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
|||
// is what makes printing partway through the download possible, without
|
||||
// pulling in a progress-bar crate for what's meant to just be reassurance.
|
||||
let content_length: Option<u64> = resp.header("Content-Length").and_then(|v| v.parse().ok());
|
||||
let show_progress = content_length.is_some() && std::io::stderr().is_terminal();
|
||||
// Progress is reassurance for multi-MB binaries. A 4 KB index fetch
|
||||
// drawing a 100% / 0.0 MB bar is noise, not feedback.
|
||||
const MIN_PROGRESS_BYTES: u64 = 256 * 1024;
|
||||
let show_progress =
|
||||
content_length.is_some_and(|n| n >= MIN_PROGRESS_BYTES) && std::io::stderr().is_terminal();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut reader = resp.into_reader();
|
||||
|
|
@ -336,27 +332,17 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
|||
bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit");
|
||||
}
|
||||
if show_progress && last_print.elapsed() >= PROGRESS_THROTTLE {
|
||||
print_progress(buf.len() as u64, content_length.unwrap());
|
||||
crate::ui::print_progress(buf.len() as u64, content_length.unwrap());
|
||||
last_print = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
if show_progress {
|
||||
print_progress(buf.len() as u64, content_length.unwrap());
|
||||
eprintln!();
|
||||
crate::ui::print_progress(buf.len() as u64, content_length.unwrap());
|
||||
crate::ui::finish_progress();
|
||||
}
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -408,10 +394,7 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
|
|||
fn stable_cache_path_matches_pre_track_filename() {
|
||||
// Must stay exactly "index.json" so an existing warm cache from a
|
||||
// pre-track bakery binary is still used after an upgrade.
|
||||
assert_eq!(
|
||||
cache_path(Track::Stable).file_name().unwrap(),
|
||||
"index.json"
|
||||
);
|
||||
assert_eq!(cache_path(Track::Stable).file_name().unwrap(), "index.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -428,13 +411,22 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
|
|||
|
||||
#[test]
|
||||
fn stable_url_has_no_track_prefix() {
|
||||
assert_eq!(primary_url(Track::Stable), format!("{}/index.json", base_url()));
|
||||
assert_eq!(
|
||||
primary_url(Track::Stable),
|
||||
format!("{}/index.json", base_url())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beta_and_dev_urls_are_track_prefixed() {
|
||||
assert_eq!(primary_url(Track::Beta), format!("{}/beta/index.json", base_url()));
|
||||
assert_eq!(primary_url(Track::Dev), format!("{}/dev/index.json", base_url()));
|
||||
assert_eq!(
|
||||
primary_url(Track::Beta),
|
||||
format!("{}/beta/index.json", base_url())
|
||||
);
|
||||
assert_eq!(
|
||||
primary_url(Track::Dev),
|
||||
format!("{}/dev/index.json", base_url())
|
||||
);
|
||||
}
|
||||
|
||||
fn minimal_package_json() -> &'static str {
|
||||
|
|
|
|||
546
bakery/src/prefix.rs
Normal file
546
bakery/src/prefix.rs
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
//! Install prefix: default `~/.local`, or a system root for BOS.
|
||||
//!
|
||||
//! Hermes and `get.sh` keep the user-local default. BOS sets
|
||||
//! `prefix = "/usr/local"` in `/etc/bakery/config.toml` (or `BAKERY_PREFIX`)
|
||||
//! so bakery-managed desktop apps live on the `@` root subvolume and ride
|
||||
//! along with snapper/grub-btrfs snapshots. Per-user state stays under
|
||||
//! `~/.local/state/bakery` either way — bakery still records what *this*
|
||||
//! user asked for; the prefix only changes where bits land on disk.
|
||||
//!
|
||||
//! Writes that hit `EACCES` use `sudo -n` first, then `pkexec` if a
|
||||
//! graphical session is available. Interactive `sudo` (password on stdin)
|
||||
//! is never used — a GUI hook must not block on a TTY prompt.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::ffi::OsStr;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Default user-local prefix when no config/env override is set.
|
||||
const DEFAULT_USER_PREFIX: &str = ".local";
|
||||
|
||||
/// System-wide user units, used when the prefix is not under `$HOME`.
|
||||
const SYSTEM_USER_UNIT_DIR: &str = "/usr/lib/systemd/user";
|
||||
|
||||
const SYSTEM_CONFIG_PATH: &str = "/etc/bakery/config.toml";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Layout {
|
||||
pub prefix: PathBuf,
|
||||
pub bin_dir: PathBuf,
|
||||
pub share_dir: PathBuf,
|
||||
pub systemd_user_dir: PathBuf,
|
||||
/// True when `prefix` is not under the user's home directory.
|
||||
pub is_system: bool,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
if self.is_system {
|
||||
"system"
|
||||
} else {
|
||||
"user"
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a (possibly custom) prefix onto bin/share/unit paths.
|
||||
/// `bin_override` is `--bin-dir` / `BAKERY_BIN_DIR` and wins for bins only.
|
||||
pub fn from_prefix(prefix: &Path, bin_override: Option<PathBuf>) -> Self {
|
||||
let prefix = normalize_prefix_path(prefix);
|
||||
let is_system = is_system_prefix(&prefix);
|
||||
let bin_dir = bin_override.unwrap_or_else(|| prefix.join("bin"));
|
||||
let share_dir = prefix.join("share");
|
||||
let systemd_user_dir = if is_system {
|
||||
PathBuf::from(SYSTEM_USER_UNIT_DIR)
|
||||
} else {
|
||||
user_systemd_dir()
|
||||
};
|
||||
Self {
|
||||
prefix,
|
||||
bin_dir,
|
||||
share_dir,
|
||||
systemd_user_dir,
|
||||
is_system,
|
||||
}
|
||||
}
|
||||
|
||||
/// Historical default: `~/.local` bins, XDG data dir for share,
|
||||
/// `~/.config/systemd/user` for units. Used when neither `BAKERY_PREFIX`
|
||||
/// nor `/etc/bakery/config.toml` sets a prefix — hermes / get.sh.
|
||||
pub fn user_default(bin_override: Option<PathBuf>) -> Self {
|
||||
let prefix = default_user_prefix();
|
||||
let bin_dir = bin_override.unwrap_or_else(|| prefix.join("bin"));
|
||||
let share_dir = dirs::data_dir().unwrap_or_else(|| prefix.join("share"));
|
||||
Self {
|
||||
prefix,
|
||||
bin_dir,
|
||||
share_dir,
|
||||
systemd_user_dir: user_systemd_dir(),
|
||||
is_system: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the active layout. `BAKERY_PREFIX` wins over `/etc/bakery/config.toml`;
|
||||
/// neither set keeps the `~/.local` default. `bin_override` is the existing
|
||||
/// `--bin-dir` / `BAKERY_BIN_DIR` knob.
|
||||
pub fn resolve(bin_override: Option<PathBuf>) -> Layout {
|
||||
let env = std::env::var("BAKERY_PREFIX").ok();
|
||||
resolve_from(env.as_deref(), Path::new(SYSTEM_CONFIG_PATH), bin_override)
|
||||
}
|
||||
|
||||
/// Same as [`resolve`] with the env value and config path injected, so
|
||||
/// tests don't have to mutate process-global env or touch `/etc`.
|
||||
pub fn resolve_from(
|
||||
env_prefix: Option<&str>,
|
||||
config_path: &Path,
|
||||
bin_override: Option<PathBuf>,
|
||||
) -> Layout {
|
||||
match configured_prefix_from(env_prefix, config_path) {
|
||||
Some(prefix) => Layout::from_prefix(&prefix, bin_override),
|
||||
None => Layout::user_default(bin_override),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configured_prefix_from(env_prefix: Option<&str>, config_path: &Path) -> Option<PathBuf> {
|
||||
if let Some(raw) = env_prefix {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(normalize_prefix(trimmed));
|
||||
}
|
||||
}
|
||||
load_config_prefix(config_path)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct BakeryConfig {
|
||||
prefix: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads `prefix = "..."` from a bakery config file. Missing file or empty
|
||||
/// key → `None` (caller falls back to the user-local default). A file that
|
||||
/// exists but fails to parse is warned about, not treated as fatal — a typo
|
||||
/// in `/etc/bakery/config.toml` must not take down `bakery list`.
|
||||
pub fn load_config_prefix(path: &Path) -> Option<PathBuf> {
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {}",
|
||||
crate::ui::warn(&format!("could not read {}: {e}", path.display()))
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match toml::from_str::<BakeryConfig>(&text) {
|
||||
Ok(cfg) => cfg
|
||||
.prefix
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(normalize_prefix),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" {}",
|
||||
crate::ui::warn(&format!("could not parse {}: {e}", path.display()))
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_user_prefix() -> PathBuf {
|
||||
home_dir().join(DEFAULT_USER_PREFIX)
|
||||
}
|
||||
|
||||
fn home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"))
|
||||
}
|
||||
|
||||
fn user_systemd_dir() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| home_dir().join(".config"))
|
||||
.join("systemd/user")
|
||||
}
|
||||
|
||||
fn is_system_prefix(prefix: &Path) -> bool {
|
||||
match dirs::home_dir() {
|
||||
Some(home) => !prefix.starts_with(&home),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_prefix(raw: &str) -> PathBuf {
|
||||
normalize_prefix_path(&expand_tilde(raw))
|
||||
}
|
||||
|
||||
fn normalize_prefix_path(path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_tilde(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
home_dir()
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
home_dir().join(rest)
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_permission_denied(err: &io::Error) -> bool {
|
||||
err.kind() == io::ErrorKind::PermissionDenied
|
||||
}
|
||||
|
||||
pub fn privilege_denied_msg(dest: &Path) -> String {
|
||||
format!(
|
||||
"permission denied writing {} — need root for this prefix. \
|
||||
bakery tried `sudo -n` then `pkexec`; neither succeeded. \
|
||||
Run from a root shell, grant passwordless sudo -n for install/rm/tar, \
|
||||
or install a polkit rule. bakery will not prompt for a sudo password.",
|
||||
dest.display()
|
||||
)
|
||||
}
|
||||
|
||||
fn has_graphical_session() -> bool {
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// Write `bytes` to `dest`, creating parent dirs. Escalates on `EACCES`.
|
||||
pub fn write_bytes(dest: &Path, bytes: &[u8], mode: u32) -> Result<()> {
|
||||
match bread_utils::atomic::write_atomic_bytes(dest, bytes, Some(mode)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if is_permission_denied(&e) => write_bytes_privileged(dest, bytes, mode),
|
||||
Err(e) => Err(e).with_context(|| format!("writing {}", dest.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_bytes_privileged(dest: &Path, bytes: &[u8], mode: u32) -> Result<()> {
|
||||
let mut tmp =
|
||||
tempfile::NamedTempFile::new().context("creating temp file for privileged write")?;
|
||||
tmp.write_all(bytes)
|
||||
.and_then(|_| tmp.flush())
|
||||
.and_then(|_| tmp.as_file().sync_all())
|
||||
.context("writing temp file for privileged write")?;
|
||||
let mode_str = format!("{mode:o}");
|
||||
run_privileged(
|
||||
Path::new("/usr/bin/install"),
|
||||
&[
|
||||
OsStr::new("-D"),
|
||||
OsStr::new("-m"),
|
||||
OsStr::new(&mode_str),
|
||||
tmp.path().as_os_str(),
|
||||
dest.as_os_str(),
|
||||
],
|
||||
dest,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_dir_all(path: &Path) -> Result<()> {
|
||||
match std::fs::create_dir_all(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if is_permission_denied(&e) => run_privileged(
|
||||
Path::new("/usr/bin/install"),
|
||||
&[
|
||||
OsStr::new("-d"),
|
||||
OsStr::new("-m"),
|
||||
OsStr::new("755"),
|
||||
path.as_os_str(),
|
||||
],
|
||||
path,
|
||||
),
|
||||
Err(e) => Err(e).with_context(|| format!("creating directory {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_file(path: &Path) -> Result<()> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) if is_permission_denied(&e) => run_privileged(
|
||||
Path::new("/usr/bin/rm"),
|
||||
&[OsStr::new("-f"), path.as_os_str()],
|
||||
path,
|
||||
),
|
||||
Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_dir_all(path: &Path) -> Result<()> {
|
||||
match std::fs::remove_dir_all(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) if is_permission_denied(&e) => run_privileged(
|
||||
Path::new("/usr/bin/rm"),
|
||||
&[OsStr::new("-rf"), path.as_os_str()],
|
||||
path,
|
||||
),
|
||||
Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract `archive` (a `.tar.gz`) into `dest_dir`. Escalates the `tar`
|
||||
/// invocation when `dest_dir` is not writable by this user — typical for
|
||||
/// `$prefix/share/<pkg>` under `/usr/local`.
|
||||
pub fn extract_tar_gz(archive: &Path, dest_dir: &Path) -> Result<()> {
|
||||
create_dir_all(dest_dir)?;
|
||||
if dir_writable_by_self(dest_dir) {
|
||||
let status = Command::new("tar")
|
||||
.args([
|
||||
"xzf",
|
||||
&archive.to_string_lossy(),
|
||||
"--no-same-owner",
|
||||
"--no-same-permissions",
|
||||
"-C",
|
||||
])
|
||||
.arg(dest_dir)
|
||||
.status()
|
||||
.with_context(|| format!("running tar to extract {}", archive.display()))?;
|
||||
if !status.success() {
|
||||
bail!("tar exited with {status} extracting {}", archive.display());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
run_privileged(
|
||||
Path::new("/usr/bin/tar"),
|
||||
&[
|
||||
OsStr::new("xzf"),
|
||||
archive.as_os_str(),
|
||||
OsStr::new("--no-same-owner"),
|
||||
OsStr::new("--no-same-permissions"),
|
||||
OsStr::new("-C"),
|
||||
dest_dir.as_os_str(),
|
||||
],
|
||||
dest_dir,
|
||||
)
|
||||
}
|
||||
|
||||
fn dir_writable_by_self(dir: &Path) -> bool {
|
||||
tempfile::Builder::new()
|
||||
.prefix(".bakery-wprobe-")
|
||||
.tempfile_in(dir)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn run_privileged(program: &Path, args: &[&OsStr], dest: &Path) -> Result<()> {
|
||||
// `sudo -n` never prompts; stdin is null so a misconfigured sudoers
|
||||
// can't fall through to a password read on a GUI hook's non-tty stdin.
|
||||
let sudo = Command::new("sudo")
|
||||
.arg("-n")
|
||||
.arg(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.status();
|
||||
if matches!(sudo, Ok(status) if status.success()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// pkexec pops a polkit dialog — only useful with a display, and the
|
||||
// one acceptable password prompt (GUI, not a stolen sudo TTY).
|
||||
if has_graphical_session() {
|
||||
let pk = Command::new("pkexec").arg(program).args(args).status();
|
||||
if matches!(pk, Ok(status) if status.success()) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("{}", privilege_denied_msg(dest))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn user_default_is_not_system_and_uses_local_bin() {
|
||||
let layout = Layout::user_default(None);
|
||||
assert!(!layout.is_system);
|
||||
assert_eq!(layout.prefix, default_user_prefix());
|
||||
assert_eq!(layout.bin_dir, default_user_prefix().join("bin"));
|
||||
assert_eq!(layout.kind_label(), "user");
|
||||
assert!(layout.systemd_user_dir.ends_with(Path::new("systemd/user")));
|
||||
assert_ne!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_default_honors_bin_override() {
|
||||
let layout = Layout::user_default(Some(PathBuf::from("/tmp/custom-bins")));
|
||||
assert_eq!(layout.bin_dir, PathBuf::from("/tmp/custom-bins"));
|
||||
assert!(!layout.is_system);
|
||||
assert_eq!(layout.prefix, default_user_prefix());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usr_local_is_system_layout() {
|
||||
let layout = Layout::from_prefix(Path::new("/usr/local"), None);
|
||||
assert!(layout.is_system);
|
||||
assert_eq!(layout.prefix, PathBuf::from("/usr/local"));
|
||||
assert_eq!(layout.bin_dir, PathBuf::from("/usr/local/bin"));
|
||||
assert_eq!(layout.share_dir, PathBuf::from("/usr/local/share"));
|
||||
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
||||
assert_eq!(layout.kind_label(), "system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_home_prefix_is_not_system() {
|
||||
let home = dirs::home_dir().expect("home dir");
|
||||
let prefix = home.join("apps");
|
||||
let layout = Layout::from_prefix(&prefix, None);
|
||||
assert!(!layout.is_system);
|
||||
assert_eq!(layout.bin_dir, prefix.join("bin"));
|
||||
assert_eq!(layout.share_dir, prefix.join("share"));
|
||||
assert_ne!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_prefix_maps_bin_and_share_under_prefix() {
|
||||
let dir = tempdir().unwrap();
|
||||
let layout = Layout::from_prefix(dir.path(), None);
|
||||
assert_eq!(layout.bin_dir, dir.path().join("bin"));
|
||||
assert_eq!(layout.share_dir, dir.path().join("share"));
|
||||
// /tmp is not under $HOME, so this is a system-shaped prefix —
|
||||
// units would go to /usr/lib/systemd/user. Writes still try
|
||||
// unprivileged first, so tests can use a temp prefix without sudo.
|
||||
assert!(layout.is_system);
|
||||
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bin_override_does_not_move_share_or_units() {
|
||||
let layout = Layout::from_prefix(
|
||||
Path::new("/usr/local"),
|
||||
Some(PathBuf::from("/opt/override/bin")),
|
||||
);
|
||||
assert_eq!(layout.bin_dir, PathBuf::from("/opt/override/bin"));
|
||||
assert_eq!(layout.share_dir, PathBuf::from("/usr/local/share"));
|
||||
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_prefix_reads_value() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
|
||||
assert_eq!(load_config_prefix(&path), Some(PathBuf::from("/usr/local")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_prefix_expands_tilde() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "prefix = \"~/.local\"\n").unwrap();
|
||||
assert_eq!(load_config_prefix(&path), Some(default_user_prefix()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_prefix_missing_file_is_none() {
|
||||
assert_eq!(
|
||||
load_config_prefix(Path::new("/no/such/bakery-config.toml")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_prefix_ignores_empty_value() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "prefix = \"\"\n").unwrap();
|
||||
assert_eq!(load_config_prefix(&path), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_prefix_malformed_is_none() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "prefix = [\n").unwrap();
|
||||
assert_eq!(load_config_prefix(&path), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_prefix_wins_over_config() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
|
||||
let layout = resolve_from(Some("/opt/bread"), &path, None);
|
||||
assert_eq!(layout.prefix, PathBuf::from("/opt/bread"));
|
||||
assert_eq!(layout.bin_dir, PathBuf::from("/opt/bread/bin"));
|
||||
assert!(layout.is_system);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_env_falls_through_to_config() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
|
||||
let layout = resolve_from(Some(" "), &path, None);
|
||||
assert_eq!(layout.prefix, PathBuf::from("/usr/local"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_env_no_config_is_user_default() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("missing.toml");
|
||||
let layout = resolve_from(None, &path, None);
|
||||
assert_eq!(layout, Layout::user_default(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_bytes_to_writable_temp_prefix_needs_no_root() {
|
||||
let dir = tempdir().unwrap();
|
||||
let dest = dir.path().join("bin").join("foo");
|
||||
write_bytes(&dest, b"hello", 0o755).unwrap();
|
||||
assert_eq!(fs::read(&dest).unwrap(), b"hello");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
fs::metadata(&dest).unwrap().permissions().mode() & 0o777,
|
||||
0o755
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_remove_under_temp_prefix() {
|
||||
let dir = tempdir().unwrap();
|
||||
let nested = dir.path().join("share/licenses/pkg");
|
||||
create_dir_all(&nested).unwrap();
|
||||
assert!(nested.is_dir());
|
||||
let file = nested.join("LICENSE");
|
||||
write_bytes(&file, b"MIT\n", 0o644).unwrap();
|
||||
remove_file(&file).unwrap();
|
||||
assert!(!file.exists());
|
||||
remove_dir_all(&dir.path().join("share")).unwrap();
|
||||
assert!(!dir.path().join("share").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privilege_denied_msg_names_the_dest() {
|
||||
let msg = privilege_denied_msg(Path::new("/usr/local/bin/breadd"));
|
||||
assert!(msg.contains("/usr/local/bin/breadd"));
|
||||
assert!(msg.contains("sudo -n"));
|
||||
assert!(msg.contains("pkexec"));
|
||||
assert!(msg.contains("will not prompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_system_prefix_classifies_home_and_usr() {
|
||||
let home = dirs::home_dir().expect("home dir");
|
||||
assert!(!is_system_prefix(&home.join(".local")));
|
||||
assert!(is_system_prefix(Path::new("/usr/local")));
|
||||
assert!(is_system_prefix(Path::new("/opt/bread")));
|
||||
}
|
||||
}
|
||||
|
|
@ -56,8 +56,7 @@ impl State {
|
|||
pub fn save(&self) -> Result<()> {
|
||||
let path = state_path();
|
||||
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
|
||||
|
|
@ -114,7 +113,13 @@ fn state_base_dir() -> PathBuf {
|
|||
}
|
||||
|
||||
fn state_path() -> PathBuf {
|
||||
state_base_dir().join("bakery/installed.json")
|
||||
bakery_state_dir().join("installed.json")
|
||||
}
|
||||
|
||||
/// Per-user bakery state dir (`~/.local/state/bakery`). Independent of the
|
||||
/// install prefix — system-prefix installs still record what this user asked for.
|
||||
pub fn bakery_state_dir() -> PathBuf {
|
||||
state_base_dir().join("bakery")
|
||||
}
|
||||
|
||||
/// Local backup dir for `pkg_name`'s `version` binaries, populated by
|
||||
|
|
@ -205,7 +210,10 @@ mod tests {
|
|||
assert_eq!(restored.packages["bar"].version, "2.0.0");
|
||||
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
|
||||
assert_eq!(restored.packages["bar"].track, Track::Beta);
|
||||
assert_eq!(restored.packages["bar"].previous_version.as_deref(), Some("1.0.0"));
|
||||
assert_eq!(
|
||||
restored.packages["bar"].previous_version.as_deref(),
|
||||
Some("1.0.0")
|
||||
);
|
||||
assert_eq!(restored.packages["bar"].binary_sha256["bar"], "abc123");
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +236,14 @@ mod tests {
|
|||
assert!(installed.binary_sha256.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bakery_state_dir_is_under_state_home_and_independent_of_prefix() {
|
||||
let dir = bakery_state_dir();
|
||||
assert!(dir.ends_with("bakery"));
|
||||
// Must not follow BAKERY_PREFIX — state is always per-user.
|
||||
assert!(!dir.starts_with("/usr/local"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_dir_is_distinct_per_package_and_version() {
|
||||
let a = backup_dir("bakery", "0.3.1");
|
||||
|
|
|
|||
385
bakery/src/ui.rs
385
bakery/src/ui.rs
|
|
@ -1,5 +1,6 @@
|
|||
use crate::track::Track;
|
||||
use std::io::IsTerminal;
|
||||
use clap::builder::styling::{AnsiColor, Effects, Styles};
|
||||
use std::io::{IsTerminal, Write};
|
||||
|
||||
pub const RESET: &str = "\x1b[0m";
|
||||
pub const BOLD: &str = "\x1b[1m";
|
||||
|
|
@ -9,6 +10,19 @@ pub const GREEN: &str = "\x1b[32m";
|
|||
pub const YELLOW: &str = "\x1b[33m";
|
||||
pub const CYAN: &str = "\x1b[36m";
|
||||
pub const MAGENTA: &str = "\x1b[35m";
|
||||
pub const BOLD_CYAN: &str = "\x1b[1;36m";
|
||||
|
||||
/// Clap help styling — same cyan headers / green literals / dim placeholders
|
||||
/// as the rest of bakery, so `bakery --help` doesn't look like a different
|
||||
/// program from `bakery list`.
|
||||
pub const CLAP_STYLES: Styles = Styles::styled()
|
||||
.header(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
|
||||
.usage(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
|
||||
.literal(AnsiColor::Green.on_default().effects(Effects::BOLD))
|
||||
.placeholder(AnsiColor::BrightBlack.on_default())
|
||||
.error(AnsiColor::Red.on_default().effects(Effects::BOLD))
|
||||
.valid(AnsiColor::Green.on_default().effects(Effects::BOLD))
|
||||
.invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD));
|
||||
|
||||
/// Colors are on only when stdout is a real terminal and `NO_COLOR` isn't
|
||||
/// set — the ecosystem's existing CLI (breadcrumbs) hardcodes ANSI
|
||||
|
|
@ -18,21 +32,52 @@ pub fn colors_enabled() -> bool {
|
|||
std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
pub fn colors_enabled_err() -> bool {
|
||||
std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
|
||||
}
|
||||
|
||||
pub fn style(s: &str, code: &str) -> String {
|
||||
if colors_enabled() {
|
||||
paint(s, code, colors_enabled())
|
||||
}
|
||||
|
||||
fn style_err(s: &str, code: &str) -> String {
|
||||
paint(s, code, colors_enabled_err())
|
||||
}
|
||||
|
||||
fn paint(s: &str, code: &str, on: bool) -> String {
|
||||
if on {
|
||||
format!("{code}{s}{RESET}")
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bold(s: &str) -> String {
|
||||
style(s, BOLD)
|
||||
}
|
||||
|
||||
pub fn dim(s: &str) -> String {
|
||||
style(s, DIM)
|
||||
}
|
||||
|
||||
/// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the
|
||||
/// common-case output is unchanged.
|
||||
#[allow(dead_code)]
|
||||
pub fn track_badge(track: Track) -> String {
|
||||
let tag = track_tag(track);
|
||||
if tag.is_empty() {
|
||||
tag
|
||||
} else {
|
||||
format!(" {tag}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `[beta]` / `[dev]` with no leading space; empty for `Stable`.
|
||||
pub fn track_tag(track: Track) -> String {
|
||||
match track {
|
||||
Track::Stable => String::new(),
|
||||
Track::Beta => format!(" {}", style("[beta]", YELLOW)),
|
||||
Track::Dev => format!(" {}", style("[dev]", MAGENTA)),
|
||||
Track::Beta => style("[beta]", YELLOW),
|
||||
Track::Dev => style("[dev]", MAGENTA),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +99,278 @@ pub fn unchanged(s: &str) -> String {
|
|||
style(&format!("· {s}"), DIM)
|
||||
}
|
||||
|
||||
pub fn warn(s: &str) -> String {
|
||||
style(&format!("warning: {s}"), YELLOW)
|
||||
}
|
||||
|
||||
pub fn note(s: &str) -> String {
|
||||
style(&format!("note: {s}"), DIM)
|
||||
}
|
||||
|
||||
/// Cyan verb + bold name + dim version — the install/update/remove banner.
|
||||
pub fn action(verb: &str, name: &str, version: Option<&str>) {
|
||||
let mut line = format!("{} {}", style(verb, BOLD_CYAN), style(name, BOLD));
|
||||
if let Some(v) = version {
|
||||
line.push_str(" ");
|
||||
line.push_str(&style(v, DIM));
|
||||
}
|
||||
println!("{line}");
|
||||
}
|
||||
|
||||
/// Section title plus dim meta (`Packages 16 · 15 installed`).
|
||||
pub fn heading(title: &str, parts: &[&str]) {
|
||||
let mut line = style(title, BOLD_CYAN);
|
||||
let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect();
|
||||
for (i, part) in visible.iter().enumerate() {
|
||||
line.push_str(" ");
|
||||
if i > 0 {
|
||||
line.push_str(&style("·", DIM));
|
||||
line.push_str(" ");
|
||||
}
|
||||
line.push_str(part);
|
||||
}
|
||||
println!("{line}");
|
||||
println!();
|
||||
}
|
||||
|
||||
pub fn summary(parts: &[&str]) {
|
||||
let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect();
|
||||
if visible.is_empty() {
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
println!("{}", style(&visible.join(" · "), BOLD));
|
||||
}
|
||||
|
||||
/// Left-aligned verb column so install chatter (`downloading` / `placed` /
|
||||
/// `unit`) lines up instead of drifting with the verb length.
|
||||
pub fn step(verb: &str, detail: &str) {
|
||||
println!(" {:<12} {}", dim(verb), detail);
|
||||
}
|
||||
|
||||
pub fn kv(key: &str, value: &str) {
|
||||
println!(" {:<12} {}", dim(key), value);
|
||||
}
|
||||
|
||||
pub fn check_row(ok_flag: bool, name: &str, name_width: usize, message: &str) {
|
||||
let glyph = if ok_flag {
|
||||
style("✓", GREEN)
|
||||
} else {
|
||||
style("✗", RED)
|
||||
};
|
||||
println!(" {glyph} {:<name_width$} {message}", name);
|
||||
}
|
||||
|
||||
pub fn unknown_row(name: &str, name_width: usize, message: &str) {
|
||||
println!(
|
||||
" {} {:<name_width$} {}",
|
||||
style("?", DIM),
|
||||
name,
|
||||
dim(message)
|
||||
);
|
||||
}
|
||||
|
||||
pub struct CatalogRow {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub installed: bool,
|
||||
/// Wrapped onto following lines (descriptions).
|
||||
pub detail: String,
|
||||
/// Same-line suffix after the version (short dates). Empty for catalog
|
||||
/// views that already use `detail`.
|
||||
pub aside: String,
|
||||
}
|
||||
|
||||
/// Two-line catalog: status glyph + aligned name/version, then a hanging
|
||||
/// description (or date) wrapped to the terminal width. Column widths are
|
||||
/// computed from the row set so long `-dev.` versions no longer smash the
|
||||
/// old `{: <10}` pad.
|
||||
pub fn print_catalog(rows: &[CatalogRow]) {
|
||||
for line in format_catalog(rows, term_width()) {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_catalog(rows: &[CatalogRow], width: usize) -> Vec<String> {
|
||||
if rows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let name_w = rows.iter().map(|r| r.name.len()).max().unwrap_or(0);
|
||||
let indent = 5; // " ✓ " / " "
|
||||
let detail_width = width.saturating_sub(indent).max(24);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for row in rows {
|
||||
let glyph = if row.installed {
|
||||
style("✓", GREEN)
|
||||
} else {
|
||||
" ".to_string()
|
||||
};
|
||||
let name = style(&format!("{:<name_w$}", row.name), BOLD);
|
||||
let version = style(&row.version, DIM);
|
||||
let mut line = format!(" {glyph} {name} {version}");
|
||||
if !row.aside.is_empty() {
|
||||
line.push_str(" ");
|
||||
line.push_str(&dim(&row.aside));
|
||||
}
|
||||
lines.push(line);
|
||||
if !row.detail.is_empty() {
|
||||
for wrapped in wrap_words(&row.detail, detail_width) {
|
||||
lines.push(format!(" {}", dim(&wrapped)));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
pub fn wrap_words(text: &str, width: usize) -> Vec<String> {
|
||||
if width == 0 {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
if cur.is_empty() {
|
||||
cur = word.to_string();
|
||||
} else if cur.len() + 1 + word.len() <= width {
|
||||
cur.push(' ');
|
||||
cur.push_str(word);
|
||||
} else {
|
||||
lines.push(std::mem::take(&mut cur));
|
||||
cur = word.to_string();
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
lines.push(cur);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
pub fn short_date(rfc3339: &str) -> String {
|
||||
chrono::DateTime::parse_from_rfc3339(rfc3339)
|
||||
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_else(|_| rfc3339.to_string())
|
||||
}
|
||||
|
||||
pub fn name_width<S: AsRef<str>>(names: impl IntoIterator<Item = S>) -> usize {
|
||||
names
|
||||
.into_iter()
|
||||
.map(|s| s.as_ref().len())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `\r`-overwritten download bar on stderr. Pads to a stable width so a
|
||||
/// shorter later frame doesn't leave leftover characters from a longer one.
|
||||
pub fn print_progress(downloaded: u64, total: u64) {
|
||||
let width = term_width().clamp(40, 72);
|
||||
let line = progress_line(downloaded, total, 20);
|
||||
let padded = fit_width(&line, width);
|
||||
eprint!("\r{padded}");
|
||||
let _ = std::io::stderr().flush();
|
||||
}
|
||||
|
||||
pub fn finish_progress() {
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
pub fn progress_line(downloaded: u64, total: u64, bar_width: usize) -> String {
|
||||
let dl = downloaded as f64 / 1_048_576.0;
|
||||
let tot = total as f64 / 1_048_576.0;
|
||||
let frac = if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(downloaded as f64 / total as f64).clamp(0.0, 1.0)
|
||||
};
|
||||
let filled = ((bar_width as f64) * frac).round() as usize;
|
||||
let filled = filled.min(bar_width);
|
||||
let bar = format!("{}{}", "█".repeat(filled), "░".repeat(bar_width - filled));
|
||||
let pct = (frac * 100.0).round() as u32;
|
||||
format!(
|
||||
" ⇣ {} {:>3}% {:.1}/{:.1} MB",
|
||||
style_err(&bar, CYAN),
|
||||
pct,
|
||||
dl,
|
||||
tot
|
||||
)
|
||||
}
|
||||
|
||||
fn fit_width(s: &str, width: usize) -> String {
|
||||
let visible = visible_len(s);
|
||||
if visible >= width {
|
||||
return s.to_string();
|
||||
}
|
||||
format!("{s}{}", " ".repeat(width - visible))
|
||||
}
|
||||
|
||||
fn visible_len(s: &str) -> usize {
|
||||
let mut n = 0;
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\u{1b}' {
|
||||
if chars.peek() == Some(&'[') {
|
||||
chars.next();
|
||||
for next in chars.by_ref() {
|
||||
if next.is_ascii_alphabetic() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
pub fn term_width() -> usize {
|
||||
if let Ok(w) = std::env::var("COLUMNS") {
|
||||
if let Ok(n) = w.parse::<usize>() {
|
||||
if n >= 40 {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
ioctl_width().filter(|&n| n >= 40).unwrap_or(80)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn ioctl_width() -> Option<usize> {
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
#[repr(C)]
|
||||
struct WinSize {
|
||||
row: u16,
|
||||
col: u16,
|
||||
x: u16,
|
||||
y: u16,
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn ioctl(fd: i32, request: u64, argp: *mut WinSize) -> i32;
|
||||
}
|
||||
|
||||
let mut ws = WinSize {
|
||||
row: 0,
|
||||
col: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
// TIOCGWINSZ on Linux.
|
||||
let fd = std::io::stdout().as_raw_fd();
|
||||
let ret = unsafe { ioctl(fd, 0x5413, &mut ws) };
|
||||
if ret == 0 && ws.col > 0 {
|
||||
Some(ws.col as usize)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn ioctl_width() -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -77,4 +394,64 @@ mod tests {
|
|||
assert!(!ok("foo").contains('·'));
|
||||
assert!(!fail("foo").contains('·'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_aligns_names_and_versions() {
|
||||
let lines = format_catalog(
|
||||
&[
|
||||
CatalogRow {
|
||||
name: "bakery".into(),
|
||||
version: "0.7.2-dev.20260815142350+30517f1".into(),
|
||||
installed: true,
|
||||
detail: "Package manager".into(),
|
||||
aside: String::new(),
|
||||
},
|
||||
CatalogRow {
|
||||
name: "breadarr".into(),
|
||||
version: "0.1.2".into(),
|
||||
installed: false,
|
||||
detail: "Homelab arr stack".into(),
|
||||
aside: String::new(),
|
||||
},
|
||||
],
|
||||
80,
|
||||
);
|
||||
assert_eq!(lines.len(), 4);
|
||||
assert!(lines[0].contains("bakery"));
|
||||
assert!(lines[0].contains("0.7.2-dev.20260815142350+30517f1"));
|
||||
assert!(lines[1].contains("Package manager"));
|
||||
// Shorter version is padded so the columns stay a block, not a
|
||||
// ragged list — the long bakery version used to overflow `{: <10}`.
|
||||
// Compare display columns, not byte offsets: the installed glyph
|
||||
// is a 3-byte checkmark sitting in a 1-column slot.
|
||||
let bakery_col = visible_len(&lines[0][..lines[0].find("0.7.2-dev").unwrap()]);
|
||||
let breadarr_col = visible_len(&lines[2][..lines[2].find("0.1.2").unwrap()]);
|
||||
assert_eq!(bakery_col, breadarr_col);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_words_breaks_on_width() {
|
||||
let lines = wrap_words("one two three four", 9);
|
||||
assert_eq!(lines, vec!["one two", "three", "four"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_line_has_bar_and_percent() {
|
||||
let line = progress_line(1_048_576, 2_097_152, 10);
|
||||
assert!(line.contains('█'));
|
||||
assert!(line.contains('░'));
|
||||
assert!(line.contains("50%"));
|
||||
assert!(line.contains("1.0/2.0 MB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_len_ignores_ansi() {
|
||||
assert_eq!(visible_len("hello"), 5);
|
||||
assert_eq!(visible_len(&format!("{CYAN}hello{RESET}")), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_date_from_rfc3339() {
|
||||
assert_eq!(short_date("2026-08-15T14:23:50+00:00"), "2026-08-15");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
bread-app/Cargo.toml
Normal file
20
bread-app/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "bread-app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "GTK application bootstrap for bread desktop tools: app id, singleton, optional overlay popup, and command listen loop"
|
||||
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
||||
keywords = ["gtk4", "wayland", "hyprland"]
|
||||
|
||||
[dependencies]
|
||||
bread-utils = { path = "../bread-utils" }
|
||||
|
||||
[features]
|
||||
# Layer-shell overlay helper (`gtk_popup`). Matches `bread-utils/gtk` so a
|
||||
# consumer that only wants app-id / singleton helpers does not pull GTK4.
|
||||
gtk = ["bread-utils/gtk"]
|
||||
# `BreadClient` listen loop on `bread.command.<app>.**`. Matches
|
||||
# `bread-utils/bread-client`.
|
||||
bread-client = ["bread-utils/bread-client"]
|
||||
121
bread-app/src/command.rs
Normal file
121
bread-app/src/command.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
//! Command-bus helpers for `bread.command.<app>.**`.
|
||||
//!
|
||||
//! The `command_id` here is the breadd sibling-app id (`clip`, `box`,
|
||||
//! `shot`) — often shorter than the GTK / singleton name (`breadclip`).
|
||||
|
||||
use crate::id::{parse_app_name, InvalidAppId};
|
||||
use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
|
||||
|
||||
/// Same charset as [`parse_app_name`]: a single command-bus segment.
|
||||
pub fn parse_command_id(command_id: &str) -> Result<&str, InvalidAppId> {
|
||||
parse_app_name(command_id)
|
||||
}
|
||||
|
||||
/// Subscribe glob: `bread.command.<app>.**`.
|
||||
pub fn command_pattern(command_id: &str) -> Result<String, InvalidAppId> {
|
||||
let id = parse_command_id(command_id)?;
|
||||
Ok(format!("bread.command.{id}.**"))
|
||||
}
|
||||
|
||||
/// The verb segment of `bread.command.<app>.<verb>` (and extra trailing
|
||||
/// segments, if any). `None` when the event is not addressed to
|
||||
/// `command_id` or the verb is missing.
|
||||
///
|
||||
/// Extra dotted remainder (`bread.command.clip.stack.clear`) yields the
|
||||
/// first remaining segment (`stack`) — a verb is one segment, matching
|
||||
/// [`BreadClient::command`].
|
||||
pub fn command_verb<'a>(event: &'a str, command_id: &str) -> Option<&'a str> {
|
||||
if command_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let prefix = format!("bread.command.{command_id}.");
|
||||
let rest = event.strip_prefix(&prefix)?;
|
||||
let verb = rest.split('.').next()?;
|
||||
if verb.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(verb)
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to `bread.command.<command_id>.**` and invoke `on_verb` with
|
||||
/// the parsed verb plus the raw event.
|
||||
///
|
||||
/// Fail-silent: constructing the client and holding the subscription never
|
||||
/// requires breadd to be running. Drop the returned [`Subscription`] (or
|
||||
/// call [`Subscription::stop`]) to end the loop.
|
||||
pub fn listen_commands<F>(command_id: &str, on_verb: F) -> Result<Subscription, InvalidAppId>
|
||||
where
|
||||
F: Fn(&str, BreadEvent) + Send + 'static,
|
||||
{
|
||||
let id = parse_command_id(command_id)?.to_string();
|
||||
let client = BreadClient::connect(id.clone());
|
||||
let pattern = format!("bread.command.{id}.**");
|
||||
Ok(client.subscribe(pattern, move |event| {
|
||||
let Some(verb) = command_verb(&event.event, &id).map(str::to_owned) else {
|
||||
return;
|
||||
};
|
||||
on_verb(&verb, event);
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_pattern_uses_double_star() {
|
||||
assert_eq!(command_pattern("clip").unwrap(), "bread.command.clip.**");
|
||||
assert_eq!(command_pattern("shot").unwrap(), "bread.command.shot.**");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_pattern_rejects_invalid_id() {
|
||||
assert!(command_pattern("").is_err());
|
||||
assert!(command_pattern("clip.clear").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_verb_strips_app_prefix() {
|
||||
assert_eq!(
|
||||
command_verb("bread.command.clip.clear", "clip"),
|
||||
Some("clear")
|
||||
);
|
||||
assert_eq!(
|
||||
command_verb("bread.command.shot.region", "shot"),
|
||||
Some("region")
|
||||
);
|
||||
assert_eq!(
|
||||
command_verb("bread.command.shot.annotate", "shot"),
|
||||
Some("annotate")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_verb_takes_first_segment_only() {
|
||||
assert_eq!(
|
||||
command_verb("bread.command.clip.stack.clear", "clip"),
|
||||
Some("stack")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_verb_rejects_other_apps_and_missing_verb() {
|
||||
assert_eq!(command_verb("bread.command.clip.clear", "shot"), None);
|
||||
assert_eq!(command_verb("bread.command.clip", "clip"), None);
|
||||
assert_eq!(command_verb("bread.command.clip.", "clip"), None);
|
||||
assert_eq!(command_verb("bread.clip.copied", "clip"), None);
|
||||
assert_eq!(command_verb("bread.command.clip.clear", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_commands_rejects_invalid_id() {
|
||||
assert!(listen_commands("", |_, _| {}).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_commands_stop_joins_without_a_daemon() {
|
||||
let sub = listen_commands("clip", |_, _| {}).unwrap();
|
||||
sub.stop();
|
||||
}
|
||||
}
|
||||
145
bread-app/src/id.rs
Normal file
145
bread-app/src/id.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
//! App-id helpers shared by GTK tools and the singleton lock.
|
||||
//!
|
||||
//! The process / pid-file name (`breadbox`, `bread-polkit`) is also the
|
||||
//! last segment of the GApplication id (`com.breadway.breadbox`). That is
|
||||
//! *not* always the breadd command-bus id (`box`, `clip`) — see
|
||||
//! [`crate::command_verb`] under feature `bread-client`.
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::singleton::{self, Acquire, Toggle};
|
||||
|
||||
/// Why [`parse_app_name`] / [`application_id`] rejected a string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InvalidAppId {
|
||||
/// The rejected input, owned so the error is `'static`.
|
||||
pub name: String,
|
||||
/// Short reason suitable for an `io::Error` / clap message.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InvalidAppId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "invalid app id '{}': {}", self.name, self.reason)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for InvalidAppId {}
|
||||
|
||||
/// Accept a process / GTK application name (`breadbox`, `bread-polkit`).
|
||||
///
|
||||
/// Rules match a GApplication id *element*: non-empty, ASCII letter first,
|
||||
/// then ASCII alphanumeric / `-` / `_`. Dots are rejected so the name can
|
||||
/// sit in `com.breadway.<name>` without creating extra segments.
|
||||
pub fn parse_app_name(name: &str) -> Result<&str, InvalidAppId> {
|
||||
if name.is_empty() {
|
||||
return Err(InvalidAppId {
|
||||
name: name.to_string(),
|
||||
reason: "must not be empty",
|
||||
});
|
||||
}
|
||||
let mut chars = name.chars();
|
||||
let first = chars.next().expect("non-empty");
|
||||
if !first.is_ascii_alphabetic() {
|
||||
return Err(InvalidAppId {
|
||||
name: name.to_string(),
|
||||
reason: "must start with an ASCII letter",
|
||||
});
|
||||
}
|
||||
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err(InvalidAppId {
|
||||
name: name.to_string(),
|
||||
reason: "only ASCII letters, digits, '-' and '_' are allowed",
|
||||
});
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Reverse-DNS GApplication id: `com.breadway.<name>`.
|
||||
pub fn application_id(app_name: &str) -> Result<String, InvalidAppId> {
|
||||
let name = parse_app_name(app_name)?;
|
||||
Ok(format!("com.breadway.{name}"))
|
||||
}
|
||||
|
||||
/// [`singleton::try_acquire`] after [`parse_app_name`].
|
||||
///
|
||||
/// Invalid names become [`io::ErrorKind::InvalidInput`] and never touch
|
||||
/// the pid file.
|
||||
pub fn try_acquire(app_name: &str) -> io::Result<Acquire> {
|
||||
let name =
|
||||
parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
singleton::try_acquire(name)
|
||||
}
|
||||
|
||||
/// [`singleton::toggle_or_kill`] after [`parse_app_name`].
|
||||
pub fn toggle_or_kill(app_name: &str) -> io::Result<Toggle> {
|
||||
let name =
|
||||
parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
singleton::toggle_or_kill(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_app_name_accepts_existing_tool_names() {
|
||||
for name in ["breadbox", "breadclip", "bread-polkit", "breadcast"] {
|
||||
assert_eq!(parse_app_name(name), Ok(name));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_name_rejects_empty_dot_and_leading_digit() {
|
||||
assert!(parse_app_name("").is_err());
|
||||
assert!(parse_app_name("bread.box").is_err());
|
||||
assert!(parse_app_name("1box").is_err());
|
||||
assert!(parse_app_name("-box").is_err());
|
||||
assert!(parse_app_name("bread box").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_id_uses_com_breadway_prefix() {
|
||||
assert_eq!(application_id("breadbox").unwrap(), "com.breadway.breadbox");
|
||||
assert_eq!(
|
||||
application_id("bread-polkit").unwrap(),
|
||||
"com.breadway.bread-polkit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_id_rejects_invalid_name() {
|
||||
assert!(application_id("").is_err());
|
||||
assert!(application_id("bread.box").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_acquire_rejects_invalid_name_before_lock() {
|
||||
match try_acquire("") {
|
||||
Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput),
|
||||
Ok(_) => panic!("empty name must not acquire a lock"),
|
||||
}
|
||||
match try_acquire("bread.box") {
|
||||
Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput),
|
||||
Ok(_) => panic!("dotted name must not acquire a lock"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_acquire_accepts_valid_name() {
|
||||
let name = format!("bread-app-id-test-{}", std::process::id());
|
||||
match try_acquire(&name).unwrap() {
|
||||
Acquire::Acquired(_guard) => {}
|
||||
Acquire::HeldByOther(_) => panic!("expected first acquire to succeed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_or_kill_starts_when_nothing_else_is_running() {
|
||||
let name = format!("bread-app-toggle-test-{}", std::process::id());
|
||||
match toggle_or_kill(&name).unwrap() {
|
||||
Toggle::Started(_guard) => {}
|
||||
Toggle::KilledExisting => panic!("expected to start as the first instance"),
|
||||
}
|
||||
}
|
||||
}
|
||||
67
bread-app/src/lib.rs
Normal file
67
bread-app/src/lib.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
//! GTK application bootstrap for bread desktop tools.
|
||||
//!
|
||||
//! New GTK tools should depend on this crate instead of copying a sixth
|
||||
//! `main.rs` that wires a `com.breadway.*` application id, a
|
||||
//! [`bread_utils::singleton`] lock, a layer-shell overlay, and a
|
||||
//! `bread.command.<app>.**` listen loop.
|
||||
//!
|
||||
//! # What this is
|
||||
//!
|
||||
//! The pieces every bread GTK binary already copies:
|
||||
//!
|
||||
//! - [`application_id`] / [`parse_app_name`] — reverse-DNS id
|
||||
//! (`com.breadway.breadbox`) and the same name used for the singleton
|
||||
//! pid file.
|
||||
//! - [`try_acquire`] / [`toggle_or_kill`] — [`bread_utils::singleton`]
|
||||
//! wrappers that reject an invalid name before touching the lock.
|
||||
//! - feature `gtk` — re-exports [`gtk_popup`] (`bread_utils::gtk_popup`)
|
||||
//! for the full-screen overlay breadbox / breadclip / breadcast start
|
||||
//! from.
|
||||
//! - feature `bread-client` — [`listen_commands`] plus [`command_verb`] /
|
||||
//! [`command_pattern`] so a tool can honor `bread.command.<app>.**`
|
||||
//! without re-deriving the prefix strip.
|
||||
//!
|
||||
//! This crate does **not** migrate existing apps. Callers still own their
|
||||
//! widgets, CSS, and clap. Screenshot / `--screenshot` helpers stay in
|
||||
//! [`bread_utils::screenshot_cli`].
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let _guard = match bread_app::try_acquire("breadbox")? {
|
||||
//! bread_app::singleton::Acquire::Acquired(g) => g,
|
||||
//! bread_app::singleton::Acquire::HeldByOther(_) => return Ok(()),
|
||||
//! };
|
||||
//! let app = gtk4::Application::builder()
|
||||
//! .application_id(&bread_app::application_id("breadbox")?)
|
||||
//! .build();
|
||||
//!
|
||||
//! #[cfg(feature = "gtk")]
|
||||
//! app.connect_activate(|app| {
|
||||
//! let window = bread_app::gtk_popup::new_overlay_window(app, "breadbox");
|
||||
//! window.present();
|
||||
//! });
|
||||
//!
|
||||
//! #[cfg(feature = "bread-client")]
|
||||
//! let _commands = bread_app::listen_commands("box", |verb, event| {
|
||||
//! // verb is the single segment after `bread.command.box.`
|
||||
//! let _ = (verb, event);
|
||||
//! })?;
|
||||
//! ```
|
||||
|
||||
pub use bread_utils::singleton;
|
||||
|
||||
#[cfg(feature = "gtk")]
|
||||
pub use bread_utils::gtk_popup;
|
||||
|
||||
mod id;
|
||||
|
||||
pub use id::{application_id, parse_app_name, toggle_or_kill, try_acquire, InvalidAppId};
|
||||
|
||||
#[cfg(feature = "bread-client")]
|
||||
mod command;
|
||||
|
||||
#[cfg(feature = "bread-client")]
|
||||
pub use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
|
||||
#[cfg(feature = "bread-client")]
|
||||
pub use command::{command_pattern, command_verb, listen_commands, parse_command_id};
|
||||
27
bread-polkit/Cargo.toml
Normal file
27
bread-polkit/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "bread-polkit"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Themed PolicyKit authentication agent for the bread desktop"
|
||||
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
||||
keywords = ["polkit", "gtk4", "wayland"]
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bread-polkit"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
bread-app = { path = "../bread-app", features = ["gtk"] }
|
||||
bread-theme = { path = "../bread-theme", features = ["gtk"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||
serde = { workspace = true }
|
||||
tokio = { version = "1", features = ["rt", "net", "sync", "time", "macros", "io-util", "process"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "std"] }
|
||||
zbus = { version = "5", default-features = false, features = ["tokio"] }
|
||||
11
bread-polkit/bakery.toml
Normal file
11
bread-polkit/bakery.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
name = "bread-polkit"
|
||||
description = "Themed PolicyKit authentication agent for the bread desktop"
|
||||
binaries = ["bread-polkit"]
|
||||
system_deps = ["gtk4", "gtk4-layer-shell", "polkit"]
|
||||
optional_system_deps = ["hyprland"]
|
||||
bread_deps = []
|
||||
license_file = "LICENSE"
|
||||
desktop_file = "bread-polkit.desktop"
|
||||
|
||||
[install]
|
||||
post_install = []
|
||||
12
bread-polkit/contrib/bread-polkit.desktop
Normal file
12
bread-polkit/contrib/bread-polkit.desktop
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Bread PolicyKit Agent
|
||||
Comment=Themed PolicyKit authentication agent for the bread desktop
|
||||
Exec=bread-polkit
|
||||
Icon=dialog-password
|
||||
Terminal=false
|
||||
Categories=System;Security;
|
||||
StartupNotify=false
|
||||
X-GNOME-Autostart-Phase=Initialization
|
||||
X-GNOME-AutoRestart=true
|
||||
X-GNOME-Autostart-Notify=false
|
||||
10
bread-polkit/contrib/hyprland.conf
Normal file
10
bread-polkit/contrib/hyprland.conf
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# bread-polkit — add to hyprland.conf
|
||||
#
|
||||
# Session authentication agent. Copy contrib/bread-polkit.desktop to
|
||||
# ~/.config/autostart/ instead if you prefer XDG autostart.
|
||||
|
||||
exec-once = bread-polkit
|
||||
|
||||
# Optional: blur the overlay panel (namespace is bread-polkit).
|
||||
layerrule = blur, bread-polkit
|
||||
layerrule = ignorezero, bread-polkit
|
||||
300
bread-polkit/src/agent.rs
Normal file
300
bread-polkit/src/agent.rs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
//! Session-bus registration and the PolicyKit1 AuthenticationAgent.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gtk4::glib;
|
||||
use gtk4::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use zbus::zvariant::{OwnedValue, Type, Value};
|
||||
use zbus::{connection, interface, proxy, DBusError};
|
||||
|
||||
use bread_polkit::helper::{discover_transport, Transport};
|
||||
use bread_polkit::identity::{current_uid, pick_user, read_passwd, users_from_uids, UnixUser};
|
||||
use bread_polkit::session::session_id;
|
||||
|
||||
use crate::auth::{self, Outcome};
|
||||
use crate::ui::{self, Prompt};
|
||||
|
||||
pub const OBJECT_PATH: &str = "/com/breadway/PolicyKit1/AuthenticationAgent";
|
||||
|
||||
/// Reply from the GTK prompt.
|
||||
#[derive(Debug)]
|
||||
pub enum UserAction {
|
||||
Submit { username: String, password: String },
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Debug, DBusError)]
|
||||
#[zbus(prefix = "org.freedesktop.PolicyKit1.Error")]
|
||||
enum AgentError {
|
||||
#[zbus(error)]
|
||||
ZBus(zbus::Error),
|
||||
Failed(String),
|
||||
Cancelled(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Type)]
|
||||
struct Identity {
|
||||
kind: String,
|
||||
details: HashMap<String, OwnedValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Type)]
|
||||
struct Subject {
|
||||
kind: String,
|
||||
details: HashMap<String, OwnedValue>,
|
||||
}
|
||||
|
||||
#[proxy(
|
||||
interface = "org.freedesktop.PolicyKit1.Authority",
|
||||
default_service = "org.freedesktop.PolicyKit1",
|
||||
default_path = "/org/freedesktop/PolicyKit1/Authority"
|
||||
)]
|
||||
trait Authority {
|
||||
fn register_authentication_agent(
|
||||
&self,
|
||||
subject: &Subject,
|
||||
locale: &str,
|
||||
object_path: &str,
|
||||
) -> zbus::Result<()>;
|
||||
|
||||
fn unregister_authentication_agent(
|
||||
&self,
|
||||
subject: &Subject,
|
||||
object_path: &str,
|
||||
) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
struct Agent {
|
||||
transport: Transport,
|
||||
pending: Arc<Mutex<Option<mpsc::Sender<UserAction>>>>,
|
||||
}
|
||||
|
||||
#[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
|
||||
impl Agent {
|
||||
async fn begin_authentication(
|
||||
&mut self,
|
||||
action_id: String,
|
||||
message: String,
|
||||
_icon_name: String,
|
||||
_details: HashMap<String, String>,
|
||||
cookie: String,
|
||||
identities: Vec<Identity>,
|
||||
) -> Result<(), AgentError> {
|
||||
tracing::info!(%action_id, %cookie, "BeginAuthentication");
|
||||
|
||||
let users = unix_users(&identities);
|
||||
let username = pick_user(&users, current_uid())
|
||||
.map(|u| u.name.clone())
|
||||
.ok_or_else(|| AgentError::Failed("no unix-user identity".into()))?;
|
||||
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
*self.pending.lock().await = Some(tx.clone());
|
||||
|
||||
let prompt = Prompt {
|
||||
cookie: cookie.clone(),
|
||||
message: message.clone(),
|
||||
action_id: action_id.clone(),
|
||||
username: username.clone(),
|
||||
reply: tx,
|
||||
};
|
||||
invoke_ui(move || {
|
||||
if let Some(app) = running_app() {
|
||||
ui::show_prompt(&app, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
let result = self.drive_prompt(&cookie, &username, &mut rx).await;
|
||||
|
||||
*self.pending.lock().await = None;
|
||||
let cookie_close = cookie.clone();
|
||||
invoke_ui(move || ui::close_prompt(&cookie_close));
|
||||
result
|
||||
}
|
||||
|
||||
async fn cancel_authentication(&self, cookie: String) {
|
||||
tracing::info!(%cookie, "CancelAuthentication");
|
||||
if let Some(tx) = self.pending.lock().await.as_ref() {
|
||||
let _ = tx.try_send(UserAction::Cancel);
|
||||
}
|
||||
invoke_ui(move || ui::close_prompt(&cookie));
|
||||
}
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
async fn drive_prompt(
|
||||
&self,
|
||||
cookie: &str,
|
||||
default_user: &str,
|
||||
rx: &mut mpsc::Receiver<UserAction>,
|
||||
) -> Result<(), AgentError> {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
None => {
|
||||
return Err(AgentError::Cancelled("authentication prompt closed".into()));
|
||||
}
|
||||
Some(UserAction::Cancel) => {
|
||||
return Err(AgentError::Cancelled("user cancelled".into()));
|
||||
}
|
||||
Some(UserAction::Submit { username, password }) => {
|
||||
let user = if username.is_empty() {
|
||||
default_user
|
||||
} else {
|
||||
username.as_str()
|
||||
};
|
||||
match auth::authenticate(&self.transport, user, cookie, &password).await {
|
||||
Ok(Outcome::Success) => return Ok(()),
|
||||
Ok(Outcome::Failure { message }) => {
|
||||
let text = message
|
||||
.unwrap_or_else(|| auth::default_failure_message().to_string());
|
||||
let cookie = cookie.to_string();
|
||||
invoke_ui(move || ui::show_retry(&cookie, &text));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("helper: {e:#}");
|
||||
let text = e.to_string();
|
||||
let cookie = cookie.to_string();
|
||||
invoke_ui(move || ui::show_retry(&cookie, &text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_users(identities: &[Identity]) -> Vec<UnixUser> {
|
||||
let mut uids = Vec::new();
|
||||
for identity in identities {
|
||||
if identity.kind != "unix-user" {
|
||||
continue;
|
||||
}
|
||||
if let Some(uid) = uid_from_details(&identity.details) {
|
||||
uids.push(uid);
|
||||
}
|
||||
}
|
||||
users_from_uids(&uids, &read_passwd())
|
||||
}
|
||||
|
||||
fn uid_from_details(details: &HashMap<String, OwnedValue>) -> Option<u32> {
|
||||
let value = details.get("uid")?;
|
||||
u32::try_from(value).ok().or_else(|| {
|
||||
i32::try_from(value)
|
||||
.ok()
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn running_app() -> Option<gtk4::Application> {
|
||||
gtk4::gio::Application::default().and_then(|app| app.downcast::<gtk4::Application>().ok())
|
||||
}
|
||||
|
||||
/// GTK thread-default context, captured in [`spawn`] so the dbus thread
|
||||
/// can `invoke` onto the UI thread instead of its own empty context.
|
||||
static GTK_CTX: OnceLock<glib::MainContext> = OnceLock::new();
|
||||
|
||||
fn invoke_ui(f: impl FnOnce() + Send + 'static) {
|
||||
let ctx = GTK_CTX
|
||||
.get()
|
||||
.cloned()
|
||||
.unwrap_or_else(glib::MainContext::default);
|
||||
ctx.invoke(f);
|
||||
}
|
||||
|
||||
fn unix_session_subject(id: &str) -> Result<Subject> {
|
||||
let value = Value::from(id.to_string());
|
||||
let owned = OwnedValue::try_from(value).context("session-id variant")?;
|
||||
let mut details = HashMap::new();
|
||||
details.insert("session-id".into(), owned);
|
||||
Ok(Subject {
|
||||
kind: "unix-session".into(),
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the system-bus agent on a background thread. Returns once the
|
||||
/// thread has been started; registration errors quit the GTK app.
|
||||
///
|
||||
/// Must be called from the GTK thread so the main context we capture is
|
||||
/// the one driving the password prompt.
|
||||
pub fn spawn() -> Result<()> {
|
||||
let _ = GTK_CTX.set(glib::MainContext::default());
|
||||
std::thread::Builder::new()
|
||||
.name("bread-polkit-dbus".into())
|
||||
.spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
invoke_ui(move || {
|
||||
eprintln!("bread-polkit: tokio runtime failed: {e}");
|
||||
if let Some(app) = running_app() {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(async move {
|
||||
if let Err(e) = run().await {
|
||||
eprintln!("bread-polkit: {e:#}");
|
||||
invoke_ui(|| {
|
||||
if let Some(app) = running_app() {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.context("spawn dbus thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run() -> Result<()> {
|
||||
let transport = discover_transport().context(
|
||||
"no polkit helper: expected /run/polkit/agent-helper.socket \
|
||||
or /usr/lib/polkit-1/polkit-agent-helper-1",
|
||||
)?;
|
||||
tracing::info!(?transport, "using polkit helper");
|
||||
|
||||
let session = session_id().context(
|
||||
"no session id (XDG_SESSION_ID / /proc/self/sessionid); \
|
||||
cannot register a session authentication agent",
|
||||
)?;
|
||||
let subject = unix_session_subject(&session)?;
|
||||
let locale = std::env::var("LANG").unwrap_or_else(|_| "C".into());
|
||||
|
||||
let agent = Agent {
|
||||
transport,
|
||||
pending: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
let connection = connection::Builder::system()?
|
||||
.serve_at(OBJECT_PATH, agent)?
|
||||
.build()
|
||||
.await
|
||||
.context("system bus")?;
|
||||
|
||||
let authority = AuthorityProxy::new(&connection)
|
||||
.await
|
||||
.context("PolicyKit1 authority proxy")?;
|
||||
authority
|
||||
.register_authentication_agent(&subject, &locale, OBJECT_PATH)
|
||||
.await
|
||||
.context("RegisterAuthenticationAgent")?;
|
||||
tracing::info!(%session, "registered as PolicyKit authentication agent");
|
||||
|
||||
std::future::pending::<()>().await;
|
||||
#[allow(unreachable_code)]
|
||||
{
|
||||
let _ = authority
|
||||
.unregister_authentication_agent(&subject, OBJECT_PATH)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
109
bread-polkit/src/auth.rs
Normal file
109
bread-polkit/src/auth.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! PAM conversation with the polkit agent helper.
|
||||
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::process::Command;
|
||||
|
||||
use bread_polkit::helper::{parse_helper_line, HelperLine, Transport};
|
||||
|
||||
/// Outcome of one helper conversation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Success,
|
||||
Failure { message: Option<String> },
|
||||
}
|
||||
|
||||
/// Handshake + PAM loop for one password attempt.
|
||||
pub async fn authenticate(
|
||||
transport: &Transport,
|
||||
username: &str,
|
||||
cookie: &str,
|
||||
password: &str,
|
||||
) -> Result<Outcome> {
|
||||
match transport {
|
||||
Transport::Socket(path) => {
|
||||
let mut stream = UnixStream::connect(path)
|
||||
.await
|
||||
.with_context(|| format!("connect {}", path.display()))?;
|
||||
stream.write_all(username.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
stream.write_all(cookie.as_bytes()).await?;
|
||||
stream.write_all(b"\n").await?;
|
||||
let (reader, writer) = stream.into_split();
|
||||
converse(BufReader::new(reader), writer, password).await
|
||||
}
|
||||
Transport::Exec(path) => {
|
||||
let mut child = Command::new(path)
|
||||
.arg(username)
|
||||
.env("LC_ALL", "C")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.with_context(|| format!("spawn {}", path.display()))?;
|
||||
let mut stdin = child.stdin.take().context("polkit helper has no stdin")?;
|
||||
let stdout = child.stdout.take().context("polkit helper has no stdout")?;
|
||||
stdin.write_all(cookie.as_bytes()).await?;
|
||||
stdin.write_all(b"\n").await?;
|
||||
let outcome = converse(BufReader::new(stdout), stdin, password).await;
|
||||
let _ = child.wait().await;
|
||||
outcome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn converse<R, W>(mut reader: BufReader<R>, mut writer: W, password: &str) -> Result<Outcome>
|
||||
where
|
||||
R: tokio::io::AsyncRead + Unpin,
|
||||
W: tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let mut last_info: Option<String> = None;
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
return Ok(Outcome::Failure {
|
||||
message: last_info.take(),
|
||||
});
|
||||
}
|
||||
match parse_helper_line(&line) {
|
||||
HelperLine::PromptEchoOff(_) => {
|
||||
writer.write_all(password.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
HelperLine::PromptEchoOn(_) => {
|
||||
// Visible prompt (username, etc.) — we already sent the
|
||||
// identity in the handshake. An empty line is safer than
|
||||
// echoing the password.
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
HelperLine::ErrorMsg(msg) | HelperLine::TextInfo(msg) => {
|
||||
if !msg.is_empty() {
|
||||
last_info = Some(msg);
|
||||
}
|
||||
}
|
||||
HelperLine::Success => return Ok(Outcome::Success),
|
||||
HelperLine::Failure => {
|
||||
return Ok(Outcome::Failure {
|
||||
message: last_info.take(),
|
||||
});
|
||||
}
|
||||
HelperLine::Other(other) => {
|
||||
if !other.is_empty() {
|
||||
tracing::debug!("helper: {other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared default when the helper gives no `PAM_*` text on failure.
|
||||
pub fn default_failure_message() -> &'static str {
|
||||
"Authentication failed. Try again."
|
||||
}
|
||||
205
bread-polkit/src/helper.rs
Normal file
205
bread-polkit/src/helper.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
//! `polkit-agent-helper-1` transport and PAM line parser.
|
||||
//!
|
||||
//! Arch polkit 127+ talks over `/run/polkit/agent-helper.socket`. Older
|
||||
//! builds still spawn the setuid helper at
|
||||
//! `/usr/lib/polkit-1/polkit-agent-helper-1`. Prefer the socket when it
|
||||
//! exists.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// How this agent will talk to polkit's helper.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Transport {
|
||||
/// systemd socket-activated helper (polkit 127+).
|
||||
Socket(PathBuf),
|
||||
/// Legacy setuid helper binary.
|
||||
Exec(PathBuf),
|
||||
}
|
||||
|
||||
const SOCKET_CANDIDATES: &[&str] = &["/run/polkit/agent-helper.socket"];
|
||||
const HELPER_CANDIDATES: &[&str] = &[
|
||||
"/usr/lib/polkit-1/polkit-agent-helper-1",
|
||||
"/usr/libexec/polkit-1/polkit-agent-helper-1",
|
||||
];
|
||||
|
||||
/// One stdout line from the helper after the cookie handshake.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HelperLine {
|
||||
PromptEchoOff(String),
|
||||
PromptEchoOn(String),
|
||||
ErrorMsg(String),
|
||||
TextInfo(String),
|
||||
Success,
|
||||
Failure,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Pick a live transport: `BREAD_POLKIT_SOCKET` / `BREAD_POLKIT_HELPER`
|
||||
/// if set and present, otherwise the first existing well-known path.
|
||||
pub fn discover_transport() -> Option<Transport> {
|
||||
discover_transport_from(
|
||||
std::env::var_os("BREAD_POLKIT_SOCKET")
|
||||
.map(PathBuf::from)
|
||||
.as_deref(),
|
||||
std::env::var_os("BREAD_POLKIT_HELPER")
|
||||
.map(PathBuf::from)
|
||||
.as_deref(),
|
||||
SOCKET_CANDIDATES,
|
||||
HELPER_CANDIDATES,
|
||||
|p| p.exists(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Testable discovery: `exists` is injected so unit tests do not need a
|
||||
/// real `/run/polkit` socket.
|
||||
pub fn discover_transport_from(
|
||||
socket_override: Option<&Path>,
|
||||
helper_override: Option<&Path>,
|
||||
sockets: &[&str],
|
||||
helpers: &[&str],
|
||||
exists: impl Fn(&Path) -> bool,
|
||||
) -> Option<Transport> {
|
||||
if let Some(path) = socket_override {
|
||||
if exists(path) {
|
||||
return Some(Transport::Socket(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
for candidate in sockets {
|
||||
let path = Path::new(candidate);
|
||||
if exists(path) {
|
||||
return Some(Transport::Socket(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
if let Some(path) = helper_override {
|
||||
if exists(path) {
|
||||
return Some(Transport::Exec(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
for candidate in helpers {
|
||||
let path = Path::new(candidate);
|
||||
if exists(path) {
|
||||
return Some(Transport::Exec(path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse one helper protocol line. Prefix match is case-sensitive and
|
||||
/// matches polkit's own `PAM_*` / `SUCCESS` / `FAILURE` tokens.
|
||||
pub fn parse_helper_line(line: &str) -> HelperLine {
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
if line == "SUCCESS" || line.starts_with("SUCCESS") {
|
||||
return HelperLine::Success;
|
||||
}
|
||||
if line == "FAILURE" || line.starts_with("FAILURE") {
|
||||
return HelperLine::Failure;
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_OFF") {
|
||||
return HelperLine::PromptEchoOff(rest.trim().to_string());
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_ON") {
|
||||
return HelperLine::PromptEchoOn(rest.trim().to_string());
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("PAM_ERROR_MSG") {
|
||||
return HelperLine::ErrorMsg(rest.trim().to_string());
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("PAM_TEXT_INFO") {
|
||||
return HelperLine::TextInfo(rest.trim().to_string());
|
||||
}
|
||||
HelperLine::Other(line.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn parse_helper_line_known_tokens() {
|
||||
assert_eq!(parse_helper_line("SUCCESS"), HelperLine::Success);
|
||||
assert_eq!(parse_helper_line("SUCCESS\n"), HelperLine::Success);
|
||||
assert_eq!(parse_helper_line("FAILURE"), HelperLine::Failure);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_PROMPT_ECHO_OFF Password:"),
|
||||
HelperLine::PromptEchoOff("Password:".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_PROMPT_ECHO_OFF"),
|
||||
HelperLine::PromptEchoOff(String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_PROMPT_ECHO_ON login:"),
|
||||
HelperLine::PromptEchoOn("login:".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_ERROR_MSG Authentication failure"),
|
||||
HelperLine::ErrorMsg("Authentication failure".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("PAM_TEXT_INFO Account locked"),
|
||||
HelperLine::TextInfo("Account locked".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_helper_line("garbage"),
|
||||
HelperLine::Other("garbage".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_prefers_socket_over_exec() {
|
||||
let present: HashSet<PathBuf> = [
|
||||
"/run/polkit/agent-helper.socket",
|
||||
"/usr/lib/polkit-1/polkit-agent-helper-1",
|
||||
]
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| {
|
||||
present.contains(p)
|
||||
});
|
||||
assert_eq!(
|
||||
got,
|
||||
Some(Transport::Socket(PathBuf::from(
|
||||
"/run/polkit/agent-helper.socket"
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_falls_back_to_helper_binary() {
|
||||
let present: HashSet<PathBuf> = ["/usr/lib/polkit-1/polkit-agent-helper-1"]
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| {
|
||||
present.contains(p)
|
||||
});
|
||||
assert_eq!(
|
||||
got,
|
||||
Some(Transport::Exec(PathBuf::from(
|
||||
"/usr/lib/polkit-1/polkit-agent-helper-1"
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_override_socket_wins_when_present() {
|
||||
let override_path = Path::new("/tmp/bread-polkit-test.sock");
|
||||
let got = discover_transport_from(
|
||||
Some(override_path),
|
||||
None,
|
||||
SOCKET_CANDIDATES,
|
||||
HELPER_CANDIDATES,
|
||||
|p| p == override_path,
|
||||
);
|
||||
assert_eq!(got, Some(Transport::Socket(override_path.to_path_buf())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_none_when_nothing_exists() {
|
||||
let got =
|
||||
discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |_| false);
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
}
|
||||
128
bread-polkit/src/identity.rs
Normal file
128
bread-polkit/src/identity.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
//! Unix-user identities from a PolicyKit `BeginAuthentication` call.
|
||||
|
||||
/// A `unix-user` identity the agent can authenticate as.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnixUser {
|
||||
pub uid: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Look up `uid` in a passwd-file dump (`name:x:uid:...` lines).
|
||||
pub fn name_for_uid(uid: u32, passwd: &str) -> Option<String> {
|
||||
for line in passwd.lines() {
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let mut parts = line.split(':');
|
||||
let name = parts.next()?;
|
||||
let _pw = parts.next()?;
|
||||
let id = parts.next()?.parse::<u32>().ok()?;
|
||||
if id == uid && !name.is_empty() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve each uid to a [`UnixUser`], falling back to `uid N` when
|
||||
/// `/etc/passwd` has no name.
|
||||
pub fn users_from_uids(uids: &[u32], passwd: &str) -> Vec<UnixUser> {
|
||||
uids.iter()
|
||||
.copied()
|
||||
.map(|uid| UnixUser {
|
||||
uid,
|
||||
name: name_for_uid(uid, passwd).unwrap_or_else(|| format!("uid {uid}")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Prefer the process's own uid when it is in `users`, otherwise the first.
|
||||
pub fn pick_user<'a>(users: &'a [UnixUser], current_uid: Option<u32>) -> Option<&'a UnixUser> {
|
||||
if let Some(uid) = current_uid {
|
||||
if let Some(user) = users.iter().find(|u| u.uid == uid) {
|
||||
return Some(user);
|
||||
}
|
||||
}
|
||||
users.first()
|
||||
}
|
||||
|
||||
/// Real uid from a `/proc/self/status` dump (`Uid:\t<real> ...`).
|
||||
pub fn uid_from_status(status: &str) -> Option<u32> {
|
||||
for line in status.lines() {
|
||||
let Some(rest) = line.strip_prefix("Uid:") else {
|
||||
continue;
|
||||
};
|
||||
return rest.split_whitespace().next()?.parse().ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Current real uid, or `None` if `/proc/self/status` is unreadable.
|
||||
pub fn current_uid() -> Option<u32> {
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
uid_from_status(&status)
|
||||
}
|
||||
|
||||
/// Contents of `/etc/passwd`, or empty if unreadable.
|
||||
pub fn read_passwd() -> String {
|
||||
std::fs::read_to_string("/etc/passwd").unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PASSWD: &str = "\
|
||||
# comment
|
||||
root:x:0:0:root:/root:/bin/sh
|
||||
alice:x:1000:1000:Alice:/home/alice:/bin/zsh
|
||||
bob:x:1001:1001:Bob:/home/bob:/bin/bash
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn name_for_uid_reads_passwd_lines() {
|
||||
assert_eq!(name_for_uid(0, PASSWD).as_deref(), Some("root"));
|
||||
assert_eq!(name_for_uid(1000, PASSWD).as_deref(), Some("alice"));
|
||||
assert_eq!(name_for_uid(99, PASSWD), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn users_from_uids_falls_back_to_uid_label() {
|
||||
let users = users_from_uids(&[1000, 42], PASSWD);
|
||||
assert_eq!(
|
||||
users,
|
||||
vec![
|
||||
UnixUser {
|
||||
uid: 1000,
|
||||
name: "alice".into()
|
||||
},
|
||||
UnixUser {
|
||||
uid: 42,
|
||||
name: "uid 42".into()
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_user_prefers_current_uid() {
|
||||
let users = users_from_uids(&[0, 1000], PASSWD);
|
||||
let picked = pick_user(&users, Some(1000)).unwrap();
|
||||
assert_eq!(picked.name, "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_user_falls_back_to_first() {
|
||||
let users = users_from_uids(&[0, 1000], PASSWD);
|
||||
let picked = pick_user(&users, Some(7)).unwrap();
|
||||
assert_eq!(picked.name, "root");
|
||||
assert!(pick_user(&[], Some(1000)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uid_from_status_reads_real_uid() {
|
||||
let status = "Name:\tbread-polkit\nUid:\t1000\t1000\t1000\t1000\n";
|
||||
assert_eq!(uid_from_status(status), Some(1000));
|
||||
assert_eq!(uid_from_status("Name:\tfoo\n"), None);
|
||||
}
|
||||
}
|
||||
10
bread-polkit/src/lib.rs
Normal file
10
bread-polkit/src/lib.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
//! Non-GTK PolicyKit helper logic for `bread-polkit`.
|
||||
//!
|
||||
//! The binary (`bread-polkit`) registers as a session authentication
|
||||
//! agent and shows a themed password prompt. This library is the
|
||||
//! transport / identity / session parsing that can be unit-tested
|
||||
//! without a display.
|
||||
|
||||
pub mod helper;
|
||||
pub mod identity;
|
||||
pub mod session;
|
||||
94
bread-polkit/src/main.rs
Normal file
94
bread-polkit/src/main.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//! bread-polkit — themed PolicyKit authentication agent.
|
||||
//!
|
||||
//! Registers on the `org.freedesktop.PolicyKit1.AuthenticationAgent`
|
||||
//! interface and shows a bread-theme GTK4 password prompt. This is an
|
||||
//! agent, not a wrapper that execs `polkit-gnome`.
|
||||
//!
|
||||
//! Autostart: copy `contrib/bread-polkit.desktop` to
|
||||
//! `~/.config/autostart/`, or add `exec-once = bread-polkit` to Hyprland.
|
||||
|
||||
mod agent;
|
||||
mod auth;
|
||||
mod ui;
|
||||
|
||||
use bread_app::singleton::Acquire;
|
||||
use gtk4::prelude::*;
|
||||
|
||||
const APP_NAME: &str = "bread-polkit";
|
||||
|
||||
fn main() {
|
||||
let arg = std::env::args().nth(1);
|
||||
match arg.as_deref() {
|
||||
Some("-h") | Some("--help") => {
|
||||
print_help();
|
||||
return;
|
||||
}
|
||||
Some("-V") | Some("--version") => {
|
||||
println!("bread-polkit {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
Some(other) => {
|
||||
eprintln!("bread-polkit: unknown argument '{other}'");
|
||||
print_help();
|
||||
std::process::exit(2);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
let _guard = match bread_app::try_acquire(APP_NAME) {
|
||||
Ok(Acquire::Acquired(g)) => Some(g),
|
||||
Ok(Acquire::HeldByOther(pid)) => {
|
||||
eprintln!("bread-polkit: already running (pid {pid:?})");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("bread-polkit: singleton lock unavailable ({e}); continuing");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let app_id = bread_app::application_id(APP_NAME).expect("static app name");
|
||||
let app = gtk4::Application::builder().application_id(&app_id).build();
|
||||
|
||||
app.connect_activate(|app| {
|
||||
bread_theme::gtk::apply_shared();
|
||||
bread_theme::gtk::apply_app_css(ui::app_css);
|
||||
// No window until polkit asks; hold so GApplication stays alive.
|
||||
std::mem::forget(app.hold());
|
||||
if let Err(e) = agent::spawn() {
|
||||
eprintln!("bread-polkit: {e:#}");
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
print!(
|
||||
"\
|
||||
bread-polkit — themed PolicyKit authentication agent
|
||||
|
||||
Usage:
|
||||
bread-polkit
|
||||
bread-polkit --help
|
||||
bread-polkit --version
|
||||
|
||||
Autostart (pick one):
|
||||
cp contrib/bread-polkit.desktop ~/.config/autostart/
|
||||
exec-once = bread-polkit # Hyprland
|
||||
|
||||
The agent talks to the polkit1 AuthenticationAgent API and prompts for
|
||||
a password. It does not exec polkit-gnome. Not a bakery product; not
|
||||
on the BOS ISO lockfile.
|
||||
"
|
||||
);
|
||||
}
|
||||
49
bread-polkit/src/session.rs
Normal file
49
bread-polkit/src/session.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! Session subject for `RegisterAuthenticationAgent`.
|
||||
|
||||
/// Logind session id from `XDG_SESSION_ID`, falling back to
|
||||
/// `/proc/self/sessionid` when the kernel has one.
|
||||
pub fn session_id() -> Option<String> {
|
||||
let xdg = std::env::var("XDG_SESSION_ID").ok();
|
||||
let proc = std::fs::read_to_string("/proc/self/sessionid").ok();
|
||||
session_id_from(xdg.as_deref(), proc.as_deref())
|
||||
}
|
||||
|
||||
/// `None` when both sources are empty or the kernel reports the
|
||||
/// unsigned `-1` sentinel (`4294967295`) meaning "no session".
|
||||
pub fn session_id_from(xdg: Option<&str>, proc_sessionid: Option<&str>) -> Option<String> {
|
||||
if let Some(id) = xdg.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
return Some(id.to_string());
|
||||
}
|
||||
let raw = proc_sessionid?.trim();
|
||||
if raw.is_empty() || raw == "4294967295" {
|
||||
return None;
|
||||
}
|
||||
Some(raw.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prefers_xdg_session_id() {
|
||||
assert_eq!(session_id_from(Some("3"), Some("7")).as_deref(), Some("3"));
|
||||
assert_eq!(
|
||||
session_id_from(Some(" 3 "), Some("7")).as_deref(),
|
||||
Some("3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_proc_sessionid() {
|
||||
assert_eq!(session_id_from(Some(""), Some("7")).as_deref(), Some("7"));
|
||||
assert_eq!(session_id_from(None, Some("7\n")).as_deref(), Some("7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unset_kernel_session() {
|
||||
assert_eq!(session_id_from(None, Some("4294967295")), None);
|
||||
assert_eq!(session_id_from(Some(""), Some("")), None);
|
||||
assert_eq!(session_id_from(None, None), None);
|
||||
}
|
||||
}
|
||||
286
bread-polkit/src/ui.rs
Normal file
286
bread-polkit/src/ui.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
//! GTK4 password prompt, themed with bread-theme.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::gdk::Key;
|
||||
use gtk4::glib::{self, Propagation};
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
Align, Application, ApplicationWindow, Box as GBox, Button, Entry, EventControllerKey, Label,
|
||||
Orientation,
|
||||
};
|
||||
|
||||
use bread_theme::tokens;
|
||||
|
||||
use crate::agent::UserAction;
|
||||
|
||||
const PANEL_WIDTH: i32 = 400;
|
||||
|
||||
struct Active {
|
||||
cookie: String,
|
||||
window: ApplicationWindow,
|
||||
password: Entry,
|
||||
error: Label,
|
||||
reply: tokio::sync::mpsc::Sender<UserAction>,
|
||||
username: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ACTIVE: RefCell<Option<Active>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// App-specific rules layered on the shared bread-theme stylesheet.
|
||||
pub fn app_css() -> String {
|
||||
format!(
|
||||
".polkit-panel {{\
|
||||
background-color: @surface; color: @on-surface;\
|
||||
border-radius: {r}px; padding: {pad}px;\
|
||||
min-width: {w}px;\
|
||||
}}\n\
|
||||
.polkit-title {{ font-size: 1.4em; font-weight: bold; }}\n\
|
||||
.polkit-message {{ opacity: 0.85; }}\n\
|
||||
.polkit-identity {{ opacity: 0.7; font-size: {sec}px; }}\n\
|
||||
.polkit-error {{ color: @on-red; }}\n\
|
||||
.polkit-buttons {{ padding-top: {sm}px; }}\n",
|
||||
r = tokens::RADIUS_PRIMARY,
|
||||
pad = tokens::SPACE_XL,
|
||||
w = PANEL_WIDTH,
|
||||
sec = tokens::FONT_SIZE_SECONDARY,
|
||||
sm = tokens::SPACE_SM,
|
||||
)
|
||||
}
|
||||
|
||||
pub struct Prompt {
|
||||
pub cookie: String,
|
||||
pub message: String,
|
||||
pub action_id: String,
|
||||
pub username: String,
|
||||
pub reply: tokio::sync::mpsc::Sender<UserAction>,
|
||||
}
|
||||
|
||||
/// Show (or replace) the password overlay for this cookie.
|
||||
pub fn show_prompt(app: &Application, prompt: Prompt) {
|
||||
close_if_other_cookie(&prompt.cookie);
|
||||
|
||||
if ACTIVE.with(|a| {
|
||||
a.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.cookie == prompt.cookie)
|
||||
}) {
|
||||
present_existing(&prompt);
|
||||
return;
|
||||
}
|
||||
|
||||
let window = bread_app::gtk_popup::new_overlay_window(app, "bread-polkit");
|
||||
|
||||
let panel = GBox::new(Orientation::Vertical, tokens::SPACE_MD as i32);
|
||||
panel.add_css_class("polkit-panel");
|
||||
panel.add_css_class("card");
|
||||
panel.set_halign(Align::Center);
|
||||
panel.set_valign(Align::Center);
|
||||
panel.set_size_request(PANEL_WIDTH, -1);
|
||||
|
||||
let title = Label::new(Some("Authentication required"));
|
||||
title.add_css_class("polkit-title");
|
||||
title.add_css_class("page-title");
|
||||
title.set_halign(Align::Start);
|
||||
title.set_wrap(true);
|
||||
panel.append(&title);
|
||||
|
||||
let message = if prompt.message.trim().is_empty() {
|
||||
prompt.action_id.clone()
|
||||
} else {
|
||||
prompt.message.clone()
|
||||
};
|
||||
let msg = Label::new(Some(&message));
|
||||
msg.add_css_class("polkit-message");
|
||||
msg.set_halign(Align::Start);
|
||||
msg.set_wrap(true);
|
||||
msg.set_xalign(0.0);
|
||||
panel.append(&msg);
|
||||
|
||||
if !prompt.username.is_empty() {
|
||||
let identity = Label::new(Some(&format!("Authenticating as {}", prompt.username)));
|
||||
identity.add_css_class("polkit-identity");
|
||||
identity.add_css_class("dim-label");
|
||||
identity.set_halign(Align::Start);
|
||||
panel.append(&identity);
|
||||
}
|
||||
|
||||
let error = Label::new(None);
|
||||
error.add_css_class("polkit-error");
|
||||
error.set_halign(Align::Start);
|
||||
error.set_wrap(true);
|
||||
error.set_visible(false);
|
||||
panel.append(&error);
|
||||
|
||||
let password = Entry::builder()
|
||||
.visibility(false)
|
||||
.input_purpose(gtk4::InputPurpose::Password)
|
||||
.placeholder_text("Password")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
panel.append(&password);
|
||||
|
||||
let buttons = GBox::new(Orientation::Horizontal, tokens::SPACE_SM as i32);
|
||||
buttons.add_css_class("polkit-buttons");
|
||||
buttons.set_halign(Align::End);
|
||||
let cancel = Button::with_label("Cancel");
|
||||
cancel.add_css_class("flat");
|
||||
let confirm = Button::with_label("Authenticate");
|
||||
confirm.add_css_class("suggested-action");
|
||||
buttons.append(&cancel);
|
||||
buttons.append(&confirm);
|
||||
panel.append(&buttons);
|
||||
|
||||
window.set_child(Some(&panel));
|
||||
bread_theme::gtk::bind_window_auto_with_app_css(&window, |_| app_css());
|
||||
|
||||
let reply = prompt.reply.clone();
|
||||
let cookie = prompt.cookie.clone();
|
||||
let username = prompt.username.clone();
|
||||
|
||||
let submit = {
|
||||
let password = password.clone();
|
||||
let reply = reply.clone();
|
||||
let username = username.clone();
|
||||
Rc::new(move || {
|
||||
let secret = password.text().to_string();
|
||||
password.set_text("");
|
||||
let _ = reply.try_send(UserAction::Submit {
|
||||
username: username.clone(),
|
||||
password: secret,
|
||||
});
|
||||
})
|
||||
};
|
||||
let cancel_fn = {
|
||||
let reply = reply.clone();
|
||||
let window = window.clone();
|
||||
Rc::new(move || {
|
||||
let _ = reply.try_send(UserAction::Cancel);
|
||||
window.close();
|
||||
ACTIVE.with(|a| a.replace(None));
|
||||
})
|
||||
};
|
||||
|
||||
confirm.connect_clicked({
|
||||
let submit = submit.clone();
|
||||
move |_| submit()
|
||||
});
|
||||
password.connect_activate({
|
||||
let submit = submit.clone();
|
||||
move |_| submit()
|
||||
});
|
||||
cancel.connect_clicked({
|
||||
let cancel_fn = cancel_fn.clone();
|
||||
move |_| cancel_fn()
|
||||
});
|
||||
|
||||
let keys = EventControllerKey::new();
|
||||
keys.connect_key_pressed({
|
||||
let cancel_fn = cancel_fn.clone();
|
||||
move |_, key, _, _| {
|
||||
if key == Key::Escape {
|
||||
cancel_fn();
|
||||
Propagation::Stop
|
||||
} else {
|
||||
Propagation::Proceed
|
||||
}
|
||||
}
|
||||
});
|
||||
window.add_controller(keys);
|
||||
|
||||
bread_app::gtk_popup::close_on_outside_click(&window, &panel, {
|
||||
let cancel_fn = cancel_fn.clone();
|
||||
move || cancel_fn()
|
||||
});
|
||||
|
||||
window.connect_close_request({
|
||||
let reply = reply.clone();
|
||||
move |_| {
|
||||
let closing_ours = ACTIVE.with(|a| {
|
||||
a.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.cookie == cookie)
|
||||
});
|
||||
if closing_ours {
|
||||
let _ = reply.try_send(UserAction::Cancel);
|
||||
ACTIVE.with(|a| a.replace(None));
|
||||
}
|
||||
glib::Propagation::Proceed
|
||||
}
|
||||
});
|
||||
|
||||
ACTIVE.with(|a| {
|
||||
*a.borrow_mut() = Some(Active {
|
||||
cookie: prompt.cookie,
|
||||
window: window.clone(),
|
||||
password: password.clone(),
|
||||
error,
|
||||
reply,
|
||||
username,
|
||||
});
|
||||
});
|
||||
|
||||
window.present();
|
||||
password.grab_focus();
|
||||
}
|
||||
|
||||
fn present_existing(prompt: &Prompt) {
|
||||
ACTIVE.with(|a| {
|
||||
if let Some(active) = a.borrow_mut().as_mut() {
|
||||
active.reply = prompt.reply.clone();
|
||||
active.username = prompt.username.clone();
|
||||
active.error.set_visible(false);
|
||||
active.password.set_text("");
|
||||
active.window.present();
|
||||
active.password.grab_focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show a retry message on the open dialog for `cookie`.
|
||||
pub fn show_retry(cookie: &str, message: &str) {
|
||||
ACTIVE.with(|a| {
|
||||
let mut guard = a.borrow_mut();
|
||||
let Some(active) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if active.cookie != cookie {
|
||||
return;
|
||||
}
|
||||
active.error.set_label(message);
|
||||
active.error.set_visible(true);
|
||||
active.password.set_text("");
|
||||
active.window.present();
|
||||
active.password.grab_focus();
|
||||
});
|
||||
}
|
||||
|
||||
/// Close the dialog if it is still showing `cookie`.
|
||||
pub fn close_prompt(cookie: &str) {
|
||||
ACTIVE.with(|a| {
|
||||
let Some(active) = a.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
if active.cookie == cookie {
|
||||
active.window.close();
|
||||
} else {
|
||||
*a.borrow_mut() = Some(active);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn close_if_other_cookie(cookie: &str) {
|
||||
ACTIVE.with(|a| {
|
||||
let Some(active) = a.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
if active.cookie == cookie {
|
||||
*a.borrow_mut() = Some(active);
|
||||
} else {
|
||||
active.window.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,11 +1,48 @@
|
|||
# bread-theme changelog
|
||||
|
||||
## 0.7.4
|
||||
|
||||
Per-output (per-monitor) theming. Session-global `theme.css` remains the
|
||||
fallback / focused-monitor sheet; each Hyprland/GDK connector can now have
|
||||
its own palette and stylesheet. BOS still keeps bg/surface/overlay/fg
|
||||
fixed — only color1–6 come from the wallpaper.
|
||||
|
||||
On disk under `$XDG_RUNTIME_DIR/bread/` (same fallback as `shared_css_path`):
|
||||
|
||||
- `palettes/<sanitized-output>.json` — accents only (round-trips through
|
||||
`from_wal_json` / a color1–6 object; never persists pywal's light bg)
|
||||
- `themes/<sanitized-output>.css` — `stylesheet()` for that palette
|
||||
|
||||
New lib API:
|
||||
|
||||
- `themes_dir`, `palettes_dir`, `output_css_path`, `output_palette_path`,
|
||||
`sanitize_output`
|
||||
- `load_palette_for`, `write_output_palette`, `write_output_css`,
|
||||
`write_shared_css_from`
|
||||
- `palette_from_image` (isolated `wal -i`, does not touch `~/.cache/wal`),
|
||||
`generate_output`, `palette_from_json`
|
||||
- `stylesheet_resolved` — inlines `@accent` / `@on-bg` / … to hex so GTK's
|
||||
display-global `@define-color` cannot leak the wrong monitor's accent
|
||||
|
||||
GTK (`gtk` feature): `bind_window`, `bind_window_with_app_css`,
|
||||
`output_for_widget`, `bind_window_auto`, `bind_window_auto_with_app_css`.
|
||||
Widget-scoped providers at `USER - 10` so they beat `apply_shared` but
|
||||
lose to user CSS. Existing `apply_shared` / `apply_app_css` /
|
||||
`apply_css` / `apply_user_css` are unchanged.
|
||||
|
||||
CLI: `bread-theme generate-output <OUTPUT> --image <PATH> | --from-json
|
||||
<FILE> [--shared]`. Does not write `theme.css` unless `--shared`.
|
||||
|
||||
## Coordinated bump policy
|
||||
|
||||
`bread-theme` is consumed by `breadbar`, `breadbox`, and `breadpad` as a pinned
|
||||
git dependency. A breaking change to `Palette`, `css_vars`, or the `gtk` feature
|
||||
API requires all three dependents to bump their `Cargo.toml` git tag and cut a
|
||||
release together. Note the impact in this file before tagging.
|
||||
`bread-theme` is consumed by `breadbar`, `breadbox`, `breadpad`, and the other
|
||||
GTK bread apps as a pinned git dependency. A breaking change to `Palette`,
|
||||
`css_vars`, or the `gtk` feature API requires dependents to bump their
|
||||
`Cargo.toml` git tag and cut a release together. Note the impact in this file
|
||||
before tagging.
|
||||
|
||||
**0.7.4** adds per-output bind APIs (`bind_window*`, `load_palette_for`,
|
||||
`generate_output`). Apps that call those must pin `tag = "v0.7.4"`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
//! # signal every running bread GUI to recolour
|
||||
//! bread-theme path # print the stylesheet path
|
||||
//! bread-theme print # render to stdout (no write)
|
||||
//! bread-theme generate-output <OUTPUT> --image <PATH> [--shared]
|
||||
//! bread-theme generate-output <OUTPUT> --from-json <PATH> [--shared]
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
|
|
@ -25,6 +27,149 @@ fn write_and_report(verb: &str) -> ExitCode {
|
|||
}
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
eprintln!(
|
||||
"bread-theme — shared stylesheet generator\n\n\
|
||||
USAGE:\n\
|
||||
\x20 bread-theme [generate|reload|path|print]\n\
|
||||
\x20 bread-theme generate-output <OUTPUT> --image <PATH> [--shared]\n\
|
||||
\x20 bread-theme generate-output <OUTPUT> --from-json <WAL-OR-PALETTE.json> [--shared]\n\n\
|
||||
generate render the pywal palette to the shared stylesheet (default)\n\
|
||||
reload re-render and signal running bread GUIs to recolour live\n\
|
||||
path print the stylesheet path ({})\n\
|
||||
print render to stdout without writing\n\
|
||||
generate-output write palettes/<OUTPUT>.json and themes/<OUTPUT>.css\n\
|
||||
\x20 --image isolated `wal -i` (does not touch ~/.cache/wal)\n\
|
||||
\x20 --from-json wal colors.json or a color1-6 object\n\
|
||||
\x20 --shared also write the session-global theme.css",
|
||||
bread_theme::shared_css_path().display()
|
||||
);
|
||||
}
|
||||
|
||||
fn generate_output_cmd() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(2).collect();
|
||||
if args.is_empty()
|
||||
|| args
|
||||
.iter()
|
||||
.any(|a| matches!(a.as_str(), "-h" | "--help" | "help"))
|
||||
{
|
||||
print_help();
|
||||
return if args.is_empty() {
|
||||
ExitCode::FAILURE
|
||||
} else {
|
||||
ExitCode::SUCCESS
|
||||
};
|
||||
}
|
||||
|
||||
let output = args[0].as_str();
|
||||
if output.starts_with('-') {
|
||||
eprintln!("bread-theme: generate-output requires an OUTPUT name (got '{output}')");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let mut image: Option<&str> = None;
|
||||
let mut from_json: Option<&str> = None;
|
||||
let mut shared = false;
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--shared" => shared = true,
|
||||
"--image" => {
|
||||
i += 1;
|
||||
match args.get(i) {
|
||||
Some(p) => image = Some(p.as_str()),
|
||||
None => {
|
||||
eprintln!("bread-theme: --image requires a path");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
"--from-json" => {
|
||||
i += 1;
|
||||
match args.get(i) {
|
||||
Some(p) => from_json = Some(p.as_str()),
|
||||
None => {
|
||||
eprintln!("bread-theme: --from-json requires a path");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
eprintln!("bread-theme: unknown generate-output flag '{other}'");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
match (image, from_json) {
|
||||
(Some(_), Some(_)) => {
|
||||
eprintln!("bread-theme: pass only one of --image or --from-json");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
(None, None) => {
|
||||
eprintln!("bread-theme: generate-output needs --image <PATH> or --from-json <PATH>");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
(Some(path), None) => {
|
||||
match bread_theme::generate_output(output, std::path::Path::new(path)) {
|
||||
Ok(css) => finish_generate_output(output, css, shared),
|
||||
Err(e) => {
|
||||
eprintln!("bread-theme: generate-output failed: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, Some(path)) => match write_output_from_json(output, path, shared) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("bread-theme: generate-output failed: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn write_output_from_json(output: &str, json_path: &str, shared: bool) -> std::io::Result<()> {
|
||||
let json = std::fs::read_to_string(json_path)?;
|
||||
let palette = bread_theme::palette_from_json(&json).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("could not parse palette JSON: {json_path}"),
|
||||
)
|
||||
})?;
|
||||
let pal_path = bread_theme::write_output_palette(output, &palette)?;
|
||||
let css_path = bread_theme::write_output_css(output, &palette)?;
|
||||
eprintln!(
|
||||
"bread-theme: wrote {} and {}",
|
||||
pal_path.display(),
|
||||
css_path.display()
|
||||
);
|
||||
if shared {
|
||||
let shared_path = bread_theme::write_shared_css_from(&palette)?;
|
||||
eprintln!("bread-theme: wrote shared {}", shared_path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_generate_output(output: &str, css: std::path::PathBuf, shared: bool) -> ExitCode {
|
||||
eprintln!("bread-theme: wrote {}", css.display());
|
||||
if shared {
|
||||
match bread_theme::write_shared_css_from(&bread_theme::load_palette_for(output)) {
|
||||
Ok(path) => {
|
||||
eprintln!("bread-theme: wrote shared {}", path.display());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("bread-theme: failed to write shared stylesheet: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
|
||||
match cmd.as_str() {
|
||||
|
|
@ -42,20 +187,15 @@ fn main() -> ExitCode {
|
|||
// the file monitor in every running bread GUI, so they all re-read the
|
||||
// palette and recolour live — shared widgets *and* each app's own rules.
|
||||
"reload" => write_and_report("reloaded"),
|
||||
"generate-output" => generate_output_cmd(),
|
||||
"-h" | "--help" | "help" => {
|
||||
eprintln!(
|
||||
"bread-theme — shared stylesheet generator\n\n\
|
||||
USAGE:\n bread-theme [generate|reload|path|print]\n\n\
|
||||
generate render the pywal palette to the shared stylesheet (default)\n\
|
||||
reload re-render and signal running bread GUIs to recolour live\n\
|
||||
path print the stylesheet path ({})\n\
|
||||
print render to stdout without writing",
|
||||
bread_theme::shared_css_path().display()
|
||||
);
|
||||
print_help();
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
other => {
|
||||
eprintln!("bread-theme: unknown command '{other}' (try generate|reload|path|print)");
|
||||
eprintln!(
|
||||
"bread-theme: unknown command '{other}' (try generate|reload|path|print|generate-output)"
|
||||
);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
use gtk4::gdk::prelude::*;
|
||||
use gtk4::gio;
|
||||
use gtk4::glib::object::ObjectType;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::CssProvider;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::Palette;
|
||||
|
||||
/// Above APPLICATION (600) so we beat [`apply_shared`], below USER (800)
|
||||
/// so `apply_user_css` still wins.
|
||||
const BIND_PRIORITY: u32 = gtk4::STYLE_PROVIDER_PRIORITY_USER - 10;
|
||||
|
||||
thread_local! {
|
||||
static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
||||
|
|
@ -14,8 +24,7 @@ thread_local! {
|
|||
}
|
||||
|
||||
fn reload_shared() {
|
||||
let css = std::fs::read_to_string(crate::shared_css_path())
|
||||
.unwrap_or_else(|_| crate::render());
|
||||
let css = std::fs::read_to_string(crate::shared_css_path()).unwrap_or_else(|_| crate::render());
|
||||
SHARED_PROVIDER.with(|cell| apply_css(&css, cell));
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +130,10 @@ pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) {
|
|||
/// breadpad, and breadman (both cream), none of which agreed with each
|
||||
/// other or with the shared token.
|
||||
pub fn chip(label: &str) -> gtk4::Button {
|
||||
gtk4::Button::builder().label(label).css_classes(["chip"]).build()
|
||||
gtk4::Button::builder()
|
||||
.label(label)
|
||||
.css_classes(["chip"])
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Toggles a chip's (or any widget's) `active` CSS class — the `.chip.active`
|
||||
|
|
@ -137,6 +149,319 @@ 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<Rc<dyn Fn(&Palette) -> String>>,
|
||||
/// Keep the directory monitor + child model alive for this widget.
|
||||
_watch: Option<gio::ListModel>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static BINDS: RefCell<HashMap<usize, WidgetBind>> = RefCell::new(HashMap::new());
|
||||
static THEMES_MONITOR: RefCell<Option<gio::FileMonitor>> = const { RefCell::new(None) };
|
||||
static DESTROY_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
||||
static AUTO_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
||||
static ENTER_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
||||
}
|
||||
|
||||
fn widget_key(widget: >k4::Widget) -> usize {
|
||||
widget.as_ptr() as usize
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
fn add_widget_provider(widget: >k4::Widget, provider: &CssProvider, prio: u32) {
|
||||
widget.style_context().add_provider(provider, prio);
|
||||
}
|
||||
|
||||
/// Same `CssProvider` on the widget and its current descendants so component
|
||||
/// rules actually reach buttons/labels (a style-context provider is not
|
||||
/// inherited by children).
|
||||
fn attach_tree(widget: >k4::Widget, theme: &CssProvider, app: Option<&CssProvider>) {
|
||||
add_widget_provider(widget, theme, BIND_PRIORITY);
|
||||
if let Some(app) = app {
|
||||
add_widget_provider(widget, app, BIND_PRIORITY + 1);
|
||||
}
|
||||
let mut child = widget.first_child();
|
||||
while let Some(c) = child {
|
||||
attach_tree(&c, theme, app);
|
||||
child = c.next_sibling();
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_destroy_cleanup(widget: >k4::Widget) {
|
||||
let key = widget_key(widget);
|
||||
let inserted = DESTROY_HOOKED.with(|s| s.borrow_mut().insert(key));
|
||||
if !inserted {
|
||||
return;
|
||||
}
|
||||
widget.connect_destroy(move |w| {
|
||||
let key = widget_key(w);
|
||||
BINDS.with(|b| {
|
||||
b.borrow_mut().remove(&key);
|
||||
});
|
||||
DESTROY_HOOKED.with(|s| {
|
||||
s.borrow_mut().remove(&key);
|
||||
});
|
||||
AUTO_HOOKED.with(|s| {
|
||||
s.borrow_mut().remove(&key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn ensure_themes_watch() {
|
||||
THEMES_MONITOR.with(|cell| {
|
||||
if cell.borrow().is_some() {
|
||||
return;
|
||||
}
|
||||
let dir = crate::themes_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let monitor = gio::File::for_path(&dir)
|
||||
.monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE)
|
||||
.ok();
|
||||
if let Some(ref m) = monitor {
|
||||
m.connect_changed(move |_, file, other, _event| {
|
||||
let path = file.path().or_else(|| other.and_then(|f| f.path()));
|
||||
let Some(path) = path else {
|
||||
return;
|
||||
};
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("css") {
|
||||
return;
|
||||
}
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
return;
|
||||
};
|
||||
reload_binds_for_sanitized(stem);
|
||||
});
|
||||
}
|
||||
*cell.borrow_mut() = monitor;
|
||||
});
|
||||
}
|
||||
|
||||
fn reload_binds_for_sanitized(sanitized: &str) {
|
||||
BINDS.with(|binds| {
|
||||
for bind in binds.borrow_mut().values_mut() {
|
||||
if crate::sanitize_output(&bind.output) != sanitized {
|
||||
continue;
|
||||
}
|
||||
let palette = crate::load_palette_for(&bind.output);
|
||||
bind.theme
|
||||
.load_from_string(&crate::stylesheet_resolved(&palette));
|
||||
if let (Some(build), Some(provider)) = (&bind.app_build, &bind.app) {
|
||||
provider.load_from_string(&crate::resolve_color_names(&build(&palette), &palette));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn watch_root_children(widget: >k4::Widget) -> gio::ListModel {
|
||||
let model = widget.observe_children();
|
||||
let root = widget.downgrade();
|
||||
model.connect_items_changed(move |_, _, _, _| {
|
||||
let Some(root) = root.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let key = widget_key(&root);
|
||||
BINDS.with(|binds| {
|
||||
if let Some(bind) = binds.borrow().get(&key) {
|
||||
attach_tree(&root, &bind.theme, bind.app.as_ref());
|
||||
}
|
||||
});
|
||||
});
|
||||
model
|
||||
}
|
||||
|
||||
fn bind_window_inner(
|
||||
widget: >k4::Widget,
|
||||
output: &str,
|
||||
app_build: Option<Rc<dyn Fn(&Palette) -> String>>,
|
||||
) {
|
||||
let key = widget_key(widget);
|
||||
let palette = crate::load_palette_for(output);
|
||||
let theme_css = crate::stylesheet_resolved(&palette);
|
||||
let app_css = app_build
|
||||
.as_ref()
|
||||
.map(|build| crate::resolve_color_names(&build(&palette), &palette));
|
||||
|
||||
BINDS.with(|binds| {
|
||||
let mut map = binds.borrow_mut();
|
||||
if let Some(existing) = map.get_mut(&key) {
|
||||
existing.output = output.to_string();
|
||||
existing.theme.load_from_string(&theme_css);
|
||||
existing.app_build = app_build.clone();
|
||||
match (&app_css, existing.app.as_ref()) {
|
||||
(Some(css), Some(p)) => p.load_from_string(css),
|
||||
(Some(css), None) => {
|
||||
let p = CssProvider::new();
|
||||
p.load_from_string(css);
|
||||
add_widget_provider(widget, &p, BIND_PRIORITY + 1);
|
||||
existing.app = Some(p);
|
||||
}
|
||||
(None, Some(p)) => p.load_from_string(""),
|
||||
(None, None) => {}
|
||||
}
|
||||
attach_tree(widget, &existing.theme, existing.app.as_ref());
|
||||
return;
|
||||
}
|
||||
|
||||
let theme = CssProvider::new();
|
||||
theme.load_from_string(&theme_css);
|
||||
add_widget_provider(widget, &theme, BIND_PRIORITY);
|
||||
|
||||
let app = app_css.map(|css| {
|
||||
let p = CssProvider::new();
|
||||
p.load_from_string(&css);
|
||||
add_widget_provider(widget, &p, BIND_PRIORITY + 1);
|
||||
p
|
||||
});
|
||||
|
||||
attach_tree(widget, &theme, app.as_ref());
|
||||
|
||||
let child_model = watch_root_children(widget);
|
||||
map.insert(
|
||||
key,
|
||||
WidgetBind {
|
||||
output: output.to_string(),
|
||||
theme,
|
||||
app,
|
||||
app_build,
|
||||
_watch: Some(child_model),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
ensure_destroy_cleanup(widget);
|
||||
ensure_themes_watch();
|
||||
ensure_map_reattach(widget);
|
||||
}
|
||||
|
||||
fn ensure_map_reattach(widget: >k4::Widget) {
|
||||
// `connect_map` once per widget — re-bind already lives in BINDS.
|
||||
thread_local! {
|
||||
static MAP_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
||||
}
|
||||
let key = widget_key(widget);
|
||||
let inserted = MAP_HOOKED.with(|s| s.borrow_mut().insert(key));
|
||||
if !inserted {
|
||||
return;
|
||||
}
|
||||
widget.connect_map(|w| {
|
||||
BINDS.with(|binds| {
|
||||
if let Some(bind) = binds.borrow().get(&widget_key(w)) {
|
||||
attach_tree(w, &bind.theme, bind.app.as_ref());
|
||||
}
|
||||
});
|
||||
});
|
||||
widget.connect_destroy(move |_| {
|
||||
MAP_HOOKED.with(|s| {
|
||||
s.borrow_mut().remove(&key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Attach a widget-level `CssProvider` with
|
||||
/// `stylesheet_resolved(load_palette_for(output))` above APPLICATION so it
|
||||
/// beats [`apply_shared`] for this widget tree. User CSS still wins.
|
||||
/// Calling again on the same widget replaces the provider; it does not stack.
|
||||
pub fn bind_window(widget: &impl IsA<gtk4::Widget>, output: &str) {
|
||||
bind_window_inner(widget.as_ref(), output, None);
|
||||
}
|
||||
|
||||
/// [`bind_window`], then also apply `build(&palette)` on the same widget.
|
||||
/// App CSS may still use `@accent` etc.; those names are inlined against
|
||||
/// the same palette before loading.
|
||||
pub fn bind_window_with_app_css<F>(widget: &impl IsA<gtk4::Widget>, output: &str, build: F)
|
||||
where
|
||||
F: Fn(&Palette) -> String + 'static,
|
||||
{
|
||||
bind_window_inner(widget.as_ref(), output, Some(Rc::new(build)));
|
||||
}
|
||||
|
||||
fn attach_enter_monitor(widget: >k4::Widget, build: Option<Rc<dyn Fn(&Palette) -> String>>) {
|
||||
let Some(native) = widget.native() else {
|
||||
return;
|
||||
};
|
||||
let Some(surface) = NativeExt::surface(&native) else {
|
||||
return;
|
||||
};
|
||||
let surf_key = surface.as_ptr() as usize;
|
||||
let already = ENTER_HOOKED.with(|s| !s.borrow_mut().insert(surf_key));
|
||||
if already {
|
||||
return;
|
||||
}
|
||||
let widget = widget.clone();
|
||||
surface.connect_enter_monitor(move |_, monitor| {
|
||||
let Some(conn) = monitor.connector() else {
|
||||
return;
|
||||
};
|
||||
bind_window_inner(&widget, conn.as_str(), build.clone());
|
||||
});
|
||||
}
|
||||
|
||||
fn bind_auto(native: >k4::Native, build: Option<Rc<dyn Fn(&Palette) -> String>>) {
|
||||
let widget = native.upcast_ref::<gtk4::Widget>().clone();
|
||||
|
||||
let apply = {
|
||||
let widget = widget.clone();
|
||||
let build = build.clone();
|
||||
Rc::new(move || {
|
||||
if let Some(output) = output_for_widget(&widget) {
|
||||
bind_window_inner(&widget, &output, build.clone());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
let key = widget_key(&widget);
|
||||
let inserted = AUTO_HOOKED.with(|s| s.borrow_mut().insert(key));
|
||||
if inserted {
|
||||
widget.connect_realize({
|
||||
let apply = apply.clone();
|
||||
let widget = widget.clone();
|
||||
let build = build.clone();
|
||||
move |_| {
|
||||
apply();
|
||||
attach_enter_monitor(&widget, build.clone());
|
||||
}
|
||||
});
|
||||
widget.connect_map({
|
||||
let apply = apply.clone();
|
||||
move |_| apply()
|
||||
});
|
||||
ensure_destroy_cleanup(&widget);
|
||||
}
|
||||
|
||||
if widget.is_realized() {
|
||||
attach_enter_monitor(&widget, build);
|
||||
}
|
||||
}
|
||||
|
||||
/// Realize + `GdkSurface::enter-monitor`: rebind when the window moves
|
||||
/// outputs. If the connector is unknown, leave unbound (display fallback)
|
||||
/// rather than guessing the wrong monitor.
|
||||
pub fn bind_window_auto(window: &impl IsA<gtk4::Native>) {
|
||||
bind_auto(window.as_ref(), None);
|
||||
}
|
||||
|
||||
/// [`bind_window_auto`] plus per-output app CSS, resolved to hex.
|
||||
pub fn bind_window_auto_with_app_css<F>(window: &impl IsA<gtk4::Native>, build: F)
|
||||
where
|
||||
F: Fn(&Palette) -> String + 'static,
|
||||
{
|
||||
bind_auto(window.as_ref(), Some(Rc::new(build)));
|
||||
}
|
||||
|
||||
/// Apply a user CSS override file at USER priority. Clears the provider if the
|
||||
/// file is absent so stale overrides don't persist across SIGHUP reloads.
|
||||
pub fn apply_user_css(path: &Path, provider: &RefCell<Option<CssProvider>>) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
pub mod palette;
|
||||
#[cfg(feature = "gtk")]
|
||||
pub mod gtk;
|
||||
#[cfg(feature = "adw")]
|
||||
pub mod adw;
|
||||
#[cfg(feature = "gtk")]
|
||||
pub mod gtk;
|
||||
mod output;
|
||||
pub mod palette;
|
||||
|
||||
pub use output::{
|
||||
generate_output, load_palette_for, output_css_path, output_palette_path, palette_from_image,
|
||||
palette_from_json, palettes_dir, sanitize_output, themes_dir, write_output_css,
|
||||
write_output_palette, write_shared_css_from,
|
||||
};
|
||||
pub use palette::{load_palette, Palette};
|
||||
|
||||
/// Design tokens from BREAD_DESIGN_SYSTEM.md.
|
||||
|
|
@ -26,6 +32,13 @@ pub mod tokens {
|
|||
pub const RADIUS_PILL: u16 = 999;
|
||||
}
|
||||
|
||||
/// CSS `font-family` list: quote the named face, leave the generic fallback
|
||||
/// unquoted. Wrapping [`tokens::FONT_FAMILY`] in one pair of quotes would
|
||||
/// make a single family named "Varela Round, sans-serif" and drop sans-serif.
|
||||
fn css_font_family() -> &'static str {
|
||||
"'Varela Round', sans-serif"
|
||||
}
|
||||
|
||||
/// Emit the `@define-color` block that all bread apps use, plus the shared
|
||||
/// font rule.
|
||||
///
|
||||
|
|
@ -41,9 +54,9 @@ pub mod tokens {
|
|||
/// one color-block implementation and it cannot drift again.
|
||||
pub fn css_vars(p: &Palette) -> String {
|
||||
format!(
|
||||
"{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n",
|
||||
"{vars}* {{ font-family: {font}; font-size: {size}px; }}\n",
|
||||
vars = define_colors(p),
|
||||
font = tokens::FONT_FAMILY,
|
||||
font = css_font_family(),
|
||||
size = tokens::FONT_SIZE_BASE,
|
||||
)
|
||||
}
|
||||
|
|
@ -53,7 +66,11 @@ pub fn luminance(hex: &str) -> f32 {
|
|||
let h = hex.trim_start_matches('#');
|
||||
let lin = |i: usize| -> f32 {
|
||||
let c = u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f32 / 255.0;
|
||||
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
if c <= 0.04045 {
|
||||
c / 12.92
|
||||
} else {
|
||||
((c + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4)
|
||||
}
|
||||
|
|
@ -64,7 +81,11 @@ pub fn luminance(hex: &str) -> f32 {
|
|||
/// text readable no matter how light or dark pywal makes a given palette slot,
|
||||
/// without altering the palette colours themselves.
|
||||
pub fn ink_on(hex: &str) -> &'static str {
|
||||
if luminance(hex) > 0.179 { "#11111b" } else { "#f5f5f5" }
|
||||
if luminance(hex) > 0.179 {
|
||||
"#11111b"
|
||||
} else {
|
||||
"#f5f5f5"
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical (name, value) list: the single naming all bread apps share.
|
||||
|
|
@ -130,7 +151,7 @@ pub fn css_tokens() -> String {
|
|||
use tokens::*;
|
||||
format!(
|
||||
":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-secondary: {sec}px;\n\
|
||||
\x20\x20--space-xs: {xs}px;\n\
|
||||
|
|
@ -143,9 +164,18 @@ pub fn css_tokens() -> String {
|
|||
\x20\x20--radius-tertiary: {r3}px;\n\
|
||||
\x20\x20--radius-pill: {pill}px;\n\
|
||||
}}\n",
|
||||
font = FONT_FAMILY, base = FONT_SIZE_BASE, sec = FONT_SIZE_SECONDARY,
|
||||
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, xl = SPACE_XL,
|
||||
r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, r3 = RADIUS_TERTIARY, pill = RADIUS_PILL,
|
||||
font = css_font_family(),
|
||||
base = FONT_SIZE_BASE,
|
||||
sec = FONT_SIZE_SECONDARY,
|
||||
xs = SPACE_XS,
|
||||
sm = SPACE_SM,
|
||||
md = SPACE_MD,
|
||||
lg = SPACE_LG,
|
||||
xl = SPACE_XL,
|
||||
r1 = RADIUS_PRIMARY,
|
||||
r2 = RADIUS_SECONDARY,
|
||||
r3 = RADIUS_TERTIARY,
|
||||
pill = RADIUS_PILL,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -159,7 +189,7 @@ pub fn stylesheet(p: &Palette) -> String {
|
|||
use tokens::*;
|
||||
format!(
|
||||
"{vars}\
|
||||
* {{ font-family: '{font}'; font-size: {base}px; }}\n\
|
||||
* {{ font-family: {font}; font-size: {base}px; }}\n\
|
||||
/* Colour is set on containers; labels inherit it, so text on any panel,\
|
||||
button, or accent is always the legible ink for that background. Bare\
|
||||
`label {{ color }}` is deliberately avoided — as a type selector it\
|
||||
|
|
@ -242,7 +272,7 @@ pub fn stylesheet(p: &Palette) -> String {
|
|||
textview, .mono {{ font-family: monospace; }}\n\
|
||||
textview text {{ background-color: @surface; color: @on-surface; }}\n",
|
||||
vars = define_colors(p),
|
||||
font = FONT_FAMILY,
|
||||
font = css_font_family(),
|
||||
base = FONT_SIZE_BASE,
|
||||
sec = FONT_SIZE_SECONDARY,
|
||||
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG,
|
||||
|
|
@ -261,28 +291,33 @@ pub fn render() -> String {
|
|||
/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`,
|
||||
/// falling back to the cache dir.
|
||||
pub fn shared_css_path() -> std::path::PathBuf {
|
||||
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
if !rt.is_empty() {
|
||||
return std::path::PathBuf::from(rt).join("bread").join("theme.css");
|
||||
}
|
||||
}
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
|
||||
.join("bread")
|
||||
.join("theme.css")
|
||||
output::runtime_bread_dir().join("theme.css")
|
||||
}
|
||||
|
||||
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
|
||||
/// the path written. Used by the `bread-theme` CLI.
|
||||
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
|
||||
let path = shared_css_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
write_shared_css_from(&load_palette())
|
||||
}
|
||||
|
||||
/// `stylesheet()` with `@name` references in rule bodies replaced by hex.
|
||||
/// Longer names first (`on-surface` before `surface`, `on-bg` before `bg`)
|
||||
/// so a prefix match cannot half-replace `@on-bg`.
|
||||
pub fn stylesheet_resolved(p: &Palette) -> String {
|
||||
resolve_color_names(&stylesheet(p), p)
|
||||
}
|
||||
|
||||
/// Replace `@define-color` names (`@accent`, `@on-bg`, …) with hex values.
|
||||
/// Used by [`stylesheet_resolved`] and by GTK `bind_window` so display-global
|
||||
/// named colors cannot leak the wrong monitor's accent.
|
||||
pub(crate) fn resolve_color_names(css: &str, p: &Palette) -> String {
|
||||
let mut pairs: Vec<(&str, String)> = color_pairs(p).into_iter().collect();
|
||||
pairs.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
|
||||
let mut out = css.to_string();
|
||||
for (name, value) in pairs {
|
||||
out = out.replace(&format!("@{name}"), &value);
|
||||
}
|
||||
let tmp = path.with_extension("css.tmp");
|
||||
std::fs::write(&tmp, render())?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
Ok(path)
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
|
||||
|
|
@ -301,15 +336,24 @@ mod tests {
|
|||
#[test]
|
||||
fn css_vars_contains_all_define_color_names() {
|
||||
let css = css_vars(&Palette::default());
|
||||
for name in &["bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
|
||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||
for name in &[
|
||||
"bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay",
|
||||
] {
|
||||
assert!(
|
||||
css.contains(&format!("@define-color {name} ")),
|
||||
"missing @define-color {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_vars_contains_font_rule() {
|
||||
let css = css_vars(&Palette::default());
|
||||
assert!(css.contains("Varela Round"));
|
||||
assert!(css.contains("font-family: 'Varela Round', sans-serif;"));
|
||||
assert!(
|
||||
!css.contains("font-family: 'Varela Round, sans-serif'"),
|
||||
"named face and generic fallback must not be one quoted family"
|
||||
);
|
||||
assert!(css.contains("14px"));
|
||||
}
|
||||
|
||||
|
|
@ -322,8 +366,18 @@ mod tests {
|
|||
// color name — the illegible-text bug. css_vars() must now emit
|
||||
// exactly the same color set as the full stylesheet.
|
||||
let css = css_vars(&Palette::default());
|
||||
for name in &["accent", "on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
|
||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||
for name in &[
|
||||
"accent",
|
||||
"on-bg",
|
||||
"on-surface",
|
||||
"on-accent",
|
||||
"on-red",
|
||||
"on-overlay",
|
||||
] {
|
||||
assert!(
|
||||
css.contains(&format!("@define-color {name} ")),
|
||||
"missing @define-color {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -334,7 +388,16 @@ mod tests {
|
|||
let p = Palette::default();
|
||||
let vars = css_vars(&p);
|
||||
let sheet = stylesheet(&p);
|
||||
for name in &["bg", "fg", "surface", "overlay", "accent", "on-bg", "on-surface", "on-accent"] {
|
||||
for name in &[
|
||||
"bg",
|
||||
"fg",
|
||||
"surface",
|
||||
"overlay",
|
||||
"accent",
|
||||
"on-bg",
|
||||
"on-surface",
|
||||
"on-accent",
|
||||
] {
|
||||
let needle = format!("@define-color {name} ");
|
||||
assert!(vars.contains(&needle) && sheet.contains(&needle));
|
||||
}
|
||||
|
|
@ -344,13 +407,28 @@ mod tests {
|
|||
fn stylesheet_defines_canonical_colors_and_components() {
|
||||
let css = stylesheet(&Palette::default());
|
||||
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
|
||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||
assert!(
|
||||
css.contains(&format!("@define-color {name} ")),
|
||||
"missing @define-color {name}"
|
||||
);
|
||||
}
|
||||
// a representative spread of the shared component selectors
|
||||
for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".page-title"] {
|
||||
for sel in &[
|
||||
"button",
|
||||
"entry",
|
||||
"switch:checked",
|
||||
".card",
|
||||
".sidebar",
|
||||
"scrollbar slider",
|
||||
".page-title",
|
||||
] {
|
||||
assert!(css.contains(sel), "stylesheet missing selector: {sel}");
|
||||
}
|
||||
assert!(css.contains("Varela Round"));
|
||||
assert!(css.contains("font-family: 'Varela Round', sans-serif;"));
|
||||
assert!(
|
||||
!css.contains("font-family: 'Varela Round, sans-serif'"),
|
||||
"named face and generic fallback must not be one quoted family"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -362,7 +440,10 @@ mod tests {
|
|||
let gtk = define_colors(&p);
|
||||
let web = css_custom_properties(&p);
|
||||
for (name, _) in color_pairs(&p) {
|
||||
assert!(gtk.contains(&format!("@define-color {name} ")), "gtk missing {name}");
|
||||
assert!(
|
||||
gtk.contains(&format!("@define-color {name} ")),
|
||||
"gtk missing {name}"
|
||||
);
|
||||
assert!(web.contains(&format!("--{name}: ")), "web missing {name}");
|
||||
}
|
||||
}
|
||||
|
|
@ -379,7 +460,11 @@ mod tests {
|
|||
#[test]
|
||||
fn css_tokens_contains_font_and_spacing_vars() {
|
||||
let css = css_tokens();
|
||||
assert!(css.contains("--font-family: 'Varela Round, sans-serif';"));
|
||||
assert!(css.contains("--font-family: 'Varela Round', sans-serif;"));
|
||||
assert!(
|
||||
!css.contains("--font-family: 'Varela Round, sans-serif'"),
|
||||
"named face and generic fallback must not be one quoted family"
|
||||
);
|
||||
assert!(css.contains("--font-size-base: 14px;"));
|
||||
assert!(css.contains("--space-md: 12px;"));
|
||||
assert!(css.contains("--radius-pill: 999px;"));
|
||||
|
|
@ -409,7 +494,10 @@ mod tests {
|
|||
fn stylesheet_defines_on_colors() {
|
||||
let css = stylesheet(&Palette::default());
|
||||
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
|
||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||
assert!(
|
||||
css.contains(&format!("@define-color {name} ")),
|
||||
"missing @define-color {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -418,13 +506,58 @@ mod tests {
|
|||
// A bare `label { color: ... }` would override container colours on child
|
||||
// labels — the bug that made coloured-background text illegible.
|
||||
let css = stylesheet(&Palette::default());
|
||||
assert!(!css.contains("label { color:"), "blanket label colour rule reintroduced");
|
||||
assert!(
|
||||
!css.contains("label { color:"),
|
||||
"blanket label colour rule reintroduced"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_css_path_uses_runtime_dir() {
|
||||
let _lock = crate::output::XDG_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
|
||||
assert_eq!(shared_css_path(), std::path::PathBuf::from("/run/user/1234/bread/theme.css"));
|
||||
assert_eq!(
|
||||
shared_css_path(),
|
||||
std::path::PathBuf::from("/run/user/1234/bread/theme.css")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_resolved_inlines_color4_and_drops_named_refs_in_rules() {
|
||||
let mut p = Palette::default();
|
||||
p.color4 = "#7aa2f7".into();
|
||||
let css = stylesheet_resolved(&p);
|
||||
assert!(css.contains("#7aa2f7"), "color4 must appear as hex: {css}");
|
||||
// Rule bodies must not keep named colors — GTK display-global
|
||||
// @define-color would otherwise leak the wrong monitor's accent.
|
||||
let rules = css
|
||||
.lines()
|
||||
.filter(|l| !l.trim_start().starts_with("@define-color"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
!rules.contains("@accent"),
|
||||
"leftover @accent in rules:\n{rules}"
|
||||
);
|
||||
assert!(
|
||||
!rules.contains("@on-bg"),
|
||||
"leftover @on-bg in rules:\n{rules}"
|
||||
);
|
||||
assert!(
|
||||
!rules.contains("@on-surface"),
|
||||
"leftover @on-surface in rules:\n{rules}"
|
||||
);
|
||||
assert!(
|
||||
!rules.contains("@on-accent"),
|
||||
"leftover @on-accent in rules:\n{rules}"
|
||||
);
|
||||
// Longer names first: @on-bg must not become @on-#...
|
||||
assert!(
|
||||
!rules.contains("@on-#"),
|
||||
"half-replaced on-* name:\n{rules}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
338
bread-theme/src/output.rs
Normal file
338
bread-theme/src/output.rs
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
//! Per-output (per-monitor) palette and stylesheet paths under
|
||||
//! `$XDG_RUNTIME_DIR/bread/{palettes,themes}/`.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::palette::{from_wal_json, Palette};
|
||||
use crate::{load_palette, stylesheet};
|
||||
|
||||
/// Session-scoped `$XDG_RUNTIME_DIR/bread`, same fallback as [`crate::shared_css_path`].
|
||||
pub(crate) fn runtime_bread_dir() -> PathBuf {
|
||||
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
if !rt.is_empty() {
|
||||
return PathBuf::from(rt).join("bread");
|
||||
}
|
||||
}
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("bread")
|
||||
}
|
||||
|
||||
/// Keep `[A-Za-z0-9._-]`; replace everything else with `_`.
|
||||
pub fn sanitize_output(output: &str) -> String {
|
||||
let s: String = output
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if s.is_empty() {
|
||||
"_".into()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
pub fn themes_dir() -> PathBuf {
|
||||
runtime_bread_dir().join("themes")
|
||||
}
|
||||
|
||||
pub fn palettes_dir() -> PathBuf {
|
||||
runtime_bread_dir().join("palettes")
|
||||
}
|
||||
|
||||
pub fn output_css_path(output: &str) -> PathBuf {
|
||||
themes_dir().join(format!("{}.css", sanitize_output(output)))
|
||||
}
|
||||
|
||||
pub fn output_palette_path(output: &str) -> PathBuf {
|
||||
palettes_dir().join(format!("{}.json", sanitize_output(output)))
|
||||
}
|
||||
|
||||
fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(name) => path.with_file_name(format!("{name}.tmp")),
|
||||
None => path.with_extension("tmp"),
|
||||
};
|
||||
std::fs::write(&tmp, contents)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accents only — never persist pywal's light background/surface/overlay/fg.
|
||||
#[derive(Serialize)]
|
||||
struct StoredColors {
|
||||
color1: String,
|
||||
color2: String,
|
||||
color3: String,
|
||||
color4: String,
|
||||
color5: String,
|
||||
color6: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StoredPalette {
|
||||
colors: StoredColors,
|
||||
}
|
||||
|
||||
/// Parse on-disk JSON: wal `colors.json` shape, or a flat `{color1..color6}` object.
|
||||
/// Always forces FIXED background/foreground/color0/color7 via [`from_wal_json`].
|
||||
pub fn palette_from_json(json: &str) -> Option<Palette> {
|
||||
let value: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
if value
|
||||
.get("colors")
|
||||
.and_then(|c| c.as_object())
|
||||
.is_some_and(|o| !o.is_empty())
|
||||
{
|
||||
return from_wal_json(json);
|
||||
}
|
||||
if value.get("color1").is_some()
|
||||
|| value.get("color2").is_some()
|
||||
|| value.get("color3").is_some()
|
||||
|| value.get("color4").is_some()
|
||||
|| value.get("color5").is_some()
|
||||
|| value.get("color6").is_some()
|
||||
{
|
||||
let wrapped = serde_json::json!({ "colors": value });
|
||||
return from_wal_json(&wrapped.to_string());
|
||||
}
|
||||
from_wal_json(json)
|
||||
}
|
||||
|
||||
/// Load `palettes/<output>.json`; fall back to [`load_palette`].
|
||||
pub fn load_palette_for(output: &str) -> Palette {
|
||||
std::fs::read_to_string(output_palette_path(output))
|
||||
.ok()
|
||||
.and_then(|s| palette_from_json(&s))
|
||||
.unwrap_or_else(load_palette)
|
||||
}
|
||||
|
||||
pub fn write_output_palette(output: &str, palette: &Palette) -> std::io::Result<PathBuf> {
|
||||
let path = output_palette_path(output);
|
||||
let stored = StoredPalette {
|
||||
colors: StoredColors {
|
||||
color1: palette.color1.clone(),
|
||||
color2: palette.color2.clone(),
|
||||
color3: palette.color3.clone(),
|
||||
color4: palette.color4.clone(),
|
||||
color5: palette.color5.clone(),
|
||||
color6: palette.color6.clone(),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&stored)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
atomic_write(&path, &json)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn write_output_css(output: &str, palette: &Palette) -> std::io::Result<PathBuf> {
|
||||
let path = output_css_path(output);
|
||||
atomic_write(&path, &stylesheet(palette))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Like [`crate::write_shared_css`] but from an explicit palette.
|
||||
pub fn write_shared_css_from(palette: &Palette) -> std::io::Result<PathBuf> {
|
||||
let path = crate::shared_css_path();
|
||||
atomic_write(&path, &stylesheet(palette))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Isolated `wal -i <image> -n -q` with `XDG_CACHE_HOME` set to a unique temp
|
||||
/// dir so the user's `~/.cache/wal` is not clobbered.
|
||||
pub fn palette_from_image(path: &Path) -> std::io::Result<Palette> {
|
||||
let pid = std::process::id();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let tmp = std::env::temp_dir().join(format!("bread-theme-wal-{pid}-{nanos}"));
|
||||
std::fs::create_dir_all(&tmp)?;
|
||||
struct Rm(PathBuf);
|
||||
impl Drop for Rm {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
let _guard = Rm(tmp.clone());
|
||||
|
||||
// Classic pywal ignores XDG_CACHE_HOME and writes $HOME/.cache/wal.
|
||||
// Point HOME at the temp dir so a per-output extract cannot clobber
|
||||
// the session cache (or the other monitor's last `wal -i`).
|
||||
let status = match std::process::Command::new("wal")
|
||||
.arg("-i")
|
||||
.arg(path)
|
||||
.args(["-n", "-q"])
|
||||
.env("HOME", &tmp)
|
||||
.env("XDG_CACHE_HOME", tmp.join(".cache"))
|
||||
.status()
|
||||
{
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"wal is not installed",
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
Ok(s) => s,
|
||||
};
|
||||
if !status.success() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("wal failed with {status}"),
|
||||
));
|
||||
}
|
||||
|
||||
let json_path = [
|
||||
tmp.join(".cache").join("wal").join("colors.json"),
|
||||
tmp.join("wal").join("colors.json"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| p.is_file())
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"wal did not write colors.json under the isolated cache",
|
||||
)
|
||||
})?;
|
||||
let json = std::fs::read_to_string(&json_path)?;
|
||||
from_wal_json(&json).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"wal produced unparseable colors.json",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// [`palette_from_image`] + [`write_output_palette`] + [`write_output_css`].
|
||||
pub fn generate_output(output: &str, image: &Path) -> std::io::Result<PathBuf> {
|
||||
let palette = palette_from_image(image)?;
|
||||
write_output_palette(output, &palette)?;
|
||||
write_output_css(output, &palette)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) static XDG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::palette::{FIXED_BACKGROUND, FIXED_FOREGROUND, FIXED_OVERLAY, FIXED_SURFACE};
|
||||
|
||||
fn lock_xdg() -> std::sync::MutexGuard<'static, ()> {
|
||||
XDG_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn with_runtime_dir<T>(f: impl FnOnce(&Path) -> T) -> T {
|
||||
let _lock = lock_xdg();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"bread-theme-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let old = std::env::var("XDG_RUNTIME_DIR").ok();
|
||||
std::env::set_var("XDG_RUNTIME_DIR", &dir);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&dir)));
|
||||
match old {
|
||||
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
|
||||
None => std::env::remove_var("XDG_RUNTIME_DIR"),
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => std::panic::resume_unwind(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_output_keeps_hyprland_connectors() {
|
||||
assert_eq!(sanitize_output("HDMI-A-1"), "HDMI-A-1");
|
||||
assert_eq!(sanitize_output("eDP-1"), "eDP-1");
|
||||
assert_eq!(sanitize_output("DP-2"), "DP-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_output_replaces_unsafe_chars() {
|
||||
assert_eq!(sanitize_output("HDMI A:1"), "HDMI_A_1");
|
||||
assert_eq!(sanitize_output("foo/bar"), "foo_bar");
|
||||
assert_eq!(sanitize_output(""), "_");
|
||||
assert_eq!(sanitize_output("..ok_name-1"), "..ok_name-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_paths_use_sanitize_and_sit_under_dirs() {
|
||||
let _lock = lock_xdg();
|
||||
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
|
||||
let css = output_css_path("HDMI A:1");
|
||||
let pal = output_palette_path("HDMI A:1");
|
||||
assert_eq!(css, themes_dir().join("HDMI_A_1.css"));
|
||||
assert_eq!(pal, palettes_dir().join("HDMI_A_1.json"));
|
||||
assert!(css.starts_with(themes_dir()));
|
||||
assert!(pal.starts_with(palettes_dir()));
|
||||
assert_eq!(
|
||||
output_css_path("eDP-1"),
|
||||
PathBuf::from("/run/user/1234/bread/themes/eDP-1.css")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_palette_for_missing_file_has_fixed_bg() {
|
||||
with_runtime_dir(|_| {
|
||||
let p = load_palette_for("no-such-output");
|
||||
assert_eq!(p.background, FIXED_BACKGROUND);
|
||||
assert!(p.color4.starts_with('#'));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_output_palette_roundtrips_color4() {
|
||||
with_runtime_dir(|_| {
|
||||
let mut p = Palette::default();
|
||||
p.color4 = "#7aa2f7".into();
|
||||
p.background = "#ffffff".into();
|
||||
write_output_palette("HDMI-A-1", &p).unwrap();
|
||||
let loaded = load_palette_for("HDMI-A-1");
|
||||
assert_eq!(loaded.color4, "#7aa2f7");
|
||||
assert_eq!(loaded.background, FIXED_BACKGROUND);
|
||||
assert_eq!(loaded.foreground, FIXED_FOREGROUND);
|
||||
assert_eq!(loaded.color0, FIXED_SURFACE);
|
||||
assert_eq!(loaded.color7, FIXED_OVERLAY);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_shared_css_from_writes_shared_css_path() {
|
||||
with_runtime_dir(|rt| {
|
||||
let path = write_shared_css_from(&Palette::default()).unwrap();
|
||||
assert_eq!(path, crate::shared_css_path());
|
||||
assert_eq!(path, rt.join("bread").join("theme.css"));
|
||||
let css = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(css.contains("@define-color accent "));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_palette_for_accepts_flat_color_object() {
|
||||
with_runtime_dir(|_| {
|
||||
let path = output_palette_path("DP-1");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, r##"{"color4":"#112233","color1":"#abcdef"}"##).unwrap();
|
||||
let p = load_palette_for("DP-1");
|
||||
assert_eq!(p.color4, "#112233");
|
||||
assert_eq!(p.color1, "#abcdef");
|
||||
assert_eq!(p.background, FIXED_BACKGROUND);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -9,10 +9,10 @@ use std::path::PathBuf;
|
|||
/// off-hue background, and every bread GUI's panels inherit it — the app
|
||||
/// stops looking like a dark BOS tool and starts looking like whatever colour
|
||||
/// the wallpaper happened to be.
|
||||
const FIXED_BACKGROUND: &str = "#0c0c0c";
|
||||
const FIXED_FOREGROUND: &str = "#e8e8e8";
|
||||
const FIXED_SURFACE: &str = "#1a1a1a";
|
||||
const FIXED_OVERLAY: &str = "#d8d8d8";
|
||||
pub(crate) const FIXED_BACKGROUND: &str = "#0c0c0c";
|
||||
pub(crate) const FIXED_FOREGROUND: &str = "#e8e8e8";
|
||||
pub(crate) const FIXED_SURFACE: &str = "#1a1a1a";
|
||||
pub(crate) const FIXED_OVERLAY: &str = "#d8d8d8";
|
||||
|
||||
/// Accent fallback when no pywal palette exists yet (fresh install, before
|
||||
/// any wallpaper has been set for real) — BOS's own bread-toned accents,
|
||||
|
|
@ -84,7 +84,10 @@ pub fn load_palette() -> Palette {
|
|||
pub(crate) fn from_wal_json(json: &str) -> Option<Palette> {
|
||||
let wal: WalColors = serde_json::from_str(json).ok()?;
|
||||
let c = |k: &str, fallback: &str| -> String {
|
||||
wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into())
|
||||
wal.colors
|
||||
.get(k)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| fallback.into())
|
||||
};
|
||||
Some(Palette {
|
||||
background: FIXED_BACKGROUND.into(),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ dirs = { workspace = true }
|
|||
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
|
||||
gtk4-layer-shell = { version = "0.8", optional = true }
|
||||
toml_edit = { version = "0.22", optional = true }
|
||||
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0", optional = true }
|
||||
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0", optional = true }
|
||||
|
||||
[features]
|
||||
# Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue