diff --git a/.forgejo/workflows/dev-bakery.yml b/.forgejo/workflows/dev-bakery.yml index a7c4bde..f63769e 100644 --- a/.forgejo/workflows/dev-bakery.yml +++ b/.forgejo/workflows/dev-bakery.yml @@ -8,7 +8,6 @@ on: branches: ['main'] paths: - 'bakery/**' - - 'bread-utils/**' - 'Cargo.toml' - 'Cargo.lock' - '.forgejo/workflows/dev-bakery.yml' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index ca941f2..6725e22 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -6,9 +6,6 @@ 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 diff --git a/.forgejo/workflows/rc-bakery.yml b/.forgejo/workflows/rc-bakery.yml index d4e7853..dc7f695 100644 --- a/.forgejo/workflows/rc-bakery.yml +++ b/.forgejo/workflows/rc-bakery.yml @@ -7,9 +7,11 @@ name: beta (rc) bakery on: push: tags: ['v*'] - # No paths: filter. Tag pushes compare against an unrelated commit and - # would skip the RC publish if bakery/** wasn't in that diff; the job - # `if: contains -rc.` is the real gate. + paths: + - 'bakery/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.forgejo/workflows/beta-bakery.yml' jobs: build: @@ -33,7 +35,6 @@ 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" diff --git a/.forgejo/workflows/rc-bread-theme.yml b/.forgejo/workflows/rc-bread-theme.yml index bd53ba1..c364ce6 100644 --- a/.forgejo/workflows/rc-bread-theme.yml +++ b/.forgejo/workflows/rc-bread-theme.yml @@ -7,9 +7,11 @@ name: beta (rc) bread-theme on: push: tags: ['v*'] - # No paths: filter. Tag pushes compare against an unrelated commit and - # would skip the RC publish if bread-theme/** wasn't in that diff; the - # job `if: contains -rc.` is the real gate. + paths: + - 'bread-theme/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.forgejo/workflows/beta-bread-theme.yml' jobs: build: @@ -30,7 +32,6 @@ 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" diff --git a/.gitignore b/.gitignore index e96063c..4c046ac 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,3 @@ # scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY). *.minisign-sec minisign.key - -# Local tool caches — not build output, never belongs in the repo. -# (breadbar already excludes graphify-out; this repo did not, and 111k lines -# of it were swept in by a `git add -A`.) -graphify-out/ -.grok/ diff --git a/AGENTS.md b/AGENTS.md index c8819c5..d338d07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,3 @@ 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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 349d06b..4d816a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,15 +3,10 @@ This repo is a Cargo workspace. Bakery-channel products shipped from here are `bakery` (the ecosystem package manager) and `bread-theme` (the shared theming crate). Shared crates that sibling apps pin — not bakery packages -of their own — are `bread-utils`, `bread-app`, `bread-onnx`, -`bread-screenshots`, and `bread-capture`. `bread-polkit` is an in-tree -session agent: it has `bread-polkit/bakery.toml` so it *can* be published, -but it is not in `registry/bread-ecosystem.toml` (unpublished — not on -the bakery index, not on the BOS ISO). Other ecosystem products -(`bread`, `breadbar`, `breadbox`, …) live in their own repos under -`Breadway/` but follow the same workflow described here. The product list -is `registry/bread-ecosystem.toml`. New GTK tools should depend on -`bread-app` instead of copying another app's bootstrap. +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`. ## Branches @@ -93,12 +88,10 @@ cargo build --release -p bakery cargo test --release -p bakery ``` -`bakery`, `bread-theme`, `bread-utils`, `bread-app`, `bread-polkit`, -`bread-onnx`, `bread-screenshots`, and `bread-capture` are all workspace -members. Run the same commands with `-p bread-theme --bin bread-theme` -for that crate, `-p bread-utils --features bread-client` for the IPC -client, or `-p bread-app --features bread-client` for the GTK bootstrap -helpers. +`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. ## CI diff --git a/Cargo.lock b/Cargo.lock index a9d191d..66eb7fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,40 +96,6 @@ 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" @@ -138,14 +104,14 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bakery" -version = "0.7.5" +version = "0.7.2" dependencies = [ "anyhow", "bread-utils", "chrono", "clap", "clap_complete", - "dirs 5.0.1", + "dirs", "fs4", "hex", "minisign-verify", @@ -154,7 +120,6 @@ dependencies = [ "serde_json", "sha2", "tempfile", - "toml 0.8.23", "ureq", ] @@ -185,16 +150,9 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bread-app" -version = "0.7.5" -dependencies = [ - "bread-utils", -] - [[package]] name = "bread-capture" -version = "0.7.5" +version = "0.7.2" dependencies = [ "anyhow", "bread-utils", @@ -202,18 +160,9 @@ dependencies = [ "image", ] -[[package]] -name = "bread-launcher" -version = "0.7.5" -dependencies = [ - "bread-utils", - "gtk4", - "serde_json", -] - [[package]] name = "bread-onnx" -version = "0.7.5" +version = "0.7.2" dependencies = [ "anyhow", "bread-utils", @@ -226,24 +175,9 @@ dependencies = [ "ureq", ] -[[package]] -name = "bread-polkit" -version = "0.7.5" -dependencies = [ - "anyhow", - "bread-app", - "bread-theme", - "gtk4", - "serde", - "tokio", - "tracing", - "tracing-subscriber", - "zbus", -] - [[package]] name = "bread-screenshots" -version = "0.7.5" +version = "0.7.2" dependencies = [ "anyhow", "bread-utils", @@ -252,36 +186,32 @@ dependencies = [ [[package]] name = "bread-shared" -version = "0.8.0" -source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0#cdd5de8f58e437b3fc6d9b9087eb7b3d0fd09704" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" dependencies = [ - "dirs 6.0.0", + "dirs", "serde", "serde_json", "toml 0.8.23", - "uuid", ] [[package]] name = "bread-theme" -version = "0.7.5" +version = "0.7.2" dependencies = [ - "anyhow", - "dirs 5.0.1", + "dirs", "gtk4", "libadwaita", "serde", "serde_json", - "toml 0.8.23", - "tracing", ] [[package]] name = "bread-utils" -version = "0.7.5" +version = "0.7.2" dependencies = [ "bread-shared", - "dirs 5.0.1", + "dirs", "gtk4", "gtk4-layer-shell", "serde", @@ -308,12 +238,6 @@ 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" @@ -425,7 +349,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -553,7 +477,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn", ] [[package]] @@ -564,7 +488,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -594,7 +518,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -604,7 +528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.119", + "syn", ] [[package]] @@ -623,16 +547,7 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "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", + "dirs-sys", ] [[package]] @@ -643,22 +558,10 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users 0.4.6", + "redox_users", "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" @@ -667,7 +570,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -682,33 +585,6 @@ 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" @@ -734,26 +610,6 @@ 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" @@ -852,19 +708,6 @@ 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" @@ -873,7 +716,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -1077,7 +920,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -1211,7 +1054,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -1458,12 +1301,6 @@ 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" @@ -1559,15 +1396,6 @@ 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" @@ -1615,17 +1443,6 @@ 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" @@ -1645,7 +1462,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -1750,16 +1567,6 @@ 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" @@ -1802,12 +1609,6 @@ 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" @@ -2000,17 +1801,6 @@ 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" @@ -2169,7 +1959,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2185,17 +1975,6 @@ 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" @@ -2225,31 +2004,12 @@ 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" @@ -2268,16 +2028,6 @@ 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" @@ -2325,17 +2075,6 @@ 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" @@ -2344,7 +2083,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2405,7 +2144,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2416,16 +2155,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "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", + "syn", ] [[package]] @@ -2472,34 +2202,6 @@ 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" @@ -2611,7 +2313,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2623,38 +2325,12 @@ 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" @@ -2742,18 +2418,6 @@ 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" @@ -2813,7 +2477,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn", "wasm-bindgen-shared", ] @@ -2875,7 +2539,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2886,7 +2550,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -3116,75 +2780,10 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", "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" @@ -3202,7 +2801,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -3222,7 +2821,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", "synstructure", ] @@ -3262,7 +2861,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -3270,44 +2869,3 @@ 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", -] diff --git a/Cargo.toml b/Cargo.toml index 183d182..555bc60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] -members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit", "bread-launcher"] +members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture"] resolver = "2" [workspace.package] -version = "0.7.5" +version = "0.7.2" edition = "2021" license = "MIT" authors = ["Breadway "] diff --git a/README.md b/README.md index 927c629..d3ed536 100644 --- a/README.md +++ b/README.md @@ -106,26 +106,6 @@ bakery remove # 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. @@ -145,15 +125,13 @@ Install all required deps with `sudo pacman -S `. Use `pacman -Q This repo is a Cargo workspace. Bakery-channel products shipped from here are `bakery` and `bread-theme`; the other members are shared crates sibling -apps pin, or in-tree tools that are not bakery packages of their own. +apps pin, 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 @@ -164,41 +142,6 @@ 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..**` - -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 diff --git a/bakery/Cargo.toml b/bakery/Cargo.toml index 06ca297..1ccd0fe 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -11,7 +11,6 @@ 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 } diff --git a/bakery/README.md b/bakery/README.md deleted file mode 100644 index 21cb2cd..0000000 --- a/bakery/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# bakery - -Package manager for the bread ecosystem. Usage lives in the -[repo README](../README.md). - -## Install prefix - -Default root is `~/.local` (bins in `~/.local/bin`, data in -`~/.local/share`). That is the hermes / `get.sh` path and must stay the -default. - -BOS sets a system prefix so bakery-managed desktop apps live on the `@` -root subvolume and are included in snapper/grub-btrfs snapshots: - -```toml -# /etc/bakery/config.toml -prefix = "/usr/local" -``` - -`BAKERY_PREFIX` overrides the config file. A non-home prefix installs: - -| Thing | Path | -|-------|------| -| bins | `$prefix/bin` | -| share / desktop / licenses / data | `$prefix/share/...` | -| systemd user units | `/usr/lib/systemd/user` | - -Per-user state (`installed.json` and pre-update backups) stays in -`~/.local/state/bakery`. Writes that need root use `sudo -n`, then -`pkexec`. `bakery doctor` prints the active prefix. diff --git a/bakery/src/doctor.rs b/bakery/src/doctor.rs index f4ef71b..7f43c19 100644 --- a/bakery/src/doctor.rs +++ b/bakery/src/doctor.rs @@ -11,16 +11,8 @@ pub struct DepReport { pub fn check_deps(required: &[String], optional: &[String]) -> Result { 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(), }) } @@ -107,24 +99,14 @@ 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], - name_width: usize, -) -> bool { +pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool { if required.is_empty() && optional.is_empty() { - ui::check_row(true, package_name, name_width, "no system deps required"); + println!(" {}", ui::ok(&format!("{package_name}: no system deps required"))); return true; } match check_deps(required, optional) { Err(e) => { - ui::check_row( - false, - package_name, - name_width, - &format!("error running doctor: {e}"), - ); + eprintln!(" {}", ui::fail(&format!("error running doctor for {package_name}: {e}"))); false } Ok(rep) => { @@ -141,24 +123,17 @@ pub fn report( ); } if rep.missing.is_empty() { - ui::check_row( - true, - package_name, - name_width, - "all required system deps satisfied", - ); + println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied"))); true } else { - ui::check_row( - false, - package_name, - name_width, - &format!("missing: {}", rep.missing.join(", ")), - ); eprintln!( " {}", - ui::dim(&format!("install with: {}", install_hint(&rep.missing))) + ui::fail(&format!( + "{package_name}: missing system deps: {}", + rep.missing.join(", ") + )) ); + eprintln!(" install with: {}", install_hint(&rep.missing)); false } } @@ -212,14 +187,22 @@ 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); } diff --git a/bakery/src/download.rs b/bakery/src/download.rs index 4d1c1c7..14a19fd 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -3,7 +3,6 @@ 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 @@ -13,16 +12,16 @@ use crate::ui; /// 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 { - ui::step("downloading", &binary.name); + println!(" 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))?; - crate::prefix::write_bytes(dest, &bytes, 0o755) + bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755)) .with_context(|| format!("placing binary at {}", dest.display()))?; - ui::step("placed", &dest.display().to_string()); + println!(" installed {}", dest.display()); Ok(binary.sha256.clone()) } diff --git a/bakery/src/install.rs b/bakery/src/install.rs index dd31145..c046a00 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -7,10 +7,8 @@ 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`/ @@ -54,7 +52,7 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool { return false; } use std::io::Write; - print!("{prompt} {} ", ui::dim("[y/N]")); + print!("{prompt} [y/N] "); std::io::stdout().flush().ok(); let mut buf = String::new(); std::io::stdin().read_line(&mut buf).ok(); @@ -69,27 +67,25 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool { /// install" for the pre-overwrite backup below. pub fn install_package( pkg: &Package, - layout: &Layout, + bin_dir: &Path, 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. 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. + // `bakery rollback` — before it's overwritten below. 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 = layout.bin_dir.join(install_name); + let dest = bin_dir.join(install_name); if let Some(dir) = &backup_dir { backup_current_binary(dir, install_name, &dest); } @@ -98,32 +94,30 @@ pub fn install_package( binary_sha256.insert(install_name.to_string(), sha256); } - // 2. Scaffold config dir + download example file. Config stays - // per-user (~/.config) regardless of prefix — it's authored content, - // not bakery-placed bits. + // 2. Scaffold config dir + download example file. 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, layout)?; + install_license(pkg, license)?; } // 4. Install desktop entry, if declared. if let Some(desktop) = &pkg.desktop_file { - install_desktop_file(pkg, desktop, layout)?; + install_desktop_file(pkg, desktop)?; } // 5. Download + extract data archive, if declared. if let Some(archive) = &pkg.data_archive { - install_data_archive(pkg, archive, layout)?; + install_data_archive(pkg, archive)?; } // 6. Install systemd user units. let mut service_names = Vec::new(); for svc in &pkg.services { - install_service(svc, layout, pkg)?; + install_service(svc, bin_dir, pkg)?; service_names.push(svc.unit.clone()); } @@ -132,13 +126,10 @@ pub fn install_package( // confirmation rather than running unconditionally. if !pkg.post_install.is_empty() { if no_hooks { - eprintln!( - " {}", - ui::note(&format!( - "skipped {} post_install hook(s) for {} (--no-hooks)", - pkg.post_install.len(), - pkg.name - )) + println!( + " note: skipped {} post_install hook(s) for {} (--no-hooks)", + pkg.post_install.len(), + pkg.name ); } else if confirm( &format!( @@ -152,13 +143,7 @@ pub fn install_package( run_hook(cmd, &pkg.name)?; } } else { - eprintln!( - " {}", - ui::note(&format!( - "skipped post_install hooks for {} (declined)", - pkg.name - )) - ); + println!(" skipped post_install hooks for {} (declined)", pkg.name); } } @@ -178,8 +163,8 @@ pub fn install_package( Ok(()) })?; - println!(" {}", ui::ok(&format!("{} installed", pkg.name))); - warn_path_if_needed(&layout.bin_dir); + println!(" {} installed successfully", pkg.name); + warn_path_if_needed(bin_dir); Ok(()) } @@ -199,39 +184,25 @@ fn backup_current_binary(backup_dir: &Path, binary_name: &str, current_path: &Pa } if let Err(e) = std::fs::create_dir_all(backup_dir) { eprintln!( - " {}", - ui::warn(&format!( - "could not create backup dir {} ({e}) — rollback won't be available for this update", - backup_dir.display() - )) + " warning: 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!( - " {}", - ui::warn(&format!( - "could not back up {binary_name} before update ({e}) — rollback won't be available for this update" - )) - ); + eprintln!(" warning: could not back up {binary_name} before update ({e}) — rollback won't be available for this update"); } } -pub fn remove_package( - pkg_name: &str, - layout: &Layout, - assume_yes: bool, - purge: bool, -) -> Result<()> { +pub fn remove_package(pkg_name: &str, bin_dir: &Path, 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!(" {}", ui::fail(&format!("{pkg_name} is not installed"))); + eprintln!("{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. @@ -240,10 +211,10 @@ pub fn remove_package( // the config/data-preserved messages below. let mut failures = Vec::new(); for bin in &installed.binaries { - let path = layout.bin_dir.join(bin); + let path = bin_dir.join(bin); if path.exists() { - match prefix::remove_file(&path) { - Ok(()) => ui::step("removed", &path.display().to_string()), + match std::fs::remove_file(&path) { + Ok(()) => println!(" removed {}", path.display()), Err(e) => failures.push(format!("{}: {e}", path.display())), } } @@ -251,7 +222,7 @@ pub fn remove_package( // Prompt for unit removal. if !installed.services.is_empty() { - let service_dir = &layout.systemd_user_dir; + let service_dir = systemd_user_dir(); for unit in &installed.services { let unit_path = service_dir.join(unit); if confirm_remove_unit(unit, assume_yes) { @@ -259,12 +230,12 @@ pub fn remove_package( .args(["--user", "disable", "--now", unit]) .status(); if unit_path.exists() { - let _ = prefix::remove_file(&unit_path); + std::fs::remove_file(&unit_path).ok(); } let _ = Command::new("systemctl") .args(["--user", "daemon-reload"]) .status(); - ui::step("removed", &format!("unit {unit}")); + println!(" removed unit {unit}"); } } } @@ -276,45 +247,34 @@ pub fn remove_package( // surprise no flag should cause. if let Some(cfg_dir) = guess_config_dir(pkg_name) { if cfg_dir.exists() { - ui::step("preserved", &format!("config {}", cfg_dir.display())); + println!(" config preserved at {}", cfg_dir.display()); } } - let share_dir = &layout.share_dir; + let share_dir = dirs::data_dir().unwrap_or_else(|| PathBuf::from("~/.local/share")); let data_dir = share_dir.join(pkg_name); 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() { - ui::step("preserved", &format!("data {}", data_dir.display())); + println!(" data preserved at {}", data_dir.display()); } if !failures.is_empty() { - eprintln!( - " {}", - ui::fail(&format!("failed to remove {} item(s):", failures.len())) - ); + eprintln!(" 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!(" {}", ui::ok(&format!("{pkg_name} removed"))); + println!(" {pkg_name} removed"); Ok(()) } @@ -324,30 +284,21 @@ pub fn remove_package( /// 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, -) { +fn remove_purged_path(path: &Path, label: &str, recursive: bool, assume_yes: bool, failures: &mut Vec) { if !path.exists() { return; } - if !confirm( - &format!(" remove {label} at {}?", path.display()), - assume_yes, - ) { - ui::step("preserved", &format!("{label} {}", path.display())); + if !confirm(&format!(" remove {label} at {}?", path.display()), assume_yes) { + println!(" {label} preserved at {}", path.display()); return; } let result = if recursive { - prefix::remove_dir_all(path) + std::fs::remove_dir_all(path) } else { - prefix::remove_file(path) + std::fs::remove_file(path) }; match result { - Ok(()) => ui::step("removed", &path.display().to_string()), + Ok(()) => println!(" removed {}", path.display()), Err(e) => failures.push(format!("{}: {e}", path.display())), } } @@ -367,48 +318,36 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu Ok(()) => { std::fs::write(&dest, &bytes) .with_context(|| format!("writing {}", dest.display()))?; - ui::step("config", &dest.display().to_string()); + println!(" installed example config at {}", dest.display()); } Err(e) => { eprintln!( - " {}", - ui::warn(&format!( - "checksum mismatch for example config {example}: {e} — not installed" - )) + " warning: checksum mismatch for example config {example}: {e} — not installed" ); - ui::step("config", &dir.display().to_string()); + println!(" config dir created at {}", dir.display()); } }, None => { eprintln!( - " {}", - ui::warn(&format!( - "index.json has no sha256 for example config \ - {example} — refusing to install an unverified download" - )) + " warning: index.json has no sha256 for example config \ + {example} — refusing to install an unverified download" ); - ui::step("config", &dir.display().to_string()); + println!(" config dir created at {}", dir.display()); } }, Err(e) => { - eprintln!( - " {}", - ui::warn(&format!("could not download example config {example}: {e}")) - ); - ui::step("config", &dir.display().to_string()); + eprintln!(" warning: could not download example config {example}: {e}"); + println!(" config dir created at {}", dir.display()); } } } else { - ui::step("config", &dir.display().to_string()); + println!(" config dir created at {}", dir.display()); } } else { - ui::step( - "config", - &format!("{} already exists, skipping", dest.display()), - ); + println!(" config at {} already exists, skipping", dest.display()); } } else { - ui::step("config", &dir.display().to_string()); + println!(" config dir created at {}", dir.display()); } Ok(()) } @@ -427,75 +366,59 @@ fn fetch_verify_write( label: &str, ) -> Result<()> { let Some((primary, fallback)) = pkg.artifact_urls(filename) else { - eprintln!( - " {}", - ui::warn(&format!("no artifact URL to download {label} ({filename})")) - ); + eprintln!(" warning: no artifact URL to download {label} ({filename})"); return Ok(()); }; let bytes = match fetch_binary(&primary, &fallback) { Ok(b) => b, Err(e) => { - eprintln!( - " {}", - ui::warn(&format!("could not download {label} {filename}: {e}")) - ); + eprintln!(" warning: could not download {label} {filename}: {e}"); return Ok(()); } }; let Some(expected) = sha256 else { eprintln!( - " {}", - ui::warn(&format!( - "index.json has no sha256 for {label} {filename} — \ - refusing to install an unverified download" - )) + " warning: 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!( - " {}", - ui::warn(&format!( - "checksum mismatch for {label} {filename}: {e} — not installed" - )) - ); + eprintln!(" warning: checksum mismatch for {label} {filename}: {e} — not installed"); return Ok(()); } - prefix::write_bytes(dest, &bytes, 0o644) - .with_context(|| format!("writing {}", dest.display()))?; - ui::step("installed", &format!("{label} {}", dest.display())); + 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()); Ok(()) } -fn install_license(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> { +fn install_license(pkg: &Package, filename: &str) -> Result<()> { ensure_safe_component(filename, "license_file")?; - let dest = layout - .share_dir + let dest = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from("~/.local/share")) .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, layout: &Layout) -> Result<()> { +fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> { ensure_safe_component(filename, "desktop_file")?; - let dest = layout - .share_dir + let dest = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from("~/.local/share")) .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, layout: &Layout) -> Result<()> { +fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> { ensure_safe_component(filename, "data_archive")?; - let data_dir = layout.share_dir.join(&pkg.name); + let data_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from("~/.local/share")) + .join(&pkg.name); fetch_extract_archive(pkg, filename, &pkg.data_archive_sha256, &data_dir) } @@ -504,7 +427,7 @@ fn install_data_archive(pkg: &Package, filename: &str, layout: &Layout) -> Resul /// crate dependency — `tar` is universally present on Linux and this file /// 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 `$prefix/share/` destination) so tests can extract into +/// the real `~/.local/share/` destination) so tests can extract into /// a tempdir instead. fn fetch_extract_archive( pkg: &Package, @@ -523,35 +446,32 @@ fn fetch_extract_archive( .into_temp_path(); fetch_verify_write(pkg, filename, sha256, &tmp_archive, "data archive")?; - // `fetch_verify_write` treats a download/checksum/missing-sha failure - // as a soft warning and returns `Ok` *without writing* — but the - // `NamedTempFile` above is already created (0 bytes), so gating on - // mere `exists()` would let an empty file through to `tar tvf`, which - // then bails with a confusing "not in gzip format" error that masks - // the real cause and aborts the whole install. Gate on *non-empty*. - if std::fs::metadata(&tmp_archive) - .map(|m| m.len()) - .unwrap_or(0) - == 0 - { + if !tmp_archive.exists() { + // fetch_verify_write already warned (download/checksum failure). return Ok(()); } verify_archive_paths(&tmp_archive)?; - 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}")) - ); - } - } + 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}"); + } Ok(()) } @@ -613,11 +533,11 @@ fn fetch_and_verify_unit(pkg: &Package, svc: &Service) -> Result> { Ok(bytes) } -fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()> { +fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { ensure_safe_component(&svc.unit, "service unit")?; - let service_dir = &layout.systemd_user_dir; - prefix::create_dir_all(service_dir)?; + let service_dir = systemd_user_dir(); + std::fs::create_dir_all(&service_dir)?; let unit_path = service_dir.join(&svc.unit); let had_existing = unit_path.exists(); @@ -627,47 +547,38 @@ fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()> // applied after the first install, unlike binaries (which always // 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. Patch ExecStart in - // memory before the write so a system-prefix install only needs one - // privileged write, not write-then-rewrite. + // regressing offline/flaky-network reliability. match fetch_and_verify_unit(pkg, svc) { Ok(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) + std::fs::write(&unit_path, &bytes) .with_context(|| format!("writing {}", unit_path.display()))?; - ui::step("unit", &unit_path.display().to_string()); + println!(" downloaded unit {}", unit_path.display()); } Err(e) => { if had_existing { eprintln!( - " {}", - ui::warn(&format!( - "could not refresh unit {} ({e}) — keeping existing copy", - svc.unit - )) + " warning: could not refresh unit {} ({e}) — keeping existing copy", + svc.unit ); - patch_exec_start(&unit_path, &layout.bin_dir)?; } else { eprintln!( - " {}", - ui::warn(&format!( - "unit file {} not found ({e}) — skipping service setup", - svc.unit - )) + " warning: 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!(" {}", ui::warn("systemctl daemon-reload failed")); + eprintln!(" warning: systemctl daemon-reload failed"); } if svc.enable { @@ -684,9 +595,9 @@ fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()> .map(|s| s.success()) .unwrap_or(false) { - ui::step("restarted", &svc.unit); + println!(" {} restarted", svc.unit); } else { - eprintln!(" {}", ui::warn(&format!("failed to restart {}", svc.unit))); + eprintln!(" warning: failed to restart {}", svc.unit); } } else if Command::new("systemctl") .args(["--user", "enable", "--now", &svc.unit]) @@ -694,9 +605,9 @@ fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()> .map(|s| s.success()) .unwrap_or(false) { - ui::step("enabled", &svc.unit); + println!(" {} enabled and started", svc.unit); } else { - eprintln!(" {}", ui::warn(&format!("failed to enable {}", svc.unit))); + eprintln!(" warning: failed to enable {}", svc.unit); } } @@ -705,12 +616,6 @@ fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()> fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> { 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| { @@ -734,21 +639,24 @@ fn patch_exec_start_text(text: &str, bin_dir: &Path) -> String { }) .collect::>() .join("\n"); - if text.ends_with('\n') { + // Preserve trailing newline if the original had one. + let output = if text.ends_with('\n') { format!("{patched}\n") } else { patched - } + }; + std::fs::write(unit_path, output)?; + Ok(()) } fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> { - ui::step("hook", cmd); + println!(" running post_install 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!(" {}", ui::warn(&format!("hook exited with {status}"))); + eprintln!(" warning: hook exited with {status}"); } Ok(()) } @@ -757,6 +665,12 @@ 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 { Some(dirs::config_dir()?.join(pkg_name)) } @@ -785,14 +699,11 @@ fn warn_path_if_needed(bin_dir: &Path) { let path_var = std::env::var("PATH").unwrap_or_default(); let bin_str = bin_dir.to_string_lossy(); if !path_var.split(':').any(|p| p == bin_str) { - eprintln!(); - eprintln!( - " {}", - ui::note(&format!( - "{bin_str} is not in PATH — add to your shell profile:" - )) + println!( + "\n note: {} is not in PATH — add to your shell profile:", + bin_str ); - println!(" export PATH=\"{bin_str}:$PATH\""); + println!(" export PATH=\"{}:$PATH\"", bin_str); } } @@ -817,7 +728,10 @@ mod tests { if let Ok((mut stream, _)) = listener.accept() { 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); } @@ -834,7 +748,10 @@ 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); } @@ -880,14 +797,8 @@ 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); } @@ -925,14 +836,8 @@ 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. @@ -980,7 +885,8 @@ 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"); @@ -1045,34 +951,6 @@ mod tests { assert!(err.to_string().contains("unsafe path")); } - #[test] - fn fetch_extract_archive_download_failure_is_non_fatal() { - // A checksum mismatch makes `fetch_verify_write` warn-and-return - // Ok without writing, leaving the (already created) NamedTempFile - // empty. The pre-extraction `tar tvf` must be skipped for an empty - // file rather than bailing, so a failed archive download degrades - // gracefully and does NOT abort the package install (regression - // for the empty-temp-file bug). - let base_url = serve_once(b"definitely not a tar.gz"); - let pkg = test_package(&base_url); - - let dest_dir = tempdir().unwrap(); - let res = fetch_extract_archive( - &pkg, - "content.tar.gz", - &Some("0".repeat(64)), // will not match the served bytes - dest_dir.path(), - ); - assert!( - res.is_ok(), - "a data-archive download/checksum failure must be non-fatal" - ); - assert!( - std::fs::read_dir(dest_dir.path()).unwrap().next().is_none(), - "nothing should be extracted into dest_dir" - ); - } - #[test] fn ensure_safe_component_accepts_plain_names() { assert!(ensure_safe_component("breadhelp", "x").is_ok()); @@ -1095,8 +973,7 @@ mod tests { let mut pkg = test_package(&base_url); pkg.name = "../evil".to_string(); let dir = tempdir().unwrap(); - let layout = Layout::from_prefix(dir.path(), None); - let err = install_package(&pkg, &layout, Track::Stable, None, true, true).unwrap_err(); + let err = install_package(&pkg, dir.path(), Track::Stable, None, true, true).unwrap_err(); assert!(err.to_string().contains("not a safe filename")); } @@ -1166,10 +1043,7 @@ 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] diff --git a/bakery/src/main.rs b/bakery/src/main.rs index 11659a4..fc7e7d1 100644 --- a/bakery/src/main.rs +++ b/bakery/src/main.rs @@ -2,7 +2,6 @@ mod doctor; mod download; mod install; mod manifest; -mod prefix; mod state; mod track; mod ui; @@ -15,12 +14,7 @@ use std::path::{Path, PathBuf}; use track::Track; #[derive(Parser)] -#[command( - name = "bakery", - about = "Package manager for the bread ecosystem", - version, - styles = ui::CLAP_STYLES -)] +#[command(name = "bakery", about = "Package manager for the bread ecosystem", version)] struct Cli { #[command(subcommand)] command: Cmd, @@ -49,7 +43,7 @@ enum Cmd { Remove { package: String, /// Also remove the license file, desktop entry, and data dir - /// ($prefix/share//) — config is still preserved + /// (~/.local/share//) — config is still preserved #[arg(long)] purge: bool, }, @@ -69,9 +63,13 @@ 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 @@ -84,11 +82,15 @@ 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)] @@ -105,9 +107,15 @@ 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 layout = prefix::resolve(cli.bin_dir); + let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir); let no_hooks = cli.no_hooks; let assume_yes = cli.yes; let dry_run = cli.dry_run; @@ -117,40 +125,26 @@ fn main() -> Result<()> { Cmd::Install { packages } => { let index = manifest::load(true, track)?; for pkg in &packages { - cmd_install(&index, pkg, &layout, track, no_hooks, assume_yes, dry_run)?; + cmd_install(&index, pkg, &bin_dir, track, no_hooks, assume_yes, dry_run)?; } Ok(()) } - 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::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::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, &layout), - Cmd::Verify { package } => cmd_verify(package.as_deref(), &layout.bin_dir), - Cmd::Rollback { package } => cmd_rollback(&package, &layout.bin_dir), + 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), // 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, - &layout, - track, - no_hooks, - assume_yes, - dry_run, - ), + Cmd::SelfUpdate => cmd_update(Some("bakery"), false, &bin_dir, track, no_hooks, assume_yes, dry_run), Cmd::Completions { shell } => cmd_completions(shell), Cmd::Track { action } => cmd_track(action), } @@ -165,11 +159,11 @@ fn cmd_track(action: TrackCmd) -> Result<()> { let state = state::State::load()?; match action { TrackCmd::Show => { - ui::heading("Track", &[&ui::style(state.track.as_str(), ui::CYAN)]); + println!("current track: {}", ui::style(state.track.as_str(), ui::CYAN)); } TrackCmd::Set { track } => { if state.track == track { - println!("{}", ui::unchanged(&format!("already on track {track}"))); + println!("already on track {track}"); return Ok(()); } // Fail fast on a bad/unreachable track rather than silently @@ -180,10 +174,10 @@ fn cmd_track(action: TrackCmd) -> Result<()> { state.set_track(track); Ok(()) })?; - ui::action("Switched", track.as_str(), None); - ui::step( - "next", - &format!("bakery update --all to install {track} builds"), + println!( + "switched to {} — run 'bakery update --all' to install {} builds", + ui::style(track.as_str(), ui::CYAN), + track ); } } @@ -194,23 +188,14 @@ fn cmd_track(action: TrackCmd) -> Result<()> { fn cmd_install( index: &manifest::Index, name: &str, - layout: &prefix::Layout, + bin_dir: &std::path::Path, track: Track, no_hooks: bool, assume_yes: bool, dry_run: bool, ) -> Result<()> { let mut visited = HashSet::new(); - install_with_deps( - index, - name, - layout, - track, - no_hooks, - assume_yes, - dry_run, - &mut visited, - ) + install_with_deps(index, name, bin_dir, track, no_hooks, assume_yes, dry_run, &mut visited) } /// Recursively installs `name` and any bread_deps, skipping already-installed @@ -219,7 +204,7 @@ fn cmd_install( fn install_with_deps( index: &manifest::Index, name: &str, - layout: &prefix::Layout, + bin_dir: &std::path::Path, track: Track, no_hooks: bool, assume_yes: bool, @@ -238,10 +223,8 @@ fn install_with_deps( let state = state::State::load()?; for dep in pkg.bread_deps.clone() { if !state.is_installed(&dep) { - ui::step(if dry_run { "would need" } else { "dependency" }, &dep); - install_with_deps( - index, &dep, layout, track, no_hooks, assume_yes, dry_run, visited, - )?; + 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)?; } } @@ -254,63 +237,30 @@ fn install_with_deps( if let Some(installed) = previous { if !is_newer(&installed.version, &pkg.version) { println!( - " {}", - ui::unchanged(&format!( - "{name} already at {} (index has {})", - installed.version, pkg.version - )) + "{name} already installed at {} (index has {})", + installed.version, pkg.version ); return Ok(()); } } - 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"); + println!("checking system dependencies for {name}…"); let rep = doctor::check_deps(&pkg.system_deps, &pkg.optional_system_deps)?; for warn in &rep.warnings { - eprintln!( - " {}", - ui::note(&format!("optional dep not installed: {warn}")) - ); + eprintln!(" note: optional dep not installed: {warn}"); } if !rep.missing.is_empty() { - eprintln!( - " {}", - ui::fail(&format!( - "missing system deps for {name}: {}", - rep.missing.join(", ") - )) - ); - eprintln!( - " {}", - ui::dim(&format!( - "install with: {}", - doctor::install_hint(&rep.missing) - )) - ); + eprintln!("missing system deps for {name}: {}", rep.missing.join(", ")); + eprintln!("install with: {}", doctor::install_hint(&rep.missing)); bail!("system deps not satisfied"); } if dry_run { - print_dry_run_plan(pkg); + print_dry_run_plan(pkg, previous); return Ok(()); } - install::install_package(pkg, layout, track, previous, no_hooks, assume_yes) + install::install_package(pkg, bin_dir, track, previous, no_hooks, assume_yes) } /// Prints what `install_with_deps`/`cmd_update` would do for `pkg` under @@ -318,36 +268,35 @@ 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) { - ui::kv( - "binaries", - &pkg.binaries - .iter() - .map(|b| b.name.as_str()) - .collect::>() - .join(", "), +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::>().join(", ") ); if !pkg.services.is_empty() { - ui::kv( - "services", - &pkg.services - .iter() - .map(|s| s.unit.as_str()) - .collect::>() - .join(", "), + println!( + " services: {}", + pkg.services.iter().map(|s| s.unit.as_str()).collect::>().join(", ") ); } } -fn cmd_remove(name: &str, layout: &prefix::Layout, assume_yes: bool, purge: bool) -> Result<()> { - install::remove_package(name, layout, assume_yes, purge) +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) } #[allow(clippy::too_many_arguments)] fn cmd_update( name: Option<&str>, all: bool, - layout: &prefix::Layout, + bin_dir: &std::path::Path, track: Track, no_hooks: bool, assume_yes: bool, @@ -362,16 +311,10 @@ fn cmd_update( }; if targets.is_empty() { - println!("{}", ui::dim("no packages installed")); + println!("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; @@ -379,10 +322,7 @@ fn cmd_update( let installed = match state.packages.get(pkg_name.as_str()) { Some(p) => p, None => { - eprintln!( - " {}", - ui::fail(&format!("{pkg_name} is not installed, skipping")) - ); + eprintln!("{pkg_name} is not installed, skipping"); any_failed = true; continue; } @@ -390,10 +330,7 @@ fn cmd_update( let latest = match index.get(pkg_name) { Some(p) => p, None => { - eprintln!( - " {}", - ui::fail(&format!("{pkg_name} not found in index, skipping")) - ); + eprintln!("{pkg_name} not found in index, skipping"); any_failed = true; continue; } @@ -413,81 +350,57 @@ fn cmd_update( // under a terminal palette that maps ANSI colors unusually. println!( " {}", - ui::unchanged(&format!("{pkg_name} already at {}", installed.version)) + ui::unchanged(&format!("{pkg_name} is already at {}", installed.version)) ); unchanged += 1; continue; } - ui::action( - if dry_run { "Would update" } else { "Updating" }, - pkg_name, - Some(&latest.version), - ); if track_switch { - ui::step( - "track", - &format!( - "{} {} {}", - ui::dim(installed.track.as_str()), - ui::style("→", ui::CYAN), - ui::bold(track.as_str()), - ), + 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) ); } else { - ui::step( - "version", - &format!( - "{} {} {}", - ui::dim(&installed.version), - ui::style("→", ui::CYAN), - ui::bold(&latest.version) - ), + println!( + "updating {pkg_name} {} {} {}", + ui::style(&installed.version, ui::DIM), + ui::style("→", ui::CYAN), + ui::style(&latest.version, ui::BOLD) ); } let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) { Ok(r) => r, Err(e) => { - eprintln!( - " {}", - ui::fail(&format!("doctor check failed for {pkg_name}: {e}")) - ); + eprintln!(" doctor check failed for {pkg_name}: {e}"); any_failed = true; continue; } }; for warn in &rep.warnings { - eprintln!( - " {}", - ui::note(&format!("optional dep not installed: {warn}")) - ); + eprintln!(" note: optional dep not installed: {warn}"); } if !rep.missing.is_empty() { eprintln!( - " {}", - ui::fail(&format!( - "missing deps for {pkg_name}: {} — skipping update", - rep.missing.join(", ") - )) + " missing deps for {pkg_name}: {} — skipping update", + rep.missing.join(", ") ); any_failed = true; continue; } if dry_run { - print_dry_run_plan(latest); + print_dry_run_plan(latest, Some(installed)); updated += 1; continue; } - 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}")) - ); + if let Err(e) = install::install_package(latest, bin_dir, track, Some(installed), no_hooks, assume_yes) { + eprintln!(" failed to update {pkg_name}: {e}"); any_failed = true; } else { updated += 1; @@ -497,14 +410,12 @@ 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 updated_s = format!("{updated} updated"); - let unchanged_s = format!("{unchanged} already current"); - let mut parts: Vec<&str> = Vec::new(); + let mut parts = Vec::new(); if updated > 0 { - parts.push(&updated_s); + parts.push(format!("{updated} updated")); } - parts.push(&unchanged_s); - ui::summary(&parts); + parts.push(format!("{unchanged} already up to date")); + println!("{}", ui::style(&parts.join(", "), ui::BOLD)); } if any_failed { @@ -516,12 +427,7 @@ 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; } @@ -535,10 +441,7 @@ fn should_update( /// (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 { @@ -552,14 +455,15 @@ fn is_newer(installed: &str, latest: &str) -> bool { } } -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(), - } +/// Prints one index entry in the shared `list`/`search` format: name, +/// version, description, and an `[installed ]` 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 cmd_list(installed_only: bool, track: Track) -> Result<()> { @@ -567,44 +471,24 @@ fn cmd_list(installed_only: bool, track: Track) -> Result<()> { if installed_only { if state.packages.is_empty() { - println!("{}", ui::dim("no packages installed")); - return Ok(()); + println!("no packages installed"); + } + for pkg in state.packages.values() { + println!(" {} {} (installed {})", pkg.name, pkg.version, pkg.installed_at); } - let mut pkgs: Vec<_> = state.packages.values().collect(); - pkgs.sort_by(|a, b| a.name.cmp(&b.name)); - ui::heading("Installed", &[&pkgs.len().to_string()]); - let rows: Vec = 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(); - 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 = names - .iter() - .map(|name| catalog_row(&index.packages[*name], &state)) - .collect(); - ui::print_catalog(&rows); + for name in names { + print_index_entry(&index.packages[name], &state); + } Ok(()) } @@ -629,19 +513,13 @@ fn cmd_search(query: &str, track: Track) -> Result<()> { names.sort(); if names.is_empty() { - println!("{}", ui::dim(&format!("no packages matched '{query}'"))); + println!("no packages matched '{query}'"); return Ok(()); } - ui::heading( - "Search", - &[&format!("'{query}'"), &format!("{} matches", names.len())], - ); - let rows: Vec = names - .iter() - .map(|name| catalog_row(&index.packages[*name], &state)) - .collect(); - ui::print_catalog(&rows); + for name in names { + print_index_entry(&index.packages[name], &state); + } Ok(()) } @@ -653,60 +531,45 @@ 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) }; - 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 + println!("{}{} {}", ui::style(&pkg.name, ui::BOLD), ui::track_badge(track), pkg.version); + println!(" {}", pkg.description); + println!(" status: {status}"); + println!( + " binaries: {}", + pkg.binaries .iter() .map(|b| b.name.as_str()) .collect::>() - .join(", "), + .join(", ") ); if !pkg.system_deps.is_empty() { - ui::kv("system deps", &pkg.system_deps.join(", ")); + println!(" system deps: {}", pkg.system_deps.join(", ")); } if !pkg.optional_system_deps.is_empty() { - ui::kv("optional", &pkg.optional_system_deps.join(", ")); + println!(" optional deps: {}", pkg.optional_system_deps.join(", ")); } if !pkg.bread_deps.is_empty() { - ui::kv("bread deps", &pkg.bread_deps.join(", ")); + println!(" bread deps: {}", pkg.bread_deps.join(", ")); } if !pkg.services.is_empty() { - ui::kv( - "services", - &pkg.services + println!( + " services: {}", + pkg.services .iter() .map(|s| s.unit.as_str()) .collect::>() - .join(", "), + .join(", ") ); } - if let Some(inst) = state.packages.get(name) { - ui::kv("installed", &ui::short_date(&inst.installed_at)); - } Ok(()) } -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<()> { +fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Result<()> { let index = manifest::load(false, track)?; let state = state::State::load()?; @@ -721,38 +584,18 @@ fn cmd_doctor(name: Option<&str>, track: Track, layout: &prefix::Layout) -> Resu }; if targets.is_empty() { - ui::heading("Doctor", &["no packages"]); - report_layout(layout); - println!(); - println!("{}", ui::dim("no packages installed — nothing to check")); + println!("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, - name_w, - ) { + if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) { all_ok = false; } } else { - ui::check_row( - false, - pkg_name, - name_w, - "not found in index (removed from registry?)", - ); + eprintln!(" {pkg_name}: not found in index (removed from registry?)"); all_ok = false; } @@ -762,7 +605,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, layout: &prefix::Layout) -> Resu // only, not a checksum re-verification — see `bakery verify` for that. if let Some(installed) = state.packages.get(pkg_name) { for bin in &installed.binaries { - let path = layout.bin_dir.join(bin); + let path = bin_dir.join(bin); if !path.exists() { eprintln!( " {}", @@ -778,7 +621,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, layout: &prefix::Layout) -> Resu } if all_ok { - ui::summary(&[&ui::ok("all checks passed")]); + println!("{}", ui::ok("all checks passed")); } Ok(()) } @@ -831,47 +674,38 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> { }; if targets.is_empty() { - println!("{}", ui::dim("no packages installed — nothing to verify")); + println!("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() { - ui::unknown_row( - pkg_name, - name_w, - "no recorded checksums (installed before verify support)", + println!( + " {} {pkg_name}: no recorded checksums (installed before 'bakery verify' support)", + ui::style("?", ui::DIM) ); continue; } for bin in &installed.binaries { match verify_binary(bin_dir, bin, installed.binary_sha256.get(bin)) { - VerifyStatus::Ok => ui::check_row(true, pkg_name, name_w, bin), + VerifyStatus::Ok => println!(" {}", ui::ok(&format!("{pkg_name}: {bin}"))), VerifyStatus::Missing => { - ui::check_row(false, pkg_name, name_w, &format!("{bin} missing")); + eprintln!(" {}", ui::fail(&format!("{pkg_name}: {bin} — MISSING"))); any_bad = true; } VerifyStatus::Tampered => { - ui::check_row( - false, - pkg_name, - name_w, - &format!("{bin} tampered (checksum mismatch)"), + eprintln!( + " {}", + ui::fail(&format!("{pkg_name}: {bin} — TAMPERED (checksum mismatch)")) ); any_bad = true; } VerifyStatus::Unknown => { - ui::unknown_row( - pkg_name, - name_w, - &format!("{bin} no recorded checksum for this binary"), + println!( + " {} {pkg_name}: {bin} — UNKNOWN (no recorded checksum for this binary)", + ui::style("?", ui::DIM) ); } } @@ -881,7 +715,7 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> { if any_bad { bail!("verification failed for one or more binaries"); } - ui::summary(&[&ui::ok("all recorded checksums match")]); + println!("{}", ui::ok("all recorded checksums match")); Ok(()) } @@ -894,25 +728,18 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> { /// why rollback is backup-based rather than a network re-pin in the first /// 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> { +fn restore_binaries(backup_dir: &Path, binaries: &[String], bin_dir: &Path) -> Result> { 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); - prefix::write_bytes(&dest, &bytes, 0o755) + bread_utils::atomic::write_atomic_bytes(&dest, &bytes, Some(0o755)) .with_context(|| format!("restoring {}", dest.display()))?; sha256.insert(bin.clone(), hash); } @@ -940,8 +767,6 @@ fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> { anyhow::anyhow!("no previous version recorded for {pkg_name} — nothing to roll back to") })?; - ui::action("Rolling back", pkg_name, Some(&target_version)); - let backup_dir = state::backup_dir(pkg_name, &target_version); if !backup_dir.exists() { bail!( @@ -969,10 +794,7 @@ fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> { println!( " {}", - ui::ok(&format!( - "rolled back {pkg_name} {} → {target_version}", - ui::dim(&from_version) - )) + ui::ok(&format!("rolled back {pkg_name} {from_version} → {target_version}")) ); Ok(()) } @@ -1016,33 +838,18 @@ 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] @@ -1095,18 +902,14 @@ 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, - &layout, + bin_dir.path(), Track::Stable, true, true, @@ -1123,10 +926,7 @@ 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] @@ -1134,30 +934,21 @@ 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] @@ -1171,14 +962,8 @@ 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] diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 0ce04d7..5b5f5aa 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -52,7 +52,8 @@ fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> { /// exercise the verification logic with a throwaway keypair instead of the /// 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 @@ -107,21 +108,21 @@ pub struct Package { #[serde(default)] pub post_install: Vec, /// License artifact filename (e.g. "LICENSE"), installed to - /// `$prefix/share/licenses//LICENSE` (`~/.local/share/...` by - /// default) — the bakery equivalent of a PKGBUILD's `package()` step. + /// `~/.local/share/licenses//LICENSE` — the bakery equivalent of + /// what a PKGBUILD's `package()` does with `/usr/share/licenses`. #[serde(default)] pub license_file: Option, #[serde(default)] pub license_file_sha256: Option, /// Desktop entry artifact filename (e.g. "breadhelp.desktop"), - /// installed to `$prefix/share/applications/.desktop` so the - /// app shows up in any XDG-compliant launcher. + /// installed to `~/.local/share/applications/.desktop` so the + /// app shows up in any XDG-compliant launcher without root. #[serde(default)] pub desktop_file: Option, #[serde(default)] pub desktop_file_sha256: Option, /// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz` - /// in the release dir, extracted to `$prefix/share//` on + /// in the release dir, extracted to `~/.local/share//` 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 @@ -182,7 +183,9 @@ pub fn load(force_refresh: bool, track: Track) -> Result { 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…" + ); } } } @@ -208,12 +211,17 @@ pub fn load(force_refresh: bool, track: Track) -> Result { } } -fn read_and_verify_cache(cache_path: &Path, sig_cache_path: &Path, track: Track) -> Result { +fn read_and_verify_cache( + cache_path: &Path, + sig_cache_path: &Path, + track: Track, +) -> Result { 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") } @@ -277,10 +285,8 @@ pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result> { Ok(bytes) => Ok(bytes), Err(primary_err) => { eprintln!( - " {}", - crate::ui::note(&format!( - "primary URL failed ({primary_err}), trying GitHub fallback…" - )) + " primary URL failed ({}), trying GitHub fallback…", + primary_err ); fetch_bytes(fallback_url).context("both primary and GitHub fallback failed") } @@ -299,7 +305,9 @@ const CHUNK_SIZE: usize = 64 * 1024; fn fetch_bytes(url: &str) -> Result> { 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}"); @@ -312,11 +320,7 @@ fn fetch_bytes(url: &str) -> Result> { // 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 = resp.header("Content-Length").and_then(|v| v.parse().ok()); - // Progress is reassurance for multi-MB binaries. A 4 KB index fetch - // drawing a 100% / 0.0 MB bar is noise, not feedback. - const MIN_PROGRESS_BYTES: u64 = 256 * 1024; - let show_progress = - content_length.is_some_and(|n| n >= MIN_PROGRESS_BYTES) && std::io::stderr().is_terminal(); + let show_progress = content_length.is_some() && std::io::stderr().is_terminal(); let mut buf = Vec::new(); let mut reader = resp.into_reader(); @@ -332,17 +336,27 @@ fn fetch_bytes(url: &str) -> Result> { bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit"); } if show_progress && last_print.elapsed() >= PROGRESS_THROTTLE { - crate::ui::print_progress(buf.len() as u64, content_length.unwrap()); + print_progress(buf.len() as u64, content_length.unwrap()); last_print = std::time::Instant::now(); } } if show_progress { - crate::ui::print_progress(buf.len() as u64, content_length.unwrap()); - crate::ui::finish_progress(); + print_progress(buf.len() as u64, content_length.unwrap()); + eprintln!(); } 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::*; @@ -394,7 +408,10 @@ 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] @@ -411,22 +428,13 @@ 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 { diff --git a/bakery/src/prefix.rs b/bakery/src/prefix.rs deleted file mode 100644 index dd5b8bd..0000000 --- a/bakery/src/prefix.rs +++ /dev/null @@ -1,546 +0,0 @@ -//! Install prefix: default `~/.local`, or a system root for BOS. -//! -//! Hermes and `get.sh` keep the user-local default. BOS sets -//! `prefix = "/usr/local"` in `/etc/bakery/config.toml` (or `BAKERY_PREFIX`) -//! so bakery-managed desktop apps live on the `@` root subvolume and ride -//! along with snapper/grub-btrfs snapshots. Per-user state stays under -//! `~/.local/state/bakery` either way — bakery still records what *this* -//! user asked for; the prefix only changes where bits land on disk. -//! -//! Writes that hit `EACCES` use `sudo -n` first, then `pkexec` if a -//! graphical session is available. Interactive `sudo` (password on stdin) -//! is never used — a GUI hook must not block on a TTY prompt. - -use anyhow::{bail, Context, Result}; -use serde::Deserialize; -use std::ffi::OsStr; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; - -/// Default user-local prefix when no config/env override is set. -const DEFAULT_USER_PREFIX: &str = ".local"; - -/// System-wide user units, used when the prefix is not under `$HOME`. -const SYSTEM_USER_UNIT_DIR: &str = "/usr/lib/systemd/user"; - -const SYSTEM_CONFIG_PATH: &str = "/etc/bakery/config.toml"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Layout { - pub prefix: PathBuf, - pub bin_dir: PathBuf, - pub share_dir: PathBuf, - pub systemd_user_dir: PathBuf, - /// True when `prefix` is not under the user's home directory. - pub is_system: bool, -} - -impl Layout { - pub fn kind_label(&self) -> &'static str { - if self.is_system { - "system" - } else { - "user" - } - } - - /// Map a (possibly custom) prefix onto bin/share/unit paths. - /// `bin_override` is `--bin-dir` / `BAKERY_BIN_DIR` and wins for bins only. - pub fn from_prefix(prefix: &Path, bin_override: Option) -> 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) -> 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) -> 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, -) -> 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 { - 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, -} - -/// 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 { - 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::(&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/` 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"))); - } -} diff --git a/bakery/src/state.rs b/bakery/src/state.rs index bee3f9c..4e29055 100644 --- a/bakery/src/state.rs +++ b/bakery/src/state.rs @@ -56,7 +56,8 @@ 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 @@ -113,13 +114,7 @@ fn state_base_dir() -> PathBuf { } fn state_path() -> PathBuf { - bakery_state_dir().join("installed.json") -} - -/// Per-user bakery state dir (`~/.local/state/bakery`). Independent of the -/// install prefix — system-prefix installs still record what this user asked for. -pub fn bakery_state_dir() -> PathBuf { - state_base_dir().join("bakery") + state_base_dir().join("bakery/installed.json") } /// Local backup dir for `pkg_name`'s `version` binaries, populated by @@ -210,10 +205,7 @@ mod tests { assert_eq!(restored.packages["bar"].version, "2.0.0"); assert_eq!(restored.packages["bar"].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"); } @@ -236,14 +228,6 @@ 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"); diff --git a/bakery/src/ui.rs b/bakery/src/ui.rs index 0a90390..c1408f3 100644 --- a/bakery/src/ui.rs +++ b/bakery/src/ui.rs @@ -1,6 +1,5 @@ use crate::track::Track; -use clap::builder::styling::{AnsiColor, Effects, Styles}; -use std::io::{IsTerminal, Write}; +use std::io::IsTerminal; pub const RESET: &str = "\x1b[0m"; pub const BOLD: &str = "\x1b[1m"; @@ -10,19 +9,6 @@ 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 @@ -32,52 +18,21 @@ pub fn colors_enabled() -> bool { std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() } -pub fn colors_enabled_err() -> bool { - std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal() -} - pub fn style(s: &str, code: &str) -> String { - paint(s, code, colors_enabled()) -} - -fn style_err(s: &str, code: &str) -> String { - paint(s, code, colors_enabled_err()) -} - -fn paint(s: &str, code: &str, on: bool) -> String { - if on { + if colors_enabled() { format!("{code}{s}{RESET}") } else { s.to_string() } } -pub fn bold(s: &str) -> String { - style(s, BOLD) -} - -pub fn dim(s: &str) -> String { - style(s, DIM) -} - /// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the /// common-case output is unchanged. -#[allow(dead_code)] pub fn track_badge(track: Track) -> String { - let tag = track_tag(track); - if tag.is_empty() { - tag - } else { - format!(" {tag}") - } -} - -/// `[beta]` / `[dev]` with no leading space; empty for `Stable`. -pub fn track_tag(track: Track) -> String { match track { Track::Stable => String::new(), - Track::Beta => style("[beta]", YELLOW), - Track::Dev => style("[dev]", MAGENTA), + Track::Beta => format!(" {}", style("[beta]", YELLOW)), + Track::Dev => format!(" {}", style("[dev]", MAGENTA)), } } @@ -99,278 +54,6 @@ 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} {: Vec { - 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!("{: Vec { - 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>(names: impl IntoIterator) -> 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::() { - if n >= 40 { - return n; - } - } - } - ioctl_width().filter(|&n| n >= 40).unwrap_or(80) -} - -#[cfg(unix)] -fn ioctl_width() -> Option { - 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 { - None -} - #[cfg(test)] mod tests { use super::*; @@ -394,64 +77,4 @@ 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"); - } } diff --git a/bread-app/Cargo.toml b/bread-app/Cargo.toml deleted file mode 100644 index a212db3..0000000 --- a/bread-app/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "bread-app" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "GTK application bootstrap for bread desktop tools: app id, singleton, optional overlay popup, and command listen loop" -repository = "https://git.breadway.dev/Breadway/bread-ecosystem" -keywords = ["gtk4", "wayland", "hyprland"] - -[dependencies] -bread-utils = { path = "../bread-utils" } - -[features] -# Layer-shell overlay helper (`gtk_popup`). Matches `bread-utils/gtk` so a -# consumer that only wants app-id / singleton helpers does not pull GTK4. -gtk = ["bread-utils/gtk"] -# `BreadClient` listen loop on `bread.command..**`. Matches -# `bread-utils/bread-client`. -bread-client = ["bread-utils/bread-client"] diff --git a/bread-app/src/command.rs b/bread-app/src/command.rs deleted file mode 100644 index 41ad12a..0000000 --- a/bread-app/src/command.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! Command-bus helpers for `bread.command..**`. -//! -//! 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..**`. -pub fn command_pattern(command_id: &str) -> Result { - let id = parse_command_id(command_id)?; - Ok(format!("bread.command.{id}.**")) -} - -/// The verb segment of `bread.command..` (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..**` 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(command_id: &str, on_verb: F) -> Result -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(); - } -} diff --git a/bread-app/src/id.rs b/bread-app/src/id.rs deleted file mode 100644 index 8fdaaab..0000000 --- a/bread-app/src/id.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! App-id helpers shared by GTK tools and the singleton lock. -//! -//! The process / pid-file name (`breadbox`, `bread-polkit`) is also the -//! last segment of the GApplication id (`com.breadway.breadbox`). That is -//! *not* always the breadd command-bus id (`box`, `clip`) — see -//! [`crate::command_verb`] under feature `bread-client`. - -use std::io; - -use crate::singleton::{self, Acquire, Toggle}; - -/// Why [`parse_app_name`] / [`application_id`] rejected a string. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct InvalidAppId { - /// The rejected input, owned so the error is `'static`. - pub name: String, - /// Short reason suitable for an `io::Error` / clap message. - pub reason: &'static str, -} - -impl std::fmt::Display for InvalidAppId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "invalid app id '{}': {}", self.name, self.reason) - } -} - -impl std::error::Error for InvalidAppId {} - -/// Accept a process / GTK application name (`breadbox`, `bread-polkit`). -/// -/// Rules match a GApplication id *element*: non-empty, ASCII letter first, -/// then ASCII alphanumeric / `-` / `_`. Dots are rejected so the name can -/// sit in `com.breadway.` 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.`. -pub fn application_id(app_name: &str) -> Result { - 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 { - 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 { - 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"), - } - } -} diff --git a/bread-app/src/lib.rs b/bread-app/src/lib.rs deleted file mode 100644 index f848509..0000000 --- a/bread-app/src/lib.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! GTK application bootstrap for bread desktop tools. -//! -//! New GTK tools should depend on this crate instead of copying a sixth -//! `main.rs` that wires a `com.breadway.*` application id, a -//! [`bread_utils::singleton`] lock, a layer-shell overlay, and a -//! `bread.command..**` 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..**` -//! 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}; diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index d61c87b..e05a756 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -50,17 +50,6 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[ ("osd-volume", "osd-volume.png"), ("osd-brightness", "osd-brightness.png"), ("wifi-add-dialog", "wifi-add-dialog.png"), - // Theme 04/spotlight's embedded capsule (only rendered under - // `BREAD_SHELL_THEME=spotlight` — every other theme's [bar.slots] - // never places launcher_entry/launcher_results anywhere). - ("capsule-collapsed", "capsule-collapsed.png"), - ("capsule-expanded", "capsule-expanded.png"), - // Phase 6c: query sections (idle "Recent"/"Apps" headers) and - // the `=` calc mode — see breadbar's own `screenshot::KNOWN_VIEWS` - // doc comment for why the search-state width/radius change - // (item E) doesn't need a view of its own. - ("capsule-sections", "capsule-sections.png"), - ("capsule-calc", "capsule-calc.png"), ], ), ("breadbox", &[("launcher", "launcher.png")]), diff --git a/bread-launcher/Cargo.toml b/bread-launcher/Cargo.toml deleted file mode 100644 index c126c18..0000000 --- a/bread-launcher/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "bread-launcher" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "Headless app-launcher core (desktop-entry discovery, fuzzy matching/ranking, launch history, launching) plus an optional GTK4 results-list widget — the shared logic behind breadbox's overlay window and breadbar's embedded capsule" -repository = "https://git.breadway.dev/Breadway/bread-ecosystem" -keywords = ["launcher", "desktop-entry", "gtk4", "wayland"] - -[dependencies] -serde_json = { workspace = true } -# `do_launch`/`emit_launched` publish a `bread..launched` event over -# breadd's IPC socket after a successful spawn, fire-and-forget — this was -# already breadbox's behaviour (`BreadClient::emit` never blocks or errors -# the launching caller), just relocated. Not optional: launching is core, -# headless functionality, unlike the GTK widget below. -bread-utils = { path = "../bread-utils", features = ["bread-client"] } -gtk4 = { version = "0.11", features = ["v4_12"], optional = true } - -[features] -# Enable the GTK4 results-list widget (`gtk` module): row building, fuzzy -# filtering, match/history sorting, and keyboard-style selection movement. -# Optional so a headless consumer of the matching/ranking/launch core (or a -# future non-GTK host) doesn't have to pull in GTK4. -gtk = ["dep:gtk4"] diff --git a/bread-launcher/src/desktop.rs b/bread-launcher/src/desktop.rs deleted file mode 100644 index 1a205c9..0000000 --- a/bread-launcher/src/desktop.rs +++ /dev/null @@ -1,151 +0,0 @@ -use std::{ - fs::{self, File}, - io::{BufRead, BufReader}, - path::{Path, PathBuf}, -}; - -use crate::paths::app_dirs; - -#[derive(Debug, Clone)] -pub struct DesktopEntry { - /// Desktop file id (the `.desktop` filename, e.g. `firefox.desktop`). - /// Empty only if the path had no file name; callers fall back to `exec`. - pub id: String, - pub name: String, - pub exec: String, - pub icon_name: String, - pub icon_path: Option, // resolved by caller from manifest - pub categories: Vec, - pub wm_class: Option, - pub terminal: bool, -} - -pub fn strip_exec_codes(exec: &str) -> String { - let mut out = String::with_capacity(exec.len()); - let mut chars = exec.chars().peekable(); - while let Some(c) = chars.next() { - if c == '%' { - match chars.peek().copied() { - Some('%') => { - chars.next(); - out.push('%'); - } - Some(n) if n.is_ascii_alphabetic() => { - chars.next(); - } - _ => out.push(c), - } - } else { - out.push(c); - } - } - out -} - -/// Returns `None` for entries that should not be shown (hidden, NoDisplay, non-Application type). -pub fn parse_desktop(path: &Path) -> Option { - let file = File::open(path).ok()?; - let mut in_entry = false; - let mut name: Option = None; - let mut exec: Option = None; - let mut icon: Option = None; - let mut categories: Option = None; - let mut wm_class: Option = None; - let mut app_type: Option = None; - let mut no_display = false; - let mut hidden = false; - let mut terminal = false; - - for line in BufReader::new(file).lines() { - let Ok(raw) = line else { continue }; - let s = raw.trim(); - if s.starts_with('#') || s.is_empty() { - continue; - } - if s.starts_with('[') { - in_entry = s == "[Desktop Entry]"; - continue; - } - if !in_entry { - continue; - } - - if let Some(v) = s.strip_prefix("Name=") { - name.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("Exec=") { - exec.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("Icon=") { - icon.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("Categories=") { - categories.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("StartupWMClass=") { - wm_class.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("Type=") { - app_type.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("NoDisplay=") { - no_display = v == "true"; - } else if let Some(v) = s.strip_prefix("Hidden=") { - hidden = v == "true"; - } else if let Some(v) = s.strip_prefix("Terminal=") { - terminal = v == "true" || v == "1"; - } - } - - if no_display || hidden { - return None; - } - if app_type.as_deref() != Some("Application") { - return None; - } - - let name = name?.trim().to_string(); - let exec = strip_exec_codes(exec?.trim()).trim().to_string(); - if name.is_empty() || exec.is_empty() { - return None; - } - - let icon_name = icon.unwrap_or_default().trim().to_string(); - let cats = categories - .unwrap_or_default() - .split(';') - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); - - let id = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .filter(|s| !s.is_empty()) - .unwrap_or_default(); - - Some(DesktopEntry { - id, - name, - exec, - icon_name, - icon_path: None, - categories: cats, - wm_class: wm_class.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), - terminal, - }) -} - -/// Walk all configured application directories and return deduplicated entries. -/// Entries from later directories (user-local) override those from earlier ones. -pub fn load_all_desktop_entries() -> Vec { - let mut seen: std::collections::HashMap = std::collections::HashMap::new(); - for dir in app_dirs() { - let Ok(entries) = fs::read_dir(&dir) else { continue }; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("desktop") { - continue; - } - let key = entry.file_name().to_string_lossy().into_owned(); - if let Some(app) = parse_desktop(&path) { - seen.insert(key, app); - } - } - } - seen.into_values().collect() -} diff --git a/bread-launcher/src/gtk.rs b/bread-launcher/src/gtk.rs deleted file mode 100644 index d365353..0000000 --- a/bread-launcher/src/gtk.rs +++ /dev/null @@ -1,297 +0,0 @@ -//! GTK4 results-list widget: the "row-building half" of what used to be -//! breadbox's `run_ui` (`THEME_SYSTEM_PLAN.md` §3) — desktop-entry rows, -//! fuzzy filtering, match/history sorting, and keyboard-style selection -//! movement, packaged as [`ResultsList`] so any host window can embed it. -//! breadbox wraps it in a full-screen overlay window today; breadbar's -//! embedded capsule (a later phase) puts the same widget in its drawer slot. - -use std::{cell::RefCell, path::Path, rc::Rc}; - -use gtk4::{ - gdk, gio, - pango::EllipsizeMode, - prelude::*, - Align, Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation, PolicyType, - ScrolledWindow, SelectionMode, -}; - -use crate::desktop::DesktopEntry; -use crate::history::LaunchHistory; -use crate::matching::{fuzzy_matches, fuzzy_score, split_sections}; - -fn make_icon(icon_name: &str, icon_path: Option<&Path>, icon_px: i32) -> Image { - // Try loading from resolved cached path via gio::File - if let Some(path) = icon_path { - let gio_file = gio::File::for_path(path); - if let Ok(texture) = gdk::Texture::from_file(&gio_file) { - let img = Image::new(); - img.set_paintable(Some(&texture)); - img.set_pixel_size(icon_px); - return img; - } - } - // Fall back to GTK icon theme lookup by name - let name = if icon_name.is_empty() { - "application-x-executable" - } else { - icon_name - }; - let img = Image::from_icon_name(name); - img.set_pixel_size(icon_px); - img -} - -fn build_row(entry: &DesktopEntry, idx: u32, icon_px: i32) -> ListBoxRow { - let row = ListBoxRow::new(); - let hbox = GBox::new(Orientation::Horizontal, 0); - hbox.set_margin_start(6); - hbox.set_margin_end(6); - hbox.set_valign(Align::Center); - - let icon = make_icon(&entry.icon_name, entry.icon_path.as_deref(), icon_px); - hbox.append(&icon); - - let name_lbl = Label::new(Some(&entry.name)); - name_lbl.add_css_class("app-name"); - name_lbl.set_xalign(0.0); - name_lbl.set_hexpand(true); - name_lbl.set_ellipsize(EllipsizeMode::End); - hbox.append(&name_lbl); - - if let Some(ref wm) = entry.wm_class { - let wm_lbl = Label::new(Some(wm)); - wm_lbl.add_css_class("app-muted"); - wm_lbl.set_xalign(1.0); - hbox.append(&wm_lbl); - } - - row.set_child(Some(&hbox)); - unsafe { row.set_data("entry", entry.clone()) }; - unsafe { row.set_data("initial_order", idx) }; - row -} - -/// A non-selectable, non-activatable "Recent"/"Apps" label row (plan phase -/// 6c, `[launcher].sections`) — deliberately carries no `"entry"` row data, -/// which is exactly what [`row_entry`] (and everything downstream of it: -/// `set_query`'s filter, `select_next`/`select_prev`'s traversal) already -/// uses to tell a header apart from a real app row. -fn build_header_row(label: &str, idx: u32) -> ListBoxRow { - let row = ListBoxRow::new(); - row.set_selectable(false); - row.set_activatable(false); - row.add_css_class("bread-drawer-section-header"); - let lbl = Label::new(Some(label)); - lbl.add_css_class("section-header-label"); - lbl.set_xalign(0.0); - row.set_child(Some(&lbl)); - unsafe { row.set_data("initial_order", idx) }; - row -} - -/// Reads the [`DesktopEntry`] a row was built from — e.g. from a -/// `ListBox::connect_row_activated` handler, which hands back a row -/// reference rather than going through [`ResultsList::selected_entry`]. -pub fn row_entry(row: &ListBoxRow) -> Option { - unsafe { row.data::("entry").map(|p| p.as_ref().clone()) } -} - -/// A scrollable, filterable, rankable list of desktop-entry rows — the -/// widget breadbox's overlay wraps today and breadbar's capsule will embed -/// next (`THEME_SYSTEM_PLAN.md` §7). A host drives it through -/// [`set_query`](Self::set_query) (wire to a search entry's `changed` -/// signal), [`select_next`](Self::select_next)/[`select_prev`](Self::select_prev) -/// (wire to arrow keys), and reads the current pick via -/// [`selected_entry`](Self::selected_entry) — `list`/`scroller` are exposed -/// directly for anything else a host needs (e.g. `connect_row_activated` -/// for click-to-launch, or placing `scroller` in a slot). -#[derive(Clone)] -pub struct ResultsList { - pub scroller: ScrolledWindow, - pub list: ListBox, - query: Rc>, - history: Rc>, -} - -impl ResultsList { - /// Builds one row per entry (in `entries`' given order — that order is - /// also the fallback sort when the query is empty) and wires up sorting - /// against `history`'s launch counts. - /// - /// `sections` (`[launcher].sections`, plan phase 6c): when true, the - /// idle (empty-query) view groups `entries` into "Recent"/"Apps" - /// [`build_header_row`]s via [`split_sections`] instead of one flat - /// list. Sections disappear the moment a query is typed — `set_query` - /// falls back to the same flat fuzzy-ranked list either way — so this - /// only changes the initial build order and the header rows' presence, - /// never the (unchanged) search behaviour. `false` reproduces the - /// exact pre-phase-6c flat list breadbox's own overlay still uses. - pub fn new( - entries: &[DesktopEntry], - icon_px: i32, - history: Rc>, - sections: bool, - ) -> Self { - let list = ListBox::new(); - list.set_selection_mode(SelectionMode::Browse); - - let mut idx = 0u32; - if sections { - let (recent, apps) = split_sections(entries.to_vec(), &history.borrow()); - if !recent.is_empty() { - list.append(&build_header_row("Recent", idx)); - idx += 1; - for entry in &recent { - list.append(&build_row(entry, idx, icon_px)); - idx += 1; - } - } - if !apps.is_empty() { - list.append(&build_header_row("Apps", idx)); - idx += 1; - for entry in &apps { - list.append(&build_row(entry, idx, icon_px)); - idx += 1; - } - } - } else { - for entry in entries { - list.append(&build_row(entry, idx, icon_px)); - idx += 1; - } - } - - let query: Rc> = Rc::new(RefCell::new(String::new())); - { - let query = Rc::clone(&query); - let history = Rc::clone(&history); - list.set_sort_func(move |row_a, row_b| { - let query = query.borrow(); - if query.is_empty() { - let oa = unsafe { - row_a.data::("initial_order").map_or(u32::MAX, |p| *p.as_ref()) - }; - let ob = unsafe { - row_b.data::("initial_order").map_or(u32::MAX, |p| *p.as_ref()) - }; - return oa.cmp(&ob).into(); - } - // A header row carries no "entry" data — sort it after any - // real row rather than treating the comparison as `Equal`, - // though `set_query` also hides every header outright once - // a query is non-empty, so this only matters for the - // underlying (invisible) list order, never what's shown. - match (row_entry(row_a), row_entry(row_b)) { - (Some(ea), Some(eb)) => { - let sa = fuzzy_score(&query, &ea); - let sb = fuzzy_score(&query, &eb); - let history = history.borrow(); - let ca = history.count(&ea.name); - let cb = history.count(&eb.name); - sa.cmp(&sb) - .then(cb.cmp(&ca)) - .then(ea.name.to_lowercase().cmp(&eb.name.to_lowercase())) - .into() - } - (None, Some(_)) => std::cmp::Ordering::Greater.into(), - (Some(_), None) => std::cmp::Ordering::Less.into(), - (None, None) => std::cmp::Ordering::Equal.into(), - } - }); - } - - let first_real = (0i32..) - .map_while(|i| list.row_at_index(i)) - .find(|r| row_entry(r).is_some()); - if let Some(first) = first_real { - list.select_row(Some(&first)); - } - - let scroller = ScrolledWindow::new(); - scroller.set_policy(PolicyType::Never, PolicyType::Automatic); - scroller.set_max_content_height(480); - scroller.set_propagate_natural_height(true); - scroller.set_child(Some(&list)); - - ResultsList { scroller, list, query, history } - } - - /// Re-filters (fuzzy match against name, `wm_class`, and `exec`) and - /// re-sorts by `query`, then selects the first visible row. A header - /// row (see [`build_header_row`]) only ever shows in the idle - /// (empty-query) browse view — it has no name/`wm_class`/`exec` of its - /// own to filter against. - pub fn set_query(&self, query: &str) { - *self.query.borrow_mut() = query.to_string(); - let mut i = 0i32; - while let Some(row) = self.list.row_at_index(i) { - let vis = match row_entry(&row) { - Some(e) => { - fuzzy_matches(query, &e.name) - || e.wm_class.as_deref().is_some_and(|w| fuzzy_matches(query, w)) - || fuzzy_matches(query, &e.exec) - } - None => query.is_empty(), - }; - row.set_visible(vis); - i += 1; - } - self.list.invalidate_sort(); - let first_vis = (0i32..) - .map_while(|j| self.list.row_at_index(j)) - .find(|r| r.is_visible() && row_entry(r).is_some()); - self.list.select_row(first_vis.as_ref()); - } - - pub fn selected_entry(&self) -> Option { - self.list.selected_row().and_then(|r| row_entry(&r)) - } - - /// Moves the selection to the next visible row, if any. Skips header - /// rows even though they may be visible (the idle browse view) — - /// `set_selectable(false)` alone doesn't stop a programmatic - /// `select_row` call from landing on one. - pub fn select_next(&self) { - let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(-1); - let mut i = cur + 1; - loop { - match self.list.row_at_index(i) { - Some(r) if r.is_visible() && row_entry(&r).is_some() => { - self.list.select_row(Some(&r)); - break; - } - Some(_) => i += 1, - None => break, - } - } - } - - /// Moves the selection to the previous visible row, if any. See - /// [`select_next`](Self::select_next) on skipping header rows. - pub fn select_prev(&self) { - let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(0); - let mut i = cur - 1; - loop { - if i < 0 { - break; - } - match self.list.row_at_index(i) { - Some(r) if r.is_visible() && row_entry(&r).is_some() => { - self.list.select_row(Some(&r)); - break; - } - Some(_) => i -= 1, - None => break, - } - } - } - - /// Records `entry` as launched in the shared history and persists it. - /// Call before actually launching (matching breadbox's original - /// increment-then-launch ordering) — history and launching are separate - /// concerns, so this doesn't call [`crate::do_launch`] itself. - pub fn record_launch(&self, entry: &DesktopEntry) { - self.history.borrow_mut().increment(&entry.name); - self.history.borrow().save(); - } -} diff --git a/bread-launcher/src/history.rs b/bread-launcher/src/history.rs deleted file mode 100644 index d642141..0000000 --- a/bread-launcher/src/history.rs +++ /dev/null @@ -1,126 +0,0 @@ -use std::{collections::HashMap, fs, path::PathBuf}; - -pub struct LaunchHistory { - counts: HashMap, - path: PathBuf, -} - -impl LaunchHistory { - /// `app` picks the cache subdirectory (see [`crate::cache_dir`]) the - /// history file lives in. - pub fn load(app: &str) -> Self { - let path = crate::paths::cache_dir(app).join("history.json"); - let counts = fs::read_to_string(&path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); - LaunchHistory { counts, path } - } - - pub fn count(&self, name: &str) -> u32 { - self.counts.get(name).copied().unwrap_or(0) - } - - pub fn increment(&mut self, name: &str) { - *self.counts.entry(name.to_string()).or_insert(0) += 1; - } - - /// Writes `counts` to `path` as JSON. Best-effort — a broken cache dir - /// (missing parent, full disk, permissions) must not stop the caller - /// from launching anything, so this never returns an error — but it now - /// logs one on failure rather than swallowing it silently. Shared by two - /// hosts (breadbox's overlay and breadbar's embedded capsule, both keyed - /// under [`crate::LAUNCHER_APP`]), so a save failure here silently stops - /// ranking history for both. - pub fn save(&self) { - match serde_json::to_string(&self.counts) { - Ok(json) => { - if let Err(err) = fs::write(&self.path, json) { - eprintln!( - "bread-launcher: failed to save launch history to {}: {err}", - self.path.display() - ); - } - } - Err(err) => { - eprintln!("bread-launcher: failed to serialize launch history: {err}"); - } - } - } - - /// In-memory history with no backing file — [`save`](Self::save) fails - /// (an empty `path` is not writable) and now logs that failure to - /// stderr rather than swallowing it, same as any other broken-path - /// case. Lets a test (or a future in-memory host) control counts - /// directly instead of writing through `~/.cache//history.json`. - #[cfg(test)] - pub(crate) fn from_counts(counts: HashMap) -> Self { - LaunchHistory { - counts, - path: PathBuf::new(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn temp_history_path(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "bread-launcher-history-test-{}-{name}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )); - std::fs::create_dir_all(&dir).unwrap(); - dir.join("history.json") - } - - #[test] - fn save_then_load_round_trips_counts() { - let path = temp_history_path("roundtrip"); - let mut history = LaunchHistory { - counts: HashMap::new(), - path: path.clone(), - }; - history.increment("firefox.desktop"); - history.increment("firefox.desktop"); - history.increment("kitty.desktop"); - history.save(); - - let text = std::fs::read_to_string(&path).expect("save should have written the file"); - let counts: HashMap = serde_json::from_str(&text).unwrap(); - assert_eq!(counts.get("firefox.desktop"), Some(&2)); - assert_eq!(counts.get("kitty.desktop"), Some(&1)); - - // load() from the same path should see the same counts. - let reloaded = LaunchHistory { - counts: serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(), - path: path.clone(), - }; - assert_eq!(reloaded.count("firefox.desktop"), 2); - - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - /// `save()` on an unwritable path (e.g. the parent directory doesn't - /// exist, or `path` is empty) must not panic — it's best-effort, called - /// from a launcher's shutdown path where a hard failure would be worse - /// than a lost history entry. This exercises exactly the failure branch - /// the `eprintln!` above was added for; there is no return value to - /// assert on, so "did not panic" is the contract under test. - #[test] - fn save_to_a_broken_path_does_not_panic() { - let history = LaunchHistory::from_counts(HashMap::from([("x".to_string(), 1)])); - history.save(); - - let history = LaunchHistory { - counts: HashMap::new(), - path: PathBuf::from("/nonexistent-dir/definitely-not-there/history.json"), - }; - history.save(); - } -} diff --git a/bread-launcher/src/icon.rs b/bread-launcher/src/icon.rs deleted file mode 100644 index f6ad616..0000000 --- a/bread-launcher/src/icon.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::{fs, path::PathBuf}; - -pub struct IconCache { - pub dir: PathBuf, -} - -impl IconCache { - /// `app` picks the cache subdirectory (see [`crate::cache_dir`]) — pass - /// the same name across a process's calls so `path_for` and - /// `manifest_path` agree on where icons live. - pub fn new(app: &str) -> Self { - IconCache { dir: crate::paths::cache_dir(app).join("icons") } - } - - pub fn path_for(&self, icon_name: &str) -> PathBuf { - self.dir.join(format!("{}.png", icon_name)) - } - - pub fn manifest_path(app: &str) -> PathBuf { - crate::paths::cache_dir(app).join("manifest.json") - } - - pub fn ensure_dir(&self) -> std::io::Result<()> { - fs::create_dir_all(&self.dir) - } -} diff --git a/bread-launcher/src/launch.rs b/bread-launcher/src/launch.rs deleted file mode 100644 index d035a52..0000000 --- a/bread-launcher/src/launch.rs +++ /dev/null @@ -1,141 +0,0 @@ -use std::{ - env, - path::Path, - process::{Command, Stdio}, -}; - -use bread_utils::bread_client::BreadClient; - -use crate::desktop::DesktopEntry; - -fn pick_terminal() -> String { - if let Ok(t) = env::var("TERMINAL") { - if !t.is_empty() { - return t; - } - } - let path_var = env::var("PATH").unwrap_or_default(); - for t in ["foot", "kitty", "alacritty", "wezterm", "ghostty", "xterm"] { - if path_var.split(':').any(|d| Path::new(d).join(t).exists()) { - return t.to_string(); - } - } - "xterm".to_string() -} - -/// Spawns `entry`'s command (through a terminal if `entry.terminal` is set) -/// and, on a successful spawn, publishes `event` via [`emit_launched`]. -/// -/// `app_id` here is the caller's **bread event-namespace id** (e.g. -/// `"box"` for breadbox) — NOT [`crate::LAUNCHER_APP`] (`"breadbox"`). -/// Those are two different identities that happen to look similar: -/// `LAUNCHER_APP` only picks the shared cache/history directory (see its own -/// doc comment), while `app_id` here is threaded straight into -/// `BreadClient::connect(app_id)` and must be the caller's own namespace, or -/// `BreadClient::emit`'s `validate_app_namespace` check -/// (`event.starts_with("bread.{app_id}.")`) rejects `event` and drops it -/// with only an eprintln — passing `LAUNCHER_APP` here by mistake is exactly -/// that bug. breadbox passes its own `"box"` (see breadbox's `APP_ID`) so -/// its events publish as `bread.box.*`, matching [`emit_launched`]'s doc -/// example below. -pub fn do_launch(entry: &DesktopEntry, app_id: &str, event: &str) { - let cmd = entry.exec.trim(); - let spawned = if entry.terminal { - let term = pick_terminal(); - Command::new(&term) - .args(["-e", "bash", "-c", cmd]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - } else { - Command::new("bash") - .args(["-c", cmd]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - }; - if spawned.is_ok() { - emit_launched(entry, app_id, event); - } -} - -/// Publishes `event` under `app_id`'s bread namespace after a successful -/// spawn — e.g. breadbox calls this with `app_id = "box"` and -/// `event = "bread.box.launched"`, its own namespace. Fire-and-forget and -/// non-fatal (`BreadClient::emit` never blocks or errors this caller) — -/// breadd being absent must never affect launching itself. `app_id` must be -/// the caller's *own* namespace id, not [`crate::LAUNCHER_APP`] — see -/// [`do_launch`]'s doc comment for why those are different identities and -/// what happens if they're confused. -pub fn emit_launched(entry: &DesktopEntry, app_id: &str, event: &str) { - let id = if entry.id.is_empty() { - entry.exec.as_str() - } else { - entry.id.as_str() - }; - BreadClient::connect(app_id).emit( - event, - serde_json::json!({ "id": id, "name": entry.name }), - ); -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Mirrors `bread_shared::apps::validate_app_namespace` exactly - /// (`event.starts_with(&format!("bread.{app}."))`) without pulling in - /// that crate here — this is the one check that decides whether - /// [`emit_launched`]'s event actually gets published. - fn passes_namespace_check(app_id: &str, event: &str) -> bool { - event.starts_with(&format!("bread.{app_id}.")) - } - - #[test] - fn documented_app_id_and_event_pair_passes_the_namespace_check() { - // breadbox's real call site (breadbox/breadbox/src/main.rs): - // APP_ID = "box", LAUNCHED_EVENT = "bread.box.launched". - assert!( - passes_namespace_check("box", "bread.box.launched"), - "do_launch/emit_launched's own doc example must actually pass \ - BreadClient::emit's namespace check" - ); - } - - #[test] - fn launcher_app_is_not_a_valid_app_id_for_the_documented_event() { - // The historical bug this doc fix guards against: passing - // `LAUNCHER_APP` ("breadbox", the cache/history identity) as - // `app_id` instead of the caller's own namespace id ("box") would - // silently drop `bread.box.launched` — event.starts_with( - // "bread.breadbox.") is false for "bread.box.launched". - assert!( - !passes_namespace_check(crate::LAUNCHER_APP, "bread.box.launched"), - "LAUNCHER_APP must NOT satisfy the namespace check for the \ - documented event — if this ever passes, do_launch's doc comment \ - warning about confusing the two identities is wrong" - ); - } - - #[test] - fn emit_launched_does_not_panic_when_breadd_is_unreachable() { - // No daemon is running in a test environment — emit_launched (and - // the BreadClient::emit it wraps) must degrade silently rather than - // panicking or blocking, for both a valid and a namespace-violating - // app_id. - let entry = DesktopEntry { - id: "firefox.desktop".to_string(), - name: "Firefox".to_string(), - exec: "firefox".to_string(), - icon_name: String::new(), - icon_path: None, - categories: vec![], - wm_class: None, - terminal: false, - }; - emit_launched(&entry, "box", "bread.box.launched"); - emit_launched(&entry, crate::LAUNCHER_APP, "bread.box.launched"); - } -} diff --git a/bread-launcher/src/lib.rs b/bread-launcher/src/lib.rs deleted file mode 100644 index 4f5952a..0000000 --- a/bread-launcher/src/lib.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Headless app-launcher core — desktop-entry discovery, fuzzy matching and -//! ranking, launch history, and process launching — plus an optional GTK4 -//! results-list widget behind the `gtk` feature. -//! -//! Lives in `bread-ecosystem`, not an app repo, so breadbar (which must not -//! depend on an app repo) can embed the same launcher logic breadbox's -//! overlay window already wraps: one implementation, two hosts -//! (`THEME_SYSTEM_PLAN.md` §3, §7). -//! -//! Every path/cache/history entry point here takes an explicit `app: &str` -//! rather than hardcoding an app name, so more than one host can use this -//! crate without colliding — see [`cache_dir`]/[`config_dir`]. [`LAUNCHER_APP`] -//! is the one identity every *launcher* host (as opposed to some unrelated -//! future consumer of `cache_dir`/`config_dir`) should actually pass — see -//! its own doc comment for why. - -mod desktop; -mod history; -mod icon; -mod launch; -mod matching; -mod paths; -mod query; - -#[cfg(feature = "gtk")] -pub mod gtk; - -pub use desktop::{load_all_desktop_entries, parse_desktop, strip_exec_codes, DesktopEntry}; -pub use history::LaunchHistory; -pub use icon::IconCache; -pub use launch::{do_launch, emit_launched}; -pub use matching::{ - fuzzy_matches, fuzzy_score, load_sorted_entries, matches_term, priority_rank, split_sections, -}; -pub use paths::{app_dirs, cache_dir, config_dir, home_dir}; -pub use query::{builtin_commands, eval_calc, filter_commands, parse_query, Command, ParsedQuery, QueryKind}; - -/// The launcher's one shared identity, passed to [`cache_dir`]/[`config_dir`]/ -/// [`IconCache::new`]/[`LaunchHistory::load`] by every host that embeds this -/// crate — breadbox's overlay window AND breadbar's embedded capsule -/// (theme 04/spotlight) alike. -/// -/// This is deliberate, not a leftover of breadbox being first: theme 04's -/// whole premise is that breadbar's capsule IS the launcher wearing a -/// different shell, not a second launcher with its own history -/// (`THEME_SYSTEM_PLAN.md` §7). If each host passed its own binary name here, -/// the same physical launcher would rank a user's apps differently -/// depending on which theme happened to be active — the icon cache and -/// "most launched" ordering would silently fork in two. Sharing this -/// constant is what keeps them one launcher. -/// -/// Do not pass a bare `"breadbox"` string literal at a call site instead of -/// this constant — that reads exactly like an unfixed bug (breadbar naming -/// another app's identity) and invites a later "fix" that would quietly -/// break the shared history this constant exists to guarantee. -/// -/// **Not** the `app_id` for [`do_launch`]/[`emit_launched`]: those publish -/// bread-bus events, which must be namespaced under the caller's *own* -/// identity (breadbox's is `"box"`, not `"breadbox"`) or -/// `BreadClient::emit`'s namespace check silently drops them. This constant -/// is scoped to the cache/history path family only — see [`do_launch`]'s -/// doc comment for the concrete failure mode if the two get swapped. -pub const LAUNCHER_APP: &str = "breadbox"; diff --git a/bread-launcher/src/matching.rs b/bread-launcher/src/matching.rs deleted file mode 100644 index e732a80..0000000 --- a/bread-launcher/src/matching.rs +++ /dev/null @@ -1,387 +0,0 @@ -use std::{collections::HashMap, path::PathBuf}; - -use crate::desktop::{load_all_desktop_entries, DesktopEntry}; -use crate::history::LaunchHistory; - -// ---- Fuzzy matching (query filter) ------------------------------------------ - -/// Subsequence match used to *filter* rows as the user types: every char of -/// `pattern`, in order, must appear somewhere in `text` (case-insensitive). -/// Looser than [`fuzzy_score`], which ranks the rows that pass this filter. -pub fn fuzzy_matches(pattern: &str, text: &str) -> bool { - if pattern.is_empty() { - return true; - } - let mut chars = text.chars(); - for pc in pattern.chars() { - let pl = pc.to_lowercase().next().unwrap_or(pc); - if !chars - .by_ref() - .any(|tc| tc.to_lowercase().next().unwrap_or(tc) == pl) - { - return false; - } - } - true -} - -/// Ranks how well `query` matches `entry` — lower is better. Exact match (by -/// name or `wm_class`) sorts first, then name-prefix, then name-contains, -/// then `wm_class`-prefix/contains, then everything else that still passed -/// [`fuzzy_matches`] (a subsequence match with no stronger relationship). -pub fn fuzzy_score(query: &str, entry: &DesktopEntry) -> u32 { - let q = query.to_lowercase(); - let name = entry.name.to_lowercase(); - let wm = entry.wm_class.as_deref().unwrap_or("").to_lowercase(); - if name == q || wm == q { - return 0; - } - if name.starts_with(&q) { - return 1; - } - if name.contains(&q) { - return 2; - } - if wm.starts_with(&q) || wm.contains(&q) { - return 3; - } - 4 // subsequence match -} - -// ---- Priority ranking (empty-query ordering) -------------------------------- - -/// Whole-word / exact match of `term` within `field` (both lowercase). Avoids -/// "code" matching "vscodium" while still matching "Code", "code-oss", and -/// "Visual Studio Code". -pub fn matches_term(field: &str, term: &str) -> bool { - if term.is_empty() || field.is_empty() { - return false; - } - if field == term { - return true; - } - let bytes = field.as_bytes(); - let tlen = term.len(); - let mut start = 0; - while let Some(pos) = field[start..].find(term) { - let i = start + pos; - let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric(); - let after = i + tlen; - let after_ok = after >= bytes.len() || !bytes[after].is_ascii_alphanumeric(); - if before_ok && after_ok { - return true; - } - // Advance past the WHOLE match, not one byte past its start. Both `i` - // (a match start) and `i + tlen` (its end) are guaranteed char - // boundaries; `i + 1` is not, so a multi-byte term that failed the - // word-boundary check left `start` inside a character and the next - // `field[start..]` slice panicked outright. `matches_term("café", "é")` - // reproduced it: "byte index 4 is not a char boundary". Reached via - // priority_rank over real .desktop `Name=` values, so any non-ASCII - // app name could crash the launcher's sort. - start = i + tlen; - if start >= field.len() { - break; - } - } - false -} - -/// Position of `entry` in the (already-lowercased) `priority` list, matched -/// against either its name or `wm_class`. `None` if `entry` isn't named -/// there at all. -pub fn priority_rank(entry: &DesktopEntry, priority_lower: &[String]) -> Option { - let name_l = entry.name.to_lowercase(); - let wm_l = entry.wm_class.as_deref().unwrap_or("").to_lowercase(); - priority_lower - .iter() - .position(|p| matches_term(&name_l, p) || matches_term(&wm_l, p)) -} - -/// Loads every known desktop entry, resolves each one's icon path from -/// `manifest`, and sorts them: entries named in `priority` come first (in -/// that order), then everything else by most-launched (via `history`), then -/// alphabetically. -pub fn load_sorted_entries( - manifest: &HashMap, - priority: &[String], - history: &LaunchHistory, -) -> Vec { - let mut entries = load_all_desktop_entries(); - - // Populate icon_path from manifest - for entry in &mut entries { - if let Some(path) = manifest.get(&entry.icon_name) { - if path.exists() { - entry.icon_path = Some(path.clone()); - } - } - } - - let priority_lower: Vec = priority.iter().map(|s| s.to_lowercase()).collect(); - - entries.sort_by(|a, b| { - let ai = priority_rank(a, &priority_lower); - let bi = priority_rank(b, &priority_lower); - match (ai, bi) { - (Some(i), Some(j)) => i.cmp(&j), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => { - // Most-launched first, then alphabetical - history - .count(&b.name) - .cmp(&history.count(&a.name)) - .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) - } - } - }); - - entries -} - -/// The demo's own cap (`BOS.pushRecent`'s `.slice(0, 4)`) on how many -/// entries the "Recent" section shows. -pub const MAX_RECENT: usize = 4; - -/// Splits `entries` (already ordered by [`load_sorted_entries`]) into a -/// "recent" section — the entries `history` has any launch count for, most- -/// launched first, capped at [`MAX_RECENT`] — and an "apps" section: every -/// other entry, in its existing relative order. No new tracking beyond -/// `LaunchHistory`'s existing counts (THEME_SYSTEM_PLAN.md phase 6c task -/// notes: "`LaunchHistory` already tracks counts for a recents list"). -/// -/// Only meaningful when `entries` has no priority-ranked prefix (breadbar's -/// capsule calls [`load_sorted_entries`] with an empty `priority` list) — -/// with a non-empty priority list, priority-ranked entries still sort first -/// and would be treated as "apps" here even if launched often, since this -/// function has no way to tell "sorted first because launched a lot" from -/// "sorted first because priority-ranked" apart from the count itself. -pub fn split_sections( - entries: Vec, - history: &LaunchHistory, -) -> (Vec, Vec) { - let mut recent = Vec::new(); - let mut apps = Vec::new(); - for entry in entries { - if recent.len() < MAX_RECENT && history.count(&entry.name) > 0 { - recent.push(entry); - } else { - apps.push(entry); - } - } - (recent, apps) -} - -#[cfg(test)] -mod tests { - #[test] - fn multibyte_term_that_fails_word_boundary_does_not_panic() { - // "é" occurs in "café" but is preceded by an alphanumeric, so the - // whole-word check fails and the scan must continue — landing mid - // character before the fix. - assert!(!super::matches_term("café", "é")); - assert!(!super::matches_term("naïve café", "ï")); - } - - #[test] - fn multibyte_whole_word_still_matches() { - assert!(super::matches_term("café bar", "café")); - assert!(super::matches_term("día", "día")); - } - - use super::*; - - fn entry(name: &str, wm_class: Option<&str>) -> DesktopEntry { - DesktopEntry { - id: format!("{name}.desktop"), - name: name.to_string(), - exec: "true".to_string(), - icon_name: String::new(), - icon_path: None, - categories: Vec::new(), - wm_class: wm_class.map(|s| s.to_string()), - terminal: false, - } - } - - // ---- fuzzy_matches ------------------------------------------------- - - #[test] - fn fuzzy_matches_empty_pattern_matches_anything() { - assert!(fuzzy_matches("", "Firefox")); - assert!(fuzzy_matches("", "")); - } - - #[test] - fn fuzzy_matches_in_order_subsequence() { - assert!(fuzzy_matches("ffx", "Firefox")); - assert!(fuzzy_matches("frfx", "Firefox")); - } - - #[test] - fn fuzzy_matches_is_case_insensitive() { - assert!(fuzzy_matches("FIREFOX", "firefox")); - assert!(fuzzy_matches("firefox", "FireFox")); - } - - #[test] - fn fuzzy_matches_rejects_out_of_order() { - assert!(!fuzzy_matches("xfr", "Firefox")); - } - - #[test] - fn fuzzy_matches_rejects_missing_chars() { - assert!(!fuzzy_matches("firefoxx", "Firefox")); - } - - // ---- fuzzy_score ----------------------------------------------------- - - #[test] - fn fuzzy_score_exact_name_match_is_best() { - let e = entry("Firefox", None); - assert_eq!(fuzzy_score("firefox", &e), 0); - } - - #[test] - fn fuzzy_score_exact_wm_class_match_is_best() { - let e = entry("Firefox Web Browser", Some("firefox")); - assert_eq!(fuzzy_score("firefox", &e), 0); - } - - #[test] - fn fuzzy_score_name_prefix_beats_name_contains() { - let prefix = entry("Firefox", None); - let contains = entry("GNU IceCat (Firefox fork)", None); - assert_eq!(fuzzy_score("fire", &prefix), 1); - assert_eq!(fuzzy_score("fire", &contains), 2); - assert!(fuzzy_score("fire", &prefix) < fuzzy_score("fire", &contains)); - } - - #[test] - fn fuzzy_score_wm_class_beats_pure_subsequence() { - let wm_hit = entry("Web Browser", Some("firefox")); - let subseq_only = entry("Fine Iris Reflex Editor for XML", None); - assert_eq!(fuzzy_score("fire", &wm_hit), 3); - assert_eq!(fuzzy_score("fire", &subseq_only), 4); - } - - // ---- matches_term ------------------------------------------------------ - - #[test] - fn matches_term_exact_field_matches() { - assert!(matches_term("code", "code")); - } - - #[test] - fn matches_term_whole_word_within_longer_field() { - assert!(matches_term("visual studio code", "code")); - } - - #[test] - fn matches_term_rejects_substring_of_a_larger_word() { - // "code" must not match inside "vscodium" — this is the whole - // reason matches_term exists instead of a plain `contains`. - assert!(!matches_term("vscodium", "code")); - } - - #[test] - fn matches_term_matches_hyphenated_variant() { - assert!(matches_term("code-oss", "code")); - } - - #[test] - fn matches_term_empty_term_or_field_never_matches() { - assert!(!matches_term("code", "")); - assert!(!matches_term("", "code")); - } - - // ---- priority_rank ----------------------------------------------------- - - #[test] - fn priority_rank_matches_by_name() { - let e = entry("Firefox", None); - let priority = vec!["firefox".to_string(), "code".to_string()]; - assert_eq!(priority_rank(&e, &priority), Some(0)); - } - - #[test] - fn priority_rank_matches_by_wm_class() { - let e = entry("Web Browser", Some("firefox")); - let priority = vec!["code".to_string(), "firefox".to_string()]; - assert_eq!(priority_rank(&e, &priority), Some(1)); - } - - #[test] - fn priority_rank_none_when_unlisted() { - let e = entry("Nautilus", None); - let priority = vec!["firefox".to_string()]; - assert_eq!(priority_rank(&e, &priority), None); - } - - #[test] - fn priority_rank_does_not_match_substring_of_a_word() { - let e = entry("VSCodium", None); - let priority = vec!["code".to_string()]; - assert_eq!(priority_rank(&e, &priority), None); - } - - // ---- split_sections -------------------------------------------------- - - #[test] - fn split_sections_no_history_is_all_apps() { - let entries = vec![entry("Firefox", None), entry("GoLand", None)]; - let history = LaunchHistory::from_counts(HashMap::new()); - let (recent, apps) = split_sections(entries, &history); - assert!(recent.is_empty()); - assert_eq!(apps.len(), 2); - } - - #[test] - fn split_sections_launched_entries_go_to_recent() { - let entries = vec![ - entry("Firefox", None), - entry("GoLand", None), - entry("Steam", None), - ]; - let mut counts = HashMap::new(); - counts.insert("Firefox".to_string(), 5); - let history = LaunchHistory::from_counts(counts); - let (recent, apps) = split_sections(entries, &history); - assert_eq!(recent.iter().map(|e| &e.name).collect::>(), vec!["Firefox"]); - assert_eq!( - apps.iter().map(|e| &e.name).collect::>(), - vec!["GoLand", "Steam"] - ); - } - - #[test] - fn split_sections_caps_recent_at_max() { - let entries: Vec = (0..(MAX_RECENT + 2)) - .map(|i| entry(&format!("App{i}"), None)) - .collect(); - let counts = entries - .iter() - .map(|e| (e.name.clone(), 1)) - .collect::>(); - let history = LaunchHistory::from_counts(counts); - let (recent, apps) = split_sections(entries, &history); - assert_eq!(recent.len(), MAX_RECENT); - assert_eq!(apps.len(), 2); - } - - #[test] - fn split_sections_preserves_relative_order_within_each_group() { - let mut counts = HashMap::new(); - counts.insert("A".to_string(), 1); - counts.insert("C".to_string(), 3); - let history = LaunchHistory::from_counts(counts); - // load_sorted_entries would already have ordered these by count - // desc before calling split_sections; split_sections itself just - // partitions in whatever order it's handed, so feed it pre-sorted. - let pre_sorted = vec![entry("C", None), entry("A", None), entry("B", None)]; - let (recent, apps) = split_sections(pre_sorted, &history); - assert_eq!(recent.iter().map(|e| &e.name).collect::>(), vec!["C", "A"]); - assert_eq!(apps.iter().map(|e| &e.name).collect::>(), vec!["B"]); - } -} diff --git a/bread-launcher/src/paths.rs b/bread-launcher/src/paths.rs deleted file mode 100644 index 5a87df0..0000000 --- a/bread-launcher/src/paths.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::{env, path::PathBuf}; - -// ---- XDG path helpers ------------------------------------------------------- - -pub fn home_dir() -> PathBuf { - PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into())) -} - -/// `$XDG_CACHE_HOME/` (or `~/.cache/`). `app` is the caller's own -/// name in this scheme — e.g. breadbox passes `"breadbox"` to keep using the -/// on-disk layout it always has; a future host picks its own. -pub fn cache_dir(app: &str) -> PathBuf { - env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")) - .join(app) -} - -/// `$XDG_CONFIG_HOME/` (or `~/.config/`). See [`cache_dir`]. -pub fn config_dir(app: &str) -> PathBuf { - env::var("XDG_CONFIG_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".config")) - .join(app) -} - -/// The `applications/` directories a `.desktop` file may live in, per the -/// XDG base-directory spec (system-wide first, user-local last so later -/// entries can override earlier ones on lookup by filename). -pub fn app_dirs() -> Vec { - let home = home_dir(); - let mut dirs = vec![PathBuf::from("/usr/share/applications")]; - - let xdg_data_dirs = - env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".into()); - for d in xdg_data_dirs.split(':') { - let p = PathBuf::from(d).join("applications"); - if p != dirs[0] { - dirs.push(p); - } - } - - dirs.push( - env::var("XDG_DATA_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home.join(".local/share")) - .join("applications"), - ); - dirs -} diff --git a/bread-launcher/src/query.rs b/bread-launcher/src/query.rs deleted file mode 100644 index e6a696b..0000000 --- a/bread-launcher/src/query.rs +++ /dev/null @@ -1,391 +0,0 @@ -//! Query modes (`04-spotlight.html`'s `BOS.parseQuery`/`BOS.evalCalc`/ -//! `BOS.COMMANDS`, THEME_SYSTEM_PLAN.md phase 6c): a leading `=`/`>`/`.` -//! switches the launcher from filtering apps to evaluating an arithmetic -//! expression, listing bread commands, or treating the rest of the query as -//! a URL to open. Pure/headless — no GTK here — so both breadbar's embedded -//! capsule and (per the module doc comment on `crate`) breadbox's own -//! overlay window can adopt the same parsing/eval/filter logic. A host is -//! expected to gate which prefixes it actually acts on against its theme's -//! `[launcher].modes` list — this module recognizes all four kinds -//! unconditionally and leaves that gating to the caller. - -use crate::matching::fuzzy_matches; - -/// Which of the four query modes a raw entry-text string names, per its -/// leading character (mirrors `BOS.parseQuery`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum QueryKind { - /// No recognized prefix — the ordinary app-filter query. - Apps, - /// Leading `=` — the rest is an arithmetic expression for [`eval_calc`]. - Calc, - /// Leading `>` — the rest filters [`builtin_commands`]. - Cmd, - /// Leading `.` — the rest is a URL to open. - Url, -} - -/// A parsed query: which mode it names, and the text after the prefix -/// character (empty string for a bare `=`/`>`/`.` with nothing typed yet). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParsedQuery { - pub kind: QueryKind, - pub value: String, -} - -/// Splits `raw` on its leading mode character, if any. Mirrors -/// `BOS.parseQuery` exactly: only the FIRST character is checked, and it is -/// stripped along with any immediately-following whitespace. -pub fn parse_query(raw: &str) -> ParsedQuery { - let (kind, rest) = if let Some(rest) = raw.strip_prefix('=') { - (QueryKind::Calc, rest) - } else if let Some(rest) = raw.strip_prefix('>') { - (QueryKind::Cmd, rest) - } else if let Some(rest) = raw.strip_prefix('.') { - (QueryKind::Url, rest) - } else { - (QueryKind::Apps, raw) - }; - ParsedQuery { - kind, - value: rest.trim().to_string(), - } -} - -// ---- Calc --------------------------------------------------------------- - -/// Evaluates `expr` as a small four-function arithmetic expression -/// (`+ - * / ( )`, decimal literals, unary minus) and formats the result -/// the same way `BOS.evalCalc` does: rounded to 8 decimal places, an -/// integer result prints with no trailing `.0`, a non-finite result prints -/// "∞", an expression containing anything outside `[0-9.\s+\-*/()]` -/// returns "bad expr", and anything else that fails to parse or evaluate -/// (unbalanced parens, division producing NaN, trailing garbage) returns -/// "err". `None` only for an empty/whitespace-only expression — the -/// caller's "nothing typed after `=` yet" case, which has no result to -/// show at all (the demo's own `if (!expr) return null;`). -pub fn eval_calc(expr: &str) -> Option { - let expr = expr.trim(); - if expr.is_empty() { - return None; - } - if !expr.chars().all(|c| c.is_ascii_digit() || " .+-*/()".contains(c)) { - return Some("bad expr".to_string()); - } - let mut p = CalcParser { - bytes: expr.as_bytes(), - pos: 0, - }; - let result = p.parse_expr().filter(|_| p.skip_ws() == p.bytes.len()); - Some(match result { - Some(n) if !n.is_finite() => "∞".to_string(), - Some(n) => format_calc_result(n), - None => "err".to_string(), - }) -} - -/// Rounds to 8 decimal places and formats without a trailing `.0` for whole -/// numbers — `String(Math.round(n * 1e8) / 1e8)` in JS, `{}` on `f64` -/// already behaves the same way in Rust (`format!("{}", 4.0_f64)` == "4"). -fn format_calc_result(n: f64) -> String { - let rounded = (n * 1e8).round() / 1e8; - // Avoid printing "-0" for a result that rounds to negative zero. - let rounded = if rounded == 0.0 { 0.0 } else { rounded }; - format!("{rounded}") -} - -/// Minimal recursive-descent parser: `expr := term (('+'|'-') term)*`, -/// `term := factor (('*'|'/') factor)*`, `factor := '-' factor | number | -/// '(' expr ')'`. Byte-indexed since the character set is already -/// restricted to ASCII by [`eval_calc`]'s pre-check. -struct CalcParser<'a> { - bytes: &'a [u8], - pos: usize, -} - -impl<'a> CalcParser<'a> { - fn skip_ws(&mut self) -> usize { - while self.pos < self.bytes.len() && self.bytes[self.pos] == b' ' { - self.pos += 1; - } - self.pos - } - - fn peek(&mut self) -> Option { - self.skip_ws(); - self.bytes.get(self.pos).copied() - } - - fn parse_expr(&mut self) -> Option { - let mut val = self.parse_term()?; - loop { - match self.peek() { - Some(b'+') => { - self.pos += 1; - val += self.parse_term()?; - } - Some(b'-') => { - self.pos += 1; - val -= self.parse_term()?; - } - _ => break, - } - } - Some(val) - } - - fn parse_term(&mut self) -> Option { - let mut val = self.parse_factor()?; - loop { - match self.peek() { - Some(b'*') => { - self.pos += 1; - val *= self.parse_factor()?; - } - Some(b'/') => { - self.pos += 1; - val /= self.parse_factor()?; - } - _ => break, - } - } - Some(val) - } - - fn parse_factor(&mut self) -> Option { - match self.peek()? { - b'-' => { - self.pos += 1; - Some(-self.parse_factor()?) - } - b'+' => { - self.pos += 1; - self.parse_factor() - } - b'(' => { - self.pos += 1; - let val = self.parse_expr()?; - if self.peek() == Some(b')') { - self.pos += 1; - Some(val) - } else { - None - } - } - c if c.is_ascii_digit() || c == b'.' => self.parse_number(), - _ => None, - } - } - - fn parse_number(&mut self) -> Option { - self.skip_ws(); - let start = self.pos; - let mut seen_dot = false; - let mut seen_digit = false; - while let Some(&c) = self.bytes.get(self.pos) { - if c.is_ascii_digit() { - seen_digit = true; - self.pos += 1; - } else if c == b'.' && !seen_dot { - seen_dot = true; - self.pos += 1; - } else { - break; - } - } - if !seen_digit { - return None; - } - std::str::from_utf8(&self.bytes[start..self.pos]) - .ok() - .and_then(|s| s.parse::().ok()) - } -} - -// ---- Commands ------------------------------------------------------------- - -/// One `>`-mode command palette entry: a display `name` and the shell -/// command it runs (via `bash -c`, same spawn convention [`crate::do_launch`] -/// already uses for a desktop entry's `Exec=` line). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Command { - pub id: &'static str, - pub name: &'static str, - pub exec: &'static str, -} - -/// A small, real bread-ecosystem command palette — deliberately not the -/// demo's placeholder set (`Lock session`/`Open settings`/`Test -/// notification` map to nothing real in this codebase). `loginctl -/// lock-session` and `bread reload` are both already-documented, safe, -/// no-argument commands (see the bread CLI reference / systemd-logind). -pub fn builtin_commands() -> &'static [Command] { - &[ - Command { - id: "lock", - name: "Lock session", - exec: "loginctl lock-session", - }, - Command { - id: "reload-breadd", - name: "Reload breadd", - exec: "bread reload", - }, - ] -} - -/// Fuzzy-filters `commands` by `query` against each command's `name` (same -/// subsequence match [`crate::fuzzy_matches`] uses for app rows) — an empty -/// query matches everything, same as the app list's own empty-query case. -pub fn filter_commands(query: &str, commands: &[Command]) -> Vec { - commands - .iter() - .filter(|c| fuzzy_matches(query, c.name)) - .cloned() - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - // ---- parse_query ------------------------------------------------- - - #[test] - fn parse_query_bare_text_is_apps() { - let p = parse_query("firefox"); - assert_eq!(p.kind, QueryKind::Apps); - assert_eq!(p.value, "firefox"); - } - - #[test] - fn parse_query_empty_is_apps() { - let p = parse_query(""); - assert_eq!(p.kind, QueryKind::Apps); - assert_eq!(p.value, ""); - } - - #[test] - fn parse_query_equals_is_calc() { - let p = parse_query("=2+2"); - assert_eq!(p.kind, QueryKind::Calc); - assert_eq!(p.value, "2+2"); - } - - #[test] - fn parse_query_gt_is_cmd() { - let p = parse_query(">lock"); - assert_eq!(p.kind, QueryKind::Cmd); - assert_eq!(p.value, "lock"); - } - - #[test] - fn parse_query_dot_is_url() { - let p = parse_query(".breadway.dev"); - assert_eq!(p.kind, QueryKind::Url); - assert_eq!(p.value, "breadway.dev"); - } - - #[test] - fn parse_query_strips_leading_whitespace_after_prefix() { - let p = parse_query("= 2 + 2 "); - assert_eq!(p.kind, QueryKind::Calc); - assert_eq!(p.value, "2 + 2"); - } - - #[test] - fn parse_query_bare_prefix_has_empty_value() { - assert_eq!(parse_query("=").value, ""); - assert_eq!(parse_query(">").value, ""); - assert_eq!(parse_query(".").value, ""); - } - - // ---- eval_calc ----------------------------------------------------- - - #[test] - fn eval_calc_empty_expr_is_none() { - assert_eq!(eval_calc(""), None); - assert_eq!(eval_calc(" "), None); - } - - #[test] - fn eval_calc_simple_addition() { - assert_eq!(eval_calc("2+2"), Some("4".to_string())); - } - - #[test] - fn eval_calc_precedence() { - assert_eq!(eval_calc("2+3*4"), Some("14".to_string())); - } - - #[test] - fn eval_calc_parens() { - assert_eq!(eval_calc("(2+3)*4"), Some("20".to_string())); - } - - #[test] - fn eval_calc_unary_minus() { - assert_eq!(eval_calc("-5+2"), Some("-3".to_string())); - } - - #[test] - fn eval_calc_decimals_round_to_8_places() { - assert_eq!(eval_calc("0.1+0.2"), Some("0.3".to_string())); - } - - #[test] - fn eval_calc_division_by_zero_is_infinity_symbol() { - assert_eq!(eval_calc("1/0"), Some("∞".to_string())); - } - - #[test] - fn eval_calc_bad_chars_is_bad_expr() { - assert_eq!(eval_calc("2+alert(1)"), Some("bad expr".to_string())); - assert_eq!(eval_calc("rm -rf /"), Some("bad expr".to_string())); - } - - #[test] - fn eval_calc_unbalanced_parens_is_err() { - assert_eq!(eval_calc("(2+3"), Some("err".to_string())); - } - - #[test] - fn eval_calc_trailing_garbage_is_err() { - assert_eq!(eval_calc("2+3)"), Some("err".to_string())); - } - - #[test] - fn eval_calc_double_operator_is_err() { - assert_eq!(eval_calc("2++"), Some("err".to_string())); - } - - #[test] - fn eval_calc_whitespace_is_tolerated() { - assert_eq!(eval_calc(" 2 + 2 "), Some("4".to_string())); - } - - // ---- commands -------------------------------------------------------- - - #[test] - fn builtin_commands_are_non_empty() { - assert!(!builtin_commands().is_empty()); - } - - #[test] - fn filter_commands_empty_query_matches_all() { - let all = builtin_commands(); - assert_eq!(filter_commands("", all).len(), all.len()); - } - - #[test] - fn filter_commands_filters_by_name_subsequence() { - let matches = filter_commands("lock", builtin_commands()); - assert!(matches.iter().any(|c| c.id == "lock")); - assert!(!matches.iter().any(|c| c.id == "reload-breadd")); - } - - #[test] - fn filter_commands_no_match_is_empty() { - assert!(filter_commands("zzzznotacommand", builtin_commands()).is_empty()); - } -} diff --git a/bread-polkit/Cargo.toml b/bread-polkit/Cargo.toml deleted file mode 100644 index b0c7603..0000000 --- a/bread-polkit/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "bread-polkit" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "Themed PolicyKit authentication agent for the bread desktop" -repository = "https://git.breadway.dev/Breadway/bread-ecosystem" -keywords = ["polkit", "gtk4", "wayland"] - -[lib] -path = "src/lib.rs" - -[[bin]] -name = "bread-polkit" -path = "src/main.rs" - -[dependencies] -anyhow = { workspace = true } -bread-app = { path = "../bread-app", features = ["gtk"] } -bread-theme = { path = "../bread-theme", features = ["gtk"] } -gtk4 = { version = "0.11", features = ["v4_12"] } -serde = { workspace = true } -tokio = { version = "1", features = ["rt", "net", "sync", "time", "macros", "io-util", "process"] } -tracing = { workspace = true } -tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "std"] } -zbus = { version = "5", default-features = false, features = ["tokio"] } diff --git a/bread-polkit/bakery.toml b/bread-polkit/bakery.toml deleted file mode 100644 index 4dd8e44..0000000 --- a/bread-polkit/bakery.toml +++ /dev/null @@ -1,11 +0,0 @@ -name = "bread-polkit" -description = "Themed PolicyKit authentication agent for the bread desktop" -binaries = ["bread-polkit"] -system_deps = ["gtk4", "gtk4-layer-shell", "polkit"] -optional_system_deps = ["hyprland"] -bread_deps = [] -license_file = "LICENSE" -desktop_file = "bread-polkit.desktop" - -[install] -post_install = [] diff --git a/bread-polkit/contrib/bread-polkit.desktop b/bread-polkit/contrib/bread-polkit.desktop deleted file mode 100644 index b30a167..0000000 --- a/bread-polkit/contrib/bread-polkit.desktop +++ /dev/null @@ -1,12 +0,0 @@ -[Desktop Entry] -Type=Application -Name=Bread PolicyKit Agent -Comment=Themed PolicyKit authentication agent for the bread desktop -Exec=bread-polkit -Icon=dialog-password -Terminal=false -Categories=System;Security; -StartupNotify=false -X-GNOME-Autostart-Phase=Initialization -X-GNOME-AutoRestart=true -X-GNOME-Autostart-Notify=false diff --git a/bread-polkit/contrib/hyprland.conf b/bread-polkit/contrib/hyprland.conf deleted file mode 100644 index 50ce845..0000000 --- a/bread-polkit/contrib/hyprland.conf +++ /dev/null @@ -1,10 +0,0 @@ -# bread-polkit — add to hyprland.conf -# -# Session authentication agent. Copy contrib/bread-polkit.desktop to -# ~/.config/autostart/ instead if you prefer XDG autostart. - -exec-once = bread-polkit - -# Optional: blur the overlay panel (namespace is bread-polkit). -layerrule = blur, bread-polkit -layerrule = ignorezero, bread-polkit diff --git a/bread-polkit/src/agent.rs b/bread-polkit/src/agent.rs deleted file mode 100644 index 27cda84..0000000 --- a/bread-polkit/src/agent.rs +++ /dev/null @@ -1,370 +0,0 @@ -//! Session-bus registration and the PolicyKit1 AuthenticationAgent. - -use std::collections::HashMap; -use std::sync::{Arc, OnceLock}; - -use anyhow::{Context, Result}; -use gtk4::glib; -use gtk4::prelude::*; -use serde::{Deserialize, Serialize}; -use tokio::sync::{mpsc, Mutex}; -use zbus::zvariant::{OwnedValue, Type, Value}; -use zbus::{connection, interface, proxy, DBusError}; - -use bread_polkit::helper::{discover_transport, Transport}; -use bread_polkit::identity::{current_uid, pick_user, read_passwd, users_from_uids, UnixUser}; -use bread_polkit::session::session_id; - -use crate::auth::{self, Outcome}; -use crate::ui::{self, Prompt}; - -pub const OBJECT_PATH: &str = "/com/breadway/PolicyKit1/AuthenticationAgent"; - -/// Reply from the GTK prompt. -#[derive(Debug)] -pub enum UserAction { - Submit { username: String, password: String }, - Cancel, -} - -#[derive(Debug, DBusError)] -#[zbus(prefix = "org.freedesktop.PolicyKit1.Error")] -enum AgentError { - #[zbus(error)] - ZBus(zbus::Error), - Failed(String), - Cancelled(String), -} - -#[derive(Debug, Deserialize, Serialize, Type)] -struct Identity { - kind: String, - details: HashMap, -} - -#[derive(Debug, Clone, Deserialize, Serialize, Type)] -struct Subject { - kind: String, - details: HashMap, -} - -#[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>>>, -} - -#[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")] -impl Agent { - async fn begin_authentication( - &mut self, - action_id: String, - message: String, - _icon_name: String, - _details: HashMap, - cookie: String, - identities: Vec, - ) -> Result<(), AgentError> { - tracing::info!(%action_id, %cookie, "BeginAuthentication"); - - let users = unix_users(&identities); - let username = pick_user(&users, current_uid()) - .map(|u| u.name.clone()) - .ok_or_else(|| AgentError::Failed("no unix-user identity".into()))?; - let allowed_users: Vec = users.iter().map(|u| u.name.clone()).collect(); - - let (tx, mut rx) = mpsc::channel(4); - *self.pending.lock().await = Some(tx.clone()); - - let prompt = Prompt { - cookie: cookie.clone(), - message: message.clone(), - action_id: action_id.clone(), - username: username.clone(), - reply: tx, - }; - invoke_ui(move || { - if let Some(app) = running_app() { - ui::show_prompt(&app, prompt); - } - }); - - let result = self.drive_prompt(&cookie, &username, &allowed_users, &mut rx).await; - - *self.pending.lock().await = None; - let cookie_close = cookie.clone(); - invoke_ui(move || ui::close_prompt(&cookie_close)); - result - } - - async fn cancel_authentication(&self, cookie: String) { - tracing::info!(%cookie, "CancelAuthentication"); - if let Some(tx) = self.pending.lock().await.as_ref() { - let _ = tx.try_send(UserAction::Cancel); - } - invoke_ui(move || ui::close_prompt(&cookie)); - } -} - -impl Agent { - async fn drive_prompt( - &self, - cookie: &str, - default_user: &str, - allowed_users: &[String], - rx: &mut mpsc::Receiver, - ) -> Result<(), AgentError> { - loop { - match rx.recv().await { - None => { - return Err(AgentError::Cancelled("authentication prompt closed".into())); - } - Some(UserAction::Cancel) => { - return Err(AgentError::Cancelled("user cancelled".into())); - } - Some(UserAction::Submit { username, password }) => { - // The username field is user-editable; only accept it when - // it's one of the identities polkit offered (empty falls - // back to the prefilled user). Anything else is rejected - // and the prompt re-shown rather than starting a PAM - // conversation for an account the request never offered. - let Some(user) = resolve_user(default_user, allowed_users, &username) else { - let cookie = cookie.to_string(); - invoke_ui(move || ui::show_retry(&cookie, INVALID_USER_MESSAGE)); - continue; - }; - match auth::authenticate(&self.transport, &user, cookie, &password).await { - Ok(Outcome::Success) => return Ok(()), - Ok(Outcome::Failure { message }) => { - let text = message - .unwrap_or_else(|| auth::default_failure_message().to_string()); - let cookie = cookie.to_string(); - invoke_ui(move || ui::show_retry(&cookie, &text)); - } - Err(e) => { - tracing::warn!("helper: {e:#}"); - let text = e.to_string(); - let cookie = cookie.to_string(); - invoke_ui(move || ui::show_retry(&cookie, &text)); - } - } - } - } - } - } -} - -const INVALID_USER_MESSAGE: &str = - "That user is not one of the identities this request offered — use the prefilled user."; - -/// Choose the username to authenticate as from the prompt input. -/// -/// Empty input falls back to `default`. Non-empty input must be one of -/// `allowed` — the `unix-user` identities polkit actually offered in -/// `BeginAuthentication` — otherwise it returns `None`. Without this, a -/// request scoped to specific accounts (say, `root` only) could have its -/// editable username field redirected to an arbitrary local user, kicking -/// off a PAM conversation for an account the request never offered. -/// (`auth::authenticate`'s PAM result still has to clear polkit's own -/// authorization, but this closes the obvious foot-gun at the agent — the -/// one place the identity list is actually known.) -fn resolve_user(default: &str, allowed: &[String], input: &str) -> Option { - if input.is_empty() { - return Some(default.to_string()); - } - if allowed.iter().any(|u| u.as_str() == input) { - return Some(input.to_string()); - } - None -} - -fn unix_users(identities: &[Identity]) -> Vec { - 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) -> Option { - 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::gio::Application::default().and_then(|app| app.downcast::().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 = 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 { - let value = Value::from(id.to_string()); - let owned = OwnedValue::try_from(value).context("session-id variant")?; - let mut details = HashMap::new(); - details.insert("session-id".into(), owned); - Ok(Subject { - kind: "unix-session".into(), - details, - }) -} - -/// Spawn the system-bus agent on a background thread. Returns once the -/// thread has been started; registration errors quit the GTK app. -/// -/// Must be called from the GTK thread so the main context we capture is -/// the one driving the password prompt. -pub fn spawn() -> Result<()> { - let _ = GTK_CTX.set(glib::MainContext::default()); - std::thread::Builder::new() - .name("bread-polkit-dbus".into()) - .spawn(move || { - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(e) => { - invoke_ui(move || { - eprintln!("bread-polkit: tokio runtime failed: {e}"); - if let Some(app) = running_app() { - app.quit(); - } - }); - return; - } - }; - rt.block_on(async move { - if let Err(e) = run().await { - eprintln!("bread-polkit: {e:#}"); - invoke_ui(|| { - if let Some(app) = running_app() { - app.quit(); - } - }); - } - }); - }) - .context("spawn dbus thread")?; - Ok(()) -} - -async fn run() -> Result<()> { - let transport = discover_transport().context( - "no polkit helper: expected /run/polkit/agent-helper.socket \ - or /usr/lib/polkit-1/polkit-agent-helper-1", - )?; - tracing::info!(?transport, "using polkit helper"); - - let session = session_id().context( - "no session id (XDG_SESSION_ID / /proc/self/sessionid); \ - cannot register a session authentication agent", - )?; - let subject = unix_session_subject(&session)?; - let locale = std::env::var("LANG").unwrap_or_else(|_| "C".into()); - - let agent = Agent { - transport, - pending: Arc::new(Mutex::new(None)), - }; - - let connection = connection::Builder::system()? - .serve_at(OBJECT_PATH, agent)? - .build() - .await - .context("system bus")?; - - let authority = AuthorityProxy::new(&connection) - .await - .context("PolicyKit1 authority proxy")?; - authority - .register_authentication_agent(&subject, &locale, OBJECT_PATH) - .await - .context("RegisterAuthenticationAgent")?; - tracing::info!(%session, "registered as PolicyKit authentication agent"); - - std::future::pending::<()>().await; - #[allow(unreachable_code)] - { - let _ = authority - .unregister_authentication_agent(&subject, OBJECT_PATH) - .await; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn allowed() -> Vec { - vec!["root".to_string(), "1000".to_string()] - } - - #[test] - fn resolve_user_falls_back_to_default_on_empty_input() { - assert_eq!( - resolve_user("root", &allowed(), ""), - Some("root".to_string()) - ); - } - - #[test] - fn resolve_user_accepts_an_offered_identity() { - assert_eq!( - resolve_user("root", &allowed(), "1000"), - Some("1000".to_string()) - ); - } - - #[test] - fn resolve_user_rejects_a_user_polkit_did_not_offer() { - assert_eq!(resolve_user("root", &allowed(), "alice"), None); - assert_eq!(resolve_user("root", &allowed(), "daemon"), None); - } - - #[test] - fn resolve_user_is_case_exact() { - // Usernames are case-significant; "ROOT" is a different principle - // than the offered "root", so it must be rejected. - assert_eq!(resolve_user("root", &allowed(), "ROOT"), None); - } -} - diff --git a/bread-polkit/src/auth.rs b/bread-polkit/src/auth.rs deleted file mode 100644 index b67bf20..0000000 --- a/bread-polkit/src/auth.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! PAM conversation with the polkit agent helper. - -use std::process::Stdio; - -use anyhow::{Context, Result}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::UnixStream; -use tokio::process::Command; - -use bread_polkit::helper::{parse_helper_line, HelperLine, Transport}; - -/// Outcome of one helper conversation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Outcome { - Success, - Failure { message: Option }, -} - -/// Handshake + PAM loop for one password attempt. -pub async fn authenticate( - transport: &Transport, - username: &str, - cookie: &str, - password: &str, -) -> Result { - 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(mut reader: BufReader, mut writer: W, password: &str) -> Result -where - R: tokio::io::AsyncRead + Unpin, - W: tokio::io::AsyncWrite + Unpin, -{ - let mut last_info: Option = 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." -} diff --git a/bread-polkit/src/helper.rs b/bread-polkit/src/helper.rs deleted file mode 100644 index c35a520..0000000 --- a/bread-polkit/src/helper.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! `polkit-agent-helper-1` transport and PAM line parser. -//! -//! Arch polkit 127+ talks over `/run/polkit/agent-helper.socket`. Older -//! builds still spawn the setuid helper at -//! `/usr/lib/polkit-1/polkit-agent-helper-1`. Prefer the socket when it -//! exists. - -use std::path::{Path, PathBuf}; - -/// How this agent will talk to polkit's helper. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Transport { - /// systemd socket-activated helper (polkit 127+). - Socket(PathBuf), - /// Legacy setuid helper binary. - Exec(PathBuf), -} - -const SOCKET_CANDIDATES: &[&str] = &["/run/polkit/agent-helper.socket"]; -const HELPER_CANDIDATES: &[&str] = &[ - "/usr/lib/polkit-1/polkit-agent-helper-1", - "/usr/libexec/polkit-1/polkit-agent-helper-1", -]; - -/// One stdout line from the helper after the cookie handshake. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum HelperLine { - PromptEchoOff(String), - PromptEchoOn(String), - ErrorMsg(String), - TextInfo(String), - Success, - Failure, - Other(String), -} - -/// Pick a live transport: `BREAD_POLKIT_SOCKET` / `BREAD_POLKIT_HELPER` -/// if set and present, otherwise the first existing well-known path. -pub fn discover_transport() -> Option { - 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 { - 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 = [ - "/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 = ["/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); - } -} diff --git a/bread-polkit/src/identity.rs b/bread-polkit/src/identity.rs deleted file mode 100644 index 0dd0496..0000000 --- a/bread-polkit/src/identity.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! Unix-user identities from a PolicyKit `BeginAuthentication` call. - -/// A `unix-user` identity the agent can authenticate as. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UnixUser { - pub uid: u32, - pub name: String, -} - -/// Look up `uid` in a passwd-file dump (`name:x:uid:...` lines). -pub fn name_for_uid(uid: u32, passwd: &str) -> Option { - 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::().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 { - uids.iter() - .copied() - .map(|uid| UnixUser { - uid, - name: name_for_uid(uid, passwd).unwrap_or_else(|| format!("uid {uid}")), - }) - .collect() -} - -/// Prefer the process's own uid when it is in `users`, otherwise the first. -pub fn pick_user(users: &[UnixUser], current_uid: Option) -> Option<&UnixUser> { - if let Some(uid) = current_uid { - if let Some(user) = users.iter().find(|u| u.uid == uid) { - return Some(user); - } - } - users.first() -} - -/// Real uid from a `/proc/self/status` dump (`Uid:\t ...`). -pub fn uid_from_status(status: &str) -> Option { - 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 { - 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); - } -} diff --git a/bread-polkit/src/lib.rs b/bread-polkit/src/lib.rs deleted file mode 100644 index 2f9e574..0000000 --- a/bread-polkit/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Non-GTK PolicyKit helper logic for `bread-polkit`. -//! -//! The binary (`bread-polkit`) registers as a session authentication -//! agent and shows a themed password prompt. This library is the -//! transport / identity / session parsing that can be unit-tested -//! without a display. - -pub mod helper; -pub mod identity; -pub mod session; diff --git a/bread-polkit/src/main.rs b/bread-polkit/src/main.rs deleted file mode 100644 index 4018b41..0000000 --- a/bread-polkit/src/main.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! bread-polkit — themed PolicyKit authentication agent. -//! -//! Registers on the `org.freedesktop.PolicyKit1.AuthenticationAgent` -//! interface and shows a bread-theme GTK4 password prompt. This is an -//! agent, not a wrapper that execs `polkit-gnome`. -//! -//! Autostart: copy `contrib/bread-polkit.desktop` to -//! `~/.config/autostart/`, or add `exec-once = bread-polkit` to Hyprland. - -mod agent; -mod auth; -mod ui; - -use bread_app::singleton::Acquire; -use gtk4::prelude::*; - -const APP_NAME: &str = "bread-polkit"; - -fn main() { - let arg = std::env::args().nth(1); - match arg.as_deref() { - Some("-h") | Some("--help") => { - print_help(); - return; - } - Some("-V") | Some("--version") => { - println!("bread-polkit {}", env!("CARGO_PKG_VERSION")); - return; - } - Some(other) => { - eprintln!("bread-polkit: unknown argument '{other}'"); - print_help(); - std::process::exit(2); - } - None => {} - } - - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .with_target(false) - .init(); - - let _guard = match bread_app::try_acquire(APP_NAME) { - Ok(Acquire::Acquired(g)) => Some(g), - Ok(Acquire::HeldByOther(pid)) => { - eprintln!("bread-polkit: already running (pid {pid:?})"); - std::process::exit(0); - } - Err(e) => { - // Don't keep running without the single-instance lock: a second - // copy would attempt to `serve_at` the same PolicyKit agent - // object path on the system bus, and a password prompt held by a - // process whose lock couldn't be taken is ambiguous state. Fail - // fast and let a wrapper/autostart retry. - eprintln!("bread-polkit: singleton lock unavailable ({e}); exiting"); - std::process::exit(1); - } - }; - - let app_id = bread_app::application_id(APP_NAME).expect("static app name"); - let app = gtk4::Application::builder().application_id(&app_id).build(); - - app.connect_activate(|app| { - bread_theme::gtk::apply_shared(); - bread_theme::gtk::apply_app_css(ui::app_css); - // No window until polkit asks; hold so GApplication stays alive. - std::mem::forget(app.hold()); - if let Err(e) = agent::spawn() { - eprintln!("bread-polkit: {e:#}"); - app.quit(); - } - }); - - app.run(); -} - -fn print_help() { - print!( - "\ -bread-polkit — themed PolicyKit authentication agent - -Usage: - bread-polkit - bread-polkit --help - bread-polkit --version - -Autostart (pick one): - cp contrib/bread-polkit.desktop ~/.config/autostart/ - exec-once = bread-polkit # Hyprland - -The agent talks to the polkit1 AuthenticationAgent API and prompts for -a password. It does not exec polkit-gnome. Not a bakery product; not -on the BOS ISO lockfile. -" - ); -} diff --git a/bread-polkit/src/session.rs b/bread-polkit/src/session.rs deleted file mode 100644 index c23cbe1..0000000 --- a/bread-polkit/src/session.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Session subject for `RegisterAuthenticationAgent`. - -/// Logind session id from `XDG_SESSION_ID`, falling back to -/// `/proc/self/sessionid` when the kernel has one. -pub fn session_id() -> Option { - 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 { - 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); - } -} diff --git a/bread-polkit/src/ui.rs b/bread-polkit/src/ui.rs deleted file mode 100644 index 033f171..0000000 --- a/bread-polkit/src/ui.rs +++ /dev/null @@ -1,286 +0,0 @@ -//! GTK4 password prompt, themed with bread-theme. - -use std::cell::RefCell; -use std::rc::Rc; - -use gtk4::gdk::Key; -use gtk4::glib::{self, Propagation}; -use gtk4::prelude::*; -use gtk4::{ - Align, Application, ApplicationWindow, Box as GBox, Button, Entry, EventControllerKey, Label, - Orientation, -}; - -use bread_theme::tokens; - -use crate::agent::UserAction; - -const PANEL_WIDTH: i32 = 400; - -struct Active { - cookie: String, - window: ApplicationWindow, - password: Entry, - error: Label, - reply: tokio::sync::mpsc::Sender, - username: String, -} - -thread_local! { - static ACTIVE: RefCell> = 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, -} - -/// 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(); - } - }); -} diff --git a/bread-theme/CHANGELOG.md b/bread-theme/CHANGELOG.md index 9b27493..2675e55 100644 --- a/bread-theme/CHANGELOG.md +++ b/bread-theme/CHANGELOG.md @@ -1,48 +1,11 @@ # 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/.json` — accents only (round-trips through - `from_wal_json` / a color1–6 object; never persists pywal's light bg) -- `themes/.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 --image | --from-json - [--shared]`. Does not write `theme.css` unless `--shared`. - ## Coordinated bump policy -`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"`. +`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. --- diff --git a/bread-theme/Cargo.toml b/bread-theme/Cargo.toml index 7298837..f5e1cda 100644 --- a/bread-theme/Cargo.toml +++ b/bread-theme/Cargo.toml @@ -12,11 +12,6 @@ keywords = ["theming", "pywal", "gtk4", "wayland"] serde = { workspace = true } serde_json = { workspace = true } dirs = { workspace = true } -# bread_theme::shell manifest parsing (theme.toml) — gtk-free, so `bread` -# (daemon) and `breadcrumbs` (CLI) can validate a theme without linking GTK. -toml = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } gtk4 = { version = "0.11", features = ["v4_12"], optional = true } # Rust bindings for libadwaita (GNOME's widget library on top of GTK4) — the # actual source of the modern GNOME look (grouped preference rows, real diff --git a/bread-theme/assets/shell/daylight/daylight.css b/bread-theme/assets/shell/daylight/daylight.css deleted file mode 100644 index eb65fe7..0000000 --- a/bread-theme/assets/shell/daylight/daylight.css +++ /dev/null @@ -1,117 +0,0 @@ -/* CSS template for the daylight builtin (bread-theme/src/shell/builtin.rs). - * Same scope and substitution rules as its three siblings: only the - * window/workspace/clock chrome the manifest's own concepts model, `{name}` - * tokens substituted, `@name` palette references passed through untouched. - * Declared-but-not-yet-consumed in production, same as every sibling - * template — see `ShellTheme::css`'s doc comment for why (breadbar hand- - * rolls its own CSS in `breadbar::theme::load_css` instead of calling this - * method). Exercised by this crate's own tests. - * - * Source: bos-ui-demos/proposed/daylight.html's