Compare commits
No commits in common. "main" and "v0.7.2" have entirely different histories.
72 changed files with 512 additions and 11479 deletions
|
|
@ -8,7 +8,6 @@ on:
|
||||||
branches: ['main']
|
branches: ['main']
|
||||||
paths:
|
paths:
|
||||||
- 'bakery/**'
|
- 'bakery/**'
|
||||||
- 'bread-utils/**'
|
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- '.forgejo/workflows/dev-bakery.yml'
|
- '.forgejo/workflows/dev-bakery.yml'
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
package:
|
package:
|
||||||
# PKGBUILD pkgver cannot contain `-`; skip RC tags the same way
|
|
||||||
# release-bakery.yml does.
|
|
||||||
if: ${{ !contains(github.ref_name, '-rc.') }}
|
|
||||||
runs-on: [self-hosted, hestia]
|
runs-on: [self-hosted, hestia]
|
||||||
container:
|
container:
|
||||||
image: archlinux:latest
|
image: archlinux:latest
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ name: beta (rc) bakery
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
# No paths: filter. Tag pushes compare against an unrelated commit and
|
paths:
|
||||||
# would skip the RC publish if bakery/** wasn't in that diff; the job
|
- 'bakery/**'
|
||||||
# `if: contains -rc.` is the real gate.
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- '.forgejo/workflows/beta-bakery.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -33,7 +35,6 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
|
||||||
PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}"
|
PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}"
|
||||||
mkdir -p "${PKG_DIR}"
|
mkdir -p "${PKG_DIR}"
|
||||||
cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64"
|
cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64"
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ name: beta (rc) bread-theme
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
# No paths: filter. Tag pushes compare against an unrelated commit and
|
paths:
|
||||||
# would skip the RC publish if bread-theme/** wasn't in that diff; the
|
- 'bread-theme/**'
|
||||||
# job `if: contains -rc.` is the real gate.
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- '.forgejo/workflows/beta-bread-theme.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -30,7 +32,6 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
|
||||||
PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}"
|
PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}"
|
||||||
mkdir -p "${PKG_DIR}"
|
mkdir -p "${PKG_DIR}"
|
||||||
cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64"
|
cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64"
|
||||||
|
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -5,9 +5,3 @@
|
||||||
# scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY).
|
# scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY).
|
||||||
*.minisign-sec
|
*.minisign-sec
|
||||||
minisign.key
|
minisign.key
|
||||||
|
|
||||||
# Local tool caches — not build output, never belongs in the repo.
|
|
||||||
# (breadbar already excludes graphify-out; this repo did not, and 111k lines
|
|
||||||
# of it were swept in by a `git add -A`.)
|
|
||||||
graphify-out/
|
|
||||||
.grok/
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,3 @@ Follow [`CONTRIBUTING.md`](CONTRIBUTING.md) for any git, branch, or release work
|
||||||
|
|
||||||
## Don't
|
## Don't
|
||||||
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||||
- Don't flip bakery's default install prefix. System prefix (`/usr/local` via
|
|
||||||
`/etc/bakery/config.toml` or `BAKERY_PREFIX`) is for BOS; hermes and
|
|
||||||
`get.sh` stay on `~/.local`. See [`bakery/README.md`](bakery/README.md).
|
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,10 @@
|
||||||
This repo is a Cargo workspace. Bakery-channel products shipped from here
|
This repo is a Cargo workspace. Bakery-channel products shipped from here
|
||||||
are `bakery` (the ecosystem package manager) and `bread-theme` (the shared
|
are `bakery` (the ecosystem package manager) and `bread-theme` (the shared
|
||||||
theming crate). Shared crates that sibling apps pin — not bakery packages
|
theming crate). Shared crates that sibling apps pin — not bakery packages
|
||||||
of their own — are `bread-utils`, `bread-app`, `bread-onnx`,
|
of their own — are `bread-utils`, `bread-onnx`, `bread-screenshots`, and
|
||||||
`bread-screenshots`, and `bread-capture`. `bread-polkit` is an in-tree
|
`bread-capture`. Other ecosystem products (`bread`, `breadbar`, `breadbox`,
|
||||||
session agent: it has `bread-polkit/bakery.toml` so it *can* be published,
|
…) live in their own repos under `Breadway/` but follow the same workflow
|
||||||
but it is not in `registry/bread-ecosystem.toml` (unpublished — not on
|
described here. The product list is `registry/bread-ecosystem.toml`.
|
||||||
the bakery index, not on the BOS ISO). Other ecosystem products
|
|
||||||
(`bread`, `breadbar`, `breadbox`, …) live in their own repos under
|
|
||||||
`Breadway/` but follow the same workflow described here. The product list
|
|
||||||
is `registry/bread-ecosystem.toml`. New GTK tools should depend on
|
|
||||||
`bread-app` instead of copying another app's bootstrap.
|
|
||||||
|
|
||||||
## Branches
|
## Branches
|
||||||
|
|
||||||
|
|
@ -93,12 +88,10 @@ cargo build --release -p bakery
|
||||||
cargo test --release -p bakery
|
cargo test --release -p bakery
|
||||||
```
|
```
|
||||||
|
|
||||||
`bakery`, `bread-theme`, `bread-utils`, `bread-app`, `bread-polkit`,
|
`bakery`, `bread-theme`, `bread-utils`, `bread-onnx`, `bread-screenshots`,
|
||||||
`bread-onnx`, `bread-screenshots`, and `bread-capture` are all workspace
|
and `bread-capture` are all workspace members. Run the same commands with
|
||||||
members. Run the same commands with `-p bread-theme --bin bread-theme`
|
`-p bread-theme --bin bread-theme` for that crate, or `-p bread-utils
|
||||||
for that crate, `-p bread-utils --features bread-client` for the IPC
|
--features bread-client` for the IPC client.
|
||||||
client, or `-p bread-app --features bread-client` for the GTK bootstrap
|
|
||||||
helpers.
|
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
|
|
|
||||||
514
Cargo.lock
generated
514
Cargo.lock
generated
|
|
@ -96,40 +96,6 @@ version = "1.0.103"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-broadcast"
|
|
||||||
version = "0.7.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
|
||||||
dependencies = [
|
|
||||||
"event-listener",
|
|
||||||
"event-listener-strategy",
|
|
||||||
"futures-core",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-recursion"
|
|
||||||
version = "1.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-trait"
|
|
||||||
version = "0.1.92"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
|
|
@ -138,14 +104,14 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bakery"
|
name = "bakery"
|
||||||
version = "0.7.5"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bread-utils",
|
"bread-utils",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"dirs 5.0.1",
|
"dirs",
|
||||||
"fs4",
|
"fs4",
|
||||||
"hex",
|
"hex",
|
||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
|
|
@ -154,7 +120,6 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"toml 0.8.23",
|
|
||||||
"ureq",
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -185,16 +150,9 @@ dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bread-app"
|
|
||||||
version = "0.7.5"
|
|
||||||
dependencies = [
|
|
||||||
"bread-utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-capture"
|
name = "bread-capture"
|
||||||
version = "0.7.5"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bread-utils",
|
"bread-utils",
|
||||||
|
|
@ -202,18 +160,9 @@ dependencies = [
|
||||||
"image",
|
"image",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bread-launcher"
|
|
||||||
version = "0.7.5"
|
|
||||||
dependencies = [
|
|
||||||
"bread-utils",
|
|
||||||
"gtk4",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-onnx"
|
name = "bread-onnx"
|
||||||
version = "0.7.5"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bread-utils",
|
"bread-utils",
|
||||||
|
|
@ -226,24 +175,9 @@ dependencies = [
|
||||||
"ureq",
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bread-polkit"
|
|
||||||
version = "0.7.5"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"bread-app",
|
|
||||||
"bread-theme",
|
|
||||||
"gtk4",
|
|
||||||
"serde",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"zbus",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-screenshots"
|
name = "bread-screenshots"
|
||||||
version = "0.7.5"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bread-utils",
|
"bread-utils",
|
||||||
|
|
@ -252,36 +186,32 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-shared"
|
name = "bread-shared"
|
||||||
version = "0.8.0"
|
version = "0.7.0"
|
||||||
source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0#cdd5de8f58e437b3fc6d9b9087eb7b3d0fd09704"
|
source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 6.0.0",
|
"dirs",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"toml 0.8.23",
|
"toml 0.8.23",
|
||||||
"uuid",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-theme"
|
name = "bread-theme"
|
||||||
version = "0.7.5"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"dirs",
|
||||||
"dirs 5.0.1",
|
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"toml 0.8.23",
|
|
||||||
"tracing",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-utils"
|
name = "bread-utils"
|
||||||
version = "0.7.5"
|
version = "0.7.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bread-shared",
|
"bread-shared",
|
||||||
"dirs 5.0.1",
|
"dirs",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
"gtk4-layer-shell",
|
"gtk4-layer-shell",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -308,12 +238,6 @@ version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bytes"
|
|
||||||
version = "1.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cairo-rs"
|
name = "cairo-rs"
|
||||||
version = "0.22.0"
|
version = "0.22.0"
|
||||||
|
|
@ -425,7 +349,7 @@ dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -553,7 +477,7 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"strsim",
|
"strsim",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -564,7 +488,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -594,7 +518,7 @@ dependencies = [
|
||||||
"darling",
|
"darling",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -604,7 +528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"derive_builder_core",
|
"derive_builder_core",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -623,16 +547,7 @@ version = "5.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs-sys 0.4.1",
|
"dirs-sys",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dirs"
|
|
||||||
version = "6.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
|
||||||
dependencies = [
|
|
||||||
"dirs-sys 0.5.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -643,22 +558,10 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users 0.4.6",
|
"redox_users",
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dirs-sys"
|
|
||||||
version = "0.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"option-ext",
|
|
||||||
"redox_users 0.5.2",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
|
|
@ -667,7 +570,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -682,33 +585,6 @@ version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "endi"
|
|
||||||
version = "1.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "enumflags2"
|
|
||||||
version = "0.7.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
|
||||||
dependencies = [
|
|
||||||
"enumflags2_derive",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "enumflags2_derive"
|
|
||||||
version = "0.7.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
|
|
@ -734,26 +610,6 @@ dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "event-listener"
|
|
||||||
version = "5.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
|
||||||
dependencies = [
|
|
||||||
"parking",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "event-listener-strategy"
|
|
||||||
version = "0.5.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
|
||||||
dependencies = [
|
|
||||||
"event-listener",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
|
|
@ -852,19 +708,6 @@ version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-lite"
|
|
||||||
version = "2.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
|
||||||
dependencies = [
|
|
||||||
"fastrand",
|
|
||||||
"futures-core",
|
|
||||||
"futures-io",
|
|
||||||
"parking",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.33"
|
version = "0.3.33"
|
||||||
|
|
@ -873,7 +716,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1077,7 +920,7 @@ dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1211,7 +1054,7 @@ dependencies = [
|
||||||
"proc-macro-crate",
|
"proc-macro-crate",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1458,12 +1301,6 @@ version = "3.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lazy_static"
|
|
||||||
version = "1.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libadwaita"
|
name = "libadwaita"
|
||||||
version = "0.9.2"
|
version = "0.9.2"
|
||||||
|
|
@ -1559,15 +1396,6 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
|
checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "matchers"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
|
||||||
dependencies = [
|
|
||||||
"regex-automata",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matrixmultiply"
|
name = "matrixmultiply"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
|
|
@ -1615,17 +1443,6 @@ dependencies = [
|
||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mio"
|
|
||||||
version = "1.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"wasi",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "monostate"
|
name = "monostate"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
|
|
@ -1645,7 +1462,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1750,16 +1567,6 @@ version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ordered-stream"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
|
||||||
dependencies = [
|
|
||||||
"futures-core",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ort"
|
name = "ort"
|
||||||
version = "2.0.0-rc.12"
|
version = "2.0.0-rc.12"
|
||||||
|
|
@ -1802,12 +1609,6 @@ dependencies = [
|
||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "parking"
|
|
||||||
version = "2.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
|
|
@ -2000,17 +1801,6 @@ dependencies = [
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "redox_users"
|
|
||||||
version = "0.5.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
"libredox",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
|
|
@ -2169,7 +1959,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2185,17 +1975,6 @@ dependencies = [
|
||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_repr"
|
|
||||||
version = "0.1.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "0.6.9"
|
version = "0.6.9"
|
||||||
|
|
@ -2225,31 +2004,12 @@ dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sharded-slab"
|
|
||||||
version = "0.1.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
|
||||||
dependencies = [
|
|
||||||
"lazy_static",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "signal-hook-registry"
|
|
||||||
version = "1.4.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
|
||||||
dependencies = [
|
|
||||||
"errno",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.10"
|
version = "0.3.10"
|
||||||
|
|
@ -2268,16 +2028,6 @@ version = "1.15.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "socket2"
|
|
||||||
version = "0.6.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spm_precompiled"
|
name = "spm_precompiled"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
|
|
@ -2325,17 +2075,6 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "syn"
|
|
||||||
version = "3.0.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
|
|
@ -2344,7 +2083,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2405,7 +2144,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2416,16 +2155,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thread_local"
|
|
||||||
version = "1.1.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2472,34 +2202,6 @@ dependencies = [
|
||||||
"unicode_categories",
|
"unicode_categories",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio"
|
|
||||||
version = "1.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"libc",
|
|
||||||
"mio",
|
|
||||||
"pin-project-lite",
|
|
||||||
"signal-hook-registry",
|
|
||||||
"socket2",
|
|
||||||
"tokio-macros",
|
|
||||||
"tracing",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-macros"
|
|
||||||
version = "2.7.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.23"
|
version = "0.8.23"
|
||||||
|
|
@ -2611,7 +2313,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2623,38 +2325,12 @@ dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tracing-subscriber"
|
|
||||||
version = "0.3.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
|
||||||
dependencies = [
|
|
||||||
"matchers",
|
|
||||||
"once_cell",
|
|
||||||
"regex-automata",
|
|
||||||
"sharded-slab",
|
|
||||||
"thread_local",
|
|
||||||
"tracing",
|
|
||||||
"tracing-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "uds_windows"
|
|
||||||
version = "1.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|
||||||
dependencies = [
|
|
||||||
"memoffset",
|
|
||||||
"tempfile",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
|
|
@ -2742,18 +2418,6 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "uuid"
|
|
||||||
version = "1.24.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.4.3",
|
|
||||||
"js-sys",
|
|
||||||
"serde_core",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -2813,7 +2477,7 @@ dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -2875,7 +2539,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2886,7 +2550,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3116,75 +2780,10 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zbus"
|
|
||||||
version = "5.19.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
|
|
||||||
dependencies = [
|
|
||||||
"async-broadcast",
|
|
||||||
"async-recursion",
|
|
||||||
"async-trait",
|
|
||||||
"enumflags2",
|
|
||||||
"event-listener",
|
|
||||||
"futures-core",
|
|
||||||
"futures-lite",
|
|
||||||
"hex",
|
|
||||||
"libc",
|
|
||||||
"ordered-stream",
|
|
||||||
"rustix 1.1.4",
|
|
||||||
"serde",
|
|
||||||
"serde_repr",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"uds_windows",
|
|
||||||
"uuid",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
"winnow 1.0.4",
|
|
||||||
"zbus_macros",
|
|
||||||
"zbus_names",
|
|
||||||
"zvariant",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zbus_macros"
|
|
||||||
version = "5.19.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro-crate",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.3",
|
|
||||||
"zbus_names",
|
|
||||||
"zvariant",
|
|
||||||
"zvariant_utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zbus_names"
|
|
||||||
version = "4.3.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"winnow 1.0.4",
|
|
||||||
"zvariant",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zcheapstr"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.54"
|
version = "0.8.54"
|
||||||
|
|
@ -3202,7 +2801,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3222,7 +2821,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -3262,7 +2861,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3270,44 +2869,3 @@ name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zvariant"
|
|
||||||
version = "5.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c"
|
|
||||||
dependencies = [
|
|
||||||
"endi",
|
|
||||||
"enumflags2",
|
|
||||||
"serde",
|
|
||||||
"winnow 1.0.4",
|
|
||||||
"zcheapstr",
|
|
||||||
"zvariant_derive",
|
|
||||||
"zvariant_utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zvariant_derive"
|
|
||||||
version = "5.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro-crate",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.3",
|
|
||||||
"zvariant_utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zvariant_utils"
|
|
||||||
version = "4.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"serde",
|
|
||||||
"syn 3.0.3",
|
|
||||||
"winnow 1.0.4",
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit", "bread-launcher"]
|
members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.7.5"
|
version = "0.7.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Breadway <plasticbread849@gmail.com>"]
|
authors = ["Breadway <plasticbread849@gmail.com>"]
|
||||||
|
|
|
||||||
59
README.md
59
README.md
|
|
@ -106,26 +106,6 @@ bakery remove <pkg> # remove a package (data files are never deleted)
|
||||||
|
|
||||||
`bakery install` runs `doctor` first and bails with a clear message if any system dependency is missing. Binaries land in `~/.local/bin` (override with `BAKERY_BIN_DIR`).
|
`bakery install` runs `doctor` first and bails with a clear message if any system dependency is missing. Binaries land in `~/.local/bin` (override with `BAKERY_BIN_DIR`).
|
||||||
|
|
||||||
## System prefix (BOS)
|
|
||||||
|
|
||||||
Default install root is `~/.local`. BOS sets a system prefix so bakery-managed
|
|
||||||
desktop apps live on the `@` root subvolume and ride along with
|
|
||||||
snapper/grub-btrfs snapshots:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# /etc/bakery/config.toml
|
|
||||||
prefix = "/usr/local"
|
|
||||||
```
|
|
||||||
|
|
||||||
`BAKERY_PREFIX` overrides the config file. A non-home prefix installs bins to
|
|
||||||
`$prefix/bin`, share/data/desktop/licenses to `$prefix/share/...`, and systemd
|
|
||||||
user units to `/usr/lib/systemd/user`. Per-user state (`installed.json`,
|
|
||||||
update backups) stays in `~/.local/state/bakery`. Writes that need root use
|
|
||||||
`sudo -n`, then `pkexec`. `bakery doctor` prints the active prefix.
|
|
||||||
|
|
||||||
Hermes and `get.sh` are unchanged — they keep the user-local default. See
|
|
||||||
[`bakery/README.md`](bakery/README.md).
|
|
||||||
|
|
||||||
## System dependencies by product
|
## System dependencies by product
|
||||||
|
|
||||||
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.
|
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.
|
||||||
|
|
@ -145,15 +125,13 @@ Install all required deps with `sudo pacman -S <packages>`. Use `pacman -Q <pkg>
|
||||||
|
|
||||||
This repo is a Cargo workspace. Bakery-channel products shipped from here
|
This repo is a Cargo workspace. Bakery-channel products shipped from here
|
||||||
are `bakery` and `bread-theme`; the other members are shared crates sibling
|
are `bakery` and `bread-theme`; the other members are shared crates sibling
|
||||||
apps pin, or in-tree tools that are not bakery packages of their own.
|
apps pin, not bakery packages of their own.
|
||||||
|
|
||||||
```
|
```
|
||||||
bread-ecosystem/
|
bread-ecosystem/
|
||||||
├── bakery/ # package manager binary
|
├── bakery/ # package manager binary
|
||||||
├── bread-theme/ # shared pywal + fixed-dark-base theming crate
|
├── bread-theme/ # shared pywal + fixed-dark-base theming crate
|
||||||
├── bread-utils/ # shared plumbing (Hyprland IPC, singleton, XDG, BreadClient, …)
|
├── bread-utils/ # shared plumbing (Hyprland IPC, singleton, XDG, BreadClient, …)
|
||||||
├── bread-app/ # GTK bootstrap new tools should use (app id, singleton, overlay, command listen)
|
|
||||||
├── bread-polkit/ # themed PolicyKit agent (bakery.toml present; unpublished)
|
|
||||||
├── bread-onnx/ # shared ONNX runtime helpers
|
├── bread-onnx/ # shared ONNX runtime helpers
|
||||||
├── bread-screenshots/ # grim capture primitive used by app `--screenshot` modes
|
├── bread-screenshots/ # grim capture primitive used by app `--screenshot` modes
|
||||||
├── bread-capture/ # orchestrator that drives those `--screenshot` modes
|
├── bread-capture/ # orchestrator that drives those `--screenshot` modes
|
||||||
|
|
@ -164,41 +142,6 @@ bread-ecosystem/
|
||||||
└── gen-readme-products.sh # rewrites the Products table from the registry
|
└── gen-readme-products.sh # rewrites the Products table from the registry
|
||||||
```
|
```
|
||||||
|
|
||||||
### New GTK tools
|
|
||||||
|
|
||||||
Do not copy another app's `main.rs`. Depend on `bread-app`:
|
|
||||||
|
|
||||||
- `bread_app::application_id` / `try_acquire` / `toggle_or_kill` for the
|
|
||||||
`com.breadway.*` application id and single-instance lock
|
|
||||||
- feature `gtk` re-exports `bread_utils::gtk_popup` (layer-shell overlay)
|
|
||||||
- feature `bread-client` for `listen_commands` on `bread.command.<app>.**`
|
|
||||||
|
|
||||||
See the `bread-app` crate docs. Existing apps are not migrated in this
|
|
||||||
tree; `bread-polkit` is the first in-tree consumer.
|
|
||||||
|
|
||||||
### bread-polkit
|
|
||||||
|
|
||||||
A session PolicyKit authentication agent (password prompt, cancel,
|
|
||||||
identity). Not a wrapper around `polkit-gnome`. `bread-polkit/bakery.toml`
|
|
||||||
exists so it can be published via bakery; it is not in
|
|
||||||
`registry/bread-ecosystem.toml` and is therefore unpublished — not on the
|
|
||||||
bakery index and not on the BOS ISO lockfile.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo run -p bread-polkit
|
|
||||||
```
|
|
||||||
|
|
||||||
Autostart — pick one:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp bread-polkit/contrib/bread-polkit.desktop ~/.config/autostart/
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
# hyprland.conf
|
|
||||||
exec-once = bread-polkit
|
|
||||||
```
|
|
||||||
|
|
||||||
## Release pipeline
|
## Release pipeline
|
||||||
|
|
||||||
Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has
|
Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
toml = { workspace = true }
|
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
ureq = { workspace = true }
|
ureq = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -11,16 +11,8 @@ pub struct DepReport {
|
||||||
|
|
||||||
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> {
|
pub fn check_deps(required: &[String], optional: &[String]) -> Result<DepReport> {
|
||||||
Ok(DepReport {
|
Ok(DepReport {
|
||||||
missing: required
|
missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(),
|
||||||
.iter()
|
warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(),
|
||||||
.filter(|d| !dep_present(d))
|
|
||||||
.cloned()
|
|
||||||
.collect(),
|
|
||||||
warnings: optional
|
|
||||||
.iter()
|
|
||||||
.filter(|d| !dep_present(d))
|
|
||||||
.cloned()
|
|
||||||
.collect(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,24 +99,14 @@ pub fn install_hint(missing: &[String]) -> String {
|
||||||
|
|
||||||
/// Print a formatted doctor report for a package's system deps.
|
/// Print a formatted doctor report for a package's system deps.
|
||||||
/// Returns true if all *required* deps are satisfied.
|
/// Returns true if all *required* deps are satisfied.
|
||||||
pub fn report(
|
pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool {
|
||||||
package_name: &str,
|
|
||||||
required: &[String],
|
|
||||||
optional: &[String],
|
|
||||||
name_width: usize,
|
|
||||||
) -> bool {
|
|
||||||
if required.is_empty() && optional.is_empty() {
|
if required.is_empty() && optional.is_empty() {
|
||||||
ui::check_row(true, package_name, name_width, "no system deps required");
|
println!(" {}", ui::ok(&format!("{package_name}: no system deps required")));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
match check_deps(required, optional) {
|
match check_deps(required, optional) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
ui::check_row(
|
eprintln!(" {}", ui::fail(&format!("error running doctor for {package_name}: {e}")));
|
||||||
false,
|
|
||||||
package_name,
|
|
||||||
name_width,
|
|
||||||
&format!("error running doctor: {e}"),
|
|
||||||
);
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
Ok(rep) => {
|
Ok(rep) => {
|
||||||
|
|
@ -141,24 +123,17 @@ pub fn report(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if rep.missing.is_empty() {
|
if rep.missing.is_empty() {
|
||||||
ui::check_row(
|
println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied")));
|
||||||
true,
|
|
||||||
package_name,
|
|
||||||
name_width,
|
|
||||||
"all required system deps satisfied",
|
|
||||||
);
|
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
ui::check_row(
|
|
||||||
false,
|
|
||||||
package_name,
|
|
||||||
name_width,
|
|
||||||
&format!("missing: {}", rep.missing.join(", ")),
|
|
||||||
);
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}",
|
" {}",
|
||||||
ui::dim(&format!("install with: {}", install_hint(&rep.missing)))
|
ui::fail(&format!(
|
||||||
|
"{package_name}: missing system deps: {}",
|
||||||
|
rep.missing.join(", ")
|
||||||
|
))
|
||||||
);
|
);
|
||||||
|
eprintln!(" install with: {}", install_hint(&rep.missing));
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,14 +187,22 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_required_dep_detected() {
|
fn missing_required_dep_detected() {
|
||||||
let rep = check_deps(&["this-package-does-not-exist-xyzzy42".to_string()], &[]).unwrap();
|
let rep = check_deps(
|
||||||
|
&["this-package-does-not-exist-xyzzy42".to_string()],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(rep.missing.len(), 1);
|
assert_eq!(rep.missing.len(), 1);
|
||||||
assert!(rep.warnings.is_empty());
|
assert!(rep.warnings.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_optional_dep_becomes_warning_not_error() {
|
fn missing_optional_dep_becomes_warning_not_error() {
|
||||||
let rep = check_deps(&[], &["this-package-does-not-exist-xyzzy42".to_string()]).unwrap();
|
let rep = check_deps(
|
||||||
|
&[],
|
||||||
|
&["this-package-does-not-exist-xyzzy42".to_string()],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(rep.missing.is_empty());
|
assert!(rep.missing.is_empty());
|
||||||
assert_eq!(rep.warnings.len(), 1);
|
assert_eq!(rep.warnings.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ use sha2::{Digest, Sha256};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::manifest::{fetch_binary, Binary};
|
use crate::manifest::{fetch_binary, Binary};
|
||||||
use crate::ui;
|
|
||||||
|
|
||||||
/// Download a binary, verify its SHA-256, then atomically write it into
|
/// Download a binary, verify its SHA-256, then atomically write it into
|
||||||
/// place (fsynced, temp-in-same-dir-with-unique-name then rename — see
|
/// place (fsynced, temp-in-same-dir-with-unique-name then rename — see
|
||||||
|
|
@ -13,16 +12,16 @@ use crate::ui;
|
||||||
/// bytes a second time — `verify_sha256` already confirmed `bytes` matches
|
/// bytes a second time — `verify_sha256` already confirmed `bytes` matches
|
||||||
/// `binary.sha256`, so that's the value to return.
|
/// `binary.sha256`, so that's the value to return.
|
||||||
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<String> {
|
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<String> {
|
||||||
ui::step("downloading", &binary.name);
|
println!(" downloading {}…", binary.name);
|
||||||
let bytes = fetch_binary(&binary.dl_url, &binary.github_url)
|
let bytes = fetch_binary(&binary.dl_url, &binary.github_url)
|
||||||
.with_context(|| format!("downloading {}", binary.name))?;
|
.with_context(|| format!("downloading {}", binary.name))?;
|
||||||
|
|
||||||
verify_sha256(&bytes, &binary.sha256)
|
verify_sha256(&bytes, &binary.sha256)
|
||||||
.with_context(|| format!("checksum mismatch for {}", binary.name))?;
|
.with_context(|| format!("checksum mismatch for {}", binary.name))?;
|
||||||
|
|
||||||
crate::prefix::write_bytes(dest, &bytes, 0o755)
|
bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755))
|
||||||
.with_context(|| format!("placing binary at {}", dest.display()))?;
|
.with_context(|| format!("placing binary at {}", dest.display()))?;
|
||||||
ui::step("placed", &dest.display().to_string());
|
println!(" installed {}", dest.display());
|
||||||
Ok(binary.sha256.clone())
|
Ok(binary.sha256.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@ use std::process::Command;
|
||||||
|
|
||||||
use crate::download::{fetch_and_place, verify_sha256};
|
use crate::download::{fetch_and_place, verify_sha256};
|
||||||
use crate::manifest::{fetch_binary, Package, Service};
|
use crate::manifest::{fetch_binary, Package, Service};
|
||||||
use crate::prefix::{self, Layout};
|
|
||||||
use crate::state::{InstalledPackage, State};
|
use crate::state::{InstalledPackage, State};
|
||||||
use crate::track::Track;
|
use crate::track::Track;
|
||||||
use crate::ui;
|
|
||||||
|
|
||||||
/// Rejects a filename that isn't a safe single path component — no `/`,
|
/// Rejects a filename that isn't a safe single path component — no `/`,
|
||||||
/// `\`, empty, `.`, or `..`. `bin.name`/`svc.unit`/`cfg.example`/`pkg.name`/
|
/// `\`, empty, `.`, or `..`. `bin.name`/`svc.unit`/`cfg.example`/`pkg.name`/
|
||||||
|
|
@ -54,7 +52,7 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
print!("{prompt} {} ", ui::dim("[y/N]"));
|
print!("{prompt} [y/N] ");
|
||||||
std::io::stdout().flush().ok();
|
std::io::stdout().flush().ok();
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
std::io::stdin().read_line(&mut buf).ok();
|
std::io::stdin().read_line(&mut buf).ok();
|
||||||
|
|
@ -69,27 +67,25 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool {
|
||||||
/// install" for the pre-overwrite backup below.
|
/// install" for the pre-overwrite backup below.
|
||||||
pub fn install_package(
|
pub fn install_package(
|
||||||
pkg: &Package,
|
pkg: &Package,
|
||||||
layout: &Layout,
|
bin_dir: &Path,
|
||||||
track: Track,
|
track: Track,
|
||||||
previous: Option<&InstalledPackage>,
|
previous: Option<&InstalledPackage>,
|
||||||
no_hooks: bool,
|
no_hooks: bool,
|
||||||
assume_yes: bool,
|
assume_yes: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
ensure_safe_component(&pkg.name, "package name")?;
|
ensure_safe_component(&pkg.name, "package name")?;
|
||||||
|
println!("installing {}@{}…", pkg.name, pkg.version);
|
||||||
|
|
||||||
// 1. Download and verify all binaries. On an update (not a fresh
|
// 1. Download and verify all binaries. On an update (not a fresh
|
||||||
// install), back up the current binary first — best-effort, feeding
|
// install), back up the current binary first — best-effort, feeding
|
||||||
// `bakery rollback` — before it's overwritten below. Backups stay
|
// `bakery rollback` — before it's overwritten below.
|
||||||
// per-user under ~/.local/state even when the live binary is in
|
|
||||||
// /usr/local/bin, so a snapper snapshot of `@` plus this local copy
|
|
||||||
// is enough to roll back; no second snapshot system.
|
|
||||||
let backup_dir = previous.map(|prev| crate::state::backup_dir(&pkg.name, &prev.version));
|
let backup_dir = previous.map(|prev| crate::state::backup_dir(&pkg.name, &prev.version));
|
||||||
let mut binary_names = Vec::new();
|
let mut binary_names = Vec::new();
|
||||||
let mut binary_sha256 = HashMap::new();
|
let mut binary_sha256 = HashMap::new();
|
||||||
for bin in &pkg.binaries {
|
for bin in &pkg.binaries {
|
||||||
ensure_safe_component(&bin.name, "binary name")?;
|
ensure_safe_component(&bin.name, "binary name")?;
|
||||||
let install_name = strip_arch_suffix(&bin.name);
|
let install_name = strip_arch_suffix(&bin.name);
|
||||||
let dest = layout.bin_dir.join(install_name);
|
let dest = bin_dir.join(install_name);
|
||||||
if let Some(dir) = &backup_dir {
|
if let Some(dir) = &backup_dir {
|
||||||
backup_current_binary(dir, install_name, &dest);
|
backup_current_binary(dir, install_name, &dest);
|
||||||
}
|
}
|
||||||
|
|
@ -98,32 +94,30 @@ pub fn install_package(
|
||||||
binary_sha256.insert(install_name.to_string(), sha256);
|
binary_sha256.insert(install_name.to_string(), sha256);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Scaffold config dir + download example file. Config stays
|
// 2. Scaffold config dir + download example file.
|
||||||
// per-user (~/.config) regardless of prefix — it's authored content,
|
|
||||||
// not bakery-placed bits.
|
|
||||||
if let Some(cfg) = &pkg.config {
|
if let Some(cfg) = &pkg.config {
|
||||||
scaffold_config(cfg, pkg)?;
|
scaffold_config(cfg, pkg)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Install license file, if declared.
|
// 3. Install license file, if declared.
|
||||||
if let Some(license) = &pkg.license_file {
|
if let Some(license) = &pkg.license_file {
|
||||||
install_license(pkg, license, layout)?;
|
install_license(pkg, license)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Install desktop entry, if declared.
|
// 4. Install desktop entry, if declared.
|
||||||
if let Some(desktop) = &pkg.desktop_file {
|
if let Some(desktop) = &pkg.desktop_file {
|
||||||
install_desktop_file(pkg, desktop, layout)?;
|
install_desktop_file(pkg, desktop)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Download + extract data archive, if declared.
|
// 5. Download + extract data archive, if declared.
|
||||||
if let Some(archive) = &pkg.data_archive {
|
if let Some(archive) = &pkg.data_archive {
|
||||||
install_data_archive(pkg, archive, layout)?;
|
install_data_archive(pkg, archive)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Install systemd user units.
|
// 6. Install systemd user units.
|
||||||
let mut service_names = Vec::new();
|
let mut service_names = Vec::new();
|
||||||
for svc in &pkg.services {
|
for svc in &pkg.services {
|
||||||
install_service(svc, layout, pkg)?;
|
install_service(svc, bin_dir, pkg)?;
|
||||||
service_names.push(svc.unit.clone());
|
service_names.push(svc.unit.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,13 +126,10 @@ pub fn install_package(
|
||||||
// confirmation rather than running unconditionally.
|
// confirmation rather than running unconditionally.
|
||||||
if !pkg.post_install.is_empty() {
|
if !pkg.post_install.is_empty() {
|
||||||
if no_hooks {
|
if no_hooks {
|
||||||
eprintln!(
|
println!(
|
||||||
" {}",
|
" note: skipped {} post_install hook(s) for {} (--no-hooks)",
|
||||||
ui::note(&format!(
|
pkg.post_install.len(),
|
||||||
"skipped {} post_install hook(s) for {} (--no-hooks)",
|
pkg.name
|
||||||
pkg.post_install.len(),
|
|
||||||
pkg.name
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
} else if confirm(
|
} else if confirm(
|
||||||
&format!(
|
&format!(
|
||||||
|
|
@ -152,13 +143,7 @@ pub fn install_package(
|
||||||
run_hook(cmd, &pkg.name)?;
|
run_hook(cmd, &pkg.name)?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
println!(" skipped post_install hooks for {} (declined)", pkg.name);
|
||||||
" {}",
|
|
||||||
ui::note(&format!(
|
|
||||||
"skipped post_install hooks for {} (declined)",
|
|
||||||
pkg.name
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,8 +163,8 @@ pub fn install_package(
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
println!(" {}", ui::ok(&format!("{} installed", pkg.name)));
|
println!(" {} installed successfully", pkg.name);
|
||||||
warn_path_if_needed(&layout.bin_dir);
|
warn_path_if_needed(bin_dir);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,39 +184,25 @@ fn backup_current_binary(backup_dir: &Path, binary_name: &str, current_path: &Pa
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::create_dir_all(backup_dir) {
|
if let Err(e) = std::fs::create_dir_all(backup_dir) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}",
|
" warning: could not create backup dir {} ({e}) — rollback won't be available for this update",
|
||||||
ui::warn(&format!(
|
backup_dir.display()
|
||||||
"could not create backup dir {} ({e}) — rollback won't be available for this update",
|
|
||||||
backup_dir.display()
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::copy(current_path, backup_dir.join(binary_name)) {
|
if let Err(e) = std::fs::copy(current_path, backup_dir.join(binary_name)) {
|
||||||
eprintln!(
|
eprintln!(" warning: could not back up {binary_name} before update ({e}) — rollback won't be available for this update");
|
||||||
" {}",
|
|
||||||
ui::warn(&format!(
|
|
||||||
"could not back up {binary_name} before update ({e}) — rollback won't be available for this update"
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_package(
|
pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: bool) -> Result<()> {
|
||||||
pkg_name: &str,
|
|
||||||
layout: &Layout,
|
|
||||||
assume_yes: bool,
|
|
||||||
purge: bool,
|
|
||||||
) -> Result<()> {
|
|
||||||
let installed = State::with_lock(|state| Ok(state.remove(pkg_name)))?;
|
let installed = State::with_lock(|state| Ok(state.remove(pkg_name)))?;
|
||||||
let installed = match installed {
|
let installed = match installed {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
eprintln!(" {}", ui::fail(&format!("{pkg_name} is not installed")));
|
eprintln!("{pkg_name} is not installed");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ui::action("Removing", pkg_name, Some(&installed.version));
|
|
||||||
// State is already committed by with_lock above — everything from here
|
// State is already committed by with_lock above — everything from here
|
||||||
// is best-effort file cleanup, and must all run even if part of it fails.
|
// is best-effort file cleanup, and must all run even if part of it fails.
|
||||||
|
|
||||||
|
|
@ -240,10 +211,10 @@ pub fn remove_package(
|
||||||
// the config/data-preserved messages below.
|
// the config/data-preserved messages below.
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
for bin in &installed.binaries {
|
for bin in &installed.binaries {
|
||||||
let path = layout.bin_dir.join(bin);
|
let path = bin_dir.join(bin);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
match prefix::remove_file(&path) {
|
match std::fs::remove_file(&path) {
|
||||||
Ok(()) => ui::step("removed", &path.display().to_string()),
|
Ok(()) => println!(" removed {}", path.display()),
|
||||||
Err(e) => failures.push(format!("{}: {e}", path.display())),
|
Err(e) => failures.push(format!("{}: {e}", path.display())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +222,7 @@ pub fn remove_package(
|
||||||
|
|
||||||
// Prompt for unit removal.
|
// Prompt for unit removal.
|
||||||
if !installed.services.is_empty() {
|
if !installed.services.is_empty() {
|
||||||
let service_dir = &layout.systemd_user_dir;
|
let service_dir = systemd_user_dir();
|
||||||
for unit in &installed.services {
|
for unit in &installed.services {
|
||||||
let unit_path = service_dir.join(unit);
|
let unit_path = service_dir.join(unit);
|
||||||
if confirm_remove_unit(unit, assume_yes) {
|
if confirm_remove_unit(unit, assume_yes) {
|
||||||
|
|
@ -259,12 +230,12 @@ pub fn remove_package(
|
||||||
.args(["--user", "disable", "--now", unit])
|
.args(["--user", "disable", "--now", unit])
|
||||||
.status();
|
.status();
|
||||||
if unit_path.exists() {
|
if unit_path.exists() {
|
||||||
let _ = prefix::remove_file(&unit_path);
|
std::fs::remove_file(&unit_path).ok();
|
||||||
}
|
}
|
||||||
let _ = Command::new("systemctl")
|
let _ = Command::new("systemctl")
|
||||||
.args(["--user", "daemon-reload"])
|
.args(["--user", "daemon-reload"])
|
||||||
.status();
|
.status();
|
||||||
ui::step("removed", &format!("unit {unit}"));
|
println!(" removed unit {unit}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -276,45 +247,34 @@ pub fn remove_package(
|
||||||
// surprise no flag should cause.
|
// surprise no flag should cause.
|
||||||
if let Some(cfg_dir) = guess_config_dir(pkg_name) {
|
if let Some(cfg_dir) = guess_config_dir(pkg_name) {
|
||||||
if cfg_dir.exists() {
|
if cfg_dir.exists() {
|
||||||
ui::step("preserved", &format!("config {}", cfg_dir.display()));
|
println!(" config preserved at {}", cfg_dir.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let share_dir = &layout.share_dir;
|
let share_dir = dirs::data_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"));
|
||||||
let data_dir = share_dir.join(pkg_name);
|
let data_dir = share_dir.join(pkg_name);
|
||||||
|
|
||||||
if purge {
|
if purge {
|
||||||
let license_dir = share_dir.join("licenses").join(pkg_name);
|
let license_dir = share_dir.join("licenses").join(pkg_name);
|
||||||
remove_purged_path(&license_dir, "license dir", true, assume_yes, &mut failures);
|
remove_purged_path(&license_dir, "license dir", true, assume_yes, &mut failures);
|
||||||
|
|
||||||
let desktop_file = share_dir
|
let desktop_file = share_dir.join("applications").join(format!("{pkg_name}.desktop"));
|
||||||
.join("applications")
|
remove_purged_path(&desktop_file, "desktop entry", false, assume_yes, &mut failures);
|
||||||
.join(format!("{pkg_name}.desktop"));
|
|
||||||
remove_purged_path(
|
|
||||||
&desktop_file,
|
|
||||||
"desktop entry",
|
|
||||||
false,
|
|
||||||
assume_yes,
|
|
||||||
&mut failures,
|
|
||||||
);
|
|
||||||
|
|
||||||
remove_purged_path(&data_dir, "data dir", true, assume_yes, &mut failures);
|
remove_purged_path(&data_dir, "data dir", true, assume_yes, &mut failures);
|
||||||
} else if data_dir.exists() {
|
} else if data_dir.exists() {
|
||||||
ui::step("preserved", &format!("data {}", data_dir.display()));
|
println!(" data preserved at {}", data_dir.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
if !failures.is_empty() {
|
if !failures.is_empty() {
|
||||||
eprintln!(
|
eprintln!(" failed to remove {} item(s):", failures.len());
|
||||||
" {}",
|
|
||||||
ui::fail(&format!("failed to remove {} item(s):", failures.len()))
|
|
||||||
);
|
|
||||||
for f in &failures {
|
for f in &failures {
|
||||||
eprintln!(" {f}");
|
eprintln!(" {f}");
|
||||||
}
|
}
|
||||||
bail!("{pkg_name} removed from state, but some files could not be deleted");
|
bail!("{pkg_name} removed from state, but some files could not be deleted");
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(" {}", ui::ok(&format!("{pkg_name} removed")));
|
println!(" {pkg_name} removed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,30 +284,21 @@ pub fn remove_package(
|
||||||
/// in place and prints the same "preserved at" wording the non-purge path
|
/// in place and prints the same "preserved at" wording the non-purge path
|
||||||
/// already uses. Shared by `remove_package`'s three `--purge` targets
|
/// already uses. Shared by `remove_package`'s three `--purge` targets
|
||||||
/// (license dir, desktop entry, data dir).
|
/// (license dir, desktop entry, data dir).
|
||||||
fn remove_purged_path(
|
fn remove_purged_path(path: &Path, label: &str, recursive: bool, assume_yes: bool, failures: &mut Vec<String>) {
|
||||||
path: &Path,
|
|
||||||
label: &str,
|
|
||||||
recursive: bool,
|
|
||||||
assume_yes: bool,
|
|
||||||
failures: &mut Vec<String>,
|
|
||||||
) {
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !confirm(
|
if !confirm(&format!(" remove {label} at {}?", path.display()), assume_yes) {
|
||||||
&format!(" remove {label} at {}?", path.display()),
|
println!(" {label} preserved at {}", path.display());
|
||||||
assume_yes,
|
|
||||||
) {
|
|
||||||
ui::step("preserved", &format!("{label} {}", path.display()));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let result = if recursive {
|
let result = if recursive {
|
||||||
prefix::remove_dir_all(path)
|
std::fs::remove_dir_all(path)
|
||||||
} else {
|
} else {
|
||||||
prefix::remove_file(path)
|
std::fs::remove_file(path)
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => ui::step("removed", &path.display().to_string()),
|
Ok(()) => println!(" removed {}", path.display()),
|
||||||
Err(e) => failures.push(format!("{}: {e}", path.display())),
|
Err(e) => failures.push(format!("{}: {e}", path.display())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -367,48 +318,36 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
std::fs::write(&dest, &bytes)
|
std::fs::write(&dest, &bytes)
|
||||||
.with_context(|| format!("writing {}", dest.display()))?;
|
.with_context(|| format!("writing {}", dest.display()))?;
|
||||||
ui::step("config", &dest.display().to_string());
|
println!(" installed example config at {}", dest.display());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}",
|
" warning: checksum mismatch for example config {example}: {e} — not installed"
|
||||||
ui::warn(&format!(
|
|
||||||
"checksum mismatch for example config {example}: {e} — not installed"
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
ui::step("config", &dir.display().to_string());
|
println!(" config dir created at {}", dir.display());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}",
|
" warning: index.json has no sha256 for example config \
|
||||||
ui::warn(&format!(
|
{example} — refusing to install an unverified download"
|
||||||
"index.json has no sha256 for example config \
|
|
||||||
{example} — refusing to install an unverified download"
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
ui::step("config", &dir.display().to_string());
|
println!(" config dir created at {}", dir.display());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
eprintln!(" warning: could not download example config {example}: {e}");
|
||||||
" {}",
|
println!(" config dir created at {}", dir.display());
|
||||||
ui::warn(&format!("could not download example config {example}: {e}"))
|
|
||||||
);
|
|
||||||
ui::step("config", &dir.display().to_string());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ui::step("config", &dir.display().to_string());
|
println!(" config dir created at {}", dir.display());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ui::step(
|
println!(" config at {} already exists, skipping", dest.display());
|
||||||
"config",
|
|
||||||
&format!("{} already exists, skipping", dest.display()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ui::step("config", &dir.display().to_string());
|
println!(" config dir created at {}", dir.display());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -427,75 +366,59 @@ fn fetch_verify_write(
|
||||||
label: &str,
|
label: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let Some((primary, fallback)) = pkg.artifact_urls(filename) else {
|
let Some((primary, fallback)) = pkg.artifact_urls(filename) else {
|
||||||
eprintln!(
|
eprintln!(" warning: no artifact URL to download {label} ({filename})");
|
||||||
" {}",
|
|
||||||
ui::warn(&format!("no artifact URL to download {label} ({filename})"))
|
|
||||||
);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let bytes = match fetch_binary(&primary, &fallback) {
|
let bytes = match fetch_binary(&primary, &fallback) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
eprintln!(" warning: could not download {label} {filename}: {e}");
|
||||||
" {}",
|
|
||||||
ui::warn(&format!("could not download {label} {filename}: {e}"))
|
|
||||||
);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some(expected) = sha256 else {
|
let Some(expected) = sha256 else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}",
|
" warning: index.json has no sha256 for {label} {filename} — \
|
||||||
ui::warn(&format!(
|
refusing to install an unverified download"
|
||||||
"index.json has no sha256 for {label} {filename} — \
|
|
||||||
refusing to install an unverified download"
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
if let Err(e) = verify_sha256(&bytes, expected) {
|
if let Err(e) = verify_sha256(&bytes, expected) {
|
||||||
eprintln!(
|
eprintln!(" warning: checksum mismatch for {label} {filename}: {e} — not installed");
|
||||||
" {}",
|
|
||||||
ui::warn(&format!(
|
|
||||||
"checksum mismatch for {label} {filename}: {e} — not installed"
|
|
||||||
))
|
|
||||||
);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
prefix::write_bytes(dest, &bytes, 0o644)
|
if let Some(parent) = dest.parent() {
|
||||||
.with_context(|| format!("writing {}", dest.display()))?;
|
std::fs::create_dir_all(parent)?;
|
||||||
ui::step("installed", &format!("{label} {}", dest.display()));
|
}
|
||||||
|
std::fs::write(dest, &bytes).with_context(|| format!("writing {}", dest.display()))?;
|
||||||
|
println!(" installed {label} at {}", dest.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_license(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> {
|
fn install_license(pkg: &Package, filename: &str) -> Result<()> {
|
||||||
ensure_safe_component(filename, "license_file")?;
|
ensure_safe_component(filename, "license_file")?;
|
||||||
let dest = layout
|
let dest = dirs::data_dir()
|
||||||
.share_dir
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||||
.join("licenses")
|
.join("licenses")
|
||||||
.join(&pkg.name)
|
.join(&pkg.name)
|
||||||
.join("LICENSE");
|
.join("LICENSE");
|
||||||
fetch_verify_write(pkg, filename, &pkg.license_file_sha256, &dest, "license")
|
fetch_verify_write(pkg, filename, &pkg.license_file_sha256, &dest, "license")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_desktop_file(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> {
|
fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> {
|
||||||
ensure_safe_component(filename, "desktop_file")?;
|
ensure_safe_component(filename, "desktop_file")?;
|
||||||
let dest = layout
|
let dest = dirs::data_dir()
|
||||||
.share_dir
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||||
.join("applications")
|
.join("applications")
|
||||||
.join(format!("{}.desktop", pkg.name));
|
.join(format!("{}.desktop", pkg.name));
|
||||||
fetch_verify_write(
|
fetch_verify_write(pkg, filename, &pkg.desktop_file_sha256, &dest, "desktop entry")
|
||||||
pkg,
|
|
||||||
filename,
|
|
||||||
&pkg.desktop_file_sha256,
|
|
||||||
&dest,
|
|
||||||
"desktop entry",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_data_archive(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> {
|
fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> {
|
||||||
ensure_safe_component(filename, "data_archive")?;
|
ensure_safe_component(filename, "data_archive")?;
|
||||||
let data_dir = layout.share_dir.join(&pkg.name);
|
let data_dir = dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||||
|
.join(&pkg.name);
|
||||||
fetch_extract_archive(pkg, filename, &pkg.data_archive_sha256, &data_dir)
|
fetch_extract_archive(pkg, filename, &pkg.data_archive_sha256, &data_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -504,7 +427,7 @@ fn install_data_archive(pkg: &Package, filename: &str, layout: &Layout) -> Resul
|
||||||
/// crate dependency — `tar` is universally present on Linux and this file
|
/// crate dependency — `tar` is universally present on Linux and this file
|
||||||
/// already shells out to `systemctl` for the same "trust the base system
|
/// already shells out to `systemctl` for the same "trust the base system
|
||||||
/// has this" reason. Split from `install_data_archive` (which just supplies
|
/// has this" reason. Split from `install_data_archive` (which just supplies
|
||||||
/// the real `$prefix/share/<name>` destination) so tests can extract into
|
/// the real `~/.local/share/<name>` destination) so tests can extract into
|
||||||
/// a tempdir instead.
|
/// a tempdir instead.
|
||||||
fn fetch_extract_archive(
|
fn fetch_extract_archive(
|
||||||
pkg: &Package,
|
pkg: &Package,
|
||||||
|
|
@ -523,35 +446,32 @@ fn fetch_extract_archive(
|
||||||
.into_temp_path();
|
.into_temp_path();
|
||||||
|
|
||||||
fetch_verify_write(pkg, filename, sha256, &tmp_archive, "data archive")?;
|
fetch_verify_write(pkg, filename, sha256, &tmp_archive, "data archive")?;
|
||||||
// `fetch_verify_write` treats a download/checksum/missing-sha failure
|
if !tmp_archive.exists() {
|
||||||
// as a soft warning and returns `Ok` *without writing* — but the
|
// fetch_verify_write already warned (download/checksum failure).
|
||||||
// `NamedTempFile` above is already created (0 bytes), so gating on
|
|
||||||
// mere `exists()` would let an empty file through to `tar tvf`, which
|
|
||||||
// then bails with a confusing "not in gzip format" error that masks
|
|
||||||
// the real cause and aborts the whole install. Gate on *non-empty*.
|
|
||||||
if std::fs::metadata(&tmp_archive)
|
|
||||||
.map(|m| m.len())
|
|
||||||
.unwrap_or(0)
|
|
||||||
== 0
|
|
||||||
{
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
verify_archive_paths(&tmp_archive)?;
|
verify_archive_paths(&tmp_archive)?;
|
||||||
|
|
||||||
match prefix::extract_tar_gz(&tmp_archive, dest_dir) {
|
std::fs::create_dir_all(dest_dir)?;
|
||||||
Ok(()) => ui::step(
|
let status = Command::new("tar")
|
||||||
"extracted",
|
.args([
|
||||||
&format!("{filename} → {}", dest_dir.display()),
|
"xzf",
|
||||||
),
|
&tmp_archive.to_string_lossy(),
|
||||||
Err(e) => {
|
"--no-same-owner",
|
||||||
eprintln!(
|
"--no-same-permissions",
|
||||||
" {}",
|
"-C",
|
||||||
ui::warn(&format!("could not extract {filename}: {e}"))
|
])
|
||||||
);
|
.arg(dest_dir)
|
||||||
}
|
.status()
|
||||||
}
|
.with_context(|| format!("running tar to extract {filename}"))?;
|
||||||
// `tmp_archive` (a `TempPath` guard) deletes the file when it drops here.
|
// `tmp_archive` (a `TempPath` guard) deletes the file when it drops here.
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
println!(" extracted {filename} to {}", dest_dir.display());
|
||||||
|
} else {
|
||||||
|
eprintln!(" warning: tar exited with {status} extracting {filename}");
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -613,11 +533,11 @@ fn fetch_and_verify_unit(pkg: &Package, svc: &Service) -> Result<Vec<u8>> {
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()> {
|
fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
|
||||||
ensure_safe_component(&svc.unit, "service unit")?;
|
ensure_safe_component(&svc.unit, "service unit")?;
|
||||||
|
|
||||||
let service_dir = &layout.systemd_user_dir;
|
let service_dir = systemd_user_dir();
|
||||||
prefix::create_dir_all(service_dir)?;
|
std::fs::create_dir_all(&service_dir)?;
|
||||||
|
|
||||||
let unit_path = service_dir.join(&svc.unit);
|
let unit_path = service_dir.join(&svc.unit);
|
||||||
let had_existing = unit_path.exists();
|
let had_existing = unit_path.exists();
|
||||||
|
|
@ -627,47 +547,38 @@ fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()>
|
||||||
// applied after the first install, unlike binaries (which always
|
// applied after the first install, unlike binaries (which always
|
||||||
// re-fetch via `fetch_and_place` on every install/update). If the fetch
|
// re-fetch via `fetch_and_place` on every install/update). If the fetch
|
||||||
// or checksum fails, fall back to whatever's already on disk rather than
|
// or checksum fails, fall back to whatever's already on disk rather than
|
||||||
// regressing offline/flaky-network reliability. Patch ExecStart in
|
// regressing offline/flaky-network reliability.
|
||||||
// memory before the write so a system-prefix install only needs one
|
|
||||||
// privileged write, not write-then-rewrite.
|
|
||||||
match fetch_and_verify_unit(pkg, svc) {
|
match fetch_and_verify_unit(pkg, svc) {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
let text = String::from_utf8_lossy(&bytes);
|
std::fs::write(&unit_path, &bytes)
|
||||||
let patched = patch_exec_start_text(&text, &layout.bin_dir);
|
|
||||||
prefix::write_bytes(&unit_path, patched.as_bytes(), 0o644)
|
|
||||||
.with_context(|| format!("writing {}", unit_path.display()))?;
|
.with_context(|| format!("writing {}", unit_path.display()))?;
|
||||||
ui::step("unit", &unit_path.display().to_string());
|
println!(" downloaded unit {}", unit_path.display());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if had_existing {
|
if had_existing {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}",
|
" warning: could not refresh unit {} ({e}) — keeping existing copy",
|
||||||
ui::warn(&format!(
|
svc.unit
|
||||||
"could not refresh unit {} ({e}) — keeping existing copy",
|
|
||||||
svc.unit
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
patch_exec_start(&unit_path, &layout.bin_dir)?;
|
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}",
|
" warning: unit file {} not found ({e}) — skipping service setup",
|
||||||
ui::warn(&format!(
|
svc.unit
|
||||||
"unit file {} not found ({e}) — skipping service setup",
|
|
||||||
svc.unit
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
patch_exec_start(&unit_path, bin_dir)?;
|
||||||
|
|
||||||
if !Command::new("systemctl")
|
if !Command::new("systemctl")
|
||||||
.args(["--user", "daemon-reload"])
|
.args(["--user", "daemon-reload"])
|
||||||
.status()
|
.status()
|
||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
eprintln!(" {}", ui::warn("systemctl daemon-reload failed"));
|
eprintln!(" warning: systemctl daemon-reload failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
if svc.enable {
|
if svc.enable {
|
||||||
|
|
@ -684,9 +595,9 @@ fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()>
|
||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
ui::step("restarted", &svc.unit);
|
println!(" {} restarted", svc.unit);
|
||||||
} else {
|
} else {
|
||||||
eprintln!(" {}", ui::warn(&format!("failed to restart {}", svc.unit)));
|
eprintln!(" warning: failed to restart {}", svc.unit);
|
||||||
}
|
}
|
||||||
} else if Command::new("systemctl")
|
} else if Command::new("systemctl")
|
||||||
.args(["--user", "enable", "--now", &svc.unit])
|
.args(["--user", "enable", "--now", &svc.unit])
|
||||||
|
|
@ -694,9 +605,9 @@ fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()>
|
||||||
.map(|s| s.success())
|
.map(|s| s.success())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
ui::step("enabled", &svc.unit);
|
println!(" {} enabled and started", svc.unit);
|
||||||
} else {
|
} else {
|
||||||
eprintln!(" {}", ui::warn(&format!("failed to enable {}", svc.unit)));
|
eprintln!(" warning: failed to enable {}", svc.unit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -705,12 +616,6 @@ fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()>
|
||||||
|
|
||||||
fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
|
fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
|
||||||
let text = std::fs::read_to_string(unit_path)?;
|
let text = std::fs::read_to_string(unit_path)?;
|
||||||
let output = patch_exec_start_text(&text, bin_dir);
|
|
||||||
prefix::write_bytes(unit_path, output.as_bytes(), 0o644)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn patch_exec_start_text(text: &str, bin_dir: &Path) -> String {
|
|
||||||
let patched: String = text
|
let patched: String = text
|
||||||
.lines()
|
.lines()
|
||||||
.map(|line| {
|
.map(|line| {
|
||||||
|
|
@ -734,21 +639,24 @@ fn patch_exec_start_text(text: &str, bin_dir: &Path) -> String {
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
if text.ends_with('\n') {
|
// Preserve trailing newline if the original had one.
|
||||||
|
let output = if text.ends_with('\n') {
|
||||||
format!("{patched}\n")
|
format!("{patched}\n")
|
||||||
} else {
|
} else {
|
||||||
patched
|
patched
|
||||||
}
|
};
|
||||||
|
std::fs::write(unit_path, output)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> {
|
fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> {
|
||||||
ui::step("hook", cmd);
|
println!(" running post_install hook: {cmd}");
|
||||||
let status = Command::new("sh")
|
let status = Command::new("sh")
|
||||||
.args(["-c", cmd])
|
.args(["-c", cmd])
|
||||||
.status()
|
.status()
|
||||||
.with_context(|| format!("running post_install hook for {pkg_name}"))?;
|
.with_context(|| format!("running post_install hook for {pkg_name}"))?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
eprintln!(" {}", ui::warn(&format!("hook exited with {status}")));
|
eprintln!(" warning: hook exited with {status}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -757,6 +665,12 @@ fn confirm_remove_unit(unit: &str, assume_yes: bool) -> bool {
|
||||||
confirm(&format!(" remove systemd unit {unit}?"), assume_yes)
|
confirm(&format!(" remove systemd unit {unit}?"), assume_yes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn systemd_user_dir() -> PathBuf {
|
||||||
|
dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
||||||
|
.join("systemd/user")
|
||||||
|
}
|
||||||
|
|
||||||
fn guess_config_dir(pkg_name: &str) -> Option<PathBuf> {
|
fn guess_config_dir(pkg_name: &str) -> Option<PathBuf> {
|
||||||
Some(dirs::config_dir()?.join(pkg_name))
|
Some(dirs::config_dir()?.join(pkg_name))
|
||||||
}
|
}
|
||||||
|
|
@ -785,14 +699,11 @@ fn warn_path_if_needed(bin_dir: &Path) {
|
||||||
let path_var = std::env::var("PATH").unwrap_or_default();
|
let path_var = std::env::var("PATH").unwrap_or_default();
|
||||||
let bin_str = bin_dir.to_string_lossy();
|
let bin_str = bin_dir.to_string_lossy();
|
||||||
if !path_var.split(':').any(|p| p == bin_str) {
|
if !path_var.split(':').any(|p| p == bin_str) {
|
||||||
eprintln!();
|
println!(
|
||||||
eprintln!(
|
"\n note: {} is not in PATH — add to your shell profile:",
|
||||||
" {}",
|
bin_str
|
||||||
ui::note(&format!(
|
|
||||||
"{bin_str} is not in PATH — add to your shell profile:"
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
println!(" export PATH=\"{bin_str}:$PATH\"");
|
println!(" export PATH=\"{}:$PATH\"", bin_str);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -817,7 +728,10 @@ mod tests {
|
||||||
if let Ok((mut stream, _)) = listener.accept() {
|
if let Ok((mut stream, _)) = listener.accept() {
|
||||||
let mut buf = [0u8; 1024];
|
let mut buf = [0u8; 1024];
|
||||||
let _ = stream.read(&mut buf);
|
let _ = stream.read(&mut buf);
|
||||||
let response = format!("HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
|
let response = format!(
|
||||||
|
"HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
let _ = stream.write_all(response.as_bytes());
|
let _ = stream.write_all(response.as_bytes());
|
||||||
let _ = stream.write_all(body);
|
let _ = stream.write_all(body);
|
||||||
}
|
}
|
||||||
|
|
@ -834,7 +748,10 @@ mod tests {
|
||||||
if let Ok((mut stream, _)) = listener.accept() {
|
if let Ok((mut stream, _)) = listener.accept() {
|
||||||
let mut buf = [0u8; 1024];
|
let mut buf = [0u8; 1024];
|
||||||
let _ = stream.read(&mut buf);
|
let _ = stream.read(&mut buf);
|
||||||
let response = format!("HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", body.len());
|
let response = format!(
|
||||||
|
"HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
let _ = stream.write_all(response.as_bytes());
|
let _ = stream.write_all(response.as_bytes());
|
||||||
let _ = stream.write_all(&body);
|
let _ = stream.write_all(&body);
|
||||||
}
|
}
|
||||||
|
|
@ -880,14 +797,8 @@ mod tests {
|
||||||
|
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let dest = dir.path().join("LICENSE");
|
let dest = dir.path().join("LICENSE");
|
||||||
fetch_verify_write(
|
fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license")
|
||||||
&pkg,
|
.unwrap();
|
||||||
"LICENSE",
|
|
||||||
&pkg.license_file_sha256.clone(),
|
|
||||||
&dest,
|
|
||||||
"license",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(fs::read(&dest).unwrap(), license_bytes);
|
assert_eq!(fs::read(&dest).unwrap(), license_bytes);
|
||||||
}
|
}
|
||||||
|
|
@ -925,14 +836,8 @@ mod tests {
|
||||||
|
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let dest = dir.path().join("LICENSE");
|
let dest = dir.path().join("LICENSE");
|
||||||
fetch_verify_write(
|
fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license")
|
||||||
&pkg,
|
.unwrap();
|
||||||
"LICENSE",
|
|
||||||
&pkg.license_file_sha256.clone(),
|
|
||||||
&dest,
|
|
||||||
"license",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Refused, not erred (matches scaffold_config's warn-and-continue
|
// Refused, not erred (matches scaffold_config's warn-and-continue
|
||||||
// posture) — the file must not have been written.
|
// posture) — the file must not have been written.
|
||||||
|
|
@ -980,7 +885,8 @@ mod tests {
|
||||||
let pkg = test_package(&base_url);
|
let pkg = test_package(&base_url);
|
||||||
|
|
||||||
let dest_dir = tempdir().unwrap();
|
let dest_dir = tempdir().unwrap();
|
||||||
fetch_extract_archive(&pkg, "content.tar.gz", &Some(sha256_hex), dest_dir.path()).unwrap();
|
fetch_extract_archive(&pkg, "content.tar.gz", &Some(sha256_hex), dest_dir.path())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let extracted = dest_dir.path().join("content/tours/onboarding.toml");
|
let extracted = dest_dir.path().join("content/tours/onboarding.toml");
|
||||||
assert_eq!(fs::read(&extracted).unwrap(), b"[[step]]\n");
|
assert_eq!(fs::read(&extracted).unwrap(), b"[[step]]\n");
|
||||||
|
|
@ -1045,34 +951,6 @@ mod tests {
|
||||||
assert!(err.to_string().contains("unsafe path"));
|
assert!(err.to_string().contains("unsafe path"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fetch_extract_archive_download_failure_is_non_fatal() {
|
|
||||||
// A checksum mismatch makes `fetch_verify_write` warn-and-return
|
|
||||||
// Ok without writing, leaving the (already created) NamedTempFile
|
|
||||||
// empty. The pre-extraction `tar tvf` must be skipped for an empty
|
|
||||||
// file rather than bailing, so a failed archive download degrades
|
|
||||||
// gracefully and does NOT abort the package install (regression
|
|
||||||
// for the empty-temp-file bug).
|
|
||||||
let base_url = serve_once(b"definitely not a tar.gz");
|
|
||||||
let pkg = test_package(&base_url);
|
|
||||||
|
|
||||||
let dest_dir = tempdir().unwrap();
|
|
||||||
let res = fetch_extract_archive(
|
|
||||||
&pkg,
|
|
||||||
"content.tar.gz",
|
|
||||||
&Some("0".repeat(64)), // will not match the served bytes
|
|
||||||
dest_dir.path(),
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
res.is_ok(),
|
|
||||||
"a data-archive download/checksum failure must be non-fatal"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
std::fs::read_dir(dest_dir.path()).unwrap().next().is_none(),
|
|
||||||
"nothing should be extracted into dest_dir"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ensure_safe_component_accepts_plain_names() {
|
fn ensure_safe_component_accepts_plain_names() {
|
||||||
assert!(ensure_safe_component("breadhelp", "x").is_ok());
|
assert!(ensure_safe_component("breadhelp", "x").is_ok());
|
||||||
|
|
@ -1095,8 +973,7 @@ mod tests {
|
||||||
let mut pkg = test_package(&base_url);
|
let mut pkg = test_package(&base_url);
|
||||||
pkg.name = "../evil".to_string();
|
pkg.name = "../evil".to_string();
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let layout = Layout::from_prefix(dir.path(), None);
|
let err = install_package(&pkg, dir.path(), Track::Stable, None, true, true).unwrap_err();
|
||||||
let err = install_package(&pkg, &layout, Track::Stable, None, true, true).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("not a safe filename"));
|
assert!(err.to_string().contains("not a safe filename"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1166,10 +1043,7 @@ mod tests {
|
||||||
|
|
||||||
backup_current_binary(&backup_dir, "mypkg", ¤t);
|
backup_current_binary(&backup_dir, "mypkg", ¤t);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(fs::read(backup_dir.join("mypkg")).unwrap(), b"old version bytes");
|
||||||
fs::read(backup_dir.join("mypkg")).unwrap(),
|
|
||||||
b"old version bytes"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ mod doctor;
|
||||||
mod download;
|
mod download;
|
||||||
mod install;
|
mod install;
|
||||||
mod manifest;
|
mod manifest;
|
||||||
mod prefix;
|
|
||||||
mod state;
|
mod state;
|
||||||
mod track;
|
mod track;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
@ -15,12 +14,7 @@ use std::path::{Path, PathBuf};
|
||||||
use track::Track;
|
use track::Track;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(name = "bakery", about = "Package manager for the bread ecosystem", version)]
|
||||||
name = "bakery",
|
|
||||||
about = "Package manager for the bread ecosystem",
|
|
||||||
version,
|
|
||||||
styles = ui::CLAP_STYLES
|
|
||||||
)]
|
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Cmd,
|
command: Cmd,
|
||||||
|
|
@ -49,7 +43,7 @@ enum Cmd {
|
||||||
Remove {
|
Remove {
|
||||||
package: String,
|
package: String,
|
||||||
/// Also remove the license file, desktop entry, and data dir
|
/// Also remove the license file, desktop entry, and data dir
|
||||||
/// ($prefix/share/<pkg>/) — config is still preserved
|
/// (~/.local/share/<pkg>/) — config is still preserved
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
purge: bool,
|
purge: bool,
|
||||||
},
|
},
|
||||||
|
|
@ -69,9 +63,13 @@ enum Cmd {
|
||||||
installed: bool,
|
installed: bool,
|
||||||
},
|
},
|
||||||
/// Show details for a package
|
/// Show details for a package
|
||||||
Info { package: String },
|
Info {
|
||||||
|
package: String,
|
||||||
|
},
|
||||||
/// Search package names and descriptions
|
/// Search package names and descriptions
|
||||||
Search { query: String },
|
Search {
|
||||||
|
query: String,
|
||||||
|
},
|
||||||
/// Check system dependencies for installed or requested packages
|
/// Check system dependencies for installed or requested packages
|
||||||
Doctor {
|
Doctor {
|
||||||
/// Package to check; omit to check all installed packages
|
/// Package to check; omit to check all installed packages
|
||||||
|
|
@ -84,11 +82,15 @@ enum Cmd {
|
||||||
},
|
},
|
||||||
/// Roll back a package to its previously installed version, from a
|
/// Roll back a package to its previously installed version, from a
|
||||||
/// local pre-update backup (not a re-download)
|
/// local pre-update backup (not a re-download)
|
||||||
Rollback { package: String },
|
Rollback {
|
||||||
|
package: String,
|
||||||
|
},
|
||||||
/// Update bakery itself
|
/// Update bakery itself
|
||||||
SelfUpdate,
|
SelfUpdate,
|
||||||
/// Generate a shell completion script
|
/// Generate a shell completion script
|
||||||
Completions { shell: clap_complete::Shell },
|
Completions {
|
||||||
|
shell: clap_complete::Shell,
|
||||||
|
},
|
||||||
/// View or switch which build track bakery follows (stable/beta/dev)
|
/// View or switch which build track bakery follows (stable/beta/dev)
|
||||||
Track {
|
Track {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
|
|
@ -105,9 +107,15 @@ enum TrackCmd {
|
||||||
Set { track: Track },
|
Set { track: Track },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_bin_dir() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("~"))
|
||||||
|
.join(".local/bin")
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let layout = prefix::resolve(cli.bin_dir);
|
let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir);
|
||||||
let no_hooks = cli.no_hooks;
|
let no_hooks = cli.no_hooks;
|
||||||
let assume_yes = cli.yes;
|
let assume_yes = cli.yes;
|
||||||
let dry_run = cli.dry_run;
|
let dry_run = cli.dry_run;
|
||||||
|
|
@ -117,40 +125,26 @@ fn main() -> Result<()> {
|
||||||
Cmd::Install { packages } => {
|
Cmd::Install { packages } => {
|
||||||
let index = manifest::load(true, track)?;
|
let index = manifest::load(true, track)?;
|
||||||
for pkg in &packages {
|
for pkg in &packages {
|
||||||
cmd_install(&index, pkg, &layout, track, no_hooks, assume_yes, dry_run)?;
|
cmd_install(&index, pkg, &bin_dir, track, no_hooks, assume_yes, dry_run)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Cmd::Remove { package, purge } => cmd_remove(&package, &layout, assume_yes, purge),
|
Cmd::Remove { package, purge } => cmd_remove(&package, &bin_dir, assume_yes, purge),
|
||||||
Cmd::Update { package, all } => cmd_update(
|
Cmd::Update { package, all } => {
|
||||||
package.as_deref(),
|
cmd_update(package.as_deref(), all, &bin_dir, track, no_hooks, assume_yes, dry_run)
|
||||||
all,
|
}
|
||||||
&layout,
|
|
||||||
track,
|
|
||||||
no_hooks,
|
|
||||||
assume_yes,
|
|
||||||
dry_run,
|
|
||||||
),
|
|
||||||
Cmd::List { installed } => cmd_list(installed, track),
|
Cmd::List { installed } => cmd_list(installed, track),
|
||||||
Cmd::Info { package } => cmd_info(&package, track),
|
Cmd::Info { package } => cmd_info(&package, track),
|
||||||
Cmd::Search { query } => cmd_search(&query, track),
|
Cmd::Search { query } => cmd_search(&query, track),
|
||||||
Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &layout),
|
Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &bin_dir),
|
||||||
Cmd::Verify { package } => cmd_verify(package.as_deref(), &layout.bin_dir),
|
Cmd::Verify { package } => cmd_verify(package.as_deref(), &bin_dir),
|
||||||
Cmd::Rollback { package } => cmd_rollback(&package, &layout.bin_dir),
|
Cmd::Rollback { package } => cmd_rollback(&package, &bin_dir),
|
||||||
// Same update logic as `bakery update bakery` — this is just a
|
// Same update logic as `bakery update bakery` — this is just a
|
||||||
// documented, discoverable entry point for it, since overwriting
|
// documented, discoverable entry point for it, since overwriting
|
||||||
// bakery's own running binary via a normal update already works
|
// bakery's own running binary via a normal update already works
|
||||||
// (rename-over-running-binary is safe on Linux) but wasn't a real
|
// (rename-over-running-binary is safe on Linux) but wasn't a real
|
||||||
// first-class command.
|
// first-class command.
|
||||||
Cmd::SelfUpdate => cmd_update(
|
Cmd::SelfUpdate => cmd_update(Some("bakery"), false, &bin_dir, track, no_hooks, assume_yes, dry_run),
|
||||||
Some("bakery"),
|
|
||||||
false,
|
|
||||||
&layout,
|
|
||||||
track,
|
|
||||||
no_hooks,
|
|
||||||
assume_yes,
|
|
||||||
dry_run,
|
|
||||||
),
|
|
||||||
Cmd::Completions { shell } => cmd_completions(shell),
|
Cmd::Completions { shell } => cmd_completions(shell),
|
||||||
Cmd::Track { action } => cmd_track(action),
|
Cmd::Track { action } => cmd_track(action),
|
||||||
}
|
}
|
||||||
|
|
@ -165,11 +159,11 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
|
||||||
let state = state::State::load()?;
|
let state = state::State::load()?;
|
||||||
match action {
|
match action {
|
||||||
TrackCmd::Show => {
|
TrackCmd::Show => {
|
||||||
ui::heading("Track", &[&ui::style(state.track.as_str(), ui::CYAN)]);
|
println!("current track: {}", ui::style(state.track.as_str(), ui::CYAN));
|
||||||
}
|
}
|
||||||
TrackCmd::Set { track } => {
|
TrackCmd::Set { track } => {
|
||||||
if state.track == track {
|
if state.track == track {
|
||||||
println!("{}", ui::unchanged(&format!("already on track {track}")));
|
println!("already on track {track}");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// Fail fast on a bad/unreachable track rather than silently
|
// Fail fast on a bad/unreachable track rather than silently
|
||||||
|
|
@ -180,10 +174,10 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
|
||||||
state.set_track(track);
|
state.set_track(track);
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
ui::action("Switched", track.as_str(), None);
|
println!(
|
||||||
ui::step(
|
"switched to {} — run 'bakery update --all' to install {} builds",
|
||||||
"next",
|
ui::style(track.as_str(), ui::CYAN),
|
||||||
&format!("bakery update --all to install {track} builds"),
|
track
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -194,23 +188,14 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
|
||||||
fn cmd_install(
|
fn cmd_install(
|
||||||
index: &manifest::Index,
|
index: &manifest::Index,
|
||||||
name: &str,
|
name: &str,
|
||||||
layout: &prefix::Layout,
|
bin_dir: &std::path::Path,
|
||||||
track: Track,
|
track: Track,
|
||||||
no_hooks: bool,
|
no_hooks: bool,
|
||||||
assume_yes: bool,
|
assume_yes: bool,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut visited = HashSet::new();
|
let mut visited = HashSet::new();
|
||||||
install_with_deps(
|
install_with_deps(index, name, bin_dir, track, no_hooks, assume_yes, dry_run, &mut visited)
|
||||||
index,
|
|
||||||
name,
|
|
||||||
layout,
|
|
||||||
track,
|
|
||||||
no_hooks,
|
|
||||||
assume_yes,
|
|
||||||
dry_run,
|
|
||||||
&mut visited,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively installs `name` and any bread_deps, skipping already-installed
|
/// Recursively installs `name` and any bread_deps, skipping already-installed
|
||||||
|
|
@ -219,7 +204,7 @@ fn cmd_install(
|
||||||
fn install_with_deps(
|
fn install_with_deps(
|
||||||
index: &manifest::Index,
|
index: &manifest::Index,
|
||||||
name: &str,
|
name: &str,
|
||||||
layout: &prefix::Layout,
|
bin_dir: &std::path::Path,
|
||||||
track: Track,
|
track: Track,
|
||||||
no_hooks: bool,
|
no_hooks: bool,
|
||||||
assume_yes: bool,
|
assume_yes: bool,
|
||||||
|
|
@ -238,10 +223,8 @@ fn install_with_deps(
|
||||||
let state = state::State::load()?;
|
let state = state::State::load()?;
|
||||||
for dep in pkg.bread_deps.clone() {
|
for dep in pkg.bread_deps.clone() {
|
||||||
if !state.is_installed(&dep) {
|
if !state.is_installed(&dep) {
|
||||||
ui::step(if dry_run { "would need" } else { "dependency" }, &dep);
|
println!("{} bread dependency: {dep}", if dry_run { "would install" } else { "installing" });
|
||||||
install_with_deps(
|
install_with_deps(index, &dep, bin_dir, track, no_hooks, assume_yes, dry_run, visited)?;
|
||||||
index, &dep, layout, track, no_hooks, assume_yes, dry_run, visited,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,63 +237,30 @@ fn install_with_deps(
|
||||||
if let Some(installed) = previous {
|
if let Some(installed) = previous {
|
||||||
if !is_newer(&installed.version, &pkg.version) {
|
if !is_newer(&installed.version, &pkg.version) {
|
||||||
println!(
|
println!(
|
||||||
" {}",
|
"{name} already installed at {} (index has {})",
|
||||||
ui::unchanged(&format!(
|
installed.version, pkg.version
|
||||||
"{name} already at {} (index has {})",
|
|
||||||
installed.version, pkg.version
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ui::action(
|
println!("checking system dependencies for {name}…");
|
||||||
if dry_run {
|
|
||||||
if previous.is_some() {
|
|
||||||
"Would update"
|
|
||||||
} else {
|
|
||||||
"Would install"
|
|
||||||
}
|
|
||||||
} else if previous.is_some() {
|
|
||||||
"Updating"
|
|
||||||
} else {
|
|
||||||
"Installing"
|
|
||||||
},
|
|
||||||
name,
|
|
||||||
Some(&pkg.version),
|
|
||||||
);
|
|
||||||
ui::step("checking", "system dependencies");
|
|
||||||
let rep = doctor::check_deps(&pkg.system_deps, &pkg.optional_system_deps)?;
|
let rep = doctor::check_deps(&pkg.system_deps, &pkg.optional_system_deps)?;
|
||||||
for warn in &rep.warnings {
|
for warn in &rep.warnings {
|
||||||
eprintln!(
|
eprintln!(" note: optional dep not installed: {warn}");
|
||||||
" {}",
|
|
||||||
ui::note(&format!("optional dep not installed: {warn}"))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if !rep.missing.is_empty() {
|
if !rep.missing.is_empty() {
|
||||||
eprintln!(
|
eprintln!("missing system deps for {name}: {}", rep.missing.join(", "));
|
||||||
" {}",
|
eprintln!("install with: {}", doctor::install_hint(&rep.missing));
|
||||||
ui::fail(&format!(
|
|
||||||
"missing system deps for {name}: {}",
|
|
||||||
rep.missing.join(", ")
|
|
||||||
))
|
|
||||||
);
|
|
||||||
eprintln!(
|
|
||||||
" {}",
|
|
||||||
ui::dim(&format!(
|
|
||||||
"install with: {}",
|
|
||||||
doctor::install_hint(&rep.missing)
|
|
||||||
))
|
|
||||||
);
|
|
||||||
bail!("system deps not satisfied");
|
bail!("system deps not satisfied");
|
||||||
}
|
}
|
||||||
|
|
||||||
if dry_run {
|
if dry_run {
|
||||||
print_dry_run_plan(pkg);
|
print_dry_run_plan(pkg, previous);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
install::install_package(pkg, layout, track, previous, no_hooks, assume_yes)
|
install::install_package(pkg, bin_dir, track, previous, no_hooks, assume_yes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prints what `install_with_deps`/`cmd_update` would do for `pkg` under
|
/// Prints what `install_with_deps`/`cmd_update` would do for `pkg` under
|
||||||
|
|
@ -318,36 +268,35 @@ fn install_with_deps(
|
||||||
/// already been made — this only renders that decision, it never
|
/// already been made — this only renders that decision, it never
|
||||||
/// recomputes it, so dry-run and real runs can't drift apart on "would this
|
/// recomputes it, so dry-run and real runs can't drift apart on "would this
|
||||||
/// update happen at all".
|
/// update happen at all".
|
||||||
fn print_dry_run_plan(pkg: &manifest::Package) {
|
fn print_dry_run_plan(pkg: &manifest::Package, previous: Option<&state::InstalledPackage>) {
|
||||||
ui::kv(
|
let verb = if previous.is_some() { "update" } else { "install" };
|
||||||
"binaries",
|
println!(
|
||||||
&pkg.binaries
|
" {} would {verb} {} to {}",
|
||||||
.iter()
|
ui::style("dry-run:", ui::DIM),
|
||||||
.map(|b| b.name.as_str())
|
pkg.name,
|
||||||
.collect::<Vec<_>>()
|
ui::style(&pkg.version, ui::BOLD)
|
||||||
.join(", "),
|
);
|
||||||
|
println!(
|
||||||
|
" binaries: {}",
|
||||||
|
pkg.binaries.iter().map(|b| b.name.as_str()).collect::<Vec<_>>().join(", ")
|
||||||
);
|
);
|
||||||
if !pkg.services.is_empty() {
|
if !pkg.services.is_empty() {
|
||||||
ui::kv(
|
println!(
|
||||||
"services",
|
" services: {}",
|
||||||
&pkg.services
|
pkg.services.iter().map(|s| s.unit.as_str()).collect::<Vec<_>>().join(", ")
|
||||||
.iter()
|
|
||||||
.map(|s| s.unit.as_str())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", "),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_remove(name: &str, layout: &prefix::Layout, assume_yes: bool, purge: bool) -> Result<()> {
|
fn cmd_remove(name: &str, bin_dir: &std::path::Path, assume_yes: bool, purge: bool) -> Result<()> {
|
||||||
install::remove_package(name, layout, assume_yes, purge)
|
install::remove_package(name, bin_dir, assume_yes, purge)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn cmd_update(
|
fn cmd_update(
|
||||||
name: Option<&str>,
|
name: Option<&str>,
|
||||||
all: bool,
|
all: bool,
|
||||||
layout: &prefix::Layout,
|
bin_dir: &std::path::Path,
|
||||||
track: Track,
|
track: Track,
|
||||||
no_hooks: bool,
|
no_hooks: bool,
|
||||||
assume_yes: bool,
|
assume_yes: bool,
|
||||||
|
|
@ -362,16 +311,10 @@ fn cmd_update(
|
||||||
};
|
};
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
println!("{}", ui::dim("no packages installed"));
|
println!("no packages installed");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut targets = targets;
|
|
||||||
targets.sort();
|
|
||||||
if all {
|
|
||||||
ui::heading("Update", &[&format!("{} packages", targets.len())]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut any_failed = false;
|
let mut any_failed = false;
|
||||||
let mut updated = 0u32;
|
let mut updated = 0u32;
|
||||||
let mut unchanged = 0u32;
|
let mut unchanged = 0u32;
|
||||||
|
|
@ -379,10 +322,7 @@ fn cmd_update(
|
||||||
let installed = match state.packages.get(pkg_name.as_str()) {
|
let installed = match state.packages.get(pkg_name.as_str()) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
eprintln!(
|
eprintln!("{pkg_name} is not installed, skipping");
|
||||||
" {}",
|
|
||||||
ui::fail(&format!("{pkg_name} is not installed, skipping"))
|
|
||||||
);
|
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -390,10 +330,7 @@ fn cmd_update(
|
||||||
let latest = match index.get(pkg_name) {
|
let latest = match index.get(pkg_name) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
eprintln!(
|
eprintln!("{pkg_name} not found in index, skipping");
|
||||||
" {}",
|
|
||||||
ui::fail(&format!("{pkg_name} not found in index, skipping"))
|
|
||||||
);
|
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -413,81 +350,57 @@ fn cmd_update(
|
||||||
// under a terminal palette that maps ANSI colors unusually.
|
// under a terminal palette that maps ANSI colors unusually.
|
||||||
println!(
|
println!(
|
||||||
" {}",
|
" {}",
|
||||||
ui::unchanged(&format!("{pkg_name} already at {}", installed.version))
|
ui::unchanged(&format!("{pkg_name} is already at {}", installed.version))
|
||||||
);
|
);
|
||||||
unchanged += 1;
|
unchanged += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui::action(
|
|
||||||
if dry_run { "Would update" } else { "Updating" },
|
|
||||||
pkg_name,
|
|
||||||
Some(&latest.version),
|
|
||||||
);
|
|
||||||
if track_switch {
|
if track_switch {
|
||||||
ui::step(
|
println!(
|
||||||
"track",
|
"{pkg_name} switching track {} {} {}, installing {}",
|
||||||
&format!(
|
ui::style(installed.track.as_str(), ui::DIM),
|
||||||
"{} {} {}",
|
ui::style("→", ui::CYAN),
|
||||||
ui::dim(installed.track.as_str()),
|
ui::style(track.as_str(), ui::BOLD),
|
||||||
ui::style("→", ui::CYAN),
|
ui::style(&latest.version, ui::BOLD)
|
||||||
ui::bold(track.as_str()),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ui::step(
|
println!(
|
||||||
"version",
|
"updating {pkg_name} {} {} {}",
|
||||||
&format!(
|
ui::style(&installed.version, ui::DIM),
|
||||||
"{} {} {}",
|
ui::style("→", ui::CYAN),
|
||||||
ui::dim(&installed.version),
|
ui::style(&latest.version, ui::BOLD)
|
||||||
ui::style("→", ui::CYAN),
|
|
||||||
ui::bold(&latest.version)
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
|
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
eprintln!(" doctor check failed for {pkg_name}: {e}");
|
||||||
" {}",
|
|
||||||
ui::fail(&format!("doctor check failed for {pkg_name}: {e}"))
|
|
||||||
);
|
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
for warn in &rep.warnings {
|
for warn in &rep.warnings {
|
||||||
eprintln!(
|
eprintln!(" note: optional dep not installed: {warn}");
|
||||||
" {}",
|
|
||||||
ui::note(&format!("optional dep not installed: {warn}"))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if !rep.missing.is_empty() {
|
if !rep.missing.is_empty() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}",
|
" missing deps for {pkg_name}: {} — skipping update",
|
||||||
ui::fail(&format!(
|
rep.missing.join(", ")
|
||||||
"missing deps for {pkg_name}: {} — skipping update",
|
|
||||||
rep.missing.join(", ")
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if dry_run {
|
if dry_run {
|
||||||
print_dry_run_plan(latest);
|
print_dry_run_plan(latest, Some(installed));
|
||||||
updated += 1;
|
updated += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) =
|
if let Err(e) = install::install_package(latest, bin_dir, track, Some(installed), no_hooks, assume_yes) {
|
||||||
install::install_package(latest, layout, track, Some(installed), no_hooks, assume_yes)
|
eprintln!(" failed to update {pkg_name}: {e}");
|
||||||
{
|
|
||||||
eprintln!(
|
|
||||||
" {}",
|
|
||||||
ui::fail(&format!("failed to update {pkg_name}: {e}"))
|
|
||||||
);
|
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
} else {
|
} else {
|
||||||
updated += 1;
|
updated += 1;
|
||||||
|
|
@ -497,14 +410,12 @@ fn cmd_update(
|
||||||
// Only for --all: a single named update already makes its own outcome
|
// Only for --all: a single named update already makes its own outcome
|
||||||
// obvious, and "1 updated, 0 already up to date" isn't a useful takeaway.
|
// obvious, and "1 updated, 0 already up to date" isn't a useful takeaway.
|
||||||
if all {
|
if all {
|
||||||
let updated_s = format!("{updated} updated");
|
let mut parts = Vec::new();
|
||||||
let unchanged_s = format!("{unchanged} already current");
|
|
||||||
let mut parts: Vec<&str> = Vec::new();
|
|
||||||
if updated > 0 {
|
if updated > 0 {
|
||||||
parts.push(&updated_s);
|
parts.push(format!("{updated} updated"));
|
||||||
}
|
}
|
||||||
parts.push(&unchanged_s);
|
parts.push(format!("{unchanged} already up to date"));
|
||||||
ui::summary(&parts);
|
println!("{}", ui::style(&parts.join(", "), ui::BOLD));
|
||||||
}
|
}
|
||||||
|
|
||||||
if any_failed {
|
if any_failed {
|
||||||
|
|
@ -516,12 +427,7 @@ fn cmd_update(
|
||||||
/// Whether `pkg_name` should be updated: always true on a track switch
|
/// Whether `pkg_name` should be updated: always true on a track switch
|
||||||
/// (an explicit user action that must take effect regardless of version
|
/// (an explicit user action that must take effect regardless of version
|
||||||
/// ordering), otherwise a real semver comparison via [`is_newer`].
|
/// ordering), otherwise a real semver comparison via [`is_newer`].
|
||||||
fn should_update(
|
fn should_update(installed_version: &str, installed_track: Track, active_track: Track, latest_version: &str) -> bool {
|
||||||
installed_version: &str,
|
|
||||||
installed_track: Track,
|
|
||||||
active_track: Track,
|
|
||||||
latest_version: &str,
|
|
||||||
) -> bool {
|
|
||||||
if installed_track != active_track {
|
if installed_track != active_track {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -535,10 +441,7 @@ fn should_update(
|
||||||
/// (with a warning) for any version string that isn't valid semver, rather
|
/// (with a warning) for any version string that isn't valid semver, rather
|
||||||
/// than hard-erroring on packages built before this convention existed.
|
/// than hard-erroring on packages built before this convention existed.
|
||||||
fn is_newer(installed: &str, latest: &str) -> bool {
|
fn is_newer(installed: &str, latest: &str) -> bool {
|
||||||
match (
|
match (semver::Version::parse(installed), semver::Version::parse(latest)) {
|
||||||
semver::Version::parse(installed),
|
|
||||||
semver::Version::parse(latest),
|
|
||||||
) {
|
|
||||||
(Ok(i), Ok(l)) => l > i,
|
(Ok(i), Ok(l)) => l > i,
|
||||||
_ => {
|
_ => {
|
||||||
if installed != latest {
|
if installed != latest {
|
||||||
|
|
@ -552,14 +455,15 @@ fn is_newer(installed: &str, latest: &str) -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn catalog_row(pkg: &manifest::Package, state: &state::State) -> ui::CatalogRow {
|
/// Prints one index entry in the shared `list`/`search` format: name,
|
||||||
ui::CatalogRow {
|
/// version, description, and an `[installed <version>]` tag when applicable.
|
||||||
name: pkg.name.clone(),
|
fn print_index_entry(pkg: &manifest::Package, state: &state::State) {
|
||||||
version: pkg.version.clone(),
|
let tag = if state.is_installed(&pkg.name) {
|
||||||
installed: state.is_installed(&pkg.name),
|
ui::style(&format!(" [installed {}]", state.packages[&pkg.name].version), ui::GREEN)
|
||||||
detail: pkg.description.clone(),
|
} else {
|
||||||
aside: String::new(),
|
String::new()
|
||||||
}
|
};
|
||||||
|
println!(" {:<14} {:<10} — {}{}", pkg.name, pkg.version, pkg.description, tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
|
fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
|
||||||
|
|
@ -567,44 +471,24 @@ fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
|
||||||
|
|
||||||
if installed_only {
|
if installed_only {
|
||||||
if state.packages.is_empty() {
|
if state.packages.is_empty() {
|
||||||
println!("{}", ui::dim("no packages installed"));
|
println!("no packages installed");
|
||||||
return Ok(());
|
}
|
||||||
|
for pkg in state.packages.values() {
|
||||||
|
println!(" {} {} (installed {})", pkg.name, pkg.version, pkg.installed_at);
|
||||||
}
|
}
|
||||||
let mut pkgs: Vec<_> = state.packages.values().collect();
|
|
||||||
pkgs.sort_by(|a, b| a.name.cmp(&b.name));
|
|
||||||
ui::heading("Installed", &[&pkgs.len().to_string()]);
|
|
||||||
let rows: Vec<ui::CatalogRow> = pkgs
|
|
||||||
.iter()
|
|
||||||
.map(|pkg| ui::CatalogRow {
|
|
||||||
name: pkg.name.clone(),
|
|
||||||
version: pkg.version.clone(),
|
|
||||||
installed: true,
|
|
||||||
detail: String::new(),
|
|
||||||
aside: ui::short_date(&pkg.installed_at),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
ui::print_catalog(&rows);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !matches!(track, Track::Stable) {
|
||||||
|
println!("tracking:{}\n", ui::track_badge(track));
|
||||||
|
}
|
||||||
|
|
||||||
let index = manifest::load(false, track)?;
|
let index = manifest::load(false, track)?;
|
||||||
let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect();
|
let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect();
|
||||||
names.sort();
|
names.sort();
|
||||||
let installed = names.iter().filter(|n| state.is_installed(n)).count();
|
for name in names {
|
||||||
let track_part = ui::track_tag(track);
|
print_index_entry(&index.packages[name], &state);
|
||||||
ui::heading(
|
}
|
||||||
"Packages",
|
|
||||||
&[
|
|
||||||
&format!("{} in index", names.len()),
|
|
||||||
&format!("{installed} installed"),
|
|
||||||
&track_part,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let rows: Vec<ui::CatalogRow> = names
|
|
||||||
.iter()
|
|
||||||
.map(|name| catalog_row(&index.packages[*name], &state))
|
|
||||||
.collect();
|
|
||||||
ui::print_catalog(&rows);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -629,19 +513,13 @@ fn cmd_search(query: &str, track: Track) -> Result<()> {
|
||||||
names.sort();
|
names.sort();
|
||||||
|
|
||||||
if names.is_empty() {
|
if names.is_empty() {
|
||||||
println!("{}", ui::dim(&format!("no packages matched '{query}'")));
|
println!("no packages matched '{query}'");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
ui::heading(
|
for name in names {
|
||||||
"Search",
|
print_index_entry(&index.packages[name], &state);
|
||||||
&[&format!("'{query}'"), &format!("{} matches", names.len())],
|
}
|
||||||
);
|
|
||||||
let rows: Vec<ui::CatalogRow> = names
|
|
||||||
.iter()
|
|
||||||
.map(|name| catalog_row(&index.packages[*name], &state))
|
|
||||||
.collect();
|
|
||||||
ui::print_catalog(&rows);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -653,60 +531,45 @@ fn cmd_info(name: &str, track: Track) -> Result<()> {
|
||||||
|
|
||||||
let state = state::State::load()?;
|
let state = state::State::load()?;
|
||||||
let status = if let Some(inst) = state.packages.get(name) {
|
let status = if let Some(inst) = state.packages.get(name) {
|
||||||
ui::style(&format!("installed {}", inst.version), ui::GREEN)
|
ui::style(&format!("installed ({})", inst.version), ui::GREEN)
|
||||||
} else {
|
} else {
|
||||||
ui::style("not installed", ui::DIM)
|
ui::style("not installed", ui::DIM)
|
||||||
};
|
};
|
||||||
|
|
||||||
let track_part = ui::track_tag(track);
|
println!("{}{} {}", ui::style(&pkg.name, ui::BOLD), ui::track_badge(track), pkg.version);
|
||||||
ui::heading(&pkg.name, &[&ui::dim(&pkg.version), &track_part]);
|
println!(" {}", pkg.description);
|
||||||
println!(" {}\n", pkg.description);
|
println!(" status: {status}");
|
||||||
ui::kv("status", &status);
|
println!(
|
||||||
ui::kv(
|
" binaries: {}",
|
||||||
"binaries",
|
pkg.binaries
|
||||||
&pkg.binaries
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|b| b.name.as_str())
|
.map(|b| b.name.as_str())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", "),
|
.join(", ")
|
||||||
);
|
);
|
||||||
if !pkg.system_deps.is_empty() {
|
if !pkg.system_deps.is_empty() {
|
||||||
ui::kv("system deps", &pkg.system_deps.join(", "));
|
println!(" system deps: {}", pkg.system_deps.join(", "));
|
||||||
}
|
}
|
||||||
if !pkg.optional_system_deps.is_empty() {
|
if !pkg.optional_system_deps.is_empty() {
|
||||||
ui::kv("optional", &pkg.optional_system_deps.join(", "));
|
println!(" optional deps: {}", pkg.optional_system_deps.join(", "));
|
||||||
}
|
}
|
||||||
if !pkg.bread_deps.is_empty() {
|
if !pkg.bread_deps.is_empty() {
|
||||||
ui::kv("bread deps", &pkg.bread_deps.join(", "));
|
println!(" bread deps: {}", pkg.bread_deps.join(", "));
|
||||||
}
|
}
|
||||||
if !pkg.services.is_empty() {
|
if !pkg.services.is_empty() {
|
||||||
ui::kv(
|
println!(
|
||||||
"services",
|
" services: {}",
|
||||||
&pkg.services
|
pkg.services
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.unit.as_str())
|
.map(|s| s.unit.as_str())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", "),
|
.join(", ")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(inst) = state.packages.get(name) {
|
|
||||||
ui::kv("installed", &ui::short_date(&inst.installed_at));
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn report_layout(layout: &prefix::Layout) {
|
fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
ui::kv(
|
|
||||||
"prefix",
|
|
||||||
&format!("{} ({})", layout.prefix.display(), layout.kind_label()),
|
|
||||||
);
|
|
||||||
ui::kv("bins", &layout.bin_dir.display().to_string());
|
|
||||||
ui::kv("share", &layout.share_dir.display().to_string());
|
|
||||||
ui::kv("units", &layout.systemd_user_dir.display().to_string());
|
|
||||||
ui::kv("state", &state::bakery_state_dir().display().to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_doctor(name: Option<&str>, track: Track, layout: &prefix::Layout) -> Result<()> {
|
|
||||||
let index = manifest::load(false, track)?;
|
let index = manifest::load(false, track)?;
|
||||||
let state = state::State::load()?;
|
let state = state::State::load()?;
|
||||||
|
|
||||||
|
|
@ -721,38 +584,18 @@ fn cmd_doctor(name: Option<&str>, track: Track, layout: &prefix::Layout) -> Resu
|
||||||
};
|
};
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
ui::heading("Doctor", &["no packages"]);
|
println!("no packages installed — nothing to check");
|
||||||
report_layout(layout);
|
|
||||||
println!();
|
|
||||||
println!("{}", ui::dim("no packages installed — nothing to check"));
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut targets = targets;
|
|
||||||
targets.sort();
|
|
||||||
ui::heading("Doctor", &[&format!("{} packages", targets.len())]);
|
|
||||||
report_layout(layout);
|
|
||||||
println!();
|
|
||||||
let name_w = ui::name_width(&targets);
|
|
||||||
|
|
||||||
let mut all_ok = true;
|
let mut all_ok = true;
|
||||||
for pkg_name in &targets {
|
for pkg_name in &targets {
|
||||||
if let Some(pkg) = index.get(pkg_name) {
|
if let Some(pkg) = index.get(pkg_name) {
|
||||||
if !doctor::report(
|
if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) {
|
||||||
pkg_name,
|
|
||||||
&pkg.system_deps,
|
|
||||||
&pkg.optional_system_deps,
|
|
||||||
name_w,
|
|
||||||
) {
|
|
||||||
all_ok = false;
|
all_ok = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ui::check_row(
|
eprintln!(" {pkg_name}: not found in index (removed from registry?)");
|
||||||
false,
|
|
||||||
pkg_name,
|
|
||||||
name_w,
|
|
||||||
"not found in index (removed from registry?)",
|
|
||||||
);
|
|
||||||
all_ok = false;
|
all_ok = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -762,7 +605,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, layout: &prefix::Layout) -> Resu
|
||||||
// only, not a checksum re-verification — see `bakery verify` for that.
|
// only, not a checksum re-verification — see `bakery verify` for that.
|
||||||
if let Some(installed) = state.packages.get(pkg_name) {
|
if let Some(installed) = state.packages.get(pkg_name) {
|
||||||
for bin in &installed.binaries {
|
for bin in &installed.binaries {
|
||||||
let path = layout.bin_dir.join(bin);
|
let path = bin_dir.join(bin);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}",
|
" {}",
|
||||||
|
|
@ -778,7 +621,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, layout: &prefix::Layout) -> Resu
|
||||||
}
|
}
|
||||||
|
|
||||||
if all_ok {
|
if all_ok {
|
||||||
ui::summary(&[&ui::ok("all checks passed")]);
|
println!("{}", ui::ok("all checks passed"));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -831,47 +674,38 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
};
|
};
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
println!("{}", ui::dim("no packages installed — nothing to verify"));
|
println!("no packages installed — nothing to verify");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut targets = targets;
|
|
||||||
targets.sort();
|
|
||||||
ui::heading("Verify", &[&format!("{} packages", targets.len())]);
|
|
||||||
let name_w = ui::name_width(&targets);
|
|
||||||
|
|
||||||
let mut any_bad = false;
|
let mut any_bad = false;
|
||||||
for pkg_name in &targets {
|
for pkg_name in &targets {
|
||||||
let installed = &state.packages[pkg_name];
|
let installed = &state.packages[pkg_name];
|
||||||
if installed.binary_sha256.is_empty() {
|
if installed.binary_sha256.is_empty() {
|
||||||
ui::unknown_row(
|
println!(
|
||||||
pkg_name,
|
" {} {pkg_name}: no recorded checksums (installed before 'bakery verify' support)",
|
||||||
name_w,
|
ui::style("?", ui::DIM)
|
||||||
"no recorded checksums (installed before verify support)",
|
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for bin in &installed.binaries {
|
for bin in &installed.binaries {
|
||||||
match verify_binary(bin_dir, bin, installed.binary_sha256.get(bin)) {
|
match verify_binary(bin_dir, bin, installed.binary_sha256.get(bin)) {
|
||||||
VerifyStatus::Ok => ui::check_row(true, pkg_name, name_w, bin),
|
VerifyStatus::Ok => println!(" {}", ui::ok(&format!("{pkg_name}: {bin}"))),
|
||||||
VerifyStatus::Missing => {
|
VerifyStatus::Missing => {
|
||||||
ui::check_row(false, pkg_name, name_w, &format!("{bin} missing"));
|
eprintln!(" {}", ui::fail(&format!("{pkg_name}: {bin} — MISSING")));
|
||||||
any_bad = true;
|
any_bad = true;
|
||||||
}
|
}
|
||||||
VerifyStatus::Tampered => {
|
VerifyStatus::Tampered => {
|
||||||
ui::check_row(
|
eprintln!(
|
||||||
false,
|
" {}",
|
||||||
pkg_name,
|
ui::fail(&format!("{pkg_name}: {bin} — TAMPERED (checksum mismatch)"))
|
||||||
name_w,
|
|
||||||
&format!("{bin} tampered (checksum mismatch)"),
|
|
||||||
);
|
);
|
||||||
any_bad = true;
|
any_bad = true;
|
||||||
}
|
}
|
||||||
VerifyStatus::Unknown => {
|
VerifyStatus::Unknown => {
|
||||||
ui::unknown_row(
|
println!(
|
||||||
pkg_name,
|
" {} {pkg_name}: {bin} — UNKNOWN (no recorded checksum for this binary)",
|
||||||
name_w,
|
ui::style("?", ui::DIM)
|
||||||
&format!("{bin} no recorded checksum for this binary"),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -881,7 +715,7 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
if any_bad {
|
if any_bad {
|
||||||
bail!("verification failed for one or more binaries");
|
bail!("verification failed for one or more binaries");
|
||||||
}
|
}
|
||||||
ui::summary(&[&ui::ok("all recorded checksums match")]);
|
println!("{}", ui::ok("all recorded checksums match"));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -894,25 +728,18 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
/// why rollback is backup-based rather than a network re-pin in the first
|
/// why rollback is backup-based rather than a network re-pin in the first
|
||||||
/// place. Pure with respect to global state (caller supplies both dirs), so
|
/// place. Pure with respect to global state (caller supplies both dirs), so
|
||||||
/// this is the piece of `bakery rollback` that's directly unit-testable.
|
/// this is the piece of `bakery rollback` that's directly unit-testable.
|
||||||
fn restore_binaries(
|
fn restore_binaries(backup_dir: &Path, binaries: &[String], bin_dir: &Path) -> Result<HashMap<String, String>> {
|
||||||
backup_dir: &Path,
|
|
||||||
binaries: &[String],
|
|
||||||
bin_dir: &Path,
|
|
||||||
) -> Result<HashMap<String, String>> {
|
|
||||||
let mut sha256 = HashMap::new();
|
let mut sha256 = HashMap::new();
|
||||||
for bin in binaries {
|
for bin in binaries {
|
||||||
let backup_path = backup_dir.join(bin);
|
let backup_path = backup_dir.join(bin);
|
||||||
if !backup_path.exists() {
|
if !backup_path.exists() {
|
||||||
bail!(
|
bail!("backup for binary '{bin}' is missing at {}", backup_path.display());
|
||||||
"backup for binary '{bin}' is missing at {}",
|
|
||||||
backup_path.display()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let bytes = std::fs::read(&backup_path)
|
let bytes = std::fs::read(&backup_path)
|
||||||
.with_context(|| format!("reading backup {}", backup_path.display()))?;
|
.with_context(|| format!("reading backup {}", backup_path.display()))?;
|
||||||
let hash = hex::encode(Sha256::digest(&bytes));
|
let hash = hex::encode(Sha256::digest(&bytes));
|
||||||
let dest = bin_dir.join(bin);
|
let dest = bin_dir.join(bin);
|
||||||
prefix::write_bytes(&dest, &bytes, 0o755)
|
bread_utils::atomic::write_atomic_bytes(&dest, &bytes, Some(0o755))
|
||||||
.with_context(|| format!("restoring {}", dest.display()))?;
|
.with_context(|| format!("restoring {}", dest.display()))?;
|
||||||
sha256.insert(bin.clone(), hash);
|
sha256.insert(bin.clone(), hash);
|
||||||
}
|
}
|
||||||
|
|
@ -940,8 +767,6 @@ fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
anyhow::anyhow!("no previous version recorded for {pkg_name} — nothing to roll back to")
|
anyhow::anyhow!("no previous version recorded for {pkg_name} — nothing to roll back to")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
ui::action("Rolling back", pkg_name, Some(&target_version));
|
|
||||||
|
|
||||||
let backup_dir = state::backup_dir(pkg_name, &target_version);
|
let backup_dir = state::backup_dir(pkg_name, &target_version);
|
||||||
if !backup_dir.exists() {
|
if !backup_dir.exists() {
|
||||||
bail!(
|
bail!(
|
||||||
|
|
@ -969,10 +794,7 @@ fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {}",
|
" {}",
|
||||||
ui::ok(&format!(
|
ui::ok(&format!("rolled back {pkg_name} {from_version} → {target_version}"))
|
||||||
"rolled back {pkg_name} {} → {target_version}",
|
|
||||||
ui::dim(&from_version)
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -1016,33 +838,18 @@ mod tests {
|
||||||
fn should_update_true_on_track_switch_even_if_not_newer_by_semver() {
|
fn should_update_true_on_track_switch_even_if_not_newer_by_semver() {
|
||||||
// "bakery track set stable && bakery update --all" from beta must
|
// "bakery track set stable && bakery update --all" from beta must
|
||||||
// always take effect, even though 0.3.0 < 0.4.0-beta by strict semver.
|
// always take effect, even though 0.3.0 < 0.4.0-beta by strict semver.
|
||||||
assert!(should_update(
|
assert!(should_update("0.4.0-beta", Track::Beta, Track::Stable, "0.3.0"));
|
||||||
"0.4.0-beta",
|
|
||||||
Track::Beta,
|
|
||||||
Track::Stable,
|
|
||||||
"0.3.0"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_update_false_when_same_track_and_not_newer() {
|
fn should_update_false_when_same_track_and_not_newer() {
|
||||||
assert!(!should_update(
|
assert!(!should_update("0.3.1", Track::Stable, Track::Stable, "0.3.1"));
|
||||||
"0.3.1",
|
|
||||||
Track::Stable,
|
|
||||||
Track::Stable,
|
|
||||||
"0.3.1"
|
|
||||||
));
|
|
||||||
assert!(!should_update("0.3.2", Track::Dev, Track::Dev, "0.3.1"));
|
assert!(!should_update("0.3.2", Track::Dev, Track::Dev, "0.3.1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_update_true_when_same_track_and_newer() {
|
fn should_update_true_when_same_track_and_newer() {
|
||||||
assert!(should_update(
|
assert!(should_update("0.3.1", Track::Stable, Track::Stable, "0.3.2"));
|
||||||
"0.3.1",
|
|
||||||
Track::Stable,
|
|
||||||
Track::Stable,
|
|
||||||
"0.3.2"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1095,18 +902,14 @@ mod tests {
|
||||||
let pkg = empty_binary_package(name, "9.9.9", "http://127.0.0.1:1/unreachable");
|
let pkg = empty_binary_package(name, "9.9.9", "http://127.0.0.1:1/unreachable");
|
||||||
let mut packages = std::collections::HashMap::new();
|
let mut packages = std::collections::HashMap::new();
|
||||||
packages.insert(name.to_string(), pkg);
|
packages.insert(name.to_string(), pkg);
|
||||||
let index = manifest::Index {
|
let index = manifest::Index { version: "1".to_string(), packages };
|
||||||
version: "1".to_string(),
|
|
||||||
packages,
|
|
||||||
};
|
|
||||||
|
|
||||||
let bin_dir = tempdir().unwrap();
|
let bin_dir = tempdir().unwrap();
|
||||||
let layout = prefix::Layout::from_prefix(bin_dir.path(), None);
|
|
||||||
let mut visited = HashSet::new();
|
let mut visited = HashSet::new();
|
||||||
install_with_deps(
|
install_with_deps(
|
||||||
&index,
|
&index,
|
||||||
name,
|
name,
|
||||||
&layout,
|
bin_dir.path(),
|
||||||
Track::Stable,
|
Track::Stable,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
|
@ -1123,10 +926,7 @@ mod tests {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
fs::write(dir.path().join("mypkg"), b"good bytes").unwrap();
|
fs::write(dir.path().join("mypkg"), b"good bytes").unwrap();
|
||||||
let hash = hex::encode(Sha256::digest(b"good bytes"));
|
let hash = hex::encode(Sha256::digest(b"good bytes"));
|
||||||
assert_eq!(
|
assert_eq!(verify_binary(dir.path(), "mypkg", Some(&hash)), VerifyStatus::Ok);
|
||||||
verify_binary(dir.path(), "mypkg", Some(&hash)),
|
|
||||||
VerifyStatus::Ok
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1134,30 +934,21 @@ mod tests {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
fs::write(dir.path().join("mypkg"), b"tampered bytes").unwrap();
|
fs::write(dir.path().join("mypkg"), b"tampered bytes").unwrap();
|
||||||
let wrong_hash = "0".repeat(64);
|
let wrong_hash = "0".repeat(64);
|
||||||
assert_eq!(
|
assert_eq!(verify_binary(dir.path(), "mypkg", Some(&wrong_hash)), VerifyStatus::Tampered);
|
||||||
verify_binary(dir.path(), "mypkg", Some(&wrong_hash)),
|
|
||||||
VerifyStatus::Tampered
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn verify_binary_missing_when_file_absent() {
|
fn verify_binary_missing_when_file_absent() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let hash = "0".repeat(64);
|
let hash = "0".repeat(64);
|
||||||
assert_eq!(
|
assert_eq!(verify_binary(dir.path(), "nope", Some(&hash)), VerifyStatus::Missing);
|
||||||
verify_binary(dir.path(), "nope", Some(&hash)),
|
|
||||||
VerifyStatus::Missing
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn verify_binary_unknown_when_no_recorded_hash() {
|
fn verify_binary_unknown_when_no_recorded_hash() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
fs::write(dir.path().join("mypkg"), b"bytes").unwrap();
|
fs::write(dir.path().join("mypkg"), b"bytes").unwrap();
|
||||||
assert_eq!(
|
assert_eq!(verify_binary(dir.path(), "mypkg", None), VerifyStatus::Unknown);
|
||||||
verify_binary(dir.path(), "mypkg", None),
|
|
||||||
VerifyStatus::Unknown
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1171,14 +962,8 @@ mod tests {
|
||||||
|
|
||||||
let hashes = restore_binaries(&backup_dir, &["mypkg".to_string()], &bin_dir).unwrap();
|
let hashes = restore_binaries(&backup_dir, &["mypkg".to_string()], &bin_dir).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(fs::read(bin_dir.join("mypkg")).unwrap(), b"old version bytes");
|
||||||
fs::read(bin_dir.join("mypkg")).unwrap(),
|
assert_eq!(hashes["mypkg"], hex::encode(Sha256::digest(b"old version bytes")));
|
||||||
b"old version bytes"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
hashes["mypkg"],
|
|
||||||
hex::encode(Sha256::digest(b"old version bytes"))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> {
|
||||||
/// exercise the verification logic with a throwaway keypair instead of the
|
/// exercise the verification logic with a throwaway keypair instead of the
|
||||||
/// real production key.
|
/// real production key.
|
||||||
fn verify_against_key(bytes: &[u8], sig_text: &str, pubkey_b64: &str) -> Result<()> {
|
fn verify_against_key(bytes: &[u8], sig_text: &str, pubkey_b64: &str) -> Result<()> {
|
||||||
let public_key = PublicKey::from_base64(pubkey_b64).context("public key is malformed")?;
|
let public_key =
|
||||||
|
PublicKey::from_base64(pubkey_b64).context("public key is malformed")?;
|
||||||
let signature =
|
let signature =
|
||||||
Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?;
|
Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?;
|
||||||
public_key
|
public_key
|
||||||
|
|
@ -107,21 +108,21 @@ pub struct Package {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub post_install: Vec<String>,
|
pub post_install: Vec<String>,
|
||||||
/// License artifact filename (e.g. "LICENSE"), installed to
|
/// License artifact filename (e.g. "LICENSE"), installed to
|
||||||
/// `$prefix/share/licenses/<name>/LICENSE` (`~/.local/share/...` by
|
/// `~/.local/share/licenses/<name>/LICENSE` — the bakery equivalent of
|
||||||
/// default) — the bakery equivalent of a PKGBUILD's `package()` step.
|
/// what a PKGBUILD's `package()` does with `/usr/share/licenses`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub license_file: Option<String>,
|
pub license_file: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub license_file_sha256: Option<String>,
|
pub license_file_sha256: Option<String>,
|
||||||
/// Desktop entry artifact filename (e.g. "breadhelp.desktop"),
|
/// Desktop entry artifact filename (e.g. "breadhelp.desktop"),
|
||||||
/// installed to `$prefix/share/applications/<name>.desktop` so the
|
/// installed to `~/.local/share/applications/<name>.desktop` so the
|
||||||
/// app shows up in any XDG-compliant launcher.
|
/// app shows up in any XDG-compliant launcher without root.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub desktop_file: Option<String>,
|
pub desktop_file: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub desktop_file_sha256: Option<String>,
|
pub desktop_file_sha256: Option<String>,
|
||||||
/// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz`
|
/// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz`
|
||||||
/// in the release dir, extracted to `$prefix/share/<name>/` on
|
/// in the release dir, extracted to `~/.local/share/<name>/` on
|
||||||
/// install. For arbitrary data a package needs at runtime beyond a
|
/// install. For arbitrary data a package needs at runtime beyond a
|
||||||
/// config example (e.g. breadhelp's guide content), where a single
|
/// config example (e.g. breadhelp's guide content), where a single
|
||||||
/// downloadable file + `tar` extraction is simpler than teaching
|
/// downloadable file + `tar` extraction is simpler than teaching
|
||||||
|
|
@ -182,7 +183,9 @@ pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
|
||||||
match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
|
match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
|
||||||
Ok(index) => return Ok(index),
|
Ok(index) => return Ok(index),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!(" warning: cached index.json failed verification ({err}), re-fetching…");
|
eprintln!(
|
||||||
|
" warning: cached index.json failed verification ({err}), re-fetching…"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -208,12 +211,17 @@ pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_and_verify_cache(cache_path: &Path, sig_cache_path: &Path, track: Track) -> Result<Index> {
|
fn read_and_verify_cache(
|
||||||
|
cache_path: &Path,
|
||||||
|
sig_cache_path: &Path,
|
||||||
|
track: Track,
|
||||||
|
) -> Result<Index> {
|
||||||
let bytes = std::fs::read(cache_path).context("reading cached index")?;
|
let bytes = std::fs::read(cache_path).context("reading cached index")?;
|
||||||
let sig_text = std::fs::read_to_string(sig_cache_path)
|
let sig_text = std::fs::read_to_string(sig_cache_path)
|
||||||
.context("reading cached index.json.minisig (cache predates signing support)")?;
|
.context("reading cached index.json.minisig (cache predates signing support)")?;
|
||||||
verify_index_signature(&bytes, &sig_text)
|
verify_index_signature(&bytes, &sig_text).with_context(|| {
|
||||||
.with_context(|| format!("cached {track} index failed signature verification"))?;
|
format!("cached {track} index failed signature verification")
|
||||||
|
})?;
|
||||||
serde_json::from_slice(&bytes).context("parsing cached index")
|
serde_json::from_slice(&bytes).context("parsing cached index")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,10 +285,8 @@ pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result<Vec<u8>> {
|
||||||
Ok(bytes) => Ok(bytes),
|
Ok(bytes) => Ok(bytes),
|
||||||
Err(primary_err) => {
|
Err(primary_err) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}",
|
" primary URL failed ({}), trying GitHub fallback…",
|
||||||
crate::ui::note(&format!(
|
primary_err
|
||||||
"primary URL failed ({primary_err}), trying GitHub fallback…"
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
fetch_bytes(fallback_url).context("both primary and GitHub fallback failed")
|
fetch_bytes(fallback_url).context("both primary and GitHub fallback failed")
|
||||||
}
|
}
|
||||||
|
|
@ -299,7 +305,9 @@ const CHUNK_SIZE: usize = 64 * 1024;
|
||||||
|
|
||||||
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
use std::io::{IsTerminal, Read};
|
use std::io::{IsTerminal, Read};
|
||||||
let resp = ureq::get(url).call().map_err(|e| anyhow::anyhow!("{e}"))?;
|
let resp = ureq::get(url)
|
||||||
|
.call()
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status != 200 {
|
if status != 200 {
|
||||||
bail!("HTTP {status} from {url}");
|
bail!("HTTP {status} from {url}");
|
||||||
|
|
@ -312,11 +320,7 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
// is what makes printing partway through the download possible, without
|
// is what makes printing partway through the download possible, without
|
||||||
// pulling in a progress-bar crate for what's meant to just be reassurance.
|
// pulling in a progress-bar crate for what's meant to just be reassurance.
|
||||||
let content_length: Option<u64> = resp.header("Content-Length").and_then(|v| v.parse().ok());
|
let content_length: Option<u64> = resp.header("Content-Length").and_then(|v| v.parse().ok());
|
||||||
// Progress is reassurance for multi-MB binaries. A 4 KB index fetch
|
let show_progress = content_length.is_some() && std::io::stderr().is_terminal();
|
||||||
// drawing a 100% / 0.0 MB bar is noise, not feedback.
|
|
||||||
const MIN_PROGRESS_BYTES: u64 = 256 * 1024;
|
|
||||||
let show_progress =
|
|
||||||
content_length.is_some_and(|n| n >= MIN_PROGRESS_BYTES) && std::io::stderr().is_terminal();
|
|
||||||
|
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let mut reader = resp.into_reader();
|
let mut reader = resp.into_reader();
|
||||||
|
|
@ -332,17 +336,27 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit");
|
bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit");
|
||||||
}
|
}
|
||||||
if show_progress && last_print.elapsed() >= PROGRESS_THROTTLE {
|
if show_progress && last_print.elapsed() >= PROGRESS_THROTTLE {
|
||||||
crate::ui::print_progress(buf.len() as u64, content_length.unwrap());
|
print_progress(buf.len() as u64, content_length.unwrap());
|
||||||
last_print = std::time::Instant::now();
|
last_print = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if show_progress {
|
if show_progress {
|
||||||
crate::ui::print_progress(buf.len() as u64, content_length.unwrap());
|
print_progress(buf.len() as u64, content_length.unwrap());
|
||||||
crate::ui::finish_progress();
|
eprintln!();
|
||||||
}
|
}
|
||||||
Ok(buf)
|
Ok(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn print_progress(downloaded: u64, total: u64) {
|
||||||
|
use std::io::Write;
|
||||||
|
eprint!(
|
||||||
|
"\r ⇣ {:.1}/{:.1} MB",
|
||||||
|
downloaded as f64 / 1_048_576.0,
|
||||||
|
total as f64 / 1_048_576.0
|
||||||
|
);
|
||||||
|
let _ = std::io::stderr().flush();
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -394,7 +408,10 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
|
||||||
fn stable_cache_path_matches_pre_track_filename() {
|
fn stable_cache_path_matches_pre_track_filename() {
|
||||||
// Must stay exactly "index.json" so an existing warm cache from a
|
// Must stay exactly "index.json" so an existing warm cache from a
|
||||||
// pre-track bakery binary is still used after an upgrade.
|
// pre-track bakery binary is still used after an upgrade.
|
||||||
assert_eq!(cache_path(Track::Stable).file_name().unwrap(), "index.json");
|
assert_eq!(
|
||||||
|
cache_path(Track::Stable).file_name().unwrap(),
|
||||||
|
"index.json"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -411,22 +428,13 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stable_url_has_no_track_prefix() {
|
fn stable_url_has_no_track_prefix() {
|
||||||
assert_eq!(
|
assert_eq!(primary_url(Track::Stable), format!("{}/index.json", base_url()));
|
||||||
primary_url(Track::Stable),
|
|
||||||
format!("{}/index.json", base_url())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn beta_and_dev_urls_are_track_prefixed() {
|
fn beta_and_dev_urls_are_track_prefixed() {
|
||||||
assert_eq!(
|
assert_eq!(primary_url(Track::Beta), format!("{}/beta/index.json", base_url()));
|
||||||
primary_url(Track::Beta),
|
assert_eq!(primary_url(Track::Dev), format!("{}/dev/index.json", base_url()));
|
||||||
format!("{}/beta/index.json", base_url())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
primary_url(Track::Dev),
|
|
||||||
format!("{}/dev/index.json", base_url())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn minimal_package_json() -> &'static str {
|
fn minimal_package_json() -> &'static str {
|
||||||
|
|
|
||||||
|
|
@ -1,546 +0,0 @@
|
||||||
//! Install prefix: default `~/.local`, or a system root for BOS.
|
|
||||||
//!
|
|
||||||
//! Hermes and `get.sh` keep the user-local default. BOS sets
|
|
||||||
//! `prefix = "/usr/local"` in `/etc/bakery/config.toml` (or `BAKERY_PREFIX`)
|
|
||||||
//! so bakery-managed desktop apps live on the `@` root subvolume and ride
|
|
||||||
//! along with snapper/grub-btrfs snapshots. Per-user state stays under
|
|
||||||
//! `~/.local/state/bakery` either way — bakery still records what *this*
|
|
||||||
//! user asked for; the prefix only changes where bits land on disk.
|
|
||||||
//!
|
|
||||||
//! Writes that hit `EACCES` use `sudo -n` first, then `pkexec` if a
|
|
||||||
//! graphical session is available. Interactive `sudo` (password on stdin)
|
|
||||||
//! is never used — a GUI hook must not block on a TTY prompt.
|
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::ffi::OsStr;
|
|
||||||
use std::io::{self, Write};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
|
|
||||||
/// Default user-local prefix when no config/env override is set.
|
|
||||||
const DEFAULT_USER_PREFIX: &str = ".local";
|
|
||||||
|
|
||||||
/// System-wide user units, used when the prefix is not under `$HOME`.
|
|
||||||
const SYSTEM_USER_UNIT_DIR: &str = "/usr/lib/systemd/user";
|
|
||||||
|
|
||||||
const SYSTEM_CONFIG_PATH: &str = "/etc/bakery/config.toml";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Layout {
|
|
||||||
pub prefix: PathBuf,
|
|
||||||
pub bin_dir: PathBuf,
|
|
||||||
pub share_dir: PathBuf,
|
|
||||||
pub systemd_user_dir: PathBuf,
|
|
||||||
/// True when `prefix` is not under the user's home directory.
|
|
||||||
pub is_system: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Layout {
|
|
||||||
pub fn kind_label(&self) -> &'static str {
|
|
||||||
if self.is_system {
|
|
||||||
"system"
|
|
||||||
} else {
|
|
||||||
"user"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a (possibly custom) prefix onto bin/share/unit paths.
|
|
||||||
/// `bin_override` is `--bin-dir` / `BAKERY_BIN_DIR` and wins for bins only.
|
|
||||||
pub fn from_prefix(prefix: &Path, bin_override: Option<PathBuf>) -> Self {
|
|
||||||
let prefix = normalize_prefix_path(prefix);
|
|
||||||
let is_system = is_system_prefix(&prefix);
|
|
||||||
let bin_dir = bin_override.unwrap_or_else(|| prefix.join("bin"));
|
|
||||||
let share_dir = prefix.join("share");
|
|
||||||
let systemd_user_dir = if is_system {
|
|
||||||
PathBuf::from(SYSTEM_USER_UNIT_DIR)
|
|
||||||
} else {
|
|
||||||
user_systemd_dir()
|
|
||||||
};
|
|
||||||
Self {
|
|
||||||
prefix,
|
|
||||||
bin_dir,
|
|
||||||
share_dir,
|
|
||||||
systemd_user_dir,
|
|
||||||
is_system,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Historical default: `~/.local` bins, XDG data dir for share,
|
|
||||||
/// `~/.config/systemd/user` for units. Used when neither `BAKERY_PREFIX`
|
|
||||||
/// nor `/etc/bakery/config.toml` sets a prefix — hermes / get.sh.
|
|
||||||
pub fn user_default(bin_override: Option<PathBuf>) -> Self {
|
|
||||||
let prefix = default_user_prefix();
|
|
||||||
let bin_dir = bin_override.unwrap_or_else(|| prefix.join("bin"));
|
|
||||||
let share_dir = dirs::data_dir().unwrap_or_else(|| prefix.join("share"));
|
|
||||||
Self {
|
|
||||||
prefix,
|
|
||||||
bin_dir,
|
|
||||||
share_dir,
|
|
||||||
systemd_user_dir: user_systemd_dir(),
|
|
||||||
is_system: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the active layout. `BAKERY_PREFIX` wins over `/etc/bakery/config.toml`;
|
|
||||||
/// neither set keeps the `~/.local` default. `bin_override` is the existing
|
|
||||||
/// `--bin-dir` / `BAKERY_BIN_DIR` knob.
|
|
||||||
pub fn resolve(bin_override: Option<PathBuf>) -> Layout {
|
|
||||||
let env = std::env::var("BAKERY_PREFIX").ok();
|
|
||||||
resolve_from(env.as_deref(), Path::new(SYSTEM_CONFIG_PATH), bin_override)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Same as [`resolve`] with the env value and config path injected, so
|
|
||||||
/// tests don't have to mutate process-global env or touch `/etc`.
|
|
||||||
pub fn resolve_from(
|
|
||||||
env_prefix: Option<&str>,
|
|
||||||
config_path: &Path,
|
|
||||||
bin_override: Option<PathBuf>,
|
|
||||||
) -> Layout {
|
|
||||||
match configured_prefix_from(env_prefix, config_path) {
|
|
||||||
Some(prefix) => Layout::from_prefix(&prefix, bin_override),
|
|
||||||
None => Layout::user_default(bin_override),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn configured_prefix_from(env_prefix: Option<&str>, config_path: &Path) -> Option<PathBuf> {
|
|
||||||
if let Some(raw) = env_prefix {
|
|
||||||
let trimmed = raw.trim();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
return Some(normalize_prefix(trimmed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load_config_prefix(config_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
struct BakeryConfig {
|
|
||||||
prefix: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads `prefix = "..."` from a bakery config file. Missing file or empty
|
|
||||||
/// key → `None` (caller falls back to the user-local default). A file that
|
|
||||||
/// exists but fails to parse is warned about, not treated as fatal — a typo
|
|
||||||
/// in `/etc/bakery/config.toml` must not take down `bakery list`.
|
|
||||||
pub fn load_config_prefix(path: &Path) -> Option<PathBuf> {
|
|
||||||
if !path.exists() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let text = match std::fs::read_to_string(path) {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
" {}",
|
|
||||||
crate::ui::warn(&format!("could not read {}: {e}", path.display()))
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match toml::from_str::<BakeryConfig>(&text) {
|
|
||||||
Ok(cfg) => cfg
|
|
||||||
.prefix
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|p| !p.is_empty())
|
|
||||||
.map(normalize_prefix),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
" {}",
|
|
||||||
crate::ui::warn(&format!("could not parse {}: {e}", path.display()))
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_user_prefix() -> PathBuf {
|
|
||||||
home_dir().join(DEFAULT_USER_PREFIX)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn home_dir() -> PathBuf {
|
|
||||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn user_systemd_dir() -> PathBuf {
|
|
||||||
dirs::config_dir()
|
|
||||||
.unwrap_or_else(|| home_dir().join(".config"))
|
|
||||||
.join("systemd/user")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_system_prefix(prefix: &Path) -> bool {
|
|
||||||
match dirs::home_dir() {
|
|
||||||
Some(home) => !prefix.starts_with(&home),
|
|
||||||
None => true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_prefix(raw: &str) -> PathBuf {
|
|
||||||
normalize_prefix_path(&expand_tilde(raw))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_prefix_path(path: &Path) -> PathBuf {
|
|
||||||
if path.is_absolute() {
|
|
||||||
path.to_path_buf()
|
|
||||||
} else {
|
|
||||||
std::env::current_dir()
|
|
||||||
.unwrap_or_else(|_| PathBuf::from("."))
|
|
||||||
.join(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expand_tilde(path: &str) -> PathBuf {
|
|
||||||
if path == "~" {
|
|
||||||
home_dir()
|
|
||||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
|
||||||
home_dir().join(rest)
|
|
||||||
} else {
|
|
||||||
PathBuf::from(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_permission_denied(err: &io::Error) -> bool {
|
|
||||||
err.kind() == io::ErrorKind::PermissionDenied
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn privilege_denied_msg(dest: &Path) -> String {
|
|
||||||
format!(
|
|
||||||
"permission denied writing {} — need root for this prefix. \
|
|
||||||
bakery tried `sudo -n` then `pkexec`; neither succeeded. \
|
|
||||||
Run from a root shell, grant passwordless sudo -n for install/rm/tar, \
|
|
||||||
or install a polkit rule. bakery will not prompt for a sudo password.",
|
|
||||||
dest.display()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_graphical_session() -> bool {
|
|
||||||
std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write `bytes` to `dest`, creating parent dirs. Escalates on `EACCES`.
|
|
||||||
pub fn write_bytes(dest: &Path, bytes: &[u8], mode: u32) -> Result<()> {
|
|
||||||
match bread_utils::atomic::write_atomic_bytes(dest, bytes, Some(mode)) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) if is_permission_denied(&e) => write_bytes_privileged(dest, bytes, mode),
|
|
||||||
Err(e) => Err(e).with_context(|| format!("writing {}", dest.display())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_bytes_privileged(dest: &Path, bytes: &[u8], mode: u32) -> Result<()> {
|
|
||||||
let mut tmp =
|
|
||||||
tempfile::NamedTempFile::new().context("creating temp file for privileged write")?;
|
|
||||||
tmp.write_all(bytes)
|
|
||||||
.and_then(|_| tmp.flush())
|
|
||||||
.and_then(|_| tmp.as_file().sync_all())
|
|
||||||
.context("writing temp file for privileged write")?;
|
|
||||||
let mode_str = format!("{mode:o}");
|
|
||||||
run_privileged(
|
|
||||||
Path::new("/usr/bin/install"),
|
|
||||||
&[
|
|
||||||
OsStr::new("-D"),
|
|
||||||
OsStr::new("-m"),
|
|
||||||
OsStr::new(&mode_str),
|
|
||||||
tmp.path().as_os_str(),
|
|
||||||
dest.as_os_str(),
|
|
||||||
],
|
|
||||||
dest,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_dir_all(path: &Path) -> Result<()> {
|
|
||||||
match std::fs::create_dir_all(path) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) if is_permission_denied(&e) => run_privileged(
|
|
||||||
Path::new("/usr/bin/install"),
|
|
||||||
&[
|
|
||||||
OsStr::new("-d"),
|
|
||||||
OsStr::new("-m"),
|
|
||||||
OsStr::new("755"),
|
|
||||||
path.as_os_str(),
|
|
||||||
],
|
|
||||||
path,
|
|
||||||
),
|
|
||||||
Err(e) => Err(e).with_context(|| format!("creating directory {}", path.display())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_file(path: &Path) -> Result<()> {
|
|
||||||
match std::fs::remove_file(path) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
|
||||||
Err(e) if is_permission_denied(&e) => run_privileged(
|
|
||||||
Path::new("/usr/bin/rm"),
|
|
||||||
&[OsStr::new("-f"), path.as_os_str()],
|
|
||||||
path,
|
|
||||||
),
|
|
||||||
Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_dir_all(path: &Path) -> Result<()> {
|
|
||||||
match std::fs::remove_dir_all(path) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
|
||||||
Err(e) if is_permission_denied(&e) => run_privileged(
|
|
||||||
Path::new("/usr/bin/rm"),
|
|
||||||
&[OsStr::new("-rf"), path.as_os_str()],
|
|
||||||
path,
|
|
||||||
),
|
|
||||||
Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract `archive` (a `.tar.gz`) into `dest_dir`. Escalates the `tar`
|
|
||||||
/// invocation when `dest_dir` is not writable by this user — typical for
|
|
||||||
/// `$prefix/share/<pkg>` under `/usr/local`.
|
|
||||||
pub fn extract_tar_gz(archive: &Path, dest_dir: &Path) -> Result<()> {
|
|
||||||
create_dir_all(dest_dir)?;
|
|
||||||
if dir_writable_by_self(dest_dir) {
|
|
||||||
let status = Command::new("tar")
|
|
||||||
.args([
|
|
||||||
"xzf",
|
|
||||||
&archive.to_string_lossy(),
|
|
||||||
"--no-same-owner",
|
|
||||||
"--no-same-permissions",
|
|
||||||
"-C",
|
|
||||||
])
|
|
||||||
.arg(dest_dir)
|
|
||||||
.status()
|
|
||||||
.with_context(|| format!("running tar to extract {}", archive.display()))?;
|
|
||||||
if !status.success() {
|
|
||||||
bail!("tar exited with {status} extracting {}", archive.display());
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
run_privileged(
|
|
||||||
Path::new("/usr/bin/tar"),
|
|
||||||
&[
|
|
||||||
OsStr::new("xzf"),
|
|
||||||
archive.as_os_str(),
|
|
||||||
OsStr::new("--no-same-owner"),
|
|
||||||
OsStr::new("--no-same-permissions"),
|
|
||||||
OsStr::new("-C"),
|
|
||||||
dest_dir.as_os_str(),
|
|
||||||
],
|
|
||||||
dest_dir,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dir_writable_by_self(dir: &Path) -> bool {
|
|
||||||
tempfile::Builder::new()
|
|
||||||
.prefix(".bakery-wprobe-")
|
|
||||||
.tempfile_in(dir)
|
|
||||||
.is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_privileged(program: &Path, args: &[&OsStr], dest: &Path) -> Result<()> {
|
|
||||||
// `sudo -n` never prompts; stdin is null so a misconfigured sudoers
|
|
||||||
// can't fall through to a password read on a GUI hook's non-tty stdin.
|
|
||||||
let sudo = Command::new("sudo")
|
|
||||||
.arg("-n")
|
|
||||||
.arg(program)
|
|
||||||
.args(args)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.status();
|
|
||||||
if matches!(sudo, Ok(status) if status.success()) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// pkexec pops a polkit dialog — only useful with a display, and the
|
|
||||||
// one acceptable password prompt (GUI, not a stolen sudo TTY).
|
|
||||||
if has_graphical_session() {
|
|
||||||
let pk = Command::new("pkexec").arg(program).args(args).status();
|
|
||||||
if matches!(pk, Ok(status) if status.success()) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bail!("{}", privilege_denied_msg(dest))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::fs;
|
|
||||||
use tempfile::tempdir;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_default_is_not_system_and_uses_local_bin() {
|
|
||||||
let layout = Layout::user_default(None);
|
|
||||||
assert!(!layout.is_system);
|
|
||||||
assert_eq!(layout.prefix, default_user_prefix());
|
|
||||||
assert_eq!(layout.bin_dir, default_user_prefix().join("bin"));
|
|
||||||
assert_eq!(layout.kind_label(), "user");
|
|
||||||
assert!(layout.systemd_user_dir.ends_with(Path::new("systemd/user")));
|
|
||||||
assert_ne!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_default_honors_bin_override() {
|
|
||||||
let layout = Layout::user_default(Some(PathBuf::from("/tmp/custom-bins")));
|
|
||||||
assert_eq!(layout.bin_dir, PathBuf::from("/tmp/custom-bins"));
|
|
||||||
assert!(!layout.is_system);
|
|
||||||
assert_eq!(layout.prefix, default_user_prefix());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn usr_local_is_system_layout() {
|
|
||||||
let layout = Layout::from_prefix(Path::new("/usr/local"), None);
|
|
||||||
assert!(layout.is_system);
|
|
||||||
assert_eq!(layout.prefix, PathBuf::from("/usr/local"));
|
|
||||||
assert_eq!(layout.bin_dir, PathBuf::from("/usr/local/bin"));
|
|
||||||
assert_eq!(layout.share_dir, PathBuf::from("/usr/local/share"));
|
|
||||||
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
|
||||||
assert_eq!(layout.kind_label(), "system");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_home_prefix_is_not_system() {
|
|
||||||
let home = dirs::home_dir().expect("home dir");
|
|
||||||
let prefix = home.join("apps");
|
|
||||||
let layout = Layout::from_prefix(&prefix, None);
|
|
||||||
assert!(!layout.is_system);
|
|
||||||
assert_eq!(layout.bin_dir, prefix.join("bin"));
|
|
||||||
assert_eq!(layout.share_dir, prefix.join("share"));
|
|
||||||
assert_ne!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn temp_prefix_maps_bin_and_share_under_prefix() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let layout = Layout::from_prefix(dir.path(), None);
|
|
||||||
assert_eq!(layout.bin_dir, dir.path().join("bin"));
|
|
||||||
assert_eq!(layout.share_dir, dir.path().join("share"));
|
|
||||||
// /tmp is not under $HOME, so this is a system-shaped prefix —
|
|
||||||
// units would go to /usr/lib/systemd/user. Writes still try
|
|
||||||
// unprivileged first, so tests can use a temp prefix without sudo.
|
|
||||||
assert!(layout.is_system);
|
|
||||||
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bin_override_does_not_move_share_or_units() {
|
|
||||||
let layout = Layout::from_prefix(
|
|
||||||
Path::new("/usr/local"),
|
|
||||||
Some(PathBuf::from("/opt/override/bin")),
|
|
||||||
);
|
|
||||||
assert_eq!(layout.bin_dir, PathBuf::from("/opt/override/bin"));
|
|
||||||
assert_eq!(layout.share_dir, PathBuf::from("/usr/local/share"));
|
|
||||||
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_config_prefix_reads_value() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
|
|
||||||
assert_eq!(load_config_prefix(&path), Some(PathBuf::from("/usr/local")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_config_prefix_expands_tilde() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
fs::write(&path, "prefix = \"~/.local\"\n").unwrap();
|
|
||||||
assert_eq!(load_config_prefix(&path), Some(default_user_prefix()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_config_prefix_missing_file_is_none() {
|
|
||||||
assert_eq!(
|
|
||||||
load_config_prefix(Path::new("/no/such/bakery-config.toml")),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_config_prefix_ignores_empty_value() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
fs::write(&path, "prefix = \"\"\n").unwrap();
|
|
||||||
assert_eq!(load_config_prefix(&path), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_config_prefix_malformed_is_none() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
fs::write(&path, "prefix = [\n").unwrap();
|
|
||||||
assert_eq!(load_config_prefix(&path), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn env_prefix_wins_over_config() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
|
|
||||||
let layout = resolve_from(Some("/opt/bread"), &path, None);
|
|
||||||
assert_eq!(layout.prefix, PathBuf::from("/opt/bread"));
|
|
||||||
assert_eq!(layout.bin_dir, PathBuf::from("/opt/bread/bin"));
|
|
||||||
assert!(layout.is_system);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_env_falls_through_to_config() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
|
|
||||||
let layout = resolve_from(Some(" "), &path, None);
|
|
||||||
assert_eq!(layout.prefix, PathBuf::from("/usr/local"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_env_no_config_is_user_default() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let path = dir.path().join("missing.toml");
|
|
||||||
let layout = resolve_from(None, &path, None);
|
|
||||||
assert_eq!(layout, Layout::user_default(None));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn write_bytes_to_writable_temp_prefix_needs_no_root() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let dest = dir.path().join("bin").join("foo");
|
|
||||||
write_bytes(&dest, b"hello", 0o755).unwrap();
|
|
||||||
assert_eq!(fs::read(&dest).unwrap(), b"hello");
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
assert_eq!(
|
|
||||||
fs::metadata(&dest).unwrap().permissions().mode() & 0o777,
|
|
||||||
0o755
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn create_and_remove_under_temp_prefix() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let nested = dir.path().join("share/licenses/pkg");
|
|
||||||
create_dir_all(&nested).unwrap();
|
|
||||||
assert!(nested.is_dir());
|
|
||||||
let file = nested.join("LICENSE");
|
|
||||||
write_bytes(&file, b"MIT\n", 0o644).unwrap();
|
|
||||||
remove_file(&file).unwrap();
|
|
||||||
assert!(!file.exists());
|
|
||||||
remove_dir_all(&dir.path().join("share")).unwrap();
|
|
||||||
assert!(!dir.path().join("share").exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn privilege_denied_msg_names_the_dest() {
|
|
||||||
let msg = privilege_denied_msg(Path::new("/usr/local/bin/breadd"));
|
|
||||||
assert!(msg.contains("/usr/local/bin/breadd"));
|
|
||||||
assert!(msg.contains("sudo -n"));
|
|
||||||
assert!(msg.contains("pkexec"));
|
|
||||||
assert!(msg.contains("will not prompt"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_system_prefix_classifies_home_and_usr() {
|
|
||||||
let home = dirs::home_dir().expect("home dir");
|
|
||||||
assert!(!is_system_prefix(&home.join(".local")));
|
|
||||||
assert!(is_system_prefix(Path::new("/usr/local")));
|
|
||||||
assert!(is_system_prefix(Path::new("/opt/bread")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -56,7 +56,8 @@ impl State {
|
||||||
pub fn save(&self) -> Result<()> {
|
pub fn save(&self) -> Result<()> {
|
||||||
let path = state_path();
|
let path = state_path();
|
||||||
let text = serde_json::to_string_pretty(self)?;
|
let text = serde_json::to_string_pretty(self)?;
|
||||||
bread_utils::atomic::write_atomic(&path, &text, None).context("writing installed.json")
|
bread_utils::atomic::write_atomic(&path, &text, None)
|
||||||
|
.context("writing installed.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs `f` against a freshly-loaded `State` while holding an exclusive
|
/// Runs `f` against a freshly-loaded `State` while holding an exclusive
|
||||||
|
|
@ -113,13 +114,7 @@ fn state_base_dir() -> PathBuf {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state_path() -> PathBuf {
|
fn state_path() -> PathBuf {
|
||||||
bakery_state_dir().join("installed.json")
|
state_base_dir().join("bakery/installed.json")
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-user bakery state dir (`~/.local/state/bakery`). Independent of the
|
|
||||||
/// install prefix — system-prefix installs still record what this user asked for.
|
|
||||||
pub fn bakery_state_dir() -> PathBuf {
|
|
||||||
state_base_dir().join("bakery")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local backup dir for `pkg_name`'s `version` binaries, populated by
|
/// Local backup dir for `pkg_name`'s `version` binaries, populated by
|
||||||
|
|
@ -210,10 +205,7 @@ mod tests {
|
||||||
assert_eq!(restored.packages["bar"].version, "2.0.0");
|
assert_eq!(restored.packages["bar"].version, "2.0.0");
|
||||||
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
|
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
|
||||||
assert_eq!(restored.packages["bar"].track, Track::Beta);
|
assert_eq!(restored.packages["bar"].track, Track::Beta);
|
||||||
assert_eq!(
|
assert_eq!(restored.packages["bar"].previous_version.as_deref(), Some("1.0.0"));
|
||||||
restored.packages["bar"].previous_version.as_deref(),
|
|
||||||
Some("1.0.0")
|
|
||||||
);
|
|
||||||
assert_eq!(restored.packages["bar"].binary_sha256["bar"], "abc123");
|
assert_eq!(restored.packages["bar"].binary_sha256["bar"], "abc123");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,14 +228,6 @@ mod tests {
|
||||||
assert!(installed.binary_sha256.is_empty());
|
assert!(installed.binary_sha256.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bakery_state_dir_is_under_state_home_and_independent_of_prefix() {
|
|
||||||
let dir = bakery_state_dir();
|
|
||||||
assert!(dir.ends_with("bakery"));
|
|
||||||
// Must not follow BAKERY_PREFIX — state is always per-user.
|
|
||||||
assert!(!dir.starts_with("/usr/local"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backup_dir_is_distinct_per_package_and_version() {
|
fn backup_dir_is_distinct_per_package_and_version() {
|
||||||
let a = backup_dir("bakery", "0.3.1");
|
let a = backup_dir("bakery", "0.3.1");
|
||||||
|
|
|
||||||
385
bakery/src/ui.rs
385
bakery/src/ui.rs
|
|
@ -1,6 +1,5 @@
|
||||||
use crate::track::Track;
|
use crate::track::Track;
|
||||||
use clap::builder::styling::{AnsiColor, Effects, Styles};
|
use std::io::IsTerminal;
|
||||||
use std::io::{IsTerminal, Write};
|
|
||||||
|
|
||||||
pub const RESET: &str = "\x1b[0m";
|
pub const RESET: &str = "\x1b[0m";
|
||||||
pub const BOLD: &str = "\x1b[1m";
|
pub const BOLD: &str = "\x1b[1m";
|
||||||
|
|
@ -10,19 +9,6 @@ pub const GREEN: &str = "\x1b[32m";
|
||||||
pub const YELLOW: &str = "\x1b[33m";
|
pub const YELLOW: &str = "\x1b[33m";
|
||||||
pub const CYAN: &str = "\x1b[36m";
|
pub const CYAN: &str = "\x1b[36m";
|
||||||
pub const MAGENTA: &str = "\x1b[35m";
|
pub const MAGENTA: &str = "\x1b[35m";
|
||||||
pub const BOLD_CYAN: &str = "\x1b[1;36m";
|
|
||||||
|
|
||||||
/// Clap help styling — same cyan headers / green literals / dim placeholders
|
|
||||||
/// as the rest of bakery, so `bakery --help` doesn't look like a different
|
|
||||||
/// program from `bakery list`.
|
|
||||||
pub const CLAP_STYLES: Styles = Styles::styled()
|
|
||||||
.header(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
|
|
||||||
.usage(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
|
|
||||||
.literal(AnsiColor::Green.on_default().effects(Effects::BOLD))
|
|
||||||
.placeholder(AnsiColor::BrightBlack.on_default())
|
|
||||||
.error(AnsiColor::Red.on_default().effects(Effects::BOLD))
|
|
||||||
.valid(AnsiColor::Green.on_default().effects(Effects::BOLD))
|
|
||||||
.invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD));
|
|
||||||
|
|
||||||
/// Colors are on only when stdout is a real terminal and `NO_COLOR` isn't
|
/// Colors are on only when stdout is a real terminal and `NO_COLOR` isn't
|
||||||
/// set — the ecosystem's existing CLI (breadcrumbs) hardcodes ANSI
|
/// set — the ecosystem's existing CLI (breadcrumbs) hardcodes ANSI
|
||||||
|
|
@ -32,52 +18,21 @@ pub fn colors_enabled() -> bool {
|
||||||
std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
|
std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn colors_enabled_err() -> bool {
|
|
||||||
std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn style(s: &str, code: &str) -> String {
|
pub fn style(s: &str, code: &str) -> String {
|
||||||
paint(s, code, colors_enabled())
|
if colors_enabled() {
|
||||||
}
|
|
||||||
|
|
||||||
fn style_err(s: &str, code: &str) -> String {
|
|
||||||
paint(s, code, colors_enabled_err())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint(s: &str, code: &str, on: bool) -> String {
|
|
||||||
if on {
|
|
||||||
format!("{code}{s}{RESET}")
|
format!("{code}{s}{RESET}")
|
||||||
} else {
|
} else {
|
||||||
s.to_string()
|
s.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bold(s: &str) -> String {
|
|
||||||
style(s, BOLD)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dim(s: &str) -> String {
|
|
||||||
style(s, DIM)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the
|
/// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the
|
||||||
/// common-case output is unchanged.
|
/// common-case output is unchanged.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn track_badge(track: Track) -> String {
|
pub fn track_badge(track: Track) -> String {
|
||||||
let tag = track_tag(track);
|
|
||||||
if tag.is_empty() {
|
|
||||||
tag
|
|
||||||
} else {
|
|
||||||
format!(" {tag}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `[beta]` / `[dev]` with no leading space; empty for `Stable`.
|
|
||||||
pub fn track_tag(track: Track) -> String {
|
|
||||||
match track {
|
match track {
|
||||||
Track::Stable => String::new(),
|
Track::Stable => String::new(),
|
||||||
Track::Beta => style("[beta]", YELLOW),
|
Track::Beta => format!(" {}", style("[beta]", YELLOW)),
|
||||||
Track::Dev => style("[dev]", MAGENTA),
|
Track::Dev => format!(" {}", style("[dev]", MAGENTA)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,278 +54,6 @@ pub fn unchanged(s: &str) -> String {
|
||||||
style(&format!("· {s}"), DIM)
|
style(&format!("· {s}"), DIM)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn warn(s: &str) -> String {
|
|
||||||
style(&format!("warning: {s}"), YELLOW)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn note(s: &str) -> String {
|
|
||||||
style(&format!("note: {s}"), DIM)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cyan verb + bold name + dim version — the install/update/remove banner.
|
|
||||||
pub fn action(verb: &str, name: &str, version: Option<&str>) {
|
|
||||||
let mut line = format!("{} {}", style(verb, BOLD_CYAN), style(name, BOLD));
|
|
||||||
if let Some(v) = version {
|
|
||||||
line.push_str(" ");
|
|
||||||
line.push_str(&style(v, DIM));
|
|
||||||
}
|
|
||||||
println!("{line}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Section title plus dim meta (`Packages 16 · 15 installed`).
|
|
||||||
pub fn heading(title: &str, parts: &[&str]) {
|
|
||||||
let mut line = style(title, BOLD_CYAN);
|
|
||||||
let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect();
|
|
||||||
for (i, part) in visible.iter().enumerate() {
|
|
||||||
line.push_str(" ");
|
|
||||||
if i > 0 {
|
|
||||||
line.push_str(&style("·", DIM));
|
|
||||||
line.push_str(" ");
|
|
||||||
}
|
|
||||||
line.push_str(part);
|
|
||||||
}
|
|
||||||
println!("{line}");
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn summary(parts: &[&str]) {
|
|
||||||
let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect();
|
|
||||||
if visible.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
println!("{}", style(&visible.join(" · "), BOLD));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Left-aligned verb column so install chatter (`downloading` / `placed` /
|
|
||||||
/// `unit`) lines up instead of drifting with the verb length.
|
|
||||||
pub fn step(verb: &str, detail: &str) {
|
|
||||||
println!(" {:<12} {}", dim(verb), detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn kv(key: &str, value: &str) {
|
|
||||||
println!(" {:<12} {}", dim(key), value);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_row(ok_flag: bool, name: &str, name_width: usize, message: &str) {
|
|
||||||
let glyph = if ok_flag {
|
|
||||||
style("✓", GREEN)
|
|
||||||
} else {
|
|
||||||
style("✗", RED)
|
|
||||||
};
|
|
||||||
println!(" {glyph} {:<name_width$} {message}", name);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unknown_row(name: &str, name_width: usize, message: &str) {
|
|
||||||
println!(
|
|
||||||
" {} {:<name_width$} {}",
|
|
||||||
style("?", DIM),
|
|
||||||
name,
|
|
||||||
dim(message)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CatalogRow {
|
|
||||||
pub name: String,
|
|
||||||
pub version: String,
|
|
||||||
pub installed: bool,
|
|
||||||
/// Wrapped onto following lines (descriptions).
|
|
||||||
pub detail: String,
|
|
||||||
/// Same-line suffix after the version (short dates). Empty for catalog
|
|
||||||
/// views that already use `detail`.
|
|
||||||
pub aside: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Two-line catalog: status glyph + aligned name/version, then a hanging
|
|
||||||
/// description (or date) wrapped to the terminal width. Column widths are
|
|
||||||
/// computed from the row set so long `-dev.` versions no longer smash the
|
|
||||||
/// old `{: <10}` pad.
|
|
||||||
pub fn print_catalog(rows: &[CatalogRow]) {
|
|
||||||
for line in format_catalog(rows, term_width()) {
|
|
||||||
println!("{line}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn format_catalog(rows: &[CatalogRow], width: usize) -> Vec<String> {
|
|
||||||
if rows.is_empty() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let name_w = rows.iter().map(|r| r.name.len()).max().unwrap_or(0);
|
|
||||||
let indent = 5; // " ✓ " / " "
|
|
||||||
let detail_width = width.saturating_sub(indent).max(24);
|
|
||||||
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
for row in rows {
|
|
||||||
let glyph = if row.installed {
|
|
||||||
style("✓", GREEN)
|
|
||||||
} else {
|
|
||||||
" ".to_string()
|
|
||||||
};
|
|
||||||
let name = style(&format!("{:<name_w$}", row.name), BOLD);
|
|
||||||
let version = style(&row.version, DIM);
|
|
||||||
let mut line = format!(" {glyph} {name} {version}");
|
|
||||||
if !row.aside.is_empty() {
|
|
||||||
line.push_str(" ");
|
|
||||||
line.push_str(&dim(&row.aside));
|
|
||||||
}
|
|
||||||
lines.push(line);
|
|
||||||
if !row.detail.is_empty() {
|
|
||||||
for wrapped in wrap_words(&row.detail, detail_width) {
|
|
||||||
lines.push(format!(" {}", dim(&wrapped)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn wrap_words(text: &str, width: usize) -> Vec<String> {
|
|
||||||
if width == 0 {
|
|
||||||
return vec![text.to_string()];
|
|
||||||
}
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
let mut cur = String::new();
|
|
||||||
for word in text.split_whitespace() {
|
|
||||||
if cur.is_empty() {
|
|
||||||
cur = word.to_string();
|
|
||||||
} else if cur.len() + 1 + word.len() <= width {
|
|
||||||
cur.push(' ');
|
|
||||||
cur.push_str(word);
|
|
||||||
} else {
|
|
||||||
lines.push(std::mem::take(&mut cur));
|
|
||||||
cur = word.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !cur.is_empty() {
|
|
||||||
lines.push(cur);
|
|
||||||
}
|
|
||||||
lines
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn short_date(rfc3339: &str) -> String {
|
|
||||||
chrono::DateTime::parse_from_rfc3339(rfc3339)
|
|
||||||
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
|
||||||
.unwrap_or_else(|_| rfc3339.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn name_width<S: AsRef<str>>(names: impl IntoIterator<Item = S>) -> usize {
|
|
||||||
names
|
|
||||||
.into_iter()
|
|
||||||
.map(|s| s.as_ref().len())
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `\r`-overwritten download bar on stderr. Pads to a stable width so a
|
|
||||||
/// shorter later frame doesn't leave leftover characters from a longer one.
|
|
||||||
pub fn print_progress(downloaded: u64, total: u64) {
|
|
||||||
let width = term_width().clamp(40, 72);
|
|
||||||
let line = progress_line(downloaded, total, 20);
|
|
||||||
let padded = fit_width(&line, width);
|
|
||||||
eprint!("\r{padded}");
|
|
||||||
let _ = std::io::stderr().flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn finish_progress() {
|
|
||||||
eprintln!();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn progress_line(downloaded: u64, total: u64, bar_width: usize) -> String {
|
|
||||||
let dl = downloaded as f64 / 1_048_576.0;
|
|
||||||
let tot = total as f64 / 1_048_576.0;
|
|
||||||
let frac = if total == 0 {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
(downloaded as f64 / total as f64).clamp(0.0, 1.0)
|
|
||||||
};
|
|
||||||
let filled = ((bar_width as f64) * frac).round() as usize;
|
|
||||||
let filled = filled.min(bar_width);
|
|
||||||
let bar = format!("{}{}", "█".repeat(filled), "░".repeat(bar_width - filled));
|
|
||||||
let pct = (frac * 100.0).round() as u32;
|
|
||||||
format!(
|
|
||||||
" ⇣ {} {:>3}% {:.1}/{:.1} MB",
|
|
||||||
style_err(&bar, CYAN),
|
|
||||||
pct,
|
|
||||||
dl,
|
|
||||||
tot
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fit_width(s: &str, width: usize) -> String {
|
|
||||||
let visible = visible_len(s);
|
|
||||||
if visible >= width {
|
|
||||||
return s.to_string();
|
|
||||||
}
|
|
||||||
format!("{s}{}", " ".repeat(width - visible))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visible_len(s: &str) -> usize {
|
|
||||||
let mut n = 0;
|
|
||||||
let mut chars = s.chars().peekable();
|
|
||||||
while let Some(c) = chars.next() {
|
|
||||||
if c == '\u{1b}' {
|
|
||||||
if chars.peek() == Some(&'[') {
|
|
||||||
chars.next();
|
|
||||||
for next in chars.by_ref() {
|
|
||||||
if next.is_ascii_alphabetic() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
n += 1;
|
|
||||||
}
|
|
||||||
n
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn term_width() -> usize {
|
|
||||||
if let Ok(w) = std::env::var("COLUMNS") {
|
|
||||||
if let Ok(n) = w.parse::<usize>() {
|
|
||||||
if n >= 40 {
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ioctl_width().filter(|&n| n >= 40).unwrap_or(80)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn ioctl_width() -> Option<usize> {
|
|
||||||
use std::os::fd::AsRawFd;
|
|
||||||
|
|
||||||
#[repr(C)]
|
|
||||||
struct WinSize {
|
|
||||||
row: u16,
|
|
||||||
col: u16,
|
|
||||||
x: u16,
|
|
||||||
y: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe extern "C" {
|
|
||||||
fn ioctl(fd: i32, request: u64, argp: *mut WinSize) -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut ws = WinSize {
|
|
||||||
row: 0,
|
|
||||||
col: 0,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
};
|
|
||||||
// TIOCGWINSZ on Linux.
|
|
||||||
let fd = std::io::stdout().as_raw_fd();
|
|
||||||
let ret = unsafe { ioctl(fd, 0x5413, &mut ws) };
|
|
||||||
if ret == 0 && ws.col > 0 {
|
|
||||||
Some(ws.col as usize)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn ioctl_width() -> Option<usize> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -394,64 +77,4 @@ mod tests {
|
||||||
assert!(!ok("foo").contains('·'));
|
assert!(!ok("foo").contains('·'));
|
||||||
assert!(!fail("foo").contains('·'));
|
assert!(!fail("foo").contains('·'));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn catalog_aligns_names_and_versions() {
|
|
||||||
let lines = format_catalog(
|
|
||||||
&[
|
|
||||||
CatalogRow {
|
|
||||||
name: "bakery".into(),
|
|
||||||
version: "0.7.2-dev.20260815142350+30517f1".into(),
|
|
||||||
installed: true,
|
|
||||||
detail: "Package manager".into(),
|
|
||||||
aside: String::new(),
|
|
||||||
},
|
|
||||||
CatalogRow {
|
|
||||||
name: "breadarr".into(),
|
|
||||||
version: "0.1.2".into(),
|
|
||||||
installed: false,
|
|
||||||
detail: "Homelab arr stack".into(),
|
|
||||||
aside: String::new(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
80,
|
|
||||||
);
|
|
||||||
assert_eq!(lines.len(), 4);
|
|
||||||
assert!(lines[0].contains("bakery"));
|
|
||||||
assert!(lines[0].contains("0.7.2-dev.20260815142350+30517f1"));
|
|
||||||
assert!(lines[1].contains("Package manager"));
|
|
||||||
// Shorter version is padded so the columns stay a block, not a
|
|
||||||
// ragged list — the long bakery version used to overflow `{: <10}`.
|
|
||||||
// Compare display columns, not byte offsets: the installed glyph
|
|
||||||
// is a 3-byte checkmark sitting in a 1-column slot.
|
|
||||||
let bakery_col = visible_len(&lines[0][..lines[0].find("0.7.2-dev").unwrap()]);
|
|
||||||
let breadarr_col = visible_len(&lines[2][..lines[2].find("0.1.2").unwrap()]);
|
|
||||||
assert_eq!(bakery_col, breadarr_col);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wrap_words_breaks_on_width() {
|
|
||||||
let lines = wrap_words("one two three four", 9);
|
|
||||||
assert_eq!(lines, vec!["one two", "three", "four"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn progress_line_has_bar_and_percent() {
|
|
||||||
let line = progress_line(1_048_576, 2_097_152, 10);
|
|
||||||
assert!(line.contains('█'));
|
|
||||||
assert!(line.contains('░'));
|
|
||||||
assert!(line.contains("50%"));
|
|
||||||
assert!(line.contains("1.0/2.0 MB"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn visible_len_ignores_ansi() {
|
|
||||||
assert_eq!(visible_len("hello"), 5);
|
|
||||||
assert_eq!(visible_len(&format!("{CYAN}hello{RESET}")), 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn short_date_from_rfc3339() {
|
|
||||||
assert_eq!(short_date("2026-08-15T14:23:50+00:00"), "2026-08-15");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "bread-app"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
authors.workspace = true
|
|
||||||
description = "GTK application bootstrap for bread desktop tools: app id, singleton, optional overlay popup, and command listen loop"
|
|
||||||
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
|
||||||
keywords = ["gtk4", "wayland", "hyprland"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
bread-utils = { path = "../bread-utils" }
|
|
||||||
|
|
||||||
[features]
|
|
||||||
# Layer-shell overlay helper (`gtk_popup`). Matches `bread-utils/gtk` so a
|
|
||||||
# consumer that only wants app-id / singleton helpers does not pull GTK4.
|
|
||||||
gtk = ["bread-utils/gtk"]
|
|
||||||
# `BreadClient` listen loop on `bread.command.<app>.**`. Matches
|
|
||||||
# `bread-utils/bread-client`.
|
|
||||||
bread-client = ["bread-utils/bread-client"]
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
//! Command-bus helpers for `bread.command.<app>.**`.
|
|
||||||
//!
|
|
||||||
//! The `command_id` here is the breadd sibling-app id (`clip`, `box`,
|
|
||||||
//! `shot`) — often shorter than the GTK / singleton name (`breadclip`).
|
|
||||||
|
|
||||||
use crate::id::{parse_app_name, InvalidAppId};
|
|
||||||
use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
|
|
||||||
|
|
||||||
/// Same charset as [`parse_app_name`]: a single command-bus segment.
|
|
||||||
pub fn parse_command_id(command_id: &str) -> Result<&str, InvalidAppId> {
|
|
||||||
parse_app_name(command_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Subscribe glob: `bread.command.<app>.**`.
|
|
||||||
pub fn command_pattern(command_id: &str) -> Result<String, InvalidAppId> {
|
|
||||||
let id = parse_command_id(command_id)?;
|
|
||||||
Ok(format!("bread.command.{id}.**"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The verb segment of `bread.command.<app>.<verb>` (and extra trailing
|
|
||||||
/// segments, if any). `None` when the event is not addressed to
|
|
||||||
/// `command_id` or the verb is missing.
|
|
||||||
///
|
|
||||||
/// Extra dotted remainder (`bread.command.clip.stack.clear`) yields the
|
|
||||||
/// first remaining segment (`stack`) — a verb is one segment, matching
|
|
||||||
/// [`BreadClient::command`].
|
|
||||||
pub fn command_verb<'a>(event: &'a str, command_id: &str) -> Option<&'a str> {
|
|
||||||
if command_id.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let prefix = format!("bread.command.{command_id}.");
|
|
||||||
let rest = event.strip_prefix(&prefix)?;
|
|
||||||
let verb = rest.split('.').next()?;
|
|
||||||
if verb.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(verb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Subscribe to `bread.command.<command_id>.**` and invoke `on_verb` with
|
|
||||||
/// the parsed verb plus the raw event.
|
|
||||||
///
|
|
||||||
/// Fail-silent: constructing the client and holding the subscription never
|
|
||||||
/// requires breadd to be running. Drop the returned [`Subscription`] (or
|
|
||||||
/// call [`Subscription::stop`]) to end the loop.
|
|
||||||
pub fn listen_commands<F>(command_id: &str, on_verb: F) -> Result<Subscription, InvalidAppId>
|
|
||||||
where
|
|
||||||
F: Fn(&str, BreadEvent) + Send + 'static,
|
|
||||||
{
|
|
||||||
let id = parse_command_id(command_id)?.to_string();
|
|
||||||
let client = BreadClient::connect(id.clone());
|
|
||||||
let pattern = format!("bread.command.{id}.**");
|
|
||||||
Ok(client.subscribe(pattern, move |event| {
|
|
||||||
let Some(verb) = command_verb(&event.event, &id).map(str::to_owned) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
on_verb(&verb, event);
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_pattern_uses_double_star() {
|
|
||||||
assert_eq!(command_pattern("clip").unwrap(), "bread.command.clip.**");
|
|
||||||
assert_eq!(command_pattern("shot").unwrap(), "bread.command.shot.**");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_pattern_rejects_invalid_id() {
|
|
||||||
assert!(command_pattern("").is_err());
|
|
||||||
assert!(command_pattern("clip.clear").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_verb_strips_app_prefix() {
|
|
||||||
assert_eq!(
|
|
||||||
command_verb("bread.command.clip.clear", "clip"),
|
|
||||||
Some("clear")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
command_verb("bread.command.shot.region", "shot"),
|
|
||||||
Some("region")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
command_verb("bread.command.shot.annotate", "shot"),
|
|
||||||
Some("annotate")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_verb_takes_first_segment_only() {
|
|
||||||
assert_eq!(
|
|
||||||
command_verb("bread.command.clip.stack.clear", "clip"),
|
|
||||||
Some("stack")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_verb_rejects_other_apps_and_missing_verb() {
|
|
||||||
assert_eq!(command_verb("bread.command.clip.clear", "shot"), None);
|
|
||||||
assert_eq!(command_verb("bread.command.clip", "clip"), None);
|
|
||||||
assert_eq!(command_verb("bread.command.clip.", "clip"), None);
|
|
||||||
assert_eq!(command_verb("bread.clip.copied", "clip"), None);
|
|
||||||
assert_eq!(command_verb("bread.command.clip.clear", ""), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn listen_commands_rejects_invalid_id() {
|
|
||||||
assert!(listen_commands("", |_, _| {}).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn listen_commands_stop_joins_without_a_daemon() {
|
|
||||||
let sub = listen_commands("clip", |_, _| {}).unwrap();
|
|
||||||
sub.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
//! App-id helpers shared by GTK tools and the singleton lock.
|
|
||||||
//!
|
|
||||||
//! The process / pid-file name (`breadbox`, `bread-polkit`) is also the
|
|
||||||
//! last segment of the GApplication id (`com.breadway.breadbox`). That is
|
|
||||||
//! *not* always the breadd command-bus id (`box`, `clip`) — see
|
|
||||||
//! [`crate::command_verb`] under feature `bread-client`.
|
|
||||||
|
|
||||||
use std::io;
|
|
||||||
|
|
||||||
use crate::singleton::{self, Acquire, Toggle};
|
|
||||||
|
|
||||||
/// Why [`parse_app_name`] / [`application_id`] rejected a string.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct InvalidAppId {
|
|
||||||
/// The rejected input, owned so the error is `'static`.
|
|
||||||
pub name: String,
|
|
||||||
/// Short reason suitable for an `io::Error` / clap message.
|
|
||||||
pub reason: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for InvalidAppId {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(f, "invalid app id '{}': {}", self.name, self.reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for InvalidAppId {}
|
|
||||||
|
|
||||||
/// Accept a process / GTK application name (`breadbox`, `bread-polkit`).
|
|
||||||
///
|
|
||||||
/// Rules match a GApplication id *element*: non-empty, ASCII letter first,
|
|
||||||
/// then ASCII alphanumeric / `-` / `_`. Dots are rejected so the name can
|
|
||||||
/// sit in `com.breadway.<name>` without creating extra segments.
|
|
||||||
pub fn parse_app_name(name: &str) -> Result<&str, InvalidAppId> {
|
|
||||||
if name.is_empty() {
|
|
||||||
return Err(InvalidAppId {
|
|
||||||
name: name.to_string(),
|
|
||||||
reason: "must not be empty",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let mut chars = name.chars();
|
|
||||||
let first = chars.next().expect("non-empty");
|
|
||||||
if !first.is_ascii_alphabetic() {
|
|
||||||
return Err(InvalidAppId {
|
|
||||||
name: name.to_string(),
|
|
||||||
reason: "must start with an ASCII letter",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
|
||||||
return Err(InvalidAppId {
|
|
||||||
name: name.to_string(),
|
|
||||||
reason: "only ASCII letters, digits, '-' and '_' are allowed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reverse-DNS GApplication id: `com.breadway.<name>`.
|
|
||||||
pub fn application_id(app_name: &str) -> Result<String, InvalidAppId> {
|
|
||||||
let name = parse_app_name(app_name)?;
|
|
||||||
Ok(format!("com.breadway.{name}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`singleton::try_acquire`] after [`parse_app_name`].
|
|
||||||
///
|
|
||||||
/// Invalid names become [`io::ErrorKind::InvalidInput`] and never touch
|
|
||||||
/// the pid file.
|
|
||||||
pub fn try_acquire(app_name: &str) -> io::Result<Acquire> {
|
|
||||||
let name =
|
|
||||||
parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
|
||||||
singleton::try_acquire(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`singleton::toggle_or_kill`] after [`parse_app_name`].
|
|
||||||
pub fn toggle_or_kill(app_name: &str) -> io::Result<Toggle> {
|
|
||||||
let name =
|
|
||||||
parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
|
||||||
singleton::toggle_or_kill(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_app_name_accepts_existing_tool_names() {
|
|
||||||
for name in ["breadbox", "breadclip", "bread-polkit", "breadcast"] {
|
|
||||||
assert_eq!(parse_app_name(name), Ok(name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_app_name_rejects_empty_dot_and_leading_digit() {
|
|
||||||
assert!(parse_app_name("").is_err());
|
|
||||||
assert!(parse_app_name("bread.box").is_err());
|
|
||||||
assert!(parse_app_name("1box").is_err());
|
|
||||||
assert!(parse_app_name("-box").is_err());
|
|
||||||
assert!(parse_app_name("bread box").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn application_id_uses_com_breadway_prefix() {
|
|
||||||
assert_eq!(application_id("breadbox").unwrap(), "com.breadway.breadbox");
|
|
||||||
assert_eq!(
|
|
||||||
application_id("bread-polkit").unwrap(),
|
|
||||||
"com.breadway.bread-polkit"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn application_id_rejects_invalid_name() {
|
|
||||||
assert!(application_id("").is_err());
|
|
||||||
assert!(application_id("bread.box").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn try_acquire_rejects_invalid_name_before_lock() {
|
|
||||||
match try_acquire("") {
|
|
||||||
Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput),
|
|
||||||
Ok(_) => panic!("empty name must not acquire a lock"),
|
|
||||||
}
|
|
||||||
match try_acquire("bread.box") {
|
|
||||||
Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput),
|
|
||||||
Ok(_) => panic!("dotted name must not acquire a lock"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn try_acquire_accepts_valid_name() {
|
|
||||||
let name = format!("bread-app-id-test-{}", std::process::id());
|
|
||||||
match try_acquire(&name).unwrap() {
|
|
||||||
Acquire::Acquired(_guard) => {}
|
|
||||||
Acquire::HeldByOther(_) => panic!("expected first acquire to succeed"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn toggle_or_kill_starts_when_nothing_else_is_running() {
|
|
||||||
let name = format!("bread-app-toggle-test-{}", std::process::id());
|
|
||||||
match toggle_or_kill(&name).unwrap() {
|
|
||||||
Toggle::Started(_guard) => {}
|
|
||||||
Toggle::KilledExisting => panic!("expected to start as the first instance"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
//! GTK application bootstrap for bread desktop tools.
|
|
||||||
//!
|
|
||||||
//! New GTK tools should depend on this crate instead of copying a sixth
|
|
||||||
//! `main.rs` that wires a `com.breadway.*` application id, a
|
|
||||||
//! [`bread_utils::singleton`] lock, a layer-shell overlay, and a
|
|
||||||
//! `bread.command.<app>.**` listen loop.
|
|
||||||
//!
|
|
||||||
//! # What this is
|
|
||||||
//!
|
|
||||||
//! The pieces every bread GTK binary already copies:
|
|
||||||
//!
|
|
||||||
//! - [`application_id`] / [`parse_app_name`] — reverse-DNS id
|
|
||||||
//! (`com.breadway.breadbox`) and the same name used for the singleton
|
|
||||||
//! pid file.
|
|
||||||
//! - [`try_acquire`] / [`toggle_or_kill`] — [`bread_utils::singleton`]
|
|
||||||
//! wrappers that reject an invalid name before touching the lock.
|
|
||||||
//! - feature `gtk` — re-exports [`gtk_popup`] (`bread_utils::gtk_popup`)
|
|
||||||
//! for the full-screen overlay breadbox / breadclip / breadcast start
|
|
||||||
//! from.
|
|
||||||
//! - feature `bread-client` — [`listen_commands`] plus [`command_verb`] /
|
|
||||||
//! [`command_pattern`] so a tool can honor `bread.command.<app>.**`
|
|
||||||
//! without re-deriving the prefix strip.
|
|
||||||
//!
|
|
||||||
//! This crate does **not** migrate existing apps. Callers still own their
|
|
||||||
//! widgets, CSS, and clap. Screenshot / `--screenshot` helpers stay in
|
|
||||||
//! [`bread_utils::screenshot_cli`].
|
|
||||||
//!
|
|
||||||
//! # Example
|
|
||||||
//!
|
|
||||||
//! ```ignore
|
|
||||||
//! let _guard = match bread_app::try_acquire("breadbox")? {
|
|
||||||
//! bread_app::singleton::Acquire::Acquired(g) => g,
|
|
||||||
//! bread_app::singleton::Acquire::HeldByOther(_) => return Ok(()),
|
|
||||||
//! };
|
|
||||||
//! let app = gtk4::Application::builder()
|
|
||||||
//! .application_id(&bread_app::application_id("breadbox")?)
|
|
||||||
//! .build();
|
|
||||||
//!
|
|
||||||
//! #[cfg(feature = "gtk")]
|
|
||||||
//! app.connect_activate(|app| {
|
|
||||||
//! let window = bread_app::gtk_popup::new_overlay_window(app, "breadbox");
|
|
||||||
//! window.present();
|
|
||||||
//! });
|
|
||||||
//!
|
|
||||||
//! #[cfg(feature = "bread-client")]
|
|
||||||
//! let _commands = bread_app::listen_commands("box", |verb, event| {
|
|
||||||
//! // verb is the single segment after `bread.command.box.`
|
|
||||||
//! let _ = (verb, event);
|
|
||||||
//! })?;
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
pub use bread_utils::singleton;
|
|
||||||
|
|
||||||
#[cfg(feature = "gtk")]
|
|
||||||
pub use bread_utils::gtk_popup;
|
|
||||||
|
|
||||||
mod id;
|
|
||||||
|
|
||||||
pub use id::{application_id, parse_app_name, toggle_or_kill, try_acquire, InvalidAppId};
|
|
||||||
|
|
||||||
#[cfg(feature = "bread-client")]
|
|
||||||
mod command;
|
|
||||||
|
|
||||||
#[cfg(feature = "bread-client")]
|
|
||||||
pub use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription};
|
|
||||||
#[cfg(feature = "bread-client")]
|
|
||||||
pub use command::{command_pattern, command_verb, listen_commands, parse_command_id};
|
|
||||||
|
|
@ -50,17 +50,6 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[
|
||||||
("osd-volume", "osd-volume.png"),
|
("osd-volume", "osd-volume.png"),
|
||||||
("osd-brightness", "osd-brightness.png"),
|
("osd-brightness", "osd-brightness.png"),
|
||||||
("wifi-add-dialog", "wifi-add-dialog.png"),
|
("wifi-add-dialog", "wifi-add-dialog.png"),
|
||||||
// Theme 04/spotlight's embedded capsule (only rendered under
|
|
||||||
// `BREAD_SHELL_THEME=spotlight` — every other theme's [bar.slots]
|
|
||||||
// never places launcher_entry/launcher_results anywhere).
|
|
||||||
("capsule-collapsed", "capsule-collapsed.png"),
|
|
||||||
("capsule-expanded", "capsule-expanded.png"),
|
|
||||||
// Phase 6c: query sections (idle "Recent"/"Apps" headers) and
|
|
||||||
// the `=` calc mode — see breadbar's own `screenshot::KNOWN_VIEWS`
|
|
||||||
// doc comment for why the search-state width/radius change
|
|
||||||
// (item E) doesn't need a view of its own.
|
|
||||||
("capsule-sections", "capsule-sections.png"),
|
|
||||||
("capsule-calc", "capsule-calc.png"),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
("breadbox", &[("launcher", "launcher.png")]),
|
("breadbox", &[("launcher", "launcher.png")]),
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "bread-launcher"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
authors.workspace = true
|
|
||||||
description = "Headless app-launcher core (desktop-entry discovery, fuzzy matching/ranking, launch history, launching) plus an optional GTK4 results-list widget — the shared logic behind breadbox's overlay window and breadbar's embedded capsule"
|
|
||||||
repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
|
||||||
keywords = ["launcher", "desktop-entry", "gtk4", "wayland"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
# `do_launch`/`emit_launched` publish a `bread.<app>.launched` event over
|
|
||||||
# breadd's IPC socket after a successful spawn, fire-and-forget — this was
|
|
||||||
# already breadbox's behaviour (`BreadClient::emit` never blocks or errors
|
|
||||||
# the launching caller), just relocated. Not optional: launching is core,
|
|
||||||
# headless functionality, unlike the GTK widget below.
|
|
||||||
bread-utils = { path = "../bread-utils", features = ["bread-client"] }
|
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
|
|
||||||
|
|
||||||
[features]
|
|
||||||
# Enable the GTK4 results-list widget (`gtk` module): row building, fuzzy
|
|
||||||
# filtering, match/history sorting, and keyboard-style selection movement.
|
|
||||||
# Optional so a headless consumer of the matching/ranking/launch core (or a
|
|
||||||
# future non-GTK host) doesn't have to pull in GTK4.
|
|
||||||
gtk = ["dep:gtk4"]
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
use std::{
|
|
||||||
fs::{self, File},
|
|
||||||
io::{BufRead, BufReader},
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::paths::app_dirs;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct DesktopEntry {
|
|
||||||
/// Desktop file id (the `.desktop` filename, e.g. `firefox.desktop`).
|
|
||||||
/// Empty only if the path had no file name; callers fall back to `exec`.
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub exec: String,
|
|
||||||
pub icon_name: String,
|
|
||||||
pub icon_path: Option<PathBuf>, // resolved by caller from manifest
|
|
||||||
pub categories: Vec<String>,
|
|
||||||
pub wm_class: Option<String>,
|
|
||||||
pub terminal: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn strip_exec_codes(exec: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(exec.len());
|
|
||||||
let mut chars = exec.chars().peekable();
|
|
||||||
while let Some(c) = chars.next() {
|
|
||||||
if c == '%' {
|
|
||||||
match chars.peek().copied() {
|
|
||||||
Some('%') => {
|
|
||||||
chars.next();
|
|
||||||
out.push('%');
|
|
||||||
}
|
|
||||||
Some(n) if n.is_ascii_alphabetic() => {
|
|
||||||
chars.next();
|
|
||||||
}
|
|
||||||
_ => out.push(c),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out.push(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `None` for entries that should not be shown (hidden, NoDisplay, non-Application type).
|
|
||||||
pub fn parse_desktop(path: &Path) -> Option<DesktopEntry> {
|
|
||||||
let file = File::open(path).ok()?;
|
|
||||||
let mut in_entry = false;
|
|
||||||
let mut name: Option<String> = None;
|
|
||||||
let mut exec: Option<String> = None;
|
|
||||||
let mut icon: Option<String> = None;
|
|
||||||
let mut categories: Option<String> = None;
|
|
||||||
let mut wm_class: Option<String> = None;
|
|
||||||
let mut app_type: Option<String> = None;
|
|
||||||
let mut no_display = false;
|
|
||||||
let mut hidden = false;
|
|
||||||
let mut terminal = false;
|
|
||||||
|
|
||||||
for line in BufReader::new(file).lines() {
|
|
||||||
let Ok(raw) = line else { continue };
|
|
||||||
let s = raw.trim();
|
|
||||||
if s.starts_with('#') || s.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if s.starts_with('[') {
|
|
||||||
in_entry = s == "[Desktop Entry]";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !in_entry {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(v) = s.strip_prefix("Name=") {
|
|
||||||
name.get_or_insert_with(|| v.to_string());
|
|
||||||
} else if let Some(v) = s.strip_prefix("Exec=") {
|
|
||||||
exec.get_or_insert_with(|| v.to_string());
|
|
||||||
} else if let Some(v) = s.strip_prefix("Icon=") {
|
|
||||||
icon.get_or_insert_with(|| v.to_string());
|
|
||||||
} else if let Some(v) = s.strip_prefix("Categories=") {
|
|
||||||
categories.get_or_insert_with(|| v.to_string());
|
|
||||||
} else if let Some(v) = s.strip_prefix("StartupWMClass=") {
|
|
||||||
wm_class.get_or_insert_with(|| v.to_string());
|
|
||||||
} else if let Some(v) = s.strip_prefix("Type=") {
|
|
||||||
app_type.get_or_insert_with(|| v.to_string());
|
|
||||||
} else if let Some(v) = s.strip_prefix("NoDisplay=") {
|
|
||||||
no_display = v == "true";
|
|
||||||
} else if let Some(v) = s.strip_prefix("Hidden=") {
|
|
||||||
hidden = v == "true";
|
|
||||||
} else if let Some(v) = s.strip_prefix("Terminal=") {
|
|
||||||
terminal = v == "true" || v == "1";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if no_display || hidden {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if app_type.as_deref() != Some("Application") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = name?.trim().to_string();
|
|
||||||
let exec = strip_exec_codes(exec?.trim()).trim().to_string();
|
|
||||||
if name.is_empty() || exec.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let icon_name = icon.unwrap_or_default().trim().to_string();
|
|
||||||
let cats = categories
|
|
||||||
.unwrap_or_default()
|
|
||||||
.split(';')
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let id = path
|
|
||||||
.file_name()
|
|
||||||
.map(|n| n.to_string_lossy().into_owned())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Some(DesktopEntry {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
exec,
|
|
||||||
icon_name,
|
|
||||||
icon_path: None,
|
|
||||||
categories: cats,
|
|
||||||
wm_class: wm_class.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
|
|
||||||
terminal,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walk all configured application directories and return deduplicated entries.
|
|
||||||
/// Entries from later directories (user-local) override those from earlier ones.
|
|
||||||
pub fn load_all_desktop_entries() -> Vec<DesktopEntry> {
|
|
||||||
let mut seen: std::collections::HashMap<String, DesktopEntry> = std::collections::HashMap::new();
|
|
||||||
for dir in app_dirs() {
|
|
||||||
let Ok(entries) = fs::read_dir(&dir) else { continue };
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let key = entry.file_name().to_string_lossy().into_owned();
|
|
||||||
if let Some(app) = parse_desktop(&path) {
|
|
||||||
seen.insert(key, app);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen.into_values().collect()
|
|
||||||
}
|
|
||||||
|
|
@ -1,297 +0,0 @@
|
||||||
//! GTK4 results-list widget: the "row-building half" of what used to be
|
|
||||||
//! breadbox's `run_ui` (`THEME_SYSTEM_PLAN.md` §3) — desktop-entry rows,
|
|
||||||
//! fuzzy filtering, match/history sorting, and keyboard-style selection
|
|
||||||
//! movement, packaged as [`ResultsList`] so any host window can embed it.
|
|
||||||
//! breadbox wraps it in a full-screen overlay window today; breadbar's
|
|
||||||
//! embedded capsule (a later phase) puts the same widget in its drawer slot.
|
|
||||||
|
|
||||||
use std::{cell::RefCell, path::Path, rc::Rc};
|
|
||||||
|
|
||||||
use gtk4::{
|
|
||||||
gdk, gio,
|
|
||||||
pango::EllipsizeMode,
|
|
||||||
prelude::*,
|
|
||||||
Align, Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation, PolicyType,
|
|
||||||
ScrolledWindow, SelectionMode,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::desktop::DesktopEntry;
|
|
||||||
use crate::history::LaunchHistory;
|
|
||||||
use crate::matching::{fuzzy_matches, fuzzy_score, split_sections};
|
|
||||||
|
|
||||||
fn make_icon(icon_name: &str, icon_path: Option<&Path>, icon_px: i32) -> Image {
|
|
||||||
// Try loading from resolved cached path via gio::File
|
|
||||||
if let Some(path) = icon_path {
|
|
||||||
let gio_file = gio::File::for_path(path);
|
|
||||||
if let Ok(texture) = gdk::Texture::from_file(&gio_file) {
|
|
||||||
let img = Image::new();
|
|
||||||
img.set_paintable(Some(&texture));
|
|
||||||
img.set_pixel_size(icon_px);
|
|
||||||
return img;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fall back to GTK icon theme lookup by name
|
|
||||||
let name = if icon_name.is_empty() {
|
|
||||||
"application-x-executable"
|
|
||||||
} else {
|
|
||||||
icon_name
|
|
||||||
};
|
|
||||||
let img = Image::from_icon_name(name);
|
|
||||||
img.set_pixel_size(icon_px);
|
|
||||||
img
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_row(entry: &DesktopEntry, idx: u32, icon_px: i32) -> ListBoxRow {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 0);
|
|
||||||
hbox.set_margin_start(6);
|
|
||||||
hbox.set_margin_end(6);
|
|
||||||
hbox.set_valign(Align::Center);
|
|
||||||
|
|
||||||
let icon = make_icon(&entry.icon_name, entry.icon_path.as_deref(), icon_px);
|
|
||||||
hbox.append(&icon);
|
|
||||||
|
|
||||||
let name_lbl = Label::new(Some(&entry.name));
|
|
||||||
name_lbl.add_css_class("app-name");
|
|
||||||
name_lbl.set_xalign(0.0);
|
|
||||||
name_lbl.set_hexpand(true);
|
|
||||||
name_lbl.set_ellipsize(EllipsizeMode::End);
|
|
||||||
hbox.append(&name_lbl);
|
|
||||||
|
|
||||||
if let Some(ref wm) = entry.wm_class {
|
|
||||||
let wm_lbl = Label::new(Some(wm));
|
|
||||||
wm_lbl.add_css_class("app-muted");
|
|
||||||
wm_lbl.set_xalign(1.0);
|
|
||||||
hbox.append(&wm_lbl);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.set_child(Some(&hbox));
|
|
||||||
unsafe { row.set_data("entry", entry.clone()) };
|
|
||||||
unsafe { row.set_data("initial_order", idx) };
|
|
||||||
row
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A non-selectable, non-activatable "Recent"/"Apps" label row (plan phase
|
|
||||||
/// 6c, `[launcher].sections`) — deliberately carries no `"entry"` row data,
|
|
||||||
/// which is exactly what [`row_entry`] (and everything downstream of it:
|
|
||||||
/// `set_query`'s filter, `select_next`/`select_prev`'s traversal) already
|
|
||||||
/// uses to tell a header apart from a real app row.
|
|
||||||
fn build_header_row(label: &str, idx: u32) -> ListBoxRow {
|
|
||||||
let row = ListBoxRow::new();
|
|
||||||
row.set_selectable(false);
|
|
||||||
row.set_activatable(false);
|
|
||||||
row.add_css_class("bread-drawer-section-header");
|
|
||||||
let lbl = Label::new(Some(label));
|
|
||||||
lbl.add_css_class("section-header-label");
|
|
||||||
lbl.set_xalign(0.0);
|
|
||||||
row.set_child(Some(&lbl));
|
|
||||||
unsafe { row.set_data("initial_order", idx) };
|
|
||||||
row
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the [`DesktopEntry`] a row was built from — e.g. from a
|
|
||||||
/// `ListBox::connect_row_activated` handler, which hands back a row
|
|
||||||
/// reference rather than going through [`ResultsList::selected_entry`].
|
|
||||||
pub fn row_entry(row: &ListBoxRow) -> Option<DesktopEntry> {
|
|
||||||
unsafe { row.data::<DesktopEntry>("entry").map(|p| p.as_ref().clone()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A scrollable, filterable, rankable list of desktop-entry rows — the
|
|
||||||
/// widget breadbox's overlay wraps today and breadbar's capsule will embed
|
|
||||||
/// next (`THEME_SYSTEM_PLAN.md` §7). A host drives it through
|
|
||||||
/// [`set_query`](Self::set_query) (wire to a search entry's `changed`
|
|
||||||
/// signal), [`select_next`](Self::select_next)/[`select_prev`](Self::select_prev)
|
|
||||||
/// (wire to arrow keys), and reads the current pick via
|
|
||||||
/// [`selected_entry`](Self::selected_entry) — `list`/`scroller` are exposed
|
|
||||||
/// directly for anything else a host needs (e.g. `connect_row_activated`
|
|
||||||
/// for click-to-launch, or placing `scroller` in a slot).
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ResultsList {
|
|
||||||
pub scroller: ScrolledWindow,
|
|
||||||
pub list: ListBox,
|
|
||||||
query: Rc<RefCell<String>>,
|
|
||||||
history: Rc<RefCell<LaunchHistory>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResultsList {
|
|
||||||
/// Builds one row per entry (in `entries`' given order — that order is
|
|
||||||
/// also the fallback sort when the query is empty) and wires up sorting
|
|
||||||
/// against `history`'s launch counts.
|
|
||||||
///
|
|
||||||
/// `sections` (`[launcher].sections`, plan phase 6c): when true, the
|
|
||||||
/// idle (empty-query) view groups `entries` into "Recent"/"Apps"
|
|
||||||
/// [`build_header_row`]s via [`split_sections`] instead of one flat
|
|
||||||
/// list. Sections disappear the moment a query is typed — `set_query`
|
|
||||||
/// falls back to the same flat fuzzy-ranked list either way — so this
|
|
||||||
/// only changes the initial build order and the header rows' presence,
|
|
||||||
/// never the (unchanged) search behaviour. `false` reproduces the
|
|
||||||
/// exact pre-phase-6c flat list breadbox's own overlay still uses.
|
|
||||||
pub fn new(
|
|
||||||
entries: &[DesktopEntry],
|
|
||||||
icon_px: i32,
|
|
||||||
history: Rc<RefCell<LaunchHistory>>,
|
|
||||||
sections: bool,
|
|
||||||
) -> Self {
|
|
||||||
let list = ListBox::new();
|
|
||||||
list.set_selection_mode(SelectionMode::Browse);
|
|
||||||
|
|
||||||
let mut idx = 0u32;
|
|
||||||
if sections {
|
|
||||||
let (recent, apps) = split_sections(entries.to_vec(), &history.borrow());
|
|
||||||
if !recent.is_empty() {
|
|
||||||
list.append(&build_header_row("Recent", idx));
|
|
||||||
idx += 1;
|
|
||||||
for entry in &recent {
|
|
||||||
list.append(&build_row(entry, idx, icon_px));
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !apps.is_empty() {
|
|
||||||
list.append(&build_header_row("Apps", idx));
|
|
||||||
idx += 1;
|
|
||||||
for entry in &apps {
|
|
||||||
list.append(&build_row(entry, idx, icon_px));
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for entry in entries {
|
|
||||||
list.append(&build_row(entry, idx, icon_px));
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let query: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
|
|
||||||
{
|
|
||||||
let query = Rc::clone(&query);
|
|
||||||
let history = Rc::clone(&history);
|
|
||||||
list.set_sort_func(move |row_a, row_b| {
|
|
||||||
let query = query.borrow();
|
|
||||||
if query.is_empty() {
|
|
||||||
let oa = unsafe {
|
|
||||||
row_a.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref())
|
|
||||||
};
|
|
||||||
let ob = unsafe {
|
|
||||||
row_b.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref())
|
|
||||||
};
|
|
||||||
return oa.cmp(&ob).into();
|
|
||||||
}
|
|
||||||
// A header row carries no "entry" data — sort it after any
|
|
||||||
// real row rather than treating the comparison as `Equal`,
|
|
||||||
// though `set_query` also hides every header outright once
|
|
||||||
// a query is non-empty, so this only matters for the
|
|
||||||
// underlying (invisible) list order, never what's shown.
|
|
||||||
match (row_entry(row_a), row_entry(row_b)) {
|
|
||||||
(Some(ea), Some(eb)) => {
|
|
||||||
let sa = fuzzy_score(&query, &ea);
|
|
||||||
let sb = fuzzy_score(&query, &eb);
|
|
||||||
let history = history.borrow();
|
|
||||||
let ca = history.count(&ea.name);
|
|
||||||
let cb = history.count(&eb.name);
|
|
||||||
sa.cmp(&sb)
|
|
||||||
.then(cb.cmp(&ca))
|
|
||||||
.then(ea.name.to_lowercase().cmp(&eb.name.to_lowercase()))
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
(None, Some(_)) => std::cmp::Ordering::Greater.into(),
|
|
||||||
(Some(_), None) => std::cmp::Ordering::Less.into(),
|
|
||||||
(None, None) => std::cmp::Ordering::Equal.into(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let first_real = (0i32..)
|
|
||||||
.map_while(|i| list.row_at_index(i))
|
|
||||||
.find(|r| row_entry(r).is_some());
|
|
||||||
if let Some(first) = first_real {
|
|
||||||
list.select_row(Some(&first));
|
|
||||||
}
|
|
||||||
|
|
||||||
let scroller = ScrolledWindow::new();
|
|
||||||
scroller.set_policy(PolicyType::Never, PolicyType::Automatic);
|
|
||||||
scroller.set_max_content_height(480);
|
|
||||||
scroller.set_propagate_natural_height(true);
|
|
||||||
scroller.set_child(Some(&list));
|
|
||||||
|
|
||||||
ResultsList { scroller, list, query, history }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-filters (fuzzy match against name, `wm_class`, and `exec`) and
|
|
||||||
/// re-sorts by `query`, then selects the first visible row. A header
|
|
||||||
/// row (see [`build_header_row`]) only ever shows in the idle
|
|
||||||
/// (empty-query) browse view — it has no name/`wm_class`/`exec` of its
|
|
||||||
/// own to filter against.
|
|
||||||
pub fn set_query(&self, query: &str) {
|
|
||||||
*self.query.borrow_mut() = query.to_string();
|
|
||||||
let mut i = 0i32;
|
|
||||||
while let Some(row) = self.list.row_at_index(i) {
|
|
||||||
let vis = match row_entry(&row) {
|
|
||||||
Some(e) => {
|
|
||||||
fuzzy_matches(query, &e.name)
|
|
||||||
|| e.wm_class.as_deref().is_some_and(|w| fuzzy_matches(query, w))
|
|
||||||
|| fuzzy_matches(query, &e.exec)
|
|
||||||
}
|
|
||||||
None => query.is_empty(),
|
|
||||||
};
|
|
||||||
row.set_visible(vis);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
self.list.invalidate_sort();
|
|
||||||
let first_vis = (0i32..)
|
|
||||||
.map_while(|j| self.list.row_at_index(j))
|
|
||||||
.find(|r| r.is_visible() && row_entry(r).is_some());
|
|
||||||
self.list.select_row(first_vis.as_ref());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn selected_entry(&self) -> Option<DesktopEntry> {
|
|
||||||
self.list.selected_row().and_then(|r| row_entry(&r))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Moves the selection to the next visible row, if any. Skips header
|
|
||||||
/// rows even though they may be visible (the idle browse view) —
|
|
||||||
/// `set_selectable(false)` alone doesn't stop a programmatic
|
|
||||||
/// `select_row` call from landing on one.
|
|
||||||
pub fn select_next(&self) {
|
|
||||||
let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(-1);
|
|
||||||
let mut i = cur + 1;
|
|
||||||
loop {
|
|
||||||
match self.list.row_at_index(i) {
|
|
||||||
Some(r) if r.is_visible() && row_entry(&r).is_some() => {
|
|
||||||
self.list.select_row(Some(&r));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(_) => i += 1,
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Moves the selection to the previous visible row, if any. See
|
|
||||||
/// [`select_next`](Self::select_next) on skipping header rows.
|
|
||||||
pub fn select_prev(&self) {
|
|
||||||
let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(0);
|
|
||||||
let mut i = cur - 1;
|
|
||||||
loop {
|
|
||||||
if i < 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match self.list.row_at_index(i) {
|
|
||||||
Some(r) if r.is_visible() && row_entry(&r).is_some() => {
|
|
||||||
self.list.select_row(Some(&r));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(_) => i -= 1,
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records `entry` as launched in the shared history and persists it.
|
|
||||||
/// Call before actually launching (matching breadbox's original
|
|
||||||
/// increment-then-launch ordering) — history and launching are separate
|
|
||||||
/// concerns, so this doesn't call [`crate::do_launch`] itself.
|
|
||||||
pub fn record_launch(&self, entry: &DesktopEntry) {
|
|
||||||
self.history.borrow_mut().increment(&entry.name);
|
|
||||||
self.history.borrow().save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
use std::{collections::HashMap, fs, path::PathBuf};
|
|
||||||
|
|
||||||
pub struct LaunchHistory {
|
|
||||||
counts: HashMap<String, u32>,
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LaunchHistory {
|
|
||||||
/// `app` picks the cache subdirectory (see [`crate::cache_dir`]) the
|
|
||||||
/// history file lives in.
|
|
||||||
pub fn load(app: &str) -> Self {
|
|
||||||
let path = crate::paths::cache_dir(app).join("history.json");
|
|
||||||
let counts = fs::read_to_string(&path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| serde_json::from_str(&s).ok())
|
|
||||||
.unwrap_or_default();
|
|
||||||
LaunchHistory { counts, path }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn count(&self, name: &str) -> u32 {
|
|
||||||
self.counts.get(name).copied().unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn increment(&mut self, name: &str) {
|
|
||||||
*self.counts.entry(name.to_string()).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes `counts` to `path` as JSON. Best-effort — a broken cache dir
|
|
||||||
/// (missing parent, full disk, permissions) must not stop the caller
|
|
||||||
/// from launching anything, so this never returns an error — but it now
|
|
||||||
/// logs one on failure rather than swallowing it silently. Shared by two
|
|
||||||
/// hosts (breadbox's overlay and breadbar's embedded capsule, both keyed
|
|
||||||
/// under [`crate::LAUNCHER_APP`]), so a save failure here silently stops
|
|
||||||
/// ranking history for both.
|
|
||||||
pub fn save(&self) {
|
|
||||||
match serde_json::to_string(&self.counts) {
|
|
||||||
Ok(json) => {
|
|
||||||
if let Err(err) = fs::write(&self.path, json) {
|
|
||||||
eprintln!(
|
|
||||||
"bread-launcher: failed to save launch history to {}: {err}",
|
|
||||||
self.path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("bread-launcher: failed to serialize launch history: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// In-memory history with no backing file — [`save`](Self::save) fails
|
|
||||||
/// (an empty `path` is not writable) and now logs that failure to
|
|
||||||
/// stderr rather than swallowing it, same as any other broken-path
|
|
||||||
/// case. Lets a test (or a future in-memory host) control counts
|
|
||||||
/// directly instead of writing through `~/.cache/<app>/history.json`.
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn from_counts(counts: HashMap<String, u32>) -> Self {
|
|
||||||
LaunchHistory {
|
|
||||||
counts,
|
|
||||||
path: PathBuf::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn temp_history_path(name: &str) -> PathBuf {
|
|
||||||
let dir = std::env::temp_dir().join(format!(
|
|
||||||
"bread-launcher-history-test-{}-{name}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_nanos()
|
|
||||||
));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
dir.join("history.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn save_then_load_round_trips_counts() {
|
|
||||||
let path = temp_history_path("roundtrip");
|
|
||||||
let mut history = LaunchHistory {
|
|
||||||
counts: HashMap::new(),
|
|
||||||
path: path.clone(),
|
|
||||||
};
|
|
||||||
history.increment("firefox.desktop");
|
|
||||||
history.increment("firefox.desktop");
|
|
||||||
history.increment("kitty.desktop");
|
|
||||||
history.save();
|
|
||||||
|
|
||||||
let text = std::fs::read_to_string(&path).expect("save should have written the file");
|
|
||||||
let counts: HashMap<String, u32> = serde_json::from_str(&text).unwrap();
|
|
||||||
assert_eq!(counts.get("firefox.desktop"), Some(&2));
|
|
||||||
assert_eq!(counts.get("kitty.desktop"), Some(&1));
|
|
||||||
|
|
||||||
// load() from the same path should see the same counts.
|
|
||||||
let reloaded = LaunchHistory {
|
|
||||||
counts: serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(),
|
|
||||||
path: path.clone(),
|
|
||||||
};
|
|
||||||
assert_eq!(reloaded.count("firefox.desktop"), 2);
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `save()` on an unwritable path (e.g. the parent directory doesn't
|
|
||||||
/// exist, or `path` is empty) must not panic — it's best-effort, called
|
|
||||||
/// from a launcher's shutdown path where a hard failure would be worse
|
|
||||||
/// than a lost history entry. This exercises exactly the failure branch
|
|
||||||
/// the `eprintln!` above was added for; there is no return value to
|
|
||||||
/// assert on, so "did not panic" is the contract under test.
|
|
||||||
#[test]
|
|
||||||
fn save_to_a_broken_path_does_not_panic() {
|
|
||||||
let history = LaunchHistory::from_counts(HashMap::from([("x".to_string(), 1)]));
|
|
||||||
history.save();
|
|
||||||
|
|
||||||
let history = LaunchHistory {
|
|
||||||
counts: HashMap::new(),
|
|
||||||
path: PathBuf::from("/nonexistent-dir/definitely-not-there/history.json"),
|
|
||||||
};
|
|
||||||
history.save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
@ -1,387 +0,0 @@
|
||||||
use std::{collections::HashMap, path::PathBuf};
|
|
||||||
|
|
||||||
use crate::desktop::{load_all_desktop_entries, DesktopEntry};
|
|
||||||
use crate::history::LaunchHistory;
|
|
||||||
|
|
||||||
// ---- Fuzzy matching (query filter) ------------------------------------------
|
|
||||||
|
|
||||||
/// Subsequence match used to *filter* rows as the user types: every char of
|
|
||||||
/// `pattern`, in order, must appear somewhere in `text` (case-insensitive).
|
|
||||||
/// Looser than [`fuzzy_score`], which ranks the rows that pass this filter.
|
|
||||||
pub fn fuzzy_matches(pattern: &str, text: &str) -> bool {
|
|
||||||
if pattern.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let mut chars = text.chars();
|
|
||||||
for pc in pattern.chars() {
|
|
||||||
let pl = pc.to_lowercase().next().unwrap_or(pc);
|
|
||||||
if !chars
|
|
||||||
.by_ref()
|
|
||||||
.any(|tc| tc.to_lowercase().next().unwrap_or(tc) == pl)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ranks how well `query` matches `entry` — lower is better. Exact match (by
|
|
||||||
/// name or `wm_class`) sorts first, then name-prefix, then name-contains,
|
|
||||||
/// then `wm_class`-prefix/contains, then everything else that still passed
|
|
||||||
/// [`fuzzy_matches`] (a subsequence match with no stronger relationship).
|
|
||||||
pub fn fuzzy_score(query: &str, entry: &DesktopEntry) -> u32 {
|
|
||||||
let q = query.to_lowercase();
|
|
||||||
let name = entry.name.to_lowercase();
|
|
||||||
let wm = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
|
|
||||||
if name == q || wm == q {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if name.starts_with(&q) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if name.contains(&q) {
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
if wm.starts_with(&q) || wm.contains(&q) {
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
4 // subsequence match
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Priority ranking (empty-query ordering) --------------------------------
|
|
||||||
|
|
||||||
/// Whole-word / exact match of `term` within `field` (both lowercase). Avoids
|
|
||||||
/// "code" matching "vscodium" while still matching "Code", "code-oss", and
|
|
||||||
/// "Visual Studio Code".
|
|
||||||
pub fn matches_term(field: &str, term: &str) -> bool {
|
|
||||||
if term.is_empty() || field.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if field == term {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let bytes = field.as_bytes();
|
|
||||||
let tlen = term.len();
|
|
||||||
let mut start = 0;
|
|
||||||
while let Some(pos) = field[start..].find(term) {
|
|
||||||
let i = start + pos;
|
|
||||||
let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
|
|
||||||
let after = i + tlen;
|
|
||||||
let after_ok = after >= bytes.len() || !bytes[after].is_ascii_alphanumeric();
|
|
||||||
if before_ok && after_ok {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Advance past the WHOLE match, not one byte past its start. Both `i`
|
|
||||||
// (a match start) and `i + tlen` (its end) are guaranteed char
|
|
||||||
// boundaries; `i + 1` is not, so a multi-byte term that failed the
|
|
||||||
// word-boundary check left `start` inside a character and the next
|
|
||||||
// `field[start..]` slice panicked outright. `matches_term("café", "é")`
|
|
||||||
// reproduced it: "byte index 4 is not a char boundary". Reached via
|
|
||||||
// priority_rank over real .desktop `Name=` values, so any non-ASCII
|
|
||||||
// app name could crash the launcher's sort.
|
|
||||||
start = i + tlen;
|
|
||||||
if start >= field.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Position of `entry` in the (already-lowercased) `priority` list, matched
|
|
||||||
/// against either its name or `wm_class`. `None` if `entry` isn't named
|
|
||||||
/// there at all.
|
|
||||||
pub fn priority_rank(entry: &DesktopEntry, priority_lower: &[String]) -> Option<usize> {
|
|
||||||
let name_l = entry.name.to_lowercase();
|
|
||||||
let wm_l = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
|
|
||||||
priority_lower
|
|
||||||
.iter()
|
|
||||||
.position(|p| matches_term(&name_l, p) || matches_term(&wm_l, p))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads every known desktop entry, resolves each one's icon path from
|
|
||||||
/// `manifest`, and sorts them: entries named in `priority` come first (in
|
|
||||||
/// that order), then everything else by most-launched (via `history`), then
|
|
||||||
/// alphabetically.
|
|
||||||
pub fn load_sorted_entries(
|
|
||||||
manifest: &HashMap<String, PathBuf>,
|
|
||||||
priority: &[String],
|
|
||||||
history: &LaunchHistory,
|
|
||||||
) -> Vec<DesktopEntry> {
|
|
||||||
let mut entries = load_all_desktop_entries();
|
|
||||||
|
|
||||||
// Populate icon_path from manifest
|
|
||||||
for entry in &mut entries {
|
|
||||||
if let Some(path) = manifest.get(&entry.icon_name) {
|
|
||||||
if path.exists() {
|
|
||||||
entry.icon_path = Some(path.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let priority_lower: Vec<String> = priority.iter().map(|s| s.to_lowercase()).collect();
|
|
||||||
|
|
||||||
entries.sort_by(|a, b| {
|
|
||||||
let ai = priority_rank(a, &priority_lower);
|
|
||||||
let bi = priority_rank(b, &priority_lower);
|
|
||||||
match (ai, bi) {
|
|
||||||
(Some(i), Some(j)) => i.cmp(&j),
|
|
||||||
(Some(_), None) => std::cmp::Ordering::Less,
|
|
||||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
||||||
(None, None) => {
|
|
||||||
// Most-launched first, then alphabetical
|
|
||||||
history
|
|
||||||
.count(&b.name)
|
|
||||||
.cmp(&history.count(&a.name))
|
|
||||||
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
entries
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The demo's own cap (`BOS.pushRecent`'s `.slice(0, 4)`) on how many
|
|
||||||
/// entries the "Recent" section shows.
|
|
||||||
pub const MAX_RECENT: usize = 4;
|
|
||||||
|
|
||||||
/// Splits `entries` (already ordered by [`load_sorted_entries`]) into a
|
|
||||||
/// "recent" section — the entries `history` has any launch count for, most-
|
|
||||||
/// launched first, capped at [`MAX_RECENT`] — and an "apps" section: every
|
|
||||||
/// other entry, in its existing relative order. No new tracking beyond
|
|
||||||
/// `LaunchHistory`'s existing counts (THEME_SYSTEM_PLAN.md phase 6c task
|
|
||||||
/// notes: "`LaunchHistory` already tracks counts for a recents list").
|
|
||||||
///
|
|
||||||
/// Only meaningful when `entries` has no priority-ranked prefix (breadbar's
|
|
||||||
/// capsule calls [`load_sorted_entries`] with an empty `priority` list) —
|
|
||||||
/// with a non-empty priority list, priority-ranked entries still sort first
|
|
||||||
/// and would be treated as "apps" here even if launched often, since this
|
|
||||||
/// function has no way to tell "sorted first because launched a lot" from
|
|
||||||
/// "sorted first because priority-ranked" apart from the count itself.
|
|
||||||
pub fn split_sections(
|
|
||||||
entries: Vec<DesktopEntry>,
|
|
||||||
history: &LaunchHistory,
|
|
||||||
) -> (Vec<DesktopEntry>, Vec<DesktopEntry>) {
|
|
||||||
let mut recent = Vec::new();
|
|
||||||
let mut apps = Vec::new();
|
|
||||||
for entry in entries {
|
|
||||||
if recent.len() < MAX_RECENT && history.count(&entry.name) > 0 {
|
|
||||||
recent.push(entry);
|
|
||||||
} else {
|
|
||||||
apps.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(recent, apps)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
#[test]
|
|
||||||
fn multibyte_term_that_fails_word_boundary_does_not_panic() {
|
|
||||||
// "é" occurs in "café" but is preceded by an alphanumeric, so the
|
|
||||||
// whole-word check fails and the scan must continue — landing mid
|
|
||||||
// character before the fix.
|
|
||||||
assert!(!super::matches_term("café", "é"));
|
|
||||||
assert!(!super::matches_term("naïve café", "ï"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn multibyte_whole_word_still_matches() {
|
|
||||||
assert!(super::matches_term("café bar", "café"));
|
|
||||||
assert!(super::matches_term("día", "día"));
|
|
||||||
}
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn entry(name: &str, wm_class: Option<&str>) -> DesktopEntry {
|
|
||||||
DesktopEntry {
|
|
||||||
id: format!("{name}.desktop"),
|
|
||||||
name: name.to_string(),
|
|
||||||
exec: "true".to_string(),
|
|
||||||
icon_name: String::new(),
|
|
||||||
icon_path: None,
|
|
||||||
categories: Vec::new(),
|
|
||||||
wm_class: wm_class.map(|s| s.to_string()),
|
|
||||||
terminal: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- fuzzy_matches -------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fuzzy_matches_empty_pattern_matches_anything() {
|
|
||||||
assert!(fuzzy_matches("", "Firefox"));
|
|
||||||
assert!(fuzzy_matches("", ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fuzzy_matches_in_order_subsequence() {
|
|
||||||
assert!(fuzzy_matches("ffx", "Firefox"));
|
|
||||||
assert!(fuzzy_matches("frfx", "Firefox"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fuzzy_matches_is_case_insensitive() {
|
|
||||||
assert!(fuzzy_matches("FIREFOX", "firefox"));
|
|
||||||
assert!(fuzzy_matches("firefox", "FireFox"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fuzzy_matches_rejects_out_of_order() {
|
|
||||||
assert!(!fuzzy_matches("xfr", "Firefox"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fuzzy_matches_rejects_missing_chars() {
|
|
||||||
assert!(!fuzzy_matches("firefoxx", "Firefox"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- fuzzy_score -----------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fuzzy_score_exact_name_match_is_best() {
|
|
||||||
let e = entry("Firefox", None);
|
|
||||||
assert_eq!(fuzzy_score("firefox", &e), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fuzzy_score_exact_wm_class_match_is_best() {
|
|
||||||
let e = entry("Firefox Web Browser", Some("firefox"));
|
|
||||||
assert_eq!(fuzzy_score("firefox", &e), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fuzzy_score_name_prefix_beats_name_contains() {
|
|
||||||
let prefix = entry("Firefox", None);
|
|
||||||
let contains = entry("GNU IceCat (Firefox fork)", None);
|
|
||||||
assert_eq!(fuzzy_score("fire", &prefix), 1);
|
|
||||||
assert_eq!(fuzzy_score("fire", &contains), 2);
|
|
||||||
assert!(fuzzy_score("fire", &prefix) < fuzzy_score("fire", &contains));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fuzzy_score_wm_class_beats_pure_subsequence() {
|
|
||||||
let wm_hit = entry("Web Browser", Some("firefox"));
|
|
||||||
let subseq_only = entry("Fine Iris Reflex Editor for XML", None);
|
|
||||||
assert_eq!(fuzzy_score("fire", &wm_hit), 3);
|
|
||||||
assert_eq!(fuzzy_score("fire", &subseq_only), 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- matches_term ------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn matches_term_exact_field_matches() {
|
|
||||||
assert!(matches_term("code", "code"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn matches_term_whole_word_within_longer_field() {
|
|
||||||
assert!(matches_term("visual studio code", "code"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn matches_term_rejects_substring_of_a_larger_word() {
|
|
||||||
// "code" must not match inside "vscodium" — this is the whole
|
|
||||||
// reason matches_term exists instead of a plain `contains`.
|
|
||||||
assert!(!matches_term("vscodium", "code"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn matches_term_matches_hyphenated_variant() {
|
|
||||||
assert!(matches_term("code-oss", "code"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn matches_term_empty_term_or_field_never_matches() {
|
|
||||||
assert!(!matches_term("code", ""));
|
|
||||||
assert!(!matches_term("", "code"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- priority_rank -----------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn priority_rank_matches_by_name() {
|
|
||||||
let e = entry("Firefox", None);
|
|
||||||
let priority = vec!["firefox".to_string(), "code".to_string()];
|
|
||||||
assert_eq!(priority_rank(&e, &priority), Some(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn priority_rank_matches_by_wm_class() {
|
|
||||||
let e = entry("Web Browser", Some("firefox"));
|
|
||||||
let priority = vec!["code".to_string(), "firefox".to_string()];
|
|
||||||
assert_eq!(priority_rank(&e, &priority), Some(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn priority_rank_none_when_unlisted() {
|
|
||||||
let e = entry("Nautilus", None);
|
|
||||||
let priority = vec!["firefox".to_string()];
|
|
||||||
assert_eq!(priority_rank(&e, &priority), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn priority_rank_does_not_match_substring_of_a_word() {
|
|
||||||
let e = entry("VSCodium", None);
|
|
||||||
let priority = vec!["code".to_string()];
|
|
||||||
assert_eq!(priority_rank(&e, &priority), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- split_sections --------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_sections_no_history_is_all_apps() {
|
|
||||||
let entries = vec![entry("Firefox", None), entry("GoLand", None)];
|
|
||||||
let history = LaunchHistory::from_counts(HashMap::new());
|
|
||||||
let (recent, apps) = split_sections(entries, &history);
|
|
||||||
assert!(recent.is_empty());
|
|
||||||
assert_eq!(apps.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_sections_launched_entries_go_to_recent() {
|
|
||||||
let entries = vec![
|
|
||||||
entry("Firefox", None),
|
|
||||||
entry("GoLand", None),
|
|
||||||
entry("Steam", None),
|
|
||||||
];
|
|
||||||
let mut counts = HashMap::new();
|
|
||||||
counts.insert("Firefox".to_string(), 5);
|
|
||||||
let history = LaunchHistory::from_counts(counts);
|
|
||||||
let (recent, apps) = split_sections(entries, &history);
|
|
||||||
assert_eq!(recent.iter().map(|e| &e.name).collect::<Vec<_>>(), vec!["Firefox"]);
|
|
||||||
assert_eq!(
|
|
||||||
apps.iter().map(|e| &e.name).collect::<Vec<_>>(),
|
|
||||||
vec!["GoLand", "Steam"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_sections_caps_recent_at_max() {
|
|
||||||
let entries: Vec<DesktopEntry> = (0..(MAX_RECENT + 2))
|
|
||||||
.map(|i| entry(&format!("App{i}"), None))
|
|
||||||
.collect();
|
|
||||||
let counts = entries
|
|
||||||
.iter()
|
|
||||||
.map(|e| (e.name.clone(), 1))
|
|
||||||
.collect::<HashMap<_, _>>();
|
|
||||||
let history = LaunchHistory::from_counts(counts);
|
|
||||||
let (recent, apps) = split_sections(entries, &history);
|
|
||||||
assert_eq!(recent.len(), MAX_RECENT);
|
|
||||||
assert_eq!(apps.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_sections_preserves_relative_order_within_each_group() {
|
|
||||||
let mut counts = HashMap::new();
|
|
||||||
counts.insert("A".to_string(), 1);
|
|
||||||
counts.insert("C".to_string(), 3);
|
|
||||||
let history = LaunchHistory::from_counts(counts);
|
|
||||||
// load_sorted_entries would already have ordered these by count
|
|
||||||
// desc before calling split_sections; split_sections itself just
|
|
||||||
// partitions in whatever order it's handed, so feed it pre-sorted.
|
|
||||||
let pre_sorted = vec![entry("C", None), entry("A", None), entry("B", None)];
|
|
||||||
let (recent, apps) = split_sections(pre_sorted, &history);
|
|
||||||
assert_eq!(recent.iter().map(|e| &e.name).collect::<Vec<_>>(), vec!["C", "A"]);
|
|
||||||
assert_eq!(apps.iter().map(|e| &e.name).collect::<Vec<_>>(), vec!["B"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
use std::{env, path::PathBuf};
|
|
||||||
|
|
||||||
// ---- XDG path helpers -------------------------------------------------------
|
|
||||||
|
|
||||||
pub fn home_dir() -> PathBuf {
|
|
||||||
PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `$XDG_CACHE_HOME/<app>` (or `~/.cache/<app>`). `app` is the caller's own
|
|
||||||
/// name in this scheme — e.g. breadbox passes `"breadbox"` to keep using the
|
|
||||||
/// on-disk layout it always has; a future host picks its own.
|
|
||||||
pub fn cache_dir(app: &str) -> PathBuf {
|
|
||||||
env::var("XDG_CACHE_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| home_dir().join(".cache"))
|
|
||||||
.join(app)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `$XDG_CONFIG_HOME/<app>` (or `~/.config/<app>`). See [`cache_dir`].
|
|
||||||
pub fn config_dir(app: &str) -> PathBuf {
|
|
||||||
env::var("XDG_CONFIG_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| home_dir().join(".config"))
|
|
||||||
.join(app)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `applications/` directories a `.desktop` file may live in, per the
|
|
||||||
/// XDG base-directory spec (system-wide first, user-local last so later
|
|
||||||
/// entries can override earlier ones on lookup by filename).
|
|
||||||
pub fn app_dirs() -> Vec<PathBuf> {
|
|
||||||
let home = home_dir();
|
|
||||||
let mut dirs = vec![PathBuf::from("/usr/share/applications")];
|
|
||||||
|
|
||||||
let xdg_data_dirs =
|
|
||||||
env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".into());
|
|
||||||
for d in xdg_data_dirs.split(':') {
|
|
||||||
let p = PathBuf::from(d).join("applications");
|
|
||||||
if p != dirs[0] {
|
|
||||||
dirs.push(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dirs.push(
|
|
||||||
env::var("XDG_DATA_HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| home.join(".local/share"))
|
|
||||||
.join("applications"),
|
|
||||||
);
|
|
||||||
dirs
|
|
||||||
}
|
|
||||||
|
|
@ -1,391 +0,0 @@
|
||||||
//! Query modes (`04-spotlight.html`'s `BOS.parseQuery`/`BOS.evalCalc`/
|
|
||||||
//! `BOS.COMMANDS`, THEME_SYSTEM_PLAN.md phase 6c): a leading `=`/`>`/`.`
|
|
||||||
//! switches the launcher from filtering apps to evaluating an arithmetic
|
|
||||||
//! expression, listing bread commands, or treating the rest of the query as
|
|
||||||
//! a URL to open. Pure/headless — no GTK here — so both breadbar's embedded
|
|
||||||
//! capsule and (per the module doc comment on `crate`) breadbox's own
|
|
||||||
//! overlay window can adopt the same parsing/eval/filter logic. A host is
|
|
||||||
//! expected to gate which prefixes it actually acts on against its theme's
|
|
||||||
//! `[launcher].modes` list — this module recognizes all four kinds
|
|
||||||
//! unconditionally and leaves that gating to the caller.
|
|
||||||
|
|
||||||
use crate::matching::fuzzy_matches;
|
|
||||||
|
|
||||||
/// Which of the four query modes a raw entry-text string names, per its
|
|
||||||
/// leading character (mirrors `BOS.parseQuery`).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum QueryKind {
|
|
||||||
/// No recognized prefix — the ordinary app-filter query.
|
|
||||||
Apps,
|
|
||||||
/// Leading `=` — the rest is an arithmetic expression for [`eval_calc`].
|
|
||||||
Calc,
|
|
||||||
/// Leading `>` — the rest filters [`builtin_commands`].
|
|
||||||
Cmd,
|
|
||||||
/// Leading `.` — the rest is a URL to open.
|
|
||||||
Url,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A parsed query: which mode it names, and the text after the prefix
|
|
||||||
/// character (empty string for a bare `=`/`>`/`.` with nothing typed yet).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ParsedQuery {
|
|
||||||
pub kind: QueryKind,
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Splits `raw` on its leading mode character, if any. Mirrors
|
|
||||||
/// `BOS.parseQuery` exactly: only the FIRST character is checked, and it is
|
|
||||||
/// stripped along with any immediately-following whitespace.
|
|
||||||
pub fn parse_query(raw: &str) -> ParsedQuery {
|
|
||||||
let (kind, rest) = if let Some(rest) = raw.strip_prefix('=') {
|
|
||||||
(QueryKind::Calc, rest)
|
|
||||||
} else if let Some(rest) = raw.strip_prefix('>') {
|
|
||||||
(QueryKind::Cmd, rest)
|
|
||||||
} else if let Some(rest) = raw.strip_prefix('.') {
|
|
||||||
(QueryKind::Url, rest)
|
|
||||||
} else {
|
|
||||||
(QueryKind::Apps, raw)
|
|
||||||
};
|
|
||||||
ParsedQuery {
|
|
||||||
kind,
|
|
||||||
value: rest.trim().to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Calc ---------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Evaluates `expr` as a small four-function arithmetic expression
|
|
||||||
/// (`+ - * / ( )`, decimal literals, unary minus) and formats the result
|
|
||||||
/// the same way `BOS.evalCalc` does: rounded to 8 decimal places, an
|
|
||||||
/// integer result prints with no trailing `.0`, a non-finite result prints
|
|
||||||
/// "∞", an expression containing anything outside `[0-9.\s+\-*/()]`
|
|
||||||
/// returns "bad expr", and anything else that fails to parse or evaluate
|
|
||||||
/// (unbalanced parens, division producing NaN, trailing garbage) returns
|
|
||||||
/// "err". `None` only for an empty/whitespace-only expression — the
|
|
||||||
/// caller's "nothing typed after `=` yet" case, which has no result to
|
|
||||||
/// show at all (the demo's own `if (!expr) return null;`).
|
|
||||||
pub fn eval_calc(expr: &str) -> Option<String> {
|
|
||||||
let expr = expr.trim();
|
|
||||||
if expr.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if !expr.chars().all(|c| c.is_ascii_digit() || " .+-*/()".contains(c)) {
|
|
||||||
return Some("bad expr".to_string());
|
|
||||||
}
|
|
||||||
let mut p = CalcParser {
|
|
||||||
bytes: expr.as_bytes(),
|
|
||||||
pos: 0,
|
|
||||||
};
|
|
||||||
let result = p.parse_expr().filter(|_| p.skip_ws() == p.bytes.len());
|
|
||||||
Some(match result {
|
|
||||||
Some(n) if !n.is_finite() => "∞".to_string(),
|
|
||||||
Some(n) => format_calc_result(n),
|
|
||||||
None => "err".to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rounds to 8 decimal places and formats without a trailing `.0` for whole
|
|
||||||
/// numbers — `String(Math.round(n * 1e8) / 1e8)` in JS, `{}` on `f64`
|
|
||||||
/// already behaves the same way in Rust (`format!("{}", 4.0_f64)` == "4").
|
|
||||||
fn format_calc_result(n: f64) -> String {
|
|
||||||
let rounded = (n * 1e8).round() / 1e8;
|
|
||||||
// Avoid printing "-0" for a result that rounds to negative zero.
|
|
||||||
let rounded = if rounded == 0.0 { 0.0 } else { rounded };
|
|
||||||
format!("{rounded}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal recursive-descent parser: `expr := term (('+'|'-') term)*`,
|
|
||||||
/// `term := factor (('*'|'/') factor)*`, `factor := '-' factor | number |
|
|
||||||
/// '(' expr ')'`. Byte-indexed since the character set is already
|
|
||||||
/// restricted to ASCII by [`eval_calc`]'s pre-check.
|
|
||||||
struct CalcParser<'a> {
|
|
||||||
bytes: &'a [u8],
|
|
||||||
pos: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> CalcParser<'a> {
|
|
||||||
fn skip_ws(&mut self) -> usize {
|
|
||||||
while self.pos < self.bytes.len() && self.bytes[self.pos] == b' ' {
|
|
||||||
self.pos += 1;
|
|
||||||
}
|
|
||||||
self.pos
|
|
||||||
}
|
|
||||||
|
|
||||||
fn peek(&mut self) -> Option<u8> {
|
|
||||||
self.skip_ws();
|
|
||||||
self.bytes.get(self.pos).copied()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_expr(&mut self) -> Option<f64> {
|
|
||||||
let mut val = self.parse_term()?;
|
|
||||||
loop {
|
|
||||||
match self.peek() {
|
|
||||||
Some(b'+') => {
|
|
||||||
self.pos += 1;
|
|
||||||
val += self.parse_term()?;
|
|
||||||
}
|
|
||||||
Some(b'-') => {
|
|
||||||
self.pos += 1;
|
|
||||||
val -= self.parse_term()?;
|
|
||||||
}
|
|
||||||
_ => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(val)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_term(&mut self) -> Option<f64> {
|
|
||||||
let mut val = self.parse_factor()?;
|
|
||||||
loop {
|
|
||||||
match self.peek() {
|
|
||||||
Some(b'*') => {
|
|
||||||
self.pos += 1;
|
|
||||||
val *= self.parse_factor()?;
|
|
||||||
}
|
|
||||||
Some(b'/') => {
|
|
||||||
self.pos += 1;
|
|
||||||
val /= self.parse_factor()?;
|
|
||||||
}
|
|
||||||
_ => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(val)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_factor(&mut self) -> Option<f64> {
|
|
||||||
match self.peek()? {
|
|
||||||
b'-' => {
|
|
||||||
self.pos += 1;
|
|
||||||
Some(-self.parse_factor()?)
|
|
||||||
}
|
|
||||||
b'+' => {
|
|
||||||
self.pos += 1;
|
|
||||||
self.parse_factor()
|
|
||||||
}
|
|
||||||
b'(' => {
|
|
||||||
self.pos += 1;
|
|
||||||
let val = self.parse_expr()?;
|
|
||||||
if self.peek() == Some(b')') {
|
|
||||||
self.pos += 1;
|
|
||||||
Some(val)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c if c.is_ascii_digit() || c == b'.' => self.parse_number(),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_number(&mut self) -> Option<f64> {
|
|
||||||
self.skip_ws();
|
|
||||||
let start = self.pos;
|
|
||||||
let mut seen_dot = false;
|
|
||||||
let mut seen_digit = false;
|
|
||||||
while let Some(&c) = self.bytes.get(self.pos) {
|
|
||||||
if c.is_ascii_digit() {
|
|
||||||
seen_digit = true;
|
|
||||||
self.pos += 1;
|
|
||||||
} else if c == b'.' && !seen_dot {
|
|
||||||
seen_dot = true;
|
|
||||||
self.pos += 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !seen_digit {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
std::str::from_utf8(&self.bytes[start..self.pos])
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.parse::<f64>().ok())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Commands -------------------------------------------------------------
|
|
||||||
|
|
||||||
/// One `>`-mode command palette entry: a display `name` and the shell
|
|
||||||
/// command it runs (via `bash -c`, same spawn convention [`crate::do_launch`]
|
|
||||||
/// already uses for a desktop entry's `Exec=` line).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Command {
|
|
||||||
pub id: &'static str,
|
|
||||||
pub name: &'static str,
|
|
||||||
pub exec: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A small, real bread-ecosystem command palette — deliberately not the
|
|
||||||
/// demo's placeholder set (`Lock session`/`Open settings`/`Test
|
|
||||||
/// notification` map to nothing real in this codebase). `loginctl
|
|
||||||
/// lock-session` and `bread reload` are both already-documented, safe,
|
|
||||||
/// no-argument commands (see the bread CLI reference / systemd-logind).
|
|
||||||
pub fn builtin_commands() -> &'static [Command] {
|
|
||||||
&[
|
|
||||||
Command {
|
|
||||||
id: "lock",
|
|
||||||
name: "Lock session",
|
|
||||||
exec: "loginctl lock-session",
|
|
||||||
},
|
|
||||||
Command {
|
|
||||||
id: "reload-breadd",
|
|
||||||
name: "Reload breadd",
|
|
||||||
exec: "bread reload",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fuzzy-filters `commands` by `query` against each command's `name` (same
|
|
||||||
/// subsequence match [`crate::fuzzy_matches`] uses for app rows) — an empty
|
|
||||||
/// query matches everything, same as the app list's own empty-query case.
|
|
||||||
pub fn filter_commands(query: &str, commands: &[Command]) -> Vec<Command> {
|
|
||||||
commands
|
|
||||||
.iter()
|
|
||||||
.filter(|c| fuzzy_matches(query, c.name))
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// ---- parse_query -------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_query_bare_text_is_apps() {
|
|
||||||
let p = parse_query("firefox");
|
|
||||||
assert_eq!(p.kind, QueryKind::Apps);
|
|
||||||
assert_eq!(p.value, "firefox");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_query_empty_is_apps() {
|
|
||||||
let p = parse_query("");
|
|
||||||
assert_eq!(p.kind, QueryKind::Apps);
|
|
||||||
assert_eq!(p.value, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_query_equals_is_calc() {
|
|
||||||
let p = parse_query("=2+2");
|
|
||||||
assert_eq!(p.kind, QueryKind::Calc);
|
|
||||||
assert_eq!(p.value, "2+2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_query_gt_is_cmd() {
|
|
||||||
let p = parse_query(">lock");
|
|
||||||
assert_eq!(p.kind, QueryKind::Cmd);
|
|
||||||
assert_eq!(p.value, "lock");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_query_dot_is_url() {
|
|
||||||
let p = parse_query(".breadway.dev");
|
|
||||||
assert_eq!(p.kind, QueryKind::Url);
|
|
||||||
assert_eq!(p.value, "breadway.dev");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_query_strips_leading_whitespace_after_prefix() {
|
|
||||||
let p = parse_query("= 2 + 2 ");
|
|
||||||
assert_eq!(p.kind, QueryKind::Calc);
|
|
||||||
assert_eq!(p.value, "2 + 2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_query_bare_prefix_has_empty_value() {
|
|
||||||
assert_eq!(parse_query("=").value, "");
|
|
||||||
assert_eq!(parse_query(">").value, "");
|
|
||||||
assert_eq!(parse_query(".").value, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- eval_calc -----------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eval_calc_empty_expr_is_none() {
|
|
||||||
assert_eq!(eval_calc(""), None);
|
|
||||||
assert_eq!(eval_calc(" "), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eval_calc_simple_addition() {
|
|
||||||
assert_eq!(eval_calc("2+2"), Some("4".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eval_calc_precedence() {
|
|
||||||
assert_eq!(eval_calc("2+3*4"), Some("14".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eval_calc_parens() {
|
|
||||||
assert_eq!(eval_calc("(2+3)*4"), Some("20".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eval_calc_unary_minus() {
|
|
||||||
assert_eq!(eval_calc("-5+2"), Some("-3".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eval_calc_decimals_round_to_8_places() {
|
|
||||||
assert_eq!(eval_calc("0.1+0.2"), Some("0.3".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eval_calc_division_by_zero_is_infinity_symbol() {
|
|
||||||
assert_eq!(eval_calc("1/0"), Some("∞".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eval_calc_bad_chars_is_bad_expr() {
|
|
||||||
assert_eq!(eval_calc("2+alert(1)"), Some("bad expr".to_string()));
|
|
||||||
assert_eq!(eval_calc("rm -rf /"), Some("bad expr".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eval_calc_unbalanced_parens_is_err() {
|
|
||||||
assert_eq!(eval_calc("(2+3"), Some("err".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eval_calc_trailing_garbage_is_err() {
|
|
||||||
assert_eq!(eval_calc("2+3)"), Some("err".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eval_calc_double_operator_is_err() {
|
|
||||||
assert_eq!(eval_calc("2++"), Some("err".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn eval_calc_whitespace_is_tolerated() {
|
|
||||||
assert_eq!(eval_calc(" 2 + 2 "), Some("4".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- commands --------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn builtin_commands_are_non_empty() {
|
|
||||||
assert!(!builtin_commands().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn filter_commands_empty_query_matches_all() {
|
|
||||||
let all = builtin_commands();
|
|
||||||
assert_eq!(filter_commands("", all).len(), all.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn filter_commands_filters_by_name_subsequence() {
|
|
||||||
let matches = filter_commands("lock", builtin_commands());
|
|
||||||
assert!(matches.iter().any(|c| c.id == "lock"));
|
|
||||||
assert!(!matches.iter().any(|c| c.id == "reload-breadd"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn filter_commands_no_match_is_empty() {
|
|
||||||
assert!(filter_commands("zzzznotacommand", builtin_commands()).is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"] }
|
|
||||||
|
|
@ -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 = []
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,370 +0,0 @@
|
||||||
//! Session-bus registration and the PolicyKit1 AuthenticationAgent.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Arc, OnceLock};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use gtk4::glib;
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tokio::sync::{mpsc, Mutex};
|
|
||||||
use zbus::zvariant::{OwnedValue, Type, Value};
|
|
||||||
use zbus::{connection, interface, proxy, DBusError};
|
|
||||||
|
|
||||||
use bread_polkit::helper::{discover_transport, Transport};
|
|
||||||
use bread_polkit::identity::{current_uid, pick_user, read_passwd, users_from_uids, UnixUser};
|
|
||||||
use bread_polkit::session::session_id;
|
|
||||||
|
|
||||||
use crate::auth::{self, Outcome};
|
|
||||||
use crate::ui::{self, Prompt};
|
|
||||||
|
|
||||||
pub const OBJECT_PATH: &str = "/com/breadway/PolicyKit1/AuthenticationAgent";
|
|
||||||
|
|
||||||
/// Reply from the GTK prompt.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum UserAction {
|
|
||||||
Submit { username: String, password: String },
|
|
||||||
Cancel,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, DBusError)]
|
|
||||||
#[zbus(prefix = "org.freedesktop.PolicyKit1.Error")]
|
|
||||||
enum AgentError {
|
|
||||||
#[zbus(error)]
|
|
||||||
ZBus(zbus::Error),
|
|
||||||
Failed(String),
|
|
||||||
Cancelled(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Type)]
|
|
||||||
struct Identity {
|
|
||||||
kind: String,
|
|
||||||
details: HashMap<String, OwnedValue>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, Type)]
|
|
||||||
struct Subject {
|
|
||||||
kind: String,
|
|
||||||
details: HashMap<String, OwnedValue>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[proxy(
|
|
||||||
interface = "org.freedesktop.PolicyKit1.Authority",
|
|
||||||
default_service = "org.freedesktop.PolicyKit1",
|
|
||||||
default_path = "/org/freedesktop/PolicyKit1/Authority"
|
|
||||||
)]
|
|
||||||
trait Authority {
|
|
||||||
fn register_authentication_agent(
|
|
||||||
&self,
|
|
||||||
subject: &Subject,
|
|
||||||
locale: &str,
|
|
||||||
object_path: &str,
|
|
||||||
) -> zbus::Result<()>;
|
|
||||||
|
|
||||||
fn unregister_authentication_agent(
|
|
||||||
&self,
|
|
||||||
subject: &Subject,
|
|
||||||
object_path: &str,
|
|
||||||
) -> zbus::Result<()>;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Agent {
|
|
||||||
transport: Transport,
|
|
||||||
pending: Arc<Mutex<Option<mpsc::Sender<UserAction>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
|
|
||||||
impl Agent {
|
|
||||||
async fn begin_authentication(
|
|
||||||
&mut self,
|
|
||||||
action_id: String,
|
|
||||||
message: String,
|
|
||||||
_icon_name: String,
|
|
||||||
_details: HashMap<String, String>,
|
|
||||||
cookie: String,
|
|
||||||
identities: Vec<Identity>,
|
|
||||||
) -> Result<(), AgentError> {
|
|
||||||
tracing::info!(%action_id, %cookie, "BeginAuthentication");
|
|
||||||
|
|
||||||
let users = unix_users(&identities);
|
|
||||||
let username = pick_user(&users, current_uid())
|
|
||||||
.map(|u| u.name.clone())
|
|
||||||
.ok_or_else(|| AgentError::Failed("no unix-user identity".into()))?;
|
|
||||||
let allowed_users: Vec<String> = users.iter().map(|u| u.name.clone()).collect();
|
|
||||||
|
|
||||||
let (tx, mut rx) = mpsc::channel(4);
|
|
||||||
*self.pending.lock().await = Some(tx.clone());
|
|
||||||
|
|
||||||
let prompt = Prompt {
|
|
||||||
cookie: cookie.clone(),
|
|
||||||
message: message.clone(),
|
|
||||||
action_id: action_id.clone(),
|
|
||||||
username: username.clone(),
|
|
||||||
reply: tx,
|
|
||||||
};
|
|
||||||
invoke_ui(move || {
|
|
||||||
if let Some(app) = running_app() {
|
|
||||||
ui::show_prompt(&app, prompt);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = self.drive_prompt(&cookie, &username, &allowed_users, &mut rx).await;
|
|
||||||
|
|
||||||
*self.pending.lock().await = None;
|
|
||||||
let cookie_close = cookie.clone();
|
|
||||||
invoke_ui(move || ui::close_prompt(&cookie_close));
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cancel_authentication(&self, cookie: String) {
|
|
||||||
tracing::info!(%cookie, "CancelAuthentication");
|
|
||||||
if let Some(tx) = self.pending.lock().await.as_ref() {
|
|
||||||
let _ = tx.try_send(UserAction::Cancel);
|
|
||||||
}
|
|
||||||
invoke_ui(move || ui::close_prompt(&cookie));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Agent {
|
|
||||||
async fn drive_prompt(
|
|
||||||
&self,
|
|
||||||
cookie: &str,
|
|
||||||
default_user: &str,
|
|
||||||
allowed_users: &[String],
|
|
||||||
rx: &mut mpsc::Receiver<UserAction>,
|
|
||||||
) -> Result<(), AgentError> {
|
|
||||||
loop {
|
|
||||||
match rx.recv().await {
|
|
||||||
None => {
|
|
||||||
return Err(AgentError::Cancelled("authentication prompt closed".into()));
|
|
||||||
}
|
|
||||||
Some(UserAction::Cancel) => {
|
|
||||||
return Err(AgentError::Cancelled("user cancelled".into()));
|
|
||||||
}
|
|
||||||
Some(UserAction::Submit { username, password }) => {
|
|
||||||
// The username field is user-editable; only accept it when
|
|
||||||
// it's one of the identities polkit offered (empty falls
|
|
||||||
// back to the prefilled user). Anything else is rejected
|
|
||||||
// and the prompt re-shown rather than starting a PAM
|
|
||||||
// conversation for an account the request never offered.
|
|
||||||
let Some(user) = resolve_user(default_user, allowed_users, &username) else {
|
|
||||||
let cookie = cookie.to_string();
|
|
||||||
invoke_ui(move || ui::show_retry(&cookie, INVALID_USER_MESSAGE));
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
match auth::authenticate(&self.transport, &user, cookie, &password).await {
|
|
||||||
Ok(Outcome::Success) => return Ok(()),
|
|
||||||
Ok(Outcome::Failure { message }) => {
|
|
||||||
let text = message
|
|
||||||
.unwrap_or_else(|| auth::default_failure_message().to_string());
|
|
||||||
let cookie = cookie.to_string();
|
|
||||||
invoke_ui(move || ui::show_retry(&cookie, &text));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("helper: {e:#}");
|
|
||||||
let text = e.to_string();
|
|
||||||
let cookie = cookie.to_string();
|
|
||||||
invoke_ui(move || ui::show_retry(&cookie, &text));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const INVALID_USER_MESSAGE: &str =
|
|
||||||
"That user is not one of the identities this request offered — use the prefilled user.";
|
|
||||||
|
|
||||||
/// Choose the username to authenticate as from the prompt input.
|
|
||||||
///
|
|
||||||
/// Empty input falls back to `default`. Non-empty input must be one of
|
|
||||||
/// `allowed` — the `unix-user` identities polkit actually offered in
|
|
||||||
/// `BeginAuthentication` — otherwise it returns `None`. Without this, a
|
|
||||||
/// request scoped to specific accounts (say, `root` only) could have its
|
|
||||||
/// editable username field redirected to an arbitrary local user, kicking
|
|
||||||
/// off a PAM conversation for an account the request never offered.
|
|
||||||
/// (`auth::authenticate`'s PAM result still has to clear polkit's own
|
|
||||||
/// authorization, but this closes the obvious foot-gun at the agent — the
|
|
||||||
/// one place the identity list is actually known.)
|
|
||||||
fn resolve_user(default: &str, allowed: &[String], input: &str) -> Option<String> {
|
|
||||||
if input.is_empty() {
|
|
||||||
return Some(default.to_string());
|
|
||||||
}
|
|
||||||
if allowed.iter().any(|u| u.as_str() == input) {
|
|
||||||
return Some(input.to_string());
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unix_users(identities: &[Identity]) -> Vec<UnixUser> {
|
|
||||||
let mut uids = Vec::new();
|
|
||||||
for identity in identities {
|
|
||||||
if identity.kind != "unix-user" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(uid) = uid_from_details(&identity.details) {
|
|
||||||
uids.push(uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
users_from_uids(&uids, &read_passwd())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uid_from_details(details: &HashMap<String, OwnedValue>) -> Option<u32> {
|
|
||||||
let value = details.get("uid")?;
|
|
||||||
u32::try_from(value).ok().or_else(|| {
|
|
||||||
i32::try_from(value)
|
|
||||||
.ok()
|
|
||||||
.and_then(|n| u32::try_from(n).ok())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn running_app() -> Option<gtk4::Application> {
|
|
||||||
gtk4::gio::Application::default().and_then(|app| app.downcast::<gtk4::Application>().ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GTK thread-default context, captured in [`spawn`] so the dbus thread
|
|
||||||
/// can `invoke` onto the UI thread instead of its own empty context.
|
|
||||||
static GTK_CTX: OnceLock<glib::MainContext> = OnceLock::new();
|
|
||||||
|
|
||||||
fn invoke_ui(f: impl FnOnce() + Send + 'static) {
|
|
||||||
let ctx = GTK_CTX
|
|
||||||
.get()
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(glib::MainContext::default);
|
|
||||||
ctx.invoke(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unix_session_subject(id: &str) -> Result<Subject> {
|
|
||||||
let value = Value::from(id.to_string());
|
|
||||||
let owned = OwnedValue::try_from(value).context("session-id variant")?;
|
|
||||||
let mut details = HashMap::new();
|
|
||||||
details.insert("session-id".into(), owned);
|
|
||||||
Ok(Subject {
|
|
||||||
kind: "unix-session".into(),
|
|
||||||
details,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn the system-bus agent on a background thread. Returns once the
|
|
||||||
/// thread has been started; registration errors quit the GTK app.
|
|
||||||
///
|
|
||||||
/// Must be called from the GTK thread so the main context we capture is
|
|
||||||
/// the one driving the password prompt.
|
|
||||||
pub fn spawn() -> Result<()> {
|
|
||||||
let _ = GTK_CTX.set(glib::MainContext::default());
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("bread-polkit-dbus".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(rt) => rt,
|
|
||||||
Err(e) => {
|
|
||||||
invoke_ui(move || {
|
|
||||||
eprintln!("bread-polkit: tokio runtime failed: {e}");
|
|
||||||
if let Some(app) = running_app() {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
rt.block_on(async move {
|
|
||||||
if let Err(e) = run().await {
|
|
||||||
eprintln!("bread-polkit: {e:#}");
|
|
||||||
invoke_ui(|| {
|
|
||||||
if let Some(app) = running_app() {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.context("spawn dbus thread")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run() -> Result<()> {
|
|
||||||
let transport = discover_transport().context(
|
|
||||||
"no polkit helper: expected /run/polkit/agent-helper.socket \
|
|
||||||
or /usr/lib/polkit-1/polkit-agent-helper-1",
|
|
||||||
)?;
|
|
||||||
tracing::info!(?transport, "using polkit helper");
|
|
||||||
|
|
||||||
let session = session_id().context(
|
|
||||||
"no session id (XDG_SESSION_ID / /proc/self/sessionid); \
|
|
||||||
cannot register a session authentication agent",
|
|
||||||
)?;
|
|
||||||
let subject = unix_session_subject(&session)?;
|
|
||||||
let locale = std::env::var("LANG").unwrap_or_else(|_| "C".into());
|
|
||||||
|
|
||||||
let agent = Agent {
|
|
||||||
transport,
|
|
||||||
pending: Arc::new(Mutex::new(None)),
|
|
||||||
};
|
|
||||||
|
|
||||||
let connection = connection::Builder::system()?
|
|
||||||
.serve_at(OBJECT_PATH, agent)?
|
|
||||||
.build()
|
|
||||||
.await
|
|
||||||
.context("system bus")?;
|
|
||||||
|
|
||||||
let authority = AuthorityProxy::new(&connection)
|
|
||||||
.await
|
|
||||||
.context("PolicyKit1 authority proxy")?;
|
|
||||||
authority
|
|
||||||
.register_authentication_agent(&subject, &locale, OBJECT_PATH)
|
|
||||||
.await
|
|
||||||
.context("RegisterAuthenticationAgent")?;
|
|
||||||
tracing::info!(%session, "registered as PolicyKit authentication agent");
|
|
||||||
|
|
||||||
std::future::pending::<()>().await;
|
|
||||||
#[allow(unreachable_code)]
|
|
||||||
{
|
|
||||||
let _ = authority
|
|
||||||
.unregister_authentication_agent(&subject, OBJECT_PATH)
|
|
||||||
.await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn allowed() -> Vec<String> {
|
|
||||||
vec!["root".to_string(), "1000".to_string()]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_user_falls_back_to_default_on_empty_input() {
|
|
||||||
assert_eq!(
|
|
||||||
resolve_user("root", &allowed(), ""),
|
|
||||||
Some("root".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_user_accepts_an_offered_identity() {
|
|
||||||
assert_eq!(
|
|
||||||
resolve_user("root", &allowed(), "1000"),
|
|
||||||
Some("1000".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_user_rejects_a_user_polkit_did_not_offer() {
|
|
||||||
assert_eq!(resolve_user("root", &allowed(), "alice"), None);
|
|
||||||
assert_eq!(resolve_user("root", &allowed(), "daemon"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_user_is_case_exact() {
|
|
||||||
// Usernames are case-significant; "ROOT" is a different principle
|
|
||||||
// than the offered "root", so it must be rejected.
|
|
||||||
assert_eq!(resolve_user("root", &allowed(), "ROOT"), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
//! PAM conversation with the polkit agent helper.
|
|
||||||
|
|
||||||
use std::process::Stdio;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
||||||
use tokio::net::UnixStream;
|
|
||||||
use tokio::process::Command;
|
|
||||||
|
|
||||||
use bread_polkit::helper::{parse_helper_line, HelperLine, Transport};
|
|
||||||
|
|
||||||
/// Outcome of one helper conversation.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum Outcome {
|
|
||||||
Success,
|
|
||||||
Failure { message: Option<String> },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handshake + PAM loop for one password attempt.
|
|
||||||
pub async fn authenticate(
|
|
||||||
transport: &Transport,
|
|
||||||
username: &str,
|
|
||||||
cookie: &str,
|
|
||||||
password: &str,
|
|
||||||
) -> Result<Outcome> {
|
|
||||||
match transport {
|
|
||||||
Transport::Socket(path) => {
|
|
||||||
let mut stream = UnixStream::connect(path)
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("connect {}", path.display()))?;
|
|
||||||
stream.write_all(username.as_bytes()).await?;
|
|
||||||
stream.write_all(b"\n").await?;
|
|
||||||
stream.write_all(cookie.as_bytes()).await?;
|
|
||||||
stream.write_all(b"\n").await?;
|
|
||||||
let (reader, writer) = stream.into_split();
|
|
||||||
converse(BufReader::new(reader), writer, password).await
|
|
||||||
}
|
|
||||||
Transport::Exec(path) => {
|
|
||||||
let mut child = Command::new(path)
|
|
||||||
.arg(username)
|
|
||||||
.env("LC_ALL", "C")
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
.with_context(|| format!("spawn {}", path.display()))?;
|
|
||||||
let mut stdin = child.stdin.take().context("polkit helper has no stdin")?;
|
|
||||||
let stdout = child.stdout.take().context("polkit helper has no stdout")?;
|
|
||||||
stdin.write_all(cookie.as_bytes()).await?;
|
|
||||||
stdin.write_all(b"\n").await?;
|
|
||||||
let outcome = converse(BufReader::new(stdout), stdin, password).await;
|
|
||||||
let _ = child.wait().await;
|
|
||||||
outcome
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn converse<R, W>(mut reader: BufReader<R>, mut writer: W, password: &str) -> Result<Outcome>
|
|
||||||
where
|
|
||||||
R: tokio::io::AsyncRead + Unpin,
|
|
||||||
W: tokio::io::AsyncWrite + Unpin,
|
|
||||||
{
|
|
||||||
let mut last_info: Option<String> = None;
|
|
||||||
let mut line = String::new();
|
|
||||||
loop {
|
|
||||||
line.clear();
|
|
||||||
let n = reader.read_line(&mut line).await?;
|
|
||||||
if n == 0 {
|
|
||||||
return Ok(Outcome::Failure {
|
|
||||||
message: last_info.take(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
match parse_helper_line(&line) {
|
|
||||||
HelperLine::PromptEchoOff(_) => {
|
|
||||||
writer.write_all(password.as_bytes()).await?;
|
|
||||||
writer.write_all(b"\n").await?;
|
|
||||||
writer.flush().await?;
|
|
||||||
}
|
|
||||||
HelperLine::PromptEchoOn(_) => {
|
|
||||||
// Visible prompt (username, etc.) — we already sent the
|
|
||||||
// identity in the handshake. An empty line is safer than
|
|
||||||
// echoing the password.
|
|
||||||
writer.write_all(b"\n").await?;
|
|
||||||
writer.flush().await?;
|
|
||||||
}
|
|
||||||
HelperLine::ErrorMsg(msg) | HelperLine::TextInfo(msg) => {
|
|
||||||
if !msg.is_empty() {
|
|
||||||
last_info = Some(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HelperLine::Success => return Ok(Outcome::Success),
|
|
||||||
HelperLine::Failure => {
|
|
||||||
return Ok(Outcome::Failure {
|
|
||||||
message: last_info.take(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
HelperLine::Other(other) => {
|
|
||||||
if !other.is_empty() {
|
|
||||||
tracing::debug!("helper: {other}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared default when the helper gives no `PAM_*` text on failure.
|
|
||||||
pub fn default_failure_message() -> &'static str {
|
|
||||||
"Authentication failed. Try again."
|
|
||||||
}
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
//! `polkit-agent-helper-1` transport and PAM line parser.
|
|
||||||
//!
|
|
||||||
//! Arch polkit 127+ talks over `/run/polkit/agent-helper.socket`. Older
|
|
||||||
//! builds still spawn the setuid helper at
|
|
||||||
//! `/usr/lib/polkit-1/polkit-agent-helper-1`. Prefer the socket when it
|
|
||||||
//! exists.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
/// How this agent will talk to polkit's helper.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum Transport {
|
|
||||||
/// systemd socket-activated helper (polkit 127+).
|
|
||||||
Socket(PathBuf),
|
|
||||||
/// Legacy setuid helper binary.
|
|
||||||
Exec(PathBuf),
|
|
||||||
}
|
|
||||||
|
|
||||||
const SOCKET_CANDIDATES: &[&str] = &["/run/polkit/agent-helper.socket"];
|
|
||||||
const HELPER_CANDIDATES: &[&str] = &[
|
|
||||||
"/usr/lib/polkit-1/polkit-agent-helper-1",
|
|
||||||
"/usr/libexec/polkit-1/polkit-agent-helper-1",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// One stdout line from the helper after the cookie handshake.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum HelperLine {
|
|
||||||
PromptEchoOff(String),
|
|
||||||
PromptEchoOn(String),
|
|
||||||
ErrorMsg(String),
|
|
||||||
TextInfo(String),
|
|
||||||
Success,
|
|
||||||
Failure,
|
|
||||||
Other(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pick a live transport: `BREAD_POLKIT_SOCKET` / `BREAD_POLKIT_HELPER`
|
|
||||||
/// if set and present, otherwise the first existing well-known path.
|
|
||||||
pub fn discover_transport() -> Option<Transport> {
|
|
||||||
discover_transport_from(
|
|
||||||
std::env::var_os("BREAD_POLKIT_SOCKET")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.as_deref(),
|
|
||||||
std::env::var_os("BREAD_POLKIT_HELPER")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.as_deref(),
|
|
||||||
SOCKET_CANDIDATES,
|
|
||||||
HELPER_CANDIDATES,
|
|
||||||
|p| p.exists(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Testable discovery: `exists` is injected so unit tests do not need a
|
|
||||||
/// real `/run/polkit` socket.
|
|
||||||
pub fn discover_transport_from(
|
|
||||||
socket_override: Option<&Path>,
|
|
||||||
helper_override: Option<&Path>,
|
|
||||||
sockets: &[&str],
|
|
||||||
helpers: &[&str],
|
|
||||||
exists: impl Fn(&Path) -> bool,
|
|
||||||
) -> Option<Transport> {
|
|
||||||
if let Some(path) = socket_override {
|
|
||||||
if exists(path) {
|
|
||||||
return Some(Transport::Socket(path.to_path_buf()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for candidate in sockets {
|
|
||||||
let path = Path::new(candidate);
|
|
||||||
if exists(path) {
|
|
||||||
return Some(Transport::Socket(path.to_path_buf()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(path) = helper_override {
|
|
||||||
if exists(path) {
|
|
||||||
return Some(Transport::Exec(path.to_path_buf()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for candidate in helpers {
|
|
||||||
let path = Path::new(candidate);
|
|
||||||
if exists(path) {
|
|
||||||
return Some(Transport::Exec(path.to_path_buf()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse one helper protocol line. Prefix match is case-sensitive and
|
|
||||||
/// matches polkit's own `PAM_*` / `SUCCESS` / `FAILURE` tokens.
|
|
||||||
pub fn parse_helper_line(line: &str) -> HelperLine {
|
|
||||||
let line = line.trim_end_matches(['\r', '\n']);
|
|
||||||
if line == "SUCCESS" || line.starts_with("SUCCESS") {
|
|
||||||
return HelperLine::Success;
|
|
||||||
}
|
|
||||||
if line == "FAILURE" || line.starts_with("FAILURE") {
|
|
||||||
return HelperLine::Failure;
|
|
||||||
}
|
|
||||||
if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_OFF") {
|
|
||||||
return HelperLine::PromptEchoOff(rest.trim().to_string());
|
|
||||||
}
|
|
||||||
if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_ON") {
|
|
||||||
return HelperLine::PromptEchoOn(rest.trim().to_string());
|
|
||||||
}
|
|
||||||
if let Some(rest) = line.strip_prefix("PAM_ERROR_MSG") {
|
|
||||||
return HelperLine::ErrorMsg(rest.trim().to_string());
|
|
||||||
}
|
|
||||||
if let Some(rest) = line.strip_prefix("PAM_TEXT_INFO") {
|
|
||||||
return HelperLine::TextInfo(rest.trim().to_string());
|
|
||||||
}
|
|
||||||
HelperLine::Other(line.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_helper_line_known_tokens() {
|
|
||||||
assert_eq!(parse_helper_line("SUCCESS"), HelperLine::Success);
|
|
||||||
assert_eq!(parse_helper_line("SUCCESS\n"), HelperLine::Success);
|
|
||||||
assert_eq!(parse_helper_line("FAILURE"), HelperLine::Failure);
|
|
||||||
assert_eq!(
|
|
||||||
parse_helper_line("PAM_PROMPT_ECHO_OFF Password:"),
|
|
||||||
HelperLine::PromptEchoOff("Password:".into())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_helper_line("PAM_PROMPT_ECHO_OFF"),
|
|
||||||
HelperLine::PromptEchoOff(String::new())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_helper_line("PAM_PROMPT_ECHO_ON login:"),
|
|
||||||
HelperLine::PromptEchoOn("login:".into())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_helper_line("PAM_ERROR_MSG Authentication failure"),
|
|
||||||
HelperLine::ErrorMsg("Authentication failure".into())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_helper_line("PAM_TEXT_INFO Account locked"),
|
|
||||||
HelperLine::TextInfo("Account locked".into())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_helper_line("garbage"),
|
|
||||||
HelperLine::Other("garbage".into())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn discover_prefers_socket_over_exec() {
|
|
||||||
let present: HashSet<PathBuf> = [
|
|
||||||
"/run/polkit/agent-helper.socket",
|
|
||||||
"/usr/lib/polkit-1/polkit-agent-helper-1",
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.collect();
|
|
||||||
let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| {
|
|
||||||
present.contains(p)
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
got,
|
|
||||||
Some(Transport::Socket(PathBuf::from(
|
|
||||||
"/run/polkit/agent-helper.socket"
|
|
||||||
)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn discover_falls_back_to_helper_binary() {
|
|
||||||
let present: HashSet<PathBuf> = ["/usr/lib/polkit-1/polkit-agent-helper-1"]
|
|
||||||
.into_iter()
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.collect();
|
|
||||||
let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| {
|
|
||||||
present.contains(p)
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
got,
|
|
||||||
Some(Transport::Exec(PathBuf::from(
|
|
||||||
"/usr/lib/polkit-1/polkit-agent-helper-1"
|
|
||||||
)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn discover_override_socket_wins_when_present() {
|
|
||||||
let override_path = Path::new("/tmp/bread-polkit-test.sock");
|
|
||||||
let got = discover_transport_from(
|
|
||||||
Some(override_path),
|
|
||||||
None,
|
|
||||||
SOCKET_CANDIDATES,
|
|
||||||
HELPER_CANDIDATES,
|
|
||||||
|p| p == override_path,
|
|
||||||
);
|
|
||||||
assert_eq!(got, Some(Transport::Socket(override_path.to_path_buf())));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn discover_none_when_nothing_exists() {
|
|
||||||
let got =
|
|
||||||
discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |_| false);
|
|
||||||
assert_eq!(got, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
//! Unix-user identities from a PolicyKit `BeginAuthentication` call.
|
|
||||||
|
|
||||||
/// A `unix-user` identity the agent can authenticate as.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct UnixUser {
|
|
||||||
pub uid: u32,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up `uid` in a passwd-file dump (`name:x:uid:...` lines).
|
|
||||||
pub fn name_for_uid(uid: u32, passwd: &str) -> Option<String> {
|
|
||||||
for line in passwd.lines() {
|
|
||||||
if line.starts_with('#') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut parts = line.split(':');
|
|
||||||
let name = parts.next()?;
|
|
||||||
let _pw = parts.next()?;
|
|
||||||
let id = parts.next()?.parse::<u32>().ok()?;
|
|
||||||
if id == uid && !name.is_empty() {
|
|
||||||
return Some(name.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve each uid to a [`UnixUser`], falling back to `uid N` when
|
|
||||||
/// `/etc/passwd` has no name.
|
|
||||||
pub fn users_from_uids(uids: &[u32], passwd: &str) -> Vec<UnixUser> {
|
|
||||||
uids.iter()
|
|
||||||
.copied()
|
|
||||||
.map(|uid| UnixUser {
|
|
||||||
uid,
|
|
||||||
name: name_for_uid(uid, passwd).unwrap_or_else(|| format!("uid {uid}")),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prefer the process's own uid when it is in `users`, otherwise the first.
|
|
||||||
pub fn pick_user(users: &[UnixUser], current_uid: Option<u32>) -> Option<&UnixUser> {
|
|
||||||
if let Some(uid) = current_uid {
|
|
||||||
if let Some(user) = users.iter().find(|u| u.uid == uid) {
|
|
||||||
return Some(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
users.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Real uid from a `/proc/self/status` dump (`Uid:\t<real> ...`).
|
|
||||||
pub fn uid_from_status(status: &str) -> Option<u32> {
|
|
||||||
for line in status.lines() {
|
|
||||||
let Some(rest) = line.strip_prefix("Uid:") else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
return rest.split_whitespace().next()?.parse().ok();
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Current real uid, or `None` if `/proc/self/status` is unreadable.
|
|
||||||
pub fn current_uid() -> Option<u32> {
|
|
||||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
|
||||||
uid_from_status(&status)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Contents of `/etc/passwd`, or empty if unreadable.
|
|
||||||
pub fn read_passwd() -> String {
|
|
||||||
std::fs::read_to_string("/etc/passwd").unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const PASSWD: &str = "\
|
|
||||||
# comment
|
|
||||||
root:x:0:0:root:/root:/bin/sh
|
|
||||||
alice:x:1000:1000:Alice:/home/alice:/bin/zsh
|
|
||||||
bob:x:1001:1001:Bob:/home/bob:/bin/bash
|
|
||||||
";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn name_for_uid_reads_passwd_lines() {
|
|
||||||
assert_eq!(name_for_uid(0, PASSWD).as_deref(), Some("root"));
|
|
||||||
assert_eq!(name_for_uid(1000, PASSWD).as_deref(), Some("alice"));
|
|
||||||
assert_eq!(name_for_uid(99, PASSWD), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn users_from_uids_falls_back_to_uid_label() {
|
|
||||||
let users = users_from_uids(&[1000, 42], PASSWD);
|
|
||||||
assert_eq!(
|
|
||||||
users,
|
|
||||||
vec![
|
|
||||||
UnixUser {
|
|
||||||
uid: 1000,
|
|
||||||
name: "alice".into()
|
|
||||||
},
|
|
||||||
UnixUser {
|
|
||||||
uid: 42,
|
|
||||||
name: "uid 42".into()
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pick_user_prefers_current_uid() {
|
|
||||||
let users = users_from_uids(&[0, 1000], PASSWD);
|
|
||||||
let picked = pick_user(&users, Some(1000)).unwrap();
|
|
||||||
assert_eq!(picked.name, "alice");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pick_user_falls_back_to_first() {
|
|
||||||
let users = users_from_uids(&[0, 1000], PASSWD);
|
|
||||||
let picked = pick_user(&users, Some(7)).unwrap();
|
|
||||||
assert_eq!(picked.name, "root");
|
|
||||||
assert!(pick_user(&[], Some(1000)).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uid_from_status_reads_real_uid() {
|
|
||||||
let status = "Name:\tbread-polkit\nUid:\t1000\t1000\t1000\t1000\n";
|
|
||||||
assert_eq!(uid_from_status(status), Some(1000));
|
|
||||||
assert_eq!(uid_from_status("Name:\tfoo\n"), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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.
|
|
||||||
"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
//! Session subject for `RegisterAuthenticationAgent`.
|
|
||||||
|
|
||||||
/// Logind session id from `XDG_SESSION_ID`, falling back to
|
|
||||||
/// `/proc/self/sessionid` when the kernel has one.
|
|
||||||
pub fn session_id() -> Option<String> {
|
|
||||||
let xdg = std::env::var("XDG_SESSION_ID").ok();
|
|
||||||
let proc = std::fs::read_to_string("/proc/self/sessionid").ok();
|
|
||||||
session_id_from(xdg.as_deref(), proc.as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `None` when both sources are empty or the kernel reports the
|
|
||||||
/// unsigned `-1` sentinel (`4294967295`) meaning "no session".
|
|
||||||
pub fn session_id_from(xdg: Option<&str>, proc_sessionid: Option<&str>) -> Option<String> {
|
|
||||||
if let Some(id) = xdg.map(str::trim).filter(|s| !s.is_empty()) {
|
|
||||||
return Some(id.to_string());
|
|
||||||
}
|
|
||||||
let raw = proc_sessionid?.trim();
|
|
||||||
if raw.is_empty() || raw == "4294967295" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(raw.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prefers_xdg_session_id() {
|
|
||||||
assert_eq!(session_id_from(Some("3"), Some("7")).as_deref(), Some("3"));
|
|
||||||
assert_eq!(
|
|
||||||
session_id_from(Some(" 3 "), Some("7")).as_deref(),
|
|
||||||
Some("3")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn falls_back_to_proc_sessionid() {
|
|
||||||
assert_eq!(session_id_from(Some(""), Some("7")).as_deref(), Some("7"));
|
|
||||||
assert_eq!(session_id_from(None, Some("7\n")).as_deref(), Some("7"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_unset_kernel_session() {
|
|
||||||
assert_eq!(session_id_from(None, Some("4294967295")), None);
|
|
||||||
assert_eq!(session_id_from(Some(""), Some("")), None);
|
|
||||||
assert_eq!(session_id_from(None, None), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,286 +0,0 @@
|
||||||
//! GTK4 password prompt, themed with bread-theme.
|
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use gtk4::gdk::Key;
|
|
||||||
use gtk4::glib::{self, Propagation};
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use gtk4::{
|
|
||||||
Align, Application, ApplicationWindow, Box as GBox, Button, Entry, EventControllerKey, Label,
|
|
||||||
Orientation,
|
|
||||||
};
|
|
||||||
|
|
||||||
use bread_theme::tokens;
|
|
||||||
|
|
||||||
use crate::agent::UserAction;
|
|
||||||
|
|
||||||
const PANEL_WIDTH: i32 = 400;
|
|
||||||
|
|
||||||
struct Active {
|
|
||||||
cookie: String,
|
|
||||||
window: ApplicationWindow,
|
|
||||||
password: Entry,
|
|
||||||
error: Label,
|
|
||||||
reply: tokio::sync::mpsc::Sender<UserAction>,
|
|
||||||
username: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
thread_local! {
|
|
||||||
static ACTIVE: RefCell<Option<Active>> = const { RefCell::new(None) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// App-specific rules layered on the shared bread-theme stylesheet.
|
|
||||||
pub fn app_css() -> String {
|
|
||||||
format!(
|
|
||||||
".polkit-panel {{\
|
|
||||||
background-color: @surface; color: @on-surface;\
|
|
||||||
border-radius: {r}px; padding: {pad}px;\
|
|
||||||
min-width: {w}px;\
|
|
||||||
}}\n\
|
|
||||||
.polkit-title {{ font-size: 1.4em; font-weight: bold; }}\n\
|
|
||||||
.polkit-message {{ opacity: 0.85; }}\n\
|
|
||||||
.polkit-identity {{ opacity: 0.7; font-size: {sec}px; }}\n\
|
|
||||||
.polkit-error {{ color: @on-red; }}\n\
|
|
||||||
.polkit-buttons {{ padding-top: {sm}px; }}\n",
|
|
||||||
r = tokens::RADIUS_PRIMARY,
|
|
||||||
pad = tokens::SPACE_XL,
|
|
||||||
w = PANEL_WIDTH,
|
|
||||||
sec = tokens::FONT_SIZE_SECONDARY,
|
|
||||||
sm = tokens::SPACE_SM,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Prompt {
|
|
||||||
pub cookie: String,
|
|
||||||
pub message: String,
|
|
||||||
pub action_id: String,
|
|
||||||
pub username: String,
|
|
||||||
pub reply: tokio::sync::mpsc::Sender<UserAction>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show (or replace) the password overlay for this cookie.
|
|
||||||
pub fn show_prompt(app: &Application, prompt: Prompt) {
|
|
||||||
close_if_other_cookie(&prompt.cookie);
|
|
||||||
|
|
||||||
if ACTIVE.with(|a| {
|
|
||||||
a.borrow()
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|active| active.cookie == prompt.cookie)
|
|
||||||
}) {
|
|
||||||
present_existing(&prompt);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let window = bread_app::gtk_popup::new_overlay_window(app, "bread-polkit");
|
|
||||||
|
|
||||||
let panel = GBox::new(Orientation::Vertical, tokens::SPACE_MD as i32);
|
|
||||||
panel.add_css_class("polkit-panel");
|
|
||||||
panel.add_css_class("card");
|
|
||||||
panel.set_halign(Align::Center);
|
|
||||||
panel.set_valign(Align::Center);
|
|
||||||
panel.set_size_request(PANEL_WIDTH, -1);
|
|
||||||
|
|
||||||
let title = Label::new(Some("Authentication required"));
|
|
||||||
title.add_css_class("polkit-title");
|
|
||||||
title.add_css_class("page-title");
|
|
||||||
title.set_halign(Align::Start);
|
|
||||||
title.set_wrap(true);
|
|
||||||
panel.append(&title);
|
|
||||||
|
|
||||||
let message = if prompt.message.trim().is_empty() {
|
|
||||||
prompt.action_id.clone()
|
|
||||||
} else {
|
|
||||||
prompt.message.clone()
|
|
||||||
};
|
|
||||||
let msg = Label::new(Some(&message));
|
|
||||||
msg.add_css_class("polkit-message");
|
|
||||||
msg.set_halign(Align::Start);
|
|
||||||
msg.set_wrap(true);
|
|
||||||
msg.set_xalign(0.0);
|
|
||||||
panel.append(&msg);
|
|
||||||
|
|
||||||
if !prompt.username.is_empty() {
|
|
||||||
let identity = Label::new(Some(&format!("Authenticating as {}", prompt.username)));
|
|
||||||
identity.add_css_class("polkit-identity");
|
|
||||||
identity.add_css_class("dim-label");
|
|
||||||
identity.set_halign(Align::Start);
|
|
||||||
panel.append(&identity);
|
|
||||||
}
|
|
||||||
|
|
||||||
let error = Label::new(None);
|
|
||||||
error.add_css_class("polkit-error");
|
|
||||||
error.set_halign(Align::Start);
|
|
||||||
error.set_wrap(true);
|
|
||||||
error.set_visible(false);
|
|
||||||
panel.append(&error);
|
|
||||||
|
|
||||||
let password = Entry::builder()
|
|
||||||
.visibility(false)
|
|
||||||
.input_purpose(gtk4::InputPurpose::Password)
|
|
||||||
.placeholder_text("Password")
|
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
|
||||||
panel.append(&password);
|
|
||||||
|
|
||||||
let buttons = GBox::new(Orientation::Horizontal, tokens::SPACE_SM as i32);
|
|
||||||
buttons.add_css_class("polkit-buttons");
|
|
||||||
buttons.set_halign(Align::End);
|
|
||||||
let cancel = Button::with_label("Cancel");
|
|
||||||
cancel.add_css_class("flat");
|
|
||||||
let confirm = Button::with_label("Authenticate");
|
|
||||||
confirm.add_css_class("suggested-action");
|
|
||||||
buttons.append(&cancel);
|
|
||||||
buttons.append(&confirm);
|
|
||||||
panel.append(&buttons);
|
|
||||||
|
|
||||||
window.set_child(Some(&panel));
|
|
||||||
bread_theme::gtk::bind_window_auto_with_app_css(&window, |_| app_css());
|
|
||||||
|
|
||||||
let reply = prompt.reply.clone();
|
|
||||||
let cookie = prompt.cookie.clone();
|
|
||||||
let username = prompt.username.clone();
|
|
||||||
|
|
||||||
let submit = {
|
|
||||||
let password = password.clone();
|
|
||||||
let reply = reply.clone();
|
|
||||||
let username = username.clone();
|
|
||||||
Rc::new(move || {
|
|
||||||
let secret = password.text().to_string();
|
|
||||||
password.set_text("");
|
|
||||||
let _ = reply.try_send(UserAction::Submit {
|
|
||||||
username: username.clone(),
|
|
||||||
password: secret,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let cancel_fn = {
|
|
||||||
let reply = reply.clone();
|
|
||||||
let window = window.clone();
|
|
||||||
Rc::new(move || {
|
|
||||||
let _ = reply.try_send(UserAction::Cancel);
|
|
||||||
window.close();
|
|
||||||
ACTIVE.with(|a| a.replace(None));
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
confirm.connect_clicked({
|
|
||||||
let submit = submit.clone();
|
|
||||||
move |_| submit()
|
|
||||||
});
|
|
||||||
password.connect_activate({
|
|
||||||
let submit = submit.clone();
|
|
||||||
move |_| submit()
|
|
||||||
});
|
|
||||||
cancel.connect_clicked({
|
|
||||||
let cancel_fn = cancel_fn.clone();
|
|
||||||
move |_| cancel_fn()
|
|
||||||
});
|
|
||||||
|
|
||||||
let keys = EventControllerKey::new();
|
|
||||||
keys.connect_key_pressed({
|
|
||||||
let cancel_fn = cancel_fn.clone();
|
|
||||||
move |_, key, _, _| {
|
|
||||||
if key == Key::Escape {
|
|
||||||
cancel_fn();
|
|
||||||
Propagation::Stop
|
|
||||||
} else {
|
|
||||||
Propagation::Proceed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.add_controller(keys);
|
|
||||||
|
|
||||||
bread_app::gtk_popup::close_on_outside_click(&window, &panel, {
|
|
||||||
let cancel_fn = cancel_fn.clone();
|
|
||||||
move || cancel_fn()
|
|
||||||
});
|
|
||||||
|
|
||||||
window.connect_close_request({
|
|
||||||
let reply = reply.clone();
|
|
||||||
move |_| {
|
|
||||||
let closing_ours = ACTIVE.with(|a| {
|
|
||||||
a.borrow()
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|active| active.cookie == cookie)
|
|
||||||
});
|
|
||||||
if closing_ours {
|
|
||||||
let _ = reply.try_send(UserAction::Cancel);
|
|
||||||
ACTIVE.with(|a| a.replace(None));
|
|
||||||
}
|
|
||||||
glib::Propagation::Proceed
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ACTIVE.with(|a| {
|
|
||||||
*a.borrow_mut() = Some(Active {
|
|
||||||
cookie: prompt.cookie,
|
|
||||||
window: window.clone(),
|
|
||||||
password: password.clone(),
|
|
||||||
error,
|
|
||||||
reply,
|
|
||||||
username,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
window.present();
|
|
||||||
password.grab_focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn present_existing(prompt: &Prompt) {
|
|
||||||
ACTIVE.with(|a| {
|
|
||||||
if let Some(active) = a.borrow_mut().as_mut() {
|
|
||||||
active.reply = prompt.reply.clone();
|
|
||||||
active.username = prompt.username.clone();
|
|
||||||
active.error.set_visible(false);
|
|
||||||
active.password.set_text("");
|
|
||||||
active.window.present();
|
|
||||||
active.password.grab_focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show a retry message on the open dialog for `cookie`.
|
|
||||||
pub fn show_retry(cookie: &str, message: &str) {
|
|
||||||
ACTIVE.with(|a| {
|
|
||||||
let mut guard = a.borrow_mut();
|
|
||||||
let Some(active) = guard.as_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if active.cookie != cookie {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
active.error.set_label(message);
|
|
||||||
active.error.set_visible(true);
|
|
||||||
active.password.set_text("");
|
|
||||||
active.window.present();
|
|
||||||
active.password.grab_focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close the dialog if it is still showing `cookie`.
|
|
||||||
pub fn close_prompt(cookie: &str) {
|
|
||||||
ACTIVE.with(|a| {
|
|
||||||
let Some(active) = a.borrow_mut().take() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if active.cookie == cookie {
|
|
||||||
active.window.close();
|
|
||||||
} else {
|
|
||||||
*a.borrow_mut() = Some(active);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close_if_other_cookie(cookie: &str) {
|
|
||||||
ACTIVE.with(|a| {
|
|
||||||
let Some(active) = a.borrow_mut().take() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if active.cookie == cookie {
|
|
||||||
*a.borrow_mut() = Some(active);
|
|
||||||
} else {
|
|
||||||
active.window.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +1,11 @@
|
||||||
# bread-theme changelog
|
# bread-theme changelog
|
||||||
|
|
||||||
## 0.7.4
|
|
||||||
|
|
||||||
Per-output (per-monitor) theming. Session-global `theme.css` remains the
|
|
||||||
fallback / focused-monitor sheet; each Hyprland/GDK connector can now have
|
|
||||||
its own palette and stylesheet. BOS still keeps bg/surface/overlay/fg
|
|
||||||
fixed — only color1–6 come from the wallpaper.
|
|
||||||
|
|
||||||
On disk under `$XDG_RUNTIME_DIR/bread/` (same fallback as `shared_css_path`):
|
|
||||||
|
|
||||||
- `palettes/<sanitized-output>.json` — accents only (round-trips through
|
|
||||||
`from_wal_json` / a color1–6 object; never persists pywal's light bg)
|
|
||||||
- `themes/<sanitized-output>.css` — `stylesheet()` for that palette
|
|
||||||
|
|
||||||
New lib API:
|
|
||||||
|
|
||||||
- `themes_dir`, `palettes_dir`, `output_css_path`, `output_palette_path`,
|
|
||||||
`sanitize_output`
|
|
||||||
- `load_palette_for`, `write_output_palette`, `write_output_css`,
|
|
||||||
`write_shared_css_from`
|
|
||||||
- `palette_from_image` (isolated `wal -i`, does not touch `~/.cache/wal`),
|
|
||||||
`generate_output`, `palette_from_json`
|
|
||||||
- `stylesheet_resolved` — inlines `@accent` / `@on-bg` / … to hex so GTK's
|
|
||||||
display-global `@define-color` cannot leak the wrong monitor's accent
|
|
||||||
|
|
||||||
GTK (`gtk` feature): `bind_window`, `bind_window_with_app_css`,
|
|
||||||
`output_for_widget`, `bind_window_auto`, `bind_window_auto_with_app_css`.
|
|
||||||
Widget-scoped providers at `USER - 10` so they beat `apply_shared` but
|
|
||||||
lose to user CSS. Existing `apply_shared` / `apply_app_css` /
|
|
||||||
`apply_css` / `apply_user_css` are unchanged.
|
|
||||||
|
|
||||||
CLI: `bread-theme generate-output <OUTPUT> --image <PATH> | --from-json
|
|
||||||
<FILE> [--shared]`. Does not write `theme.css` unless `--shared`.
|
|
||||||
|
|
||||||
## Coordinated bump policy
|
## Coordinated bump policy
|
||||||
|
|
||||||
`bread-theme` is consumed by `breadbar`, `breadbox`, `breadpad`, and the other
|
`bread-theme` is consumed by `breadbar`, `breadbox`, and `breadpad` as a pinned
|
||||||
GTK bread apps as a pinned git dependency. A breaking change to `Palette`,
|
git dependency. A breaking change to `Palette`, `css_vars`, or the `gtk` feature
|
||||||
`css_vars`, or the `gtk` feature API requires dependents to bump their
|
API requires all three dependents to bump their `Cargo.toml` git tag and cut a
|
||||||
`Cargo.toml` git tag and cut a release together. Note the impact in this file
|
release together. Note the impact in this file before tagging.
|
||||||
before tagging.
|
|
||||||
|
|
||||||
**0.7.4** adds per-output bind APIs (`bind_window*`, `load_palette_for`,
|
|
||||||
`generate_output`). Apps that call those must pin `tag = "v0.7.4"`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,6 @@ keywords = ["theming", "pywal", "gtk4", "wayland"]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
# bread_theme::shell manifest parsing (theme.toml) — gtk-free, so `bread`
|
|
||||||
# (daemon) and `breadcrumbs` (CLI) can validate a theme without linking GTK.
|
|
||||||
toml = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
|
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
|
||||||
# Rust bindings for libadwaita (GNOME's widget library on top of GTK4) — the
|
# Rust bindings for libadwaita (GNOME's widget library on top of GTK4) — the
|
||||||
# actual source of the modern GNOME look (grouped preference rows, real
|
# actual source of the modern GNOME look (grouped preference rows, real
|
||||||
|
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
/* CSS template for the daylight builtin (bread-theme/src/shell/builtin.rs).
|
|
||||||
* Same scope and substitution rules as its three siblings: only the
|
|
||||||
* window/workspace/clock chrome the manifest's own concepts model, `{name}`
|
|
||||||
* tokens substituted, `@name` palette references passed through untouched.
|
|
||||||
* Declared-but-not-yet-consumed in production, same as every sibling
|
|
||||||
* template — see `ShellTheme::css`'s doc comment for why (breadbar hand-
|
|
||||||
* rolls its own CSS in `breadbar::theme::load_css` instead of calling this
|
|
||||||
* method). Exercised by this crate's own tests.
|
|
||||||
*
|
|
||||||
* Source: bos-ui-demos/proposed/daylight.html's <style> block.
|
|
||||||
*
|
|
||||||
* Unlike its three siblings, this template hardcodes which of `@bg`/`@on-bg`
|
|
||||||
* plays "paper surface" versus "ink" directly (`@on-bg` as the fill, `@bg`
|
|
||||||
* as the text) rather than branching on `{light}` at substitution time —
|
|
||||||
* `Tokens::substitute` only does `{name}` replacement, not conditionals, and
|
|
||||||
* this template is Daylight's alone, so there is nothing to branch FOR here.
|
|
||||||
* `breadbar::theme::load_css` is the one place that genuinely needs to pick
|
|
||||||
* between the two directions at runtime (it renders all four themes from
|
|
||||||
* one function) — see its own `panel`/`ink` locals and Tokens::light's doc
|
|
||||||
* comment for the general mechanism this template only needs one side of.
|
|
||||||
*/
|
|
||||||
|
|
||||||
window.breadbar {
|
|
||||||
background-color: transparent;
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
color: @bg;
|
|
||||||
}
|
|
||||||
window.breadbar > box > centerbox { padding: 0 14px; }
|
|
||||||
window.breadbar button { min-height: 0; min-width: 0; }
|
|
||||||
|
|
||||||
/* The three detached pills (axis 3, segmented) — see
|
|
||||||
* breadbar::theme::load_css's `.bar-segment` rule for the identical
|
|
||||||
* production version of this block.
|
|
||||||
*/
|
|
||||||
.bar-segment {
|
|
||||||
background-color: alpha(@on-bg, {bg_alpha});
|
|
||||||
border: 1px solid alpha(@bg, 0.10);
|
|
||||||
border-radius: {radius_bar}px;
|
|
||||||
box-shadow: 0 2px 10px alpha(@bg, 0.13);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-trail {
|
|
||||||
background-image: linear-gradient(90deg, @{accent_from}, @{accent_to});
|
|
||||||
background-color: @{accent_from};
|
|
||||||
border-radius: {radius_sm}px;
|
|
||||||
}
|
|
||||||
.workspace-btn {
|
|
||||||
background: transparent;
|
|
||||||
opacity: 0.36;
|
|
||||||
color: @bg;
|
|
||||||
border-radius: {radius_sm}px;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
box-shadow: none;
|
|
||||||
min-width: 28px;
|
|
||||||
min-height: {chip_height}px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 7px;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: bold;
|
|
||||||
transition: opacity 0.22s {spring_settle}, background-color 0.22s {spring_settle};
|
|
||||||
}
|
|
||||||
.workspace-btn:hover { opacity: 0.85; background: alpha(@bg, 0.08); }
|
|
||||||
.workspace-btn.occupied { opacity: 0.78; }
|
|
||||||
.workspace-btn.active { background: transparent; color: @on-accent; opacity: 1; }
|
|
||||||
.workspace-btn.active:hover { background: transparent; }
|
|
||||||
|
|
||||||
.clock-plain { padding: 0 4px; }
|
|
||||||
.clock-plain-time {
|
|
||||||
font-size: {font_size_base}px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.date-label {
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.48;
|
|
||||||
font-weight: 400;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Warm amber equaliser — the second accent (axis 1's colour-mapping note):
|
|
||||||
* distinct from the teal workspace-trail accent above.
|
|
||||||
*/
|
|
||||||
.media-eq-bar {
|
|
||||||
background-color: @{accent2};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light-theme overrides of the notification/OSD surfaces — the demo's own
|
|
||||||
* `.notifs`/`.note`/`.osd` block, which explicitly replaces shared.css's
|
|
||||||
* dark defaults. `@on-bg` (paper) / `@bg` (ink), not `@bg` (glass) / `@on-bg`
|
|
||||||
* (light ink) the way every sibling theme's identical rule reads — this is
|
|
||||||
* the one deliberate inversion this template makes relative to its
|
|
||||||
* siblings, and it is exactly what `{light}` conditions in the shared Rust
|
|
||||||
* implementation (breadbar::theme::load_css). See axis 1 in the task
|
|
||||||
* report for the full inventory this one inversion stands in for.
|
|
||||||
*/
|
|
||||||
window.breadbar-osd {
|
|
||||||
background-color: alpha(@on-bg, {bg_alpha});
|
|
||||||
color: @bg;
|
|
||||||
border-radius: {radius_pill}px;
|
|
||||||
border: 1px solid alpha(@bg, 0.10);
|
|
||||||
box-shadow: 0 8px 22px alpha(@bg, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.breadbar-panel {
|
|
||||||
background-color: alpha(@on-bg, {bg_alpha});
|
|
||||||
color: @bg;
|
|
||||||
border-radius: {radius_card}px;
|
|
||||||
border: 1px solid alpha(@bg, 0.10);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.breadbar-dismiss {
|
|
||||||
background-color: alpha(#000000, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bread-widget-slot { margin-right: {pad}px; }
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
# The fourth compiled-in builtin (bread-theme/src/shell/builtin.rs), demo
|
|
||||||
# "Daylight" in bos-ui-demos/proposed/daylight.html — a bottom-anchored dock
|
|
||||||
# split into three DETACHED pills (workspaces | clock+media | stats)
|
|
||||||
# floating on an otherwise fully transparent bar, ink-on-paper: near-opaque
|
|
||||||
# white surfaces, soft drop shadows (blur is OFF — see [compositor] below),
|
|
||||||
# deep teal accent, warm amber equaliser. This is the first built-in that is
|
|
||||||
# LIGHT, the first anchored BOTTOM, the first SEGMENTED (three pills instead
|
|
||||||
# of one bar surface), and the first with blur deliberately disabled — see
|
|
||||||
# the task report for what each of those four axes required.
|
|
||||||
#
|
|
||||||
# Values are taken from bos-ui-demos/proposed/daylight.html's <style> block;
|
|
||||||
# where the demo is silent (satellite offsets, bar-adjacent chip sizing)
|
|
||||||
# this file re-derives from the dock's own edge, same method every sibling
|
|
||||||
# theme.toml already documents.
|
|
||||||
#
|
|
||||||
# Font (Outfit is not installed on the dev machine, same gap liquid-motion/
|
|
||||||
# spotlight already have — renders in fallback until ttf-outfit is
|
|
||||||
# installed) and colour (the demo's #2f6d7a teal / #c2683c amber are *flat*
|
|
||||||
# accents — mapped to this palette's `teal` / `yellow` tokens, see [tokens]'s
|
|
||||||
# own note — never a literal hex value, or pywal theming breaks).
|
|
||||||
|
|
||||||
name = "Daylight"
|
|
||||||
id = "daylight"
|
|
||||||
|
|
||||||
[tokens]
|
|
||||||
font_family = "Outfit, sans-serif"
|
|
||||||
font_fallback = "sans-serif"
|
|
||||||
font_size_base = 14
|
|
||||||
# `.bar-segment`'s radius (see `bar_border = "segmented"` below) — the
|
|
||||||
# window itself (`window.breadbar`) draws no radius at all, since it draws
|
|
||||||
# no fill/border either. Demo: `.seg { border-radius: 14px }`.
|
|
||||||
radius_bar = 14
|
|
||||||
# Notification/history card radius. Demo has no explicit `.note` radius of
|
|
||||||
# its own (its override block only touches background/border/shadow/colour
|
|
||||||
# — see shared.css's `.note { border-radius: 12px }` default) — 14px keeps
|
|
||||||
# it a close, deliberate match to this theme's own popover/segment radius
|
|
||||||
# instead of inheriting an unrelated demo's 12px.
|
|
||||||
radius_card = 14
|
|
||||||
# Chip/workspace-pill radius. Demo: `.chip`/`.wp`/`.trail` all draw a 9px
|
|
||||||
# pill (concentric inside the 14px segment with a 7px inset, per the demo's
|
|
||||||
# own comment next to `.chip`).
|
|
||||||
radius_sm = 9
|
|
||||||
radius_pill = 999
|
|
||||||
# Demo: `.pop { padding: 13px }`.
|
|
||||||
pad = 13
|
|
||||||
# `.bar-segment` fill alpha — demo: `.seg { background: rgba(255,255,255,.94) }`,
|
|
||||||
# a near-OPAQUE paper pill, not liquid-motion/glass-workbench's translucent
|
|
||||||
# glass (0.72). Also reused for the notification/OSD/panel surfaces below
|
|
||||||
# (the same `bg_alpha` token every sibling theme already reuses for more
|
|
||||||
# than just the bar).
|
|
||||||
bg_alpha = 0.94
|
|
||||||
# The demo defines both curves distinctly (`--spring`/`--settle`), like
|
|
||||||
# liquid-motion — not glass-workbench/spotlight's single shared curve.
|
|
||||||
spring = "cubic-bezier(0.22, 1.35, 0.36, 1)"
|
|
||||||
spring_settle = "cubic-bezier(0.22, 1.2, 0.36, 1)"
|
|
||||||
# Palette token NAMES, not hex — #2f6d7a is this palette's `teal`. Equal
|
|
||||||
# from/to because the demo's workspace-trail fill is flat
|
|
||||||
# (`.trail { background: var(--accent) }`), not a gradient — same reasoning
|
|
||||||
# as glass-workbench/spotlight's own flat accents. accent_from IS read (the
|
|
||||||
# Trail gradient's start stop, and now — this theme is the reason — its end
|
|
||||||
# stop too, see accent_to's own doc comment in types.rs).
|
|
||||||
accent_from = "teal"
|
|
||||||
accent_to = "teal"
|
|
||||||
# A SECOND accent, independent of accent_from/accent_to: the demo's media
|
|
||||||
# equaliser bars are warm amber (`--accent2: #c2683c`), not the workspace
|
|
||||||
# trail's deep teal — one accent value can no longer describe both, which is
|
|
||||||
# why this key exists at all (see Tokens::accent2's doc comment). Mapped to
|
|
||||||
# `yellow` (pywal ANSI color3): the demo's amber reads warmer/more orange
|
|
||||||
# than a pure hue, and `yellow` is the only remaining named accent slot not
|
|
||||||
# already claimed by `accent`/`teal`/`green`/`pink` across the four built-in
|
|
||||||
# themes that reads plausibly warm regardless of wallpaper. Consumed by
|
|
||||||
# breadbar::theme::load_css's `.media-eq-bar` rule.
|
|
||||||
accent2 = "yellow"
|
|
||||||
# Demo: `.chip { height: 26px }` / `.wp { height: 26px }` on the 40px dock —
|
|
||||||
# the same 26px liquid-motion's own Trail-style bar already uses (see
|
|
||||||
# `breadbar::theme::approved_chip_height`, which is what actually governs
|
|
||||||
# this in practice — see that function's own note on why this token is
|
|
||||||
# otherwise a restated truth, not a live input).
|
|
||||||
chip_height = 26
|
|
||||||
# No single demo number for bar-icon glyph size (the demo's `.gl` box is
|
|
||||||
# 16x16, but that is the icon's *swatch*, not the glyph-drawing size other
|
|
||||||
# themes' icon_px models) — chosen proportionally between liquid-motion's
|
|
||||||
# 24px (44px bar) and glass-workbench's 18px (36px bar) for this theme's
|
|
||||||
# 40px dock.
|
|
||||||
icon_px = 20
|
|
||||||
# THE axis-3 (segmented) knob: `window.breadbar` itself gets no fill/
|
|
||||||
# border/radius at all (drawn fully transparent); the bar's three slot-group
|
|
||||||
# containers each get their own `.bar-segment` pill surface instead. See
|
|
||||||
# Tokens::bar_border's doc comment and breadbar::theme::load_css's
|
|
||||||
# `segmented` local for exactly what this changes.
|
|
||||||
bar_border = "segmented"
|
|
||||||
# THE axis-1 (light) knob — see Tokens::light's doc comment for why this
|
|
||||||
# exists and exactly what it swaps. Every dark-assuming hardcode this flag
|
|
||||||
# fixes is listed in the task report.
|
|
||||||
light = true
|
|
||||||
|
|
||||||
[bar.window]
|
|
||||||
# THE axis-2 (bottom-anchored) knob. Full-bleed left/right (`window.
|
|
||||||
# breadbar`'s own bounds touch both screen edges, like every sibling
|
|
||||||
# theme's top-anchored bar), with `bottom_margin` floating it 12px off the
|
|
||||||
# bottom edge — the demo's own `.dock { left: 0; right: 0; bottom: 12px }`.
|
|
||||||
# The 14px horizontal inset the demo's `.dock` draws via CSS `padding`
|
|
||||||
# (not a window margin) is reproduced the same way: see `centerbox_padding`
|
|
||||||
# in breadbar::theme::load_css, keyed off `bar_border == "segmented"`
|
|
||||||
# exactly like glass-workbench's flush bar already keys its own padding off
|
|
||||||
# `bar_border == "bottom"`.
|
|
||||||
anchors = ["bottom", "left", "right"]
|
|
||||||
width = "fill"
|
|
||||||
height = 40
|
|
||||||
margin = { top = 0, left = 0, right = 0, bottom = 12 }
|
|
||||||
exclusive = "auto"
|
|
||||||
keyboard = "none"
|
|
||||||
layer = "top"
|
|
||||||
|
|
||||||
[bar.slots]
|
|
||||||
# Same widget-alias shape as liquid-motion (plan §11 phase 1's fixed
|
|
||||||
# interleave points), even though the demo itself shows no extra Lua
|
|
||||||
# widget — so a widget requesting one of these placements has a home under
|
|
||||||
# Daylight too, the same reasoning spotlight's own `widget:left_of_stats`
|
|
||||||
# documents for its slimmer slot set.
|
|
||||||
left = ["workspaces", "widget:right_of_workspaces"]
|
|
||||||
centre = ["media", "widget:left_of_clock", "clock", "widget:right_of_clock"]
|
|
||||||
right = ["widget:left_of_stats", "volume", "wifi", "battery", "control"]
|
|
||||||
drawer = []
|
|
||||||
|
|
||||||
[modules.workspaces]
|
|
||||||
style = "trail"
|
|
||||||
show_empty = true
|
|
||||||
|
|
||||||
[modules.clock]
|
|
||||||
# Demo: one time label + one date label (`.clock`/`.date`), not liquid-
|
|
||||||
# motion's per-digit flip — same reasoning as glass-workbench's own "plain"
|
|
||||||
# choice.
|
|
||||||
style = "plain"
|
|
||||||
format = "%H:%M"
|
|
||||||
show_date = true
|
|
||||||
|
|
||||||
[launcher]
|
|
||||||
mode = "overlay"
|
|
||||||
# Demo: `.bx { width: min(430px, 90%) }`.
|
|
||||||
width = 430
|
|
||||||
# NOTE — known gap, see task report: `breadbox` (the process that actually
|
|
||||||
# reads this field) is not one of this task's two repos, and `Launcher` has
|
|
||||||
# no "anchor from bottom" concept at all — `top` is unconditionally a
|
|
||||||
# distance from the TOP of the screen in every existing consumer. The demo's
|
|
||||||
# launcher rises from just above the dock (`.bx { bottom: 62px }`), which
|
|
||||||
# this schema/consumer pairing cannot express without a breadbox-side change
|
|
||||||
# this task cannot make. 55% is a best-effort placement (roughly the lower
|
|
||||||
# half of the screen, not flush against either edge) documented here as a
|
|
||||||
# stopgap, not a real fix.
|
|
||||||
top = "55%"
|
|
||||||
# Demo: `.bx { border-radius: 18px }`.
|
|
||||||
radius = 18
|
|
||||||
# Demo: `.bx .ico { width: 26px; height: 26px }`.
|
|
||||||
icon_px = 26
|
|
||||||
# Demo's rows all fade/slide in together with a small per-index stagger
|
|
||||||
# (`animation-delay: min(i,10)*20ms`) — closest existing `row_anim` value is
|
|
||||||
# glass-workbench's "stagger" (the CSS classes it reuses are already 28ms
|
|
||||||
# apart, not 20ms, but this schema has no numeric stagger-interval knob of
|
|
||||||
# its own to match the demo's exactly).
|
|
||||||
row_anim = "stagger"
|
|
||||||
# Demo: a plain 1px hairline under the search field
|
|
||||||
# (`.bx .q { border-bottom: 1px solid rgba(26,29,34,.09) }`), not liquid-
|
|
||||||
# motion's gradient rule.
|
|
||||||
rule = "hairline"
|
|
||||||
footer = "count_apps"
|
|
||||||
sections = true
|
|
||||||
# Demo supports all four query prefixes (bare/`=`/`>`/`.`), same as
|
|
||||||
# spotlight — its `<script>` reuses the identical shared.js query parser.
|
|
||||||
modes = ["apps", "calc", "cmd", "url"]
|
|
||||||
# `.bx .r { border-radius: 10px; margin: 0 7px; padding: 9px 11px }`
|
|
||||||
row_radius = 10
|
|
||||||
row_inset = 7
|
|
||||||
row_padding_v = 9
|
|
||||||
row_padding_h = 11
|
|
||||||
# `.bx .ico { border-radius: 8px }` (paired with icon_px = 26 above)
|
|
||||||
icon_radius = 8
|
|
||||||
# `.bx .q { padding: 15px 17px; font-size: 15px }`
|
|
||||||
search_font_size = 15
|
|
||||||
search_padding_v = 15
|
|
||||||
search_padding_h = 17
|
|
||||||
# Demo: `.bx { background: var(--paper) }` — fully OPAQUE white, not
|
|
||||||
# liquid-motion/glass-workbench's translucent glass. 0.97, not a bare 1.0,
|
|
||||||
# to read as a deliberate "near-opaque paper" choice rather than a rounding
|
|
||||||
# accident, matching this theme's own `bg_alpha`/pill-surface language.
|
|
||||||
panel_alpha = 0.97
|
|
||||||
# Demo: `.bx .r.sel { background: rgba(47,109,122,.15) }` — teal at 15%.
|
|
||||||
selection_alpha = 0.15
|
|
||||||
|
|
||||||
# Keyed by layer-shell namespace, matching [compositor.*] below.
|
|
||||||
#
|
|
||||||
# `bottom_right`, not `top_right`: THE OTHER axis-2 gap this theme exposed
|
|
||||||
# and fixed. Every sibling theme's bar sits at the TOP, so `top_right`
|
|
||||||
# always put breadbar-notif/breadbar-panel naturally close to the bar; a
|
|
||||||
# bottom-anchored dock has nothing in the original three-shape anchor set
|
|
||||||
# that keeps its satellites near it. `bottom_right` is a new fourth anchor
|
|
||||||
# shape (bread-theme/src/shell/manifest.rs + breadbar/src/surface.rs),
|
|
||||||
# validated the same "typo'd key is a hard error" way as its siblings.
|
|
||||||
# `offset` is `[right, bottom]`, mirroring `top_right`'s `[right, top]`
|
|
||||||
# convention. This bar's own bottom edge sits at margin.bottom(12) +
|
|
||||||
# height(40) = 52px, so 64 (52 + a 12px gap, the same gap-beyond-the-bar-
|
|
||||||
# edge relationship every sibling theme's own top_right offset already
|
|
||||||
# uses) keeps the popup just clear of the dock.
|
|
||||||
[surfaces."breadbar-notif"]
|
|
||||||
anchor = "bottom_right"
|
|
||||||
offset = [16, 64]
|
|
||||||
# 320, not the demo's own shared.css `.notifs { width: 250px }` — every
|
|
||||||
# sibling theme's breadbar-notif width already follows breadbar's real
|
|
||||||
# `set_default_width(320)` live-toast constant instead of the demo markup's
|
|
||||||
# arbitrary number (see liquid-motion/theme.toml's own note on this), and
|
|
||||||
# this theme follows that same real-app-fidelity precedent rather than the
|
|
||||||
# demo's number.
|
|
||||||
width = 320
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
[surfaces."breadbar-osd"]
|
|
||||||
# Demo: `.osd { bottom: 64px }` — independent of `anchor`, this already
|
|
||||||
# matches the notif/panel offset above (both landed on 64 from the same
|
|
||||||
# "12px clear of the dock's 52px edge" reasoning, coincidentally the same
|
|
||||||
# number as the notif/panel `offset[1]`).
|
|
||||||
anchor = "bottom_centre"
|
|
||||||
offset = 64
|
|
||||||
width = 180
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
[surfaces."breadbar-panel"]
|
|
||||||
anchor = "bottom_right"
|
|
||||||
offset = [16, 64]
|
|
||||||
width = "auto"
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
[surfaces."breadbar-dismiss"]
|
|
||||||
# THE THIRD axis-2 consequence: `fill`'s `offset` was single-value
|
|
||||||
# (top-margin-only) before this theme — a bottom-anchored bar needs the
|
|
||||||
# scrim's GAP at the BOTTOM instead, so `breadbar/src/surface.rs::apply`'s
|
|
||||||
# "fill" arm now reads a `[top, bottom]` pair (falling back to `[value, 0]`
|
|
||||||
# for every existing single-value theme, so liquid-motion/glass-workbench/
|
|
||||||
# spotlight are unaffected). `0` here means the scrim covers all the way to
|
|
||||||
# the true top of the screen (nothing to clear up there); `52` is this bar's
|
|
||||||
# own edge (margin.bottom(12) + height(40)), leaving the dock's own
|
|
||||||
# footprint click-through the way every sibling theme's single top offset
|
|
||||||
# already leaves its own bar's footprint alone.
|
|
||||||
anchor = "fill"
|
|
||||||
offset = [0, 52]
|
|
||||||
width = "fill"
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
# Appearance-only, never placement/workspace/focus (plan §12 Layer B
|
|
||||||
# boundary). THE axis-4 knob: `blur = false` everywhere, and `blur_popups`
|
|
||||||
# dropped too — the demo draws depth with `box-shadow`, not
|
|
||||||
# `backdrop-filter`, and per the task brief this also sidesteps the
|
|
||||||
# shadow-halo trap a blurred `ignore_alpha` surface has (fixed in breadbox
|
|
||||||
# by removing the shadow entirely — see that fix's history) since there is
|
|
||||||
# no blur here for a shadow to get caught inside. No `ignore_alpha`
|
|
||||||
# anywhere below either: that key only means anything to a BLURRED surface,
|
|
||||||
# and setting it on an unblurred one would be exactly the kind of
|
|
||||||
# never-consumed key this project's rules forbid.
|
|
||||||
[compositor."breadbar"]
|
|
||||||
blur = false
|
|
||||||
blur_popups = false
|
|
||||||
animation = "slide bottom"
|
|
||||||
|
|
||||||
[compositor."breadbar-osd"]
|
|
||||||
blur = false
|
|
||||||
animation = "slide bottom"
|
|
||||||
|
|
||||||
[compositor."breadbar-notif"]
|
|
||||||
blur = false
|
|
||||||
animation = "slide right"
|
|
||||||
|
|
||||||
[compositor."breadbar-panel"]
|
|
||||||
blur = false
|
|
||||||
animation = "slide right"
|
|
||||||
|
|
||||||
[compositor."breadbar-dismiss"]
|
|
||||||
no_anim = true
|
|
||||||
|
|
||||||
[compositor."breadbox"]
|
|
||||||
blur = false
|
|
||||||
|
|
||||||
# No `css = "..."` overlay — compiled-in builtin, same as its three siblings.
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
/* CSS template for the glass-workbench builtin (bread-theme/src/shell/
|
|
||||||
* builtin.rs). Same scope and substitution rules as liquid-motion.css: only
|
|
||||||
* the window/workspace/clock chrome the manifest's own concepts model, `{name}`
|
|
||||||
* tokens substituted, `@name` palette references passed through untouched.
|
|
||||||
*
|
|
||||||
* Source: bos-ui-demos/02-glass-workbench.html's <style> block. No
|
|
||||||
* per-digit flip (this theme has no `.clock-digit` markup at all — plain
|
|
||||||
* style is a date label + one time label) and no gradient trail (pill style
|
|
||||||
* never makes `.workspace-trail` visible — see breadbar's
|
|
||||||
* `WorkspaceTrail::place`/`stretch`, which this theme's slot config simply
|
|
||||||
* never calls).
|
|
||||||
*/
|
|
||||||
|
|
||||||
window.breadbar {
|
|
||||||
background-color: alpha(@bg, {bg_alpha});
|
|
||||||
color: @on-bg;
|
|
||||||
border-radius: {radius_bar}px;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid alpha(@on-bg, 0.07);
|
|
||||||
}
|
|
||||||
window.breadbar > centerbox { padding: 0 {pad}px; }
|
|
||||||
window.breadbar button { min-height: 0; min-width: 0; }
|
|
||||||
|
|
||||||
/* Pill workspaces: solid accent fill when active, dimmed when empty — no
|
|
||||||
* trail overlay is ever shown (place()/stretch() are never called for this
|
|
||||||
* style), so `.workspace-trail` itself needs no rule here.
|
|
||||||
*/
|
|
||||||
.workspace-btn {
|
|
||||||
background: transparent;
|
|
||||||
opacity: 1;
|
|
||||||
color: alpha(@on-bg, 0.4);
|
|
||||||
border-radius: {radius_sm}px;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
box-shadow: none;
|
|
||||||
min-width: 22px;
|
|
||||||
min-height: {chip_height}px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: background-color 0.22s {spring_settle}, color 0.22s {spring_settle},
|
|
||||||
opacity 0.22s {spring_settle};
|
|
||||||
}
|
|
||||||
.workspace-btn:hover { background: alpha(@on-bg, 0.08); }
|
|
||||||
.workspace-btn.occupied { color: alpha(@on-bg, 0.8); }
|
|
||||||
.workspace-btn:not(.occupied):not(.active) { opacity: 0.35; }
|
|
||||||
.workspace-btn.active {
|
|
||||||
background: @{accent_from};
|
|
||||||
color: @on-accent;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.workspace-btn.active:hover { background: @{accent_from}; }
|
|
||||||
|
|
||||||
.clock-plain { padding: 0 4px; }
|
|
||||||
.clock-plain-time {
|
|
||||||
font-size: {font_size_base}px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.date-label {
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.48;
|
|
||||||
font-weight: 400;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.breadbar-osd {
|
|
||||||
background-color: alpha(@bg, 0.72);
|
|
||||||
color: @on-bg;
|
|
||||||
border-radius: {radius_pill}px;
|
|
||||||
border: 1px solid alpha(@on-bg, 0.10);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.breadbar-panel {
|
|
||||||
background-color: alpha(@bg, 0.86);
|
|
||||||
color: @on-bg;
|
|
||||||
border-radius: {radius_card}px;
|
|
||||||
border: 1px solid alpha(@on-bg, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.breadbar-dismiss {
|
|
||||||
background-color: alpha(#000000, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bread-widget-slot { margin-right: {pad}px; }
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
# The second compiled-in builtin (bread-theme/src/shell/builtin.rs), demo 02
|
|
||||||
# in THEME_SYSTEM_PLAN.md — "Glass Workbench": a flush edge-to-edge bar with
|
|
||||||
# plain pill workspaces, a plain date+time clock, and cpu/ram chips instead
|
|
||||||
# of the media widget liquid-motion carries. Values are taken from
|
|
||||||
# bos-ui-demos/02-glass-workbench.html's <style> block; where the demo is
|
|
||||||
# silent on a number (bar-adjacent chip sizing, satellite offsets) this file
|
|
||||||
# scales liquid-motion's own numbers down by the same 36/44 bar-height ratio
|
|
||||||
# rather than inventing something unrelated to either source.
|
|
||||||
#
|
|
||||||
# Unlike liquid-motion, this builtin has no "as the code exists today" tether
|
|
||||||
# — it is new, so demo fidelity is the only source of truth. See Phase 5's
|
|
||||||
# task notes for the two exceptions: font (IBM Plex Sans is not installed on
|
|
||||||
# the dev machine, so this renders in fallback until the user installs
|
|
||||||
# ttf-ibm-plex — this now matters for real: breadbox's launcher panel
|
|
||||||
# consumes font_family/font_fallback below, see [tokens]'s own note) and
|
|
||||||
# colour (the demo's #7a9a88 sage is a *flat* accent, so it maps to the
|
|
||||||
# palette's `green` token for both accent_from and accent_to — never a
|
|
||||||
# literal hex value, or pywal theming breaks).
|
|
||||||
|
|
||||||
name = "Glass Workbench"
|
|
||||||
id = "glass-workbench"
|
|
||||||
|
|
||||||
[tokens]
|
|
||||||
# font_family/font_fallback/font_size_base are consumed by breadbox's
|
|
||||||
# launcher panel only (breadbox::main::build_css's entry.search/row rules —
|
|
||||||
# see Tokens::font_family's doc comment for the exact scope); breadbar's
|
|
||||||
# bar/chip/clock text still comes from bread_theme::stylesheet()'s own
|
|
||||||
# hardcoded FONT_FAMILY constant, unaffected by this key.
|
|
||||||
font_family = "IBM Plex Sans"
|
|
||||||
font_fallback = "Inter, Noto Sans, sans-serif"
|
|
||||||
font_size_base = 13
|
|
||||||
radius_bar = 0
|
|
||||||
radius_card = 10
|
|
||||||
radius_sm = 6
|
|
||||||
radius_pill = 999
|
|
||||||
pad = 12
|
|
||||||
bg_alpha = 0.72
|
|
||||||
# The demo defines exactly one easing curve (`--spring: cubic-bezier(.22,
|
|
||||||
# 1.2, .36, 1)`), not liquid-motion's overshoot/settle pair — there's no
|
|
||||||
# digit-flip or trail-stretch here to want a bounce for. Both `spring` and
|
|
||||||
# `spring_settle` point at the demo's one curve rather than inventing a
|
|
||||||
# second value the demo never specifies.
|
|
||||||
spring = "cubic-bezier(0.22, 1.2, 0.36, 1)"
|
|
||||||
spring_settle = "cubic-bezier(0.22, 1.2, 0.36, 1)"
|
|
||||||
# Palette token NAMES, not hex — #7a9a88 is this palette's `green`. Equal
|
|
||||||
# from/to because the demo's accent is flat, not a gradient. accent_from IS
|
|
||||||
# read (Pill-style workspace fill, theme.rs); accent_to is declared-but-
|
|
||||||
# not-yet-consumed regardless of style — nothing reads it outside the
|
|
||||||
# breadbar Trail gradient this theme doesn't use, and even Trail hardcodes
|
|
||||||
# its own literal gradient rather than substituting it. See
|
|
||||||
# Tokens::accent_to's doc comment.
|
|
||||||
accent_from = "green"
|
|
||||||
accent_to = "green"
|
|
||||||
# Demo: `.ws button { height: 20px }`. Liquid-motion's chip_height (32) is
|
|
||||||
# tied to its 44px island; 20px is the demo's own number for this bar's
|
|
||||||
# workspace pills, and is reused for the bar's other small chips (cpu/ram/
|
|
||||||
# wifi/battery/control) so they all sit at the same height on the thinner
|
|
||||||
# 36px flush bar.
|
|
||||||
chip_height = 22
|
|
||||||
# No demo number for bar-icon size; scaled from liquid-motion's 24px by the
|
|
||||||
# same 36/44 bar-height ratio liquid-motion itself uses for its 44px bar.
|
|
||||||
icon_px = 18
|
|
||||||
# Flush edge-to-edge bar: a full border would draw a stray line along the
|
|
||||||
# top/side screen edges a floating island doesn't have to worry about. See
|
|
||||||
# Tokens::bar_border's doc comment.
|
|
||||||
bar_border = "bottom"
|
|
||||||
|
|
||||||
[bar.window]
|
|
||||||
anchors = ["top", "left", "right"]
|
|
||||||
width = "fill"
|
|
||||||
height = 36
|
|
||||||
margin = { top = 0, left = 0, right = 0 }
|
|
||||||
exclusive = "auto"
|
|
||||||
keyboard = "none"
|
|
||||||
layer = "top"
|
|
||||||
|
|
||||||
[bar.slots]
|
|
||||||
# No media module — demo 02 has no media widget at all (plan §1 table).
|
|
||||||
# breadbar must tolerate the omission: `media_widget` is still built and
|
|
||||||
# registered under the "media" module name (main.rs), it just never appears
|
|
||||||
# in any of this theme's slot lists, so it's never appended anywhere.
|
|
||||||
left = ["workspaces"]
|
|
||||||
centre = ["clock"]
|
|
||||||
right = ["cpu", "ram", "wifi", "battery", "control"]
|
|
||||||
drawer = []
|
|
||||||
|
|
||||||
[modules.workspaces]
|
|
||||||
style = "pill"
|
|
||||||
show_empty = true
|
|
||||||
|
|
||||||
[modules.clock]
|
|
||||||
style = "plain"
|
|
||||||
format = "%H:%M"
|
|
||||||
show_date = true
|
|
||||||
|
|
||||||
[launcher]
|
|
||||||
# Glass Workbench — dense and technical, the deliberate opposite of Liquid
|
|
||||||
# Motion's soft/roomy numbers: a tight panel radius, snug row insets/padding,
|
|
||||||
# small 22px icons, a flat (no section headers) list, and a denser accent
|
|
||||||
# selection fill. Spec: bos-ui-demos/proposed/glass-workbench.html's `.bx`
|
|
||||||
# block. breadbox reads every key below now; see main.rs's build_css.
|
|
||||||
mode = "overlay"
|
|
||||||
width = 560
|
|
||||||
top = "64px"
|
|
||||||
radius = 10
|
|
||||||
icon_px = 22
|
|
||||||
row_anim = "stagger"
|
|
||||||
rule = "hairline"
|
|
||||||
footer = "count_results"
|
|
||||||
sections = false
|
|
||||||
modes = ["apps"]
|
|
||||||
# `.bx .r { border-radius: 6px; margin: 0 6px; padding: 8px 10px }`
|
|
||||||
row_radius = 6
|
|
||||||
row_inset = 6
|
|
||||||
row_padding_v = 8
|
|
||||||
row_padding_h = 10
|
|
||||||
# `.bx .ico { border-radius: 5px }` (paired with icon_px = 22 above)
|
|
||||||
icon_radius = 5
|
|
||||||
# `.bx .q { padding: 12px 15px; font-size: 14px }`
|
|
||||||
search_font_size = 14
|
|
||||||
search_padding_v = 12
|
|
||||||
search_padding_h = 15
|
|
||||||
# `.bx .r.sel { background: rgba(accent, .28) }`
|
|
||||||
# Panel opacity, separate from tokens.bg_alpha (the bar). Matches the
|
|
||||||
# approved reference: demo `.bx.gw{background:rgba(16,20,18,.95)}`. breadbox hardcoded 0.60, which washed the
|
|
||||||
# panel out over a bright wallpaper.
|
|
||||||
panel_alpha = 0.95
|
|
||||||
selection_alpha = 0.28
|
|
||||||
|
|
||||||
# Same four satellite namespaces as liquid-motion, keyed identically so the
|
|
||||||
# two manifests validate against the same [compositor.*] keyspace. Offsets
|
|
||||||
# are liquid-motion's own numbers re-derived from this bar's actual edge
|
|
||||||
# instead of the island's: liquid-motion's bar bottom edge sits at
|
|
||||||
# margin.top(12) + height(44) = 56px, and its breadbar-notif/-panel top
|
|
||||||
# offset (64) is that plus an 8px gap, while breadbar-dismiss (56) sits
|
|
||||||
# flush against it with no gap. This bar's edge is margin.top(0) +
|
|
||||||
# height(36) = 36px, so the same two relationships give 44 (36 + 8) and 36.
|
|
||||||
[surfaces."breadbar-notif"]
|
|
||||||
anchor = "top_right"
|
|
||||||
offset = [16, 44]
|
|
||||||
width = 320
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
[surfaces."breadbar-osd"]
|
|
||||||
# Independent of bar height (an OSD pill near the bottom of the screen) —
|
|
||||||
# unchanged from liquid-motion.
|
|
||||||
anchor = "bottom_centre"
|
|
||||||
offset = 80
|
|
||||||
width = 180
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
[surfaces."breadbar-panel"]
|
|
||||||
anchor = "top_right"
|
|
||||||
offset = [16, 44]
|
|
||||||
width = "auto"
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
[surfaces."breadbar-dismiss"]
|
|
||||||
anchor = "fill"
|
|
||||||
offset = 36
|
|
||||||
width = "fill"
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
# Appearance-only, never placement/workspace/focus (plan §12 Layer B
|
|
||||||
# boundary) — identical to liquid-motion's rules. Blur *strength* is global
|
|
||||||
# (plan §9 known limit), so this theme cannot independently match the demo's
|
|
||||||
# per-surface blur radii (20px bar / 16px launcher / 22px popovers / 6px
|
|
||||||
# window borders); only on/off, ignore_alpha and slide direction are
|
|
||||||
# per-namespace knobs this system actually has.
|
|
||||||
[compositor."breadbar"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
blur_popups = true
|
|
||||||
animation = "slide top"
|
|
||||||
|
|
||||||
[compositor."breadbar-osd"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
animation = "slide bottom"
|
|
||||||
|
|
||||||
[compositor."breadbar-notif"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
animation = "slide right"
|
|
||||||
|
|
||||||
[compositor."breadbar-panel"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
animation = "slide right"
|
|
||||||
|
|
||||||
[compositor."breadbar-dismiss"]
|
|
||||||
no_anim = true
|
|
||||||
|
|
||||||
[compositor."breadbox"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
|
|
||||||
# No `css = "..."` overlay — compiled-in builtin, same as liquid-motion.
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
/* CSS template for the liquid-motion builtin (bread-theme/src/shell/builtin.rs).
|
|
||||||
*
|
|
||||||
* Curly-brace placeholders like {radius_bar} or {spring} are substituted
|
|
||||||
* from [tokens] by ShellTheme::css / Tokens::substitute; @-prefixed palette
|
|
||||||
* references (@accent, @on-bg, ...) pass through untouched, exactly like
|
|
||||||
* `bread_theme::stylesheet()` — GTK's own @define-color mechanism resolves
|
|
||||||
* them when the CSS provider is attached to a display/output.
|
|
||||||
*
|
|
||||||
* Scope: this is the window/workspace/clock chrome the manifest's own
|
|
||||||
* concepts (bar.window, modules.workspaces, modules.clock) actually model —
|
|
||||||
* not a byte-for-byte copy of breadbar/src/theme.rs::load_css's full ~250
|
|
||||||
* lines (notification cards, wifi popover, control panel, media widget, ...).
|
|
||||||
* Those stay hand-written in breadbar for now; migrating them behind this
|
|
||||||
* same token-substitution mechanism is Phase 2/3 work (plan §6), once
|
|
||||||
* breadbar actually consumes ShellTheme and can verify pixel parity itself
|
|
||||||
* via --screenshot. Phase 1's job is the mechanism, exercised end-to-end
|
|
||||||
* here with a representative slice, not the full port.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@keyframes row-in {
|
|
||||||
from { opacity: 0; margin-top: 8px; }
|
|
||||||
to { opacity: 1; margin-top: 0; }
|
|
||||||
}
|
|
||||||
@keyframes digit-flip {
|
|
||||||
from { opacity: 0; margin-top: 7px; }
|
|
||||||
to { opacity: 1; margin-top: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
window.breadbar {
|
|
||||||
background-color: alpha(@bg, {bg_alpha});
|
|
||||||
color: @on-bg;
|
|
||||||
border-radius: {radius_bar}px;
|
|
||||||
border: 1px solid alpha(@on-bg, 0.08);
|
|
||||||
}
|
|
||||||
window.breadbar > centerbox { padding: 0 8px 0 6px; }
|
|
||||||
window.breadbar button { min-height: 0; min-width: 0; }
|
|
||||||
|
|
||||||
.workspace-trail {
|
|
||||||
background-image: linear-gradient(90deg, @{accent_from}, @{accent_to});
|
|
||||||
background-color: @{accent_from};
|
|
||||||
border-radius: {radius_card}px;
|
|
||||||
}
|
|
||||||
.workspace-btn {
|
|
||||||
background: transparent;
|
|
||||||
opacity: 0.36;
|
|
||||||
color: @on-bg;
|
|
||||||
border-radius: {radius_card}px;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
box-shadow: none;
|
|
||||||
min-width: 28px;
|
|
||||||
min-height: {chip_height}px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 7px;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: bold;
|
|
||||||
transition: opacity 0.22s {spring_settle}, background-color 0.22s {spring_settle};
|
|
||||||
}
|
|
||||||
.workspace-btn:hover { opacity: 0.85; background: alpha(@on-bg, 0.08); }
|
|
||||||
.workspace-btn.occupied { opacity: 0.78; }
|
|
||||||
.workspace-btn.active { background: transparent; color: @on-accent; opacity: 1; }
|
|
||||||
.workspace-btn.active:hover { background: transparent; }
|
|
||||||
.workspace-btn.ws-in { animation: row-in 0.32s {spring_settle} both; }
|
|
||||||
|
|
||||||
.clock-box { padding: 0 4px; }
|
|
||||||
.clock-label {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
.clock-digit {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
min-width: 15px;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.clock-colon { min-width: 10px; opacity: 0.7; }
|
|
||||||
.clock-digit.flip { animation: digit-flip 0.45s {spring} both; }
|
|
||||||
|
|
||||||
window.breadbar-osd {
|
|
||||||
background-color: alpha(@bg, 0.70);
|
|
||||||
color: @on-bg;
|
|
||||||
border-radius: {radius_pill}px;
|
|
||||||
border: 1px solid alpha(@on-bg, 0.10);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.breadbar-panel {
|
|
||||||
background-color: alpha(@bg, {bg_alpha});
|
|
||||||
color: @on-bg;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid alpha(@on-bg, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.breadbar-dismiss {
|
|
||||||
background-color: alpha(#000000, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bread-widget-slot { margin-right: {pad}px; }
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
# The compiled-in builtin theme (bread-theme/src/shell/builtin.rs). The bar
|
|
||||||
# side (workspaces/clock/chips/satellites) describes breadbar AS IT EXISTS
|
|
||||||
# TODAY, not the 01-liquid-motion.html demo — where the two disagree (bar
|
|
||||||
# side margin, the two easing curves), this file follows the current Rust
|
|
||||||
# source, since Phase 2's acceptance test is pixel-identical rendering
|
|
||||||
# against today's bar. The `[launcher]` table is the one exception: it now
|
|
||||||
# follows bos-ui-demos/proposed/liquid-motion.html's `.bx` block (the
|
|
||||||
# launcher-core redesign's spec) rather than breadbox's old, pre-redesign
|
|
||||||
# hardcoded CSS — see that section's own note.
|
|
||||||
#
|
|
||||||
# Sources: breadbar/src/main.rs:16-22 (BAR_* / CHIP_HEIGHT / ICON_PX consts),
|
|
||||||
# breadbar/src/theme.rs (load_css's radius/pad/spring locals and the actual
|
|
||||||
# CSS selectors), breadbar/src/{panel,osd}.rs and
|
|
||||||
# breadbar/src/notifications/{popup,history}.rs (satellite window anchors,
|
|
||||||
# margins, namespaces), bos-ui-demos/proposed/liquid-motion.html (launcher),
|
|
||||||
# ~/.config/hypr/scripts/ui/rules.lua (compositor rules).
|
|
||||||
|
|
||||||
name = "Liquid Motion"
|
|
||||||
id = "liquid-motion"
|
|
||||||
|
|
||||||
[tokens]
|
|
||||||
# font_family/font_fallback are now consumed, but only by breadbox's launcher
|
|
||||||
# panel (breadbox::main::build_css's entry.search/row font-family) — see
|
|
||||||
# Tokens::font_family's doc comment for the exact scope. breadbar's bar/chip/
|
|
||||||
# clock text still comes from bread_theme::stylesheet()'s own hardcoded
|
|
||||||
# FONT_FAMILY constant, unaffected by this key. font_size_base is likewise
|
|
||||||
# now consumed for the launcher's row font-size only.
|
|
||||||
font_family = "Outfit, sans-serif"
|
|
||||||
font_fallback = "sans-serif"
|
|
||||||
font_size_base = 14
|
|
||||||
radius_bar = 16
|
|
||||||
radius_card = 12
|
|
||||||
radius_sm = 9
|
|
||||||
# Not in the plan's §4 schema list, but a named local in theme.rs::load_css
|
|
||||||
# alongside radius_bar/radius_card/radius_sm (`radius_pill = "999px"`) — the
|
|
||||||
# OSD pill's corner radius.
|
|
||||||
radius_pill = 999
|
|
||||||
pad = 12
|
|
||||||
bg_alpha = 0.72
|
|
||||||
# Two curves theme.rs actually uses, not one: `spring` is the overshoot/bounce
|
|
||||||
# curve (clock flips, pop-ins, the workspace caret draw); `spring_settle` is
|
|
||||||
# the flatter curve used for hovers and workspace/stat-pair transitions. The
|
|
||||||
# plan's §4 example names only `spring`.
|
|
||||||
spring = "cubic-bezier(0.22, 1.35, 0.36, 1)"
|
|
||||||
spring_settle = "cubic-bezier(0.22, 1.2, 0.36, 1)"
|
|
||||||
# "accent" flows through as @accent (the workspace-trail gradient's start);
|
|
||||||
# these are palette token NAMES, not hex - pywal still drives colour.
|
|
||||||
# Declared-but-not-yet-consumed (accent_to only): breadbar's Trail-style CSS
|
|
||||||
# hardcodes the literal gradient `linear-gradient(90deg, @accent, @teal)`
|
|
||||||
# instead of substituting these two tokens (it never calls
|
|
||||||
# ShellTheme::css(), the only thing that does the substitution) — this
|
|
||||||
# theme's "teal" is what the demo intends, not what actually renders if a
|
|
||||||
# future theme changed it. See Tokens::accent_to's doc comment.
|
|
||||||
accent_from = "accent"
|
|
||||||
accent_to = "teal"
|
|
||||||
# Not in the plan's §4 schema list, but breadbar::CHIP_HEIGHT / ::ICON_PX
|
|
||||||
# today.
|
|
||||||
chip_height = 26
|
|
||||||
icon_px = 24
|
|
||||||
|
|
||||||
[bar.window]
|
|
||||||
anchors = ["top", "left", "right"]
|
|
||||||
width = "fill"
|
|
||||||
height = 44
|
|
||||||
# No `bottom` set here (or by either sibling builtin) — it's accepted by the
|
|
||||||
# schema but breadbar never applies it (no set_margin(Edge::Bottom, ...)
|
|
||||||
# call exists). See Margin::bottom's doc comment before relying on it.
|
|
||||||
margin = { top = 12, left = 16, right = 16 }
|
|
||||||
exclusive = "auto"
|
|
||||||
# breadbar never calls gtk4-layer-shell's set_keyboard_mode today, which
|
|
||||||
# defaults to KeyboardMode::None — spelled out explicitly here rather than
|
|
||||||
# left to omit-means-default, since "the shell must never fail to start
|
|
||||||
# because a theme file is malformed" cuts both ways: an explicit builtin
|
|
||||||
# value can't silently drift if the crate's own Default ever changes.
|
|
||||||
keyboard = "none"
|
|
||||||
layer = "top"
|
|
||||||
|
|
||||||
[bar.slots]
|
|
||||||
# The `widget:*` entries reproduce breadbar's pre-Phase-3b fixed Lua-widget
|
|
||||||
# interleave exactly (main.rs's now-removed widget_right_of_workspaces /
|
|
||||||
# widget_left_of_clock / widget_right_of_clock / widget_left_of_stats), so
|
|
||||||
# this manifest still renders pixel-identically to today's bar. `tray`
|
|
||||||
# deliberately has NO slot entry anywhere — it lives in the control-panel
|
|
||||||
# popover, not the bar, and is keyed directly by breadbar regardless of
|
|
||||||
# `[bar.slots]`.
|
|
||||||
left = ["workspaces", "widget:right_of_workspaces"]
|
|
||||||
centre = ["media", "widget:left_of_clock", "clock", "widget:right_of_clock"]
|
|
||||||
right = ["widget:left_of_stats", "volume", "wifi", "battery", "control"]
|
|
||||||
drawer = []
|
|
||||||
|
|
||||||
[modules.workspaces]
|
|
||||||
style = "trail"
|
|
||||||
show_empty = true
|
|
||||||
|
|
||||||
[modules.clock]
|
|
||||||
style = "flip"
|
|
||||||
# Declared-but-not-yet-consumed under style = "flip": bar::clock::time()
|
|
||||||
# hardcodes a 24h HH:MM layout regardless of `format`; only style = "plain"
|
|
||||||
# ever calls formatted(&format). Set here to state the layout this clock
|
|
||||||
# actually draws, not because anything reads it. Same for show_date — only
|
|
||||||
# ever attached to the Plain style's box.
|
|
||||||
format = "%H:%M"
|
|
||||||
show_date = false
|
|
||||||
|
|
||||||
[launcher]
|
|
||||||
# Liquid Motion / Glass Workbench redesign (bos-ui-demos/proposed/
|
|
||||||
# liquid-motion.html's `.bx` block is the spec): soft and roomy — a big
|
|
||||||
# panel radius, generous row insets/padding, 28px icons, section headers,
|
|
||||||
# and a translucent (not solid) accent selection fill. breadbox reads every
|
|
||||||
# key below now; see main.rs's build_css for the consuming CSS.
|
|
||||||
mode = "overlay"
|
|
||||||
width = 600
|
|
||||||
top = "120px"
|
|
||||||
radius = 20
|
|
||||||
icon_px = 28
|
|
||||||
row_anim = "flip"
|
|
||||||
rule = "gradient"
|
|
||||||
footer = "count_apps"
|
|
||||||
sections = true
|
|
||||||
modes = ["apps"]
|
|
||||||
# `.bx .r { border-radius: 12px; margin: 0 8px; padding: 10px 12px }`
|
|
||||||
row_radius = 12
|
|
||||||
row_inset = 8
|
|
||||||
row_padding_v = 10
|
|
||||||
row_padding_h = 12
|
|
||||||
# `.bx .ico { border-radius: 9px }` (paired with icon_px = 28 above)
|
|
||||||
icon_radius = 9
|
|
||||||
# `.bx .q { padding: 16px 18px; font-size: 16px }`
|
|
||||||
search_font_size = 16
|
|
||||||
search_padding_v = 16
|
|
||||||
search_padding_h = 18
|
|
||||||
# `.bx .r.sel { background: rgba(accent, .22) }`
|
|
||||||
# Panel opacity, separate from tokens.bg_alpha (the bar). Matches the
|
|
||||||
# approved reference: demo `.bx.lm{background:rgba(21,14,23,.93)}`. breadbox hardcoded 0.60, which washed the
|
|
||||||
# panel out over a bright wallpaper.
|
|
||||||
panel_alpha = 0.93
|
|
||||||
selection_alpha = 0.22
|
|
||||||
|
|
||||||
# Keyed by layer-shell namespace, matching [compositor.*] below, so the two
|
|
||||||
# tables share one keyspace and can be validated against each other.
|
|
||||||
# breadbar-notif's popup toast uses set_default_width(320) (its history
|
|
||||||
# sibling on the same namespace uses 360 — 320 is the live-toast surface, the
|
|
||||||
# one this positions). breadbar-panel sets no window width at all: its
|
|
||||||
# popovers size from their own CSS (.control-panel-inner / .wifi-popover-inner
|
|
||||||
# min-width), which is why its width is "auto" rather than a number.
|
|
||||||
[surfaces."breadbar-notif"]
|
|
||||||
anchor = "top_right"
|
|
||||||
offset = [16, 64]
|
|
||||||
width = 320
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
[surfaces."breadbar-osd"]
|
|
||||||
anchor = "bottom_centre"
|
|
||||||
offset = 80
|
|
||||||
width = 180
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
[surfaces."breadbar-panel"]
|
|
||||||
anchor = "top_right"
|
|
||||||
offset = [16, 64]
|
|
||||||
width = "auto"
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
[surfaces."breadbar-dismiss"]
|
|
||||||
# Anchored to all four edges (a fullscreen click-away scrim) with only a top
|
|
||||||
# margin, so it starts below the bar rather than covering it.
|
|
||||||
anchor = "fill"
|
|
||||||
offset = 56
|
|
||||||
width = "fill"
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
# Faithful to scripts/ui/rules.lua's five breadbar namespaces + breadbox.
|
|
||||||
[compositor."breadbar"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
blur_popups = true
|
|
||||||
animation = "slide top"
|
|
||||||
|
|
||||||
[compositor."breadbar-osd"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
animation = "slide bottom"
|
|
||||||
|
|
||||||
[compositor."breadbar-notif"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
animation = "slide right"
|
|
||||||
|
|
||||||
[compositor."breadbar-panel"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
animation = "slide right"
|
|
||||||
|
|
||||||
[compositor."breadbar-dismiss"]
|
|
||||||
no_anim = true
|
|
||||||
|
|
||||||
[compositor."breadbox"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
|
|
||||||
# No `css = "..."` overlay: the builtin is compiled in via `include_str!`
|
|
||||||
# and has no on-disk directory to resolve a relative overlay path against.
|
|
||||||
# `extra.css` is for user/system themes, which do have one — see
|
|
||||||
# `resolve_theme` in mod.rs.
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
/* CSS template for the spotlight builtin (bread-theme/src/shell/builtin.rs).
|
|
||||||
* Same scope and substitution rules as liquid-motion.css/glass-workbench.css:
|
|
||||||
* only the window/workspace/clock chrome the manifest's own concepts model.
|
|
||||||
*
|
|
||||||
* NOTE (unlike its two siblings): breadbar does not actually call
|
|
||||||
* `ShellTheme::css()` today — its live stylesheet is the hand-written,
|
|
||||||
* token-driven `breadbar::theme::load_css()`, which is where the capsule's
|
|
||||||
* real chrome (dots, `.launcher-entry`, `.bread-drawer`/results rows) lives.
|
|
||||||
* This file exists so the manifest's own template mechanism stays exercised
|
|
||||||
* for all three builtins equally (and so a future `bread-theme` CLI/test
|
|
||||||
* that DOES call `.css()` sees a real spotlight template) — see
|
|
||||||
* `ShellTheme::css`'s own doc comment for why that gap between "what the
|
|
||||||
* manifest models" and "what breadbar actually renders" is pre-existing,
|
|
||||||
* not new to this file.
|
|
||||||
*
|
|
||||||
* Source: bos-ui-demos/04-spotlight.html's <style> block. No trail overlay
|
|
||||||
* (dots never call `WorkspaceTrail::place`/`stretch`, same as pill) and no
|
|
||||||
* per-digit flip (`style = "none"` — no clock label markup at all).
|
|
||||||
*/
|
|
||||||
|
|
||||||
window.breadbar {
|
|
||||||
background-color: alpha(@bg, {bg_alpha});
|
|
||||||
color: @on-bg;
|
|
||||||
border-radius: {radius_bar}px;
|
|
||||||
border: 1px solid alpha(@on-bg, 0.08);
|
|
||||||
}
|
|
||||||
window.breadbar > box > centerbox { padding: 0 {pad}px; }
|
|
||||||
window.breadbar button { min-height: 0; min-width: 0; }
|
|
||||||
|
|
||||||
/* Dots: colour/opacity/radius only — width is per-instance data
|
|
||||||
* (`modules.workspaces.dot_widths`), set directly via `set_size_request` in
|
|
||||||
* Rust (`bar::workspaces::make_dot_button`), since GTK CSS has no
|
|
||||||
* per-instance variable width the way the demo's `[data-n]` attribute
|
|
||||||
* selectors do.
|
|
||||||
*/
|
|
||||||
.workspace-dot {
|
|
||||||
background-color: alpha(@on-bg, 0.35);
|
|
||||||
border-radius: {radius_pill}px;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
box-shadow: none;
|
|
||||||
min-height: 6px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
transition: background-color 0.25s {spring_settle}, opacity 0.25s {spring_settle};
|
|
||||||
}
|
|
||||||
.workspace-dot:hover { background-color: alpha(@on-bg, 0.55); }
|
|
||||||
.workspace-dot:not(.occupied):not(.active) { opacity: 0.35; }
|
|
||||||
.workspace-dot.active {
|
|
||||||
background-color: @{accent_from};
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.workspace-dot.active:hover { background-color: @{accent_from}; }
|
|
||||||
|
|
||||||
window.breadbar-osd {
|
|
||||||
background-color: alpha(@bg, 0.72);
|
|
||||||
color: @on-bg;
|
|
||||||
border-radius: {radius_pill}px;
|
|
||||||
border: 1px solid alpha(@on-bg, 0.10);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.breadbar-panel {
|
|
||||||
background-color: alpha(@bg, 0.86);
|
|
||||||
color: @on-bg;
|
|
||||||
border-radius: {radius_card}px;
|
|
||||||
border: 1px solid alpha(@on-bg, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.breadbar-dismiss {
|
|
||||||
background-color: alpha(#000000, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bread-widget-slot { margin-right: {pad}px; }
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
# The third compiled-in builtin (bread-theme/src/shell/builtin.rs), demo 04
|
|
||||||
# in THEME_SYSTEM_PLAN.md — "Spotlight": breadbar's bar IS the launcher, a
|
|
||||||
# centred capsule that expands into results (plan §7). Values are taken from
|
|
||||||
# bos-ui-demos/04-spotlight.html's <style> block; where the demo is silent
|
|
||||||
# (satellite offsets, bar-adjacent chip sizing) this file re-derives from the
|
|
||||||
# capsule's own top edge the same way glass-workbench re-derives from its
|
|
||||||
# flush bar's edge — see that file's header comment for the method.
|
|
||||||
#
|
|
||||||
# Two exceptions, same shape as glass-workbench's: font (Outfit is not
|
|
||||||
# installed on the dev machine, so this renders in fallback until the user
|
|
||||||
# installs ttf-outfit — same gap liquid-motion already has) and colour (the
|
|
||||||
# demo's #e87898 pink is a *flat* accent — `--accent` doubling as both the
|
|
||||||
# capsule's dot fill and its only gradient stop — so it maps to the
|
|
||||||
# palette's `pink` token for both accent_from and accent_to, never a literal
|
|
||||||
# hex value).
|
|
||||||
#
|
|
||||||
# Phase 6c (this pass): query modes (`=` calc, `>` command, `.` url) and
|
|
||||||
# result sections ("recent"/"apps") are now implemented — `modes` lists all
|
|
||||||
# four and `sections` is `true`. The demo's `.searching .capsule` state
|
|
||||||
# (480px -> 520px width, 22px -> 20px radius) is modeled via two new
|
|
||||||
# `[launcher]` keys, `search_width`/`search_radius`, read only by
|
|
||||||
# `LauncherMode::Embedded` hosts (breadbar's capsule; unread by breadbox's
|
|
||||||
# overlay window, which stays `mode = "overlay"`).
|
|
||||||
|
|
||||||
name = "Spotlight"
|
|
||||||
id = "spotlight"
|
|
||||||
|
|
||||||
[tokens]
|
|
||||||
# Declared-but-not-yet-consumed: see liquid-motion/theme.toml's identical
|
|
||||||
# note next to these three keys — neither breadbar nor breadbox reads them.
|
|
||||||
font_family = "Outfit"
|
|
||||||
font_fallback = "Varela Round, sans-serif"
|
|
||||||
font_size_base = 13
|
|
||||||
radius_bar = 22
|
|
||||||
radius_card = 12
|
|
||||||
radius_sm = 8
|
|
||||||
radius_pill = 999
|
|
||||||
pad = 14
|
|
||||||
# Demo: `background: color-mix(in oklab, var(--glass) 82%, transparent)`.
|
|
||||||
bg_alpha = 0.82
|
|
||||||
# The demo's one curve (`--spring: cubic-bezier(.22,1.35,.36,1)`) — the same
|
|
||||||
# overshoot liquid-motion calls `spring`. No separate flatter "settle" curve
|
|
||||||
# is defined in 04's stylesheet, so both accessors point at it, same
|
|
||||||
# reasoning as glass-workbench's single-curve tokens.
|
|
||||||
spring = "cubic-bezier(0.22, 1.35, 0.36, 1)"
|
|
||||||
spring_settle = "cubic-bezier(0.22, 1.35, 0.36, 1)"
|
|
||||||
# Palette token NAMES, not hex — #e87898 is this palette's `pink`. Equal
|
|
||||||
# from/to because the demo's accent here is flat, not liquid-motion's
|
|
||||||
# accent→teal gradient. accent_from IS read (the dots' active fill,
|
|
||||||
# theme.rs); accent_to is declared-but-not-yet-consumed, same as every
|
|
||||||
# other theme's — see Tokens::accent_to's doc comment.
|
|
||||||
accent_from = "pink"
|
|
||||||
accent_to = "pink"
|
|
||||||
# Demo: `.barrow { height: 36px }` with no separate workspace-chip height —
|
|
||||||
# the dots (`.dots button`) are sized entirely by `dot_widths` below, not by
|
|
||||||
# chip_height, but this token still feeds any other chip-shaped element
|
|
||||||
# (e.g. a future result-row affordance) at the bar's own row height.
|
|
||||||
chip_height = 22
|
|
||||||
# Demo: `.ico { width: 30px; height: 30px }` for a result row's icon
|
|
||||||
# swatch — the icon glyph itself sits inside that box with implicit
|
|
||||||
# padding, so the icon_px passed to bread-launcher's `ResultsList` is sized
|
|
||||||
# a little under the swatch box, same margin glass-workbench leaves between
|
|
||||||
# its 18px icon_px and larger chip_height.
|
|
||||||
icon_px = 22
|
|
||||||
# Floating centred capsule, full rounded border on every edge — same
|
|
||||||
# reasoning as liquid-motion's island, not glass-workbench's flush bar.
|
|
||||||
bar_border = "full"
|
|
||||||
|
|
||||||
[bar.window]
|
|
||||||
# Anchored top ONLY: gtk4-layer-shell centres a surface anchored to
|
|
||||||
# neither left nor right (THEME_SYSTEM_PLAN.md §7) — this is what makes the
|
|
||||||
# capsule float centred instead of spanning the screen width.
|
|
||||||
anchors = ["top"]
|
|
||||||
width = 480
|
|
||||||
height = 36
|
|
||||||
margin = { top = 16 }
|
|
||||||
# No exclusive zone: tiled clients run full-height behind the capsule,
|
|
||||||
# unlike Island/Edge's reserved strip — the capsule floats over content.
|
|
||||||
exclusive = "none"
|
|
||||||
# Hands keyboard focus over only while `launcher_entry` holds it (plan §7)
|
|
||||||
# — Island/Edge themes keep "none", unchanged.
|
|
||||||
keyboard = "on_demand"
|
|
||||||
layer = "top"
|
|
||||||
|
|
||||||
[bar.slots]
|
|
||||||
left = ["workspaces"]
|
|
||||||
centre = ["launcher_entry"]
|
|
||||||
# `widget:left_of_stats` (Phase 6c decision, see task notes): a Lua widget
|
|
||||||
# that requests this same placement (`bread-shared`'s `WidgetPlacement::
|
|
||||||
# LeftOfStats`, e.g. a real user's `git-branch-widget.lua`) would otherwise
|
|
||||||
# be silently undeliverable under spotlight — no `[bar.slots]` entry named
|
|
||||||
# any `widget:*` key at all, so `reconcile_widgets` drops it every time
|
|
||||||
# (quietly, since the Phase 6b fix, but still dropped). Deliberately just
|
|
||||||
# this one alias, not all five liquid-motion carries: spotlight has no
|
|
||||||
# clock module to anchor `left_of_clock`/`right_of_clock` against and no
|
|
||||||
# workspace-adjacent affordance next to its compact dots, so adding those
|
|
||||||
# would clutter a launcher-focused capsule with slots nothing here asks
|
|
||||||
# for. `left_of_stats` sits right where `battery` already does, matching
|
|
||||||
# liquid-motion's own ordering (`widget:left_of_stats` before its module).
|
|
||||||
right = ["widget:left_of_stats", "battery"]
|
|
||||||
drawer = ["launcher_results"]
|
|
||||||
|
|
||||||
[modules.workspaces]
|
|
||||||
style = "dots"
|
|
||||||
show_empty = true
|
|
||||||
# 0/1/2/3-or-more open windows → dot width in px
|
|
||||||
# (`04-spotlight.html`'s `.dots button[data-n="N"]` rules).
|
|
||||||
dot_widths = [8, 13, 17, 22]
|
|
||||||
|
|
||||||
[modules.clock]
|
|
||||||
# No clock label at all — `launcher_entry`'s placeholder IS the clock
|
|
||||||
# (`04-spotlight.html`: `q.placeholder = t` in `clock()`, replaced by the
|
|
||||||
# search prompt only once focused/open).
|
|
||||||
style = "none"
|
|
||||||
# Declared-but-not-yet-consumed under style = "none": no clock module is
|
|
||||||
# built at all, so there's no format/date to apply this to — see
|
|
||||||
# ClockModule::format's doc comment. Kept for the same "state actual design
|
|
||||||
# intent" reason as liquid-motion's Flip-style note.
|
|
||||||
format = "%H:%M"
|
|
||||||
show_date = false
|
|
||||||
placeholder_clock = true
|
|
||||||
|
|
||||||
[launcher]
|
|
||||||
mode = "embedded"
|
|
||||||
# Unread by an embedded launcher (breadbar's launcher_entry/launcher_results
|
|
||||||
# read [bar.window].width and this table's radius/icon_px instead of
|
|
||||||
# width/top — those two fields exist only for LauncherMode::Overlay, which
|
|
||||||
# breadbox's own window still uses). Kept at the bar's own width/22px radius
|
|
||||||
# for consistency with a reader who expects [launcher] to describe the
|
|
||||||
# launcher's shape regardless of mode.
|
|
||||||
width = 480
|
|
||||||
top = "16px"
|
|
||||||
radius = 22
|
|
||||||
icon_px = 22
|
|
||||||
row_anim = "none"
|
|
||||||
rule = "none"
|
|
||||||
footer = "count_apps"
|
|
||||||
# Phase 6c: "recent" / "apps" headers in the idle (empty-query) drawer view
|
|
||||||
# — `bread_launcher::split_sections` groups the same already-loaded/sorted
|
|
||||||
# entries `LaunchHistory`'s counts already rank, no new tracking needed.
|
|
||||||
sections = true
|
|
||||||
# `04-spotlight.html`'s three prefixes: `=` calc, `>` command, `.` url —
|
|
||||||
# `bread_launcher::parse_query`/`eval_calc`/`filter_commands` implement the
|
|
||||||
# pure logic; breadbar's capsule wiring (main.rs) gates on this list so an
|
|
||||||
# unlisted prefix character just falls through to a literal "apps" query.
|
|
||||||
modes = ["apps", "calc", "cmd", "url"]
|
|
||||||
# `04-spotlight.html`: `.searching .capsule { width: 520px; border-radius:
|
|
||||||
# 20px }` vs the idle 480px/22px above — animated via
|
|
||||||
# `bread_theme::anim::spring_to` (width) and a `.searching` CSS class
|
|
||||||
# (radius) in breadbar's capsule wiring.
|
|
||||||
search_width = 520
|
|
||||||
search_radius = 20
|
|
||||||
|
|
||||||
# Same five satellite namespaces as liquid-motion/glass-workbench, keyed
|
|
||||||
# identically. This bar's edge sits at margin.top(16) + height(36) = 52px —
|
|
||||||
# re-deriving glass-workbench's own edge+8/edge relationship (44 = 36+8,
|
|
||||||
# 36 = 36) against 52 gives 60 (52+8) and 52.
|
|
||||||
[surfaces."breadbar-notif"]
|
|
||||||
anchor = "top_right"
|
|
||||||
offset = [16, 60]
|
|
||||||
width = 320
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
[surfaces."breadbar-osd"]
|
|
||||||
# Independent of bar height — unchanged from liquid-motion/glass-workbench.
|
|
||||||
anchor = "bottom_centre"
|
|
||||||
offset = 80
|
|
||||||
width = 180
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
[surfaces."breadbar-panel"]
|
|
||||||
anchor = "top_right"
|
|
||||||
offset = [16, 60]
|
|
||||||
width = "auto"
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
[surfaces."breadbar-dismiss"]
|
|
||||||
anchor = "fill"
|
|
||||||
offset = 52
|
|
||||||
width = "fill"
|
|
||||||
layer = "overlay"
|
|
||||||
|
|
||||||
# Appearance-only, identical in shape to liquid-motion/glass-workbench's
|
|
||||||
# rules — blur strength is still global (plan §9 known limit).
|
|
||||||
[compositor."breadbar"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.18
|
|
||||||
blur_popups = true
|
|
||||||
animation = "slide top"
|
|
||||||
|
|
||||||
[compositor."breadbar-osd"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
animation = "slide bottom"
|
|
||||||
|
|
||||||
[compositor."breadbar-notif"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
animation = "slide right"
|
|
||||||
|
|
||||||
[compositor."breadbar-panel"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
animation = "slide right"
|
|
||||||
|
|
||||||
[compositor."breadbar-dismiss"]
|
|
||||||
no_anim = true
|
|
||||||
|
|
||||||
[compositor."breadbox"]
|
|
||||||
blur = true
|
|
||||||
ignore_alpha = 0.2
|
|
||||||
|
|
||||||
# No `css = "..."` overlay — compiled-in builtin, same as its two siblings.
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
//! `anim::spring_to` — THEME_SYSTEM_PLAN.md §7/§8: GTK4 has no CSS
|
|
||||||
//! width/height transition on a widget or a layer-shell surface (unlike the
|
|
||||||
//! demos' `transition: width .45s var(--spring)` /
|
|
||||||
//! `transition: max-height .4s var(--spring)`), so a size change that wants
|
|
||||||
//! to animate has to interpolate a plain integer over the frame clock
|
|
||||||
//! instead and re-apply it (`set_size_request`, `set_default_width`, ...)
|
|
||||||
//! every frame.
|
|
||||||
//!
|
|
||||||
//! This is the same house technique `breadbar::bar::workspaces::WorkspaceTrail`
|
|
||||||
//! already uses for the workspace trail's stretch/snap (a local, un-exported
|
|
||||||
//! `ease`/`ease_overshoot` pair driven by `add_tick_callback` and
|
|
||||||
//! `Instant::elapsed`) — lifted here, generalized to a plain `i32 -> i32`
|
|
||||||
//! interpolation with a caller-supplied frame callback, so theme 04's
|
|
||||||
//! capsule-drawer expand/collapse (and any future popover that wants the
|
|
||||||
//! same effect) doesn't have to reimplement it.
|
|
||||||
|
|
||||||
use gtk4::glib::ControlFlow;
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
|
|
||||||
/// Approximates `cubic-bezier(0.22, 1.35, 0.36, 1)` — the overshoot/"spring"
|
|
||||||
/// curve every builtin theme's `tokens.spring` names (`Tokens::spring`'s own
|
|
||||||
/// default). Not a literal bezier solve (same approximation
|
|
||||||
/// `bar::workspaces::ease_overshoot` uses) — exact enough that the eye can't
|
|
||||||
/// tell it apart from the real curve at animation speeds.
|
|
||||||
fn spring_ease(t: f64) -> f64 {
|
|
||||||
let t = t.clamp(0.0, 1.0);
|
|
||||||
let c = 1.35;
|
|
||||||
let t1 = t - 1.0;
|
|
||||||
1.0 + t1 * t1 * ((c + 1.0) * t1 + c)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One frame's worth of `spring_to`'s interpolation math — split out from
|
|
||||||
/// the tick callback below purely so it has a name a unit test can call
|
|
||||||
/// directly instead of re-deriving the same arithmetic.
|
|
||||||
///
|
|
||||||
/// `spring_ease` is a backOut curve that legitimately overshoots past 1.0
|
|
||||||
/// (peaks ~1.065) partway through the run — fine for a growing animation
|
|
||||||
/// (`to` > `from`), but on a *shrink* (`from` > `to`, e.g. a drawer
|
|
||||||
/// collapsing to 0) that overshoot drives the raw interpolated value BELOW
|
|
||||||
/// `to`, which can go negative for a size request and trip GTK's
|
|
||||||
/// `height >= -1` assertion. Clamping here, once, protects every caller
|
|
||||||
/// automatically instead of relying on each call site to remember
|
|
||||||
/// `h.max(0)` — this already bit breadbar once (see
|
|
||||||
/// `breadbar::main::animate_drawer_height`'s own clamp, now
|
|
||||||
/// redundant-but-harmless defense in depth on top of this).
|
|
||||||
fn frame_value(from: i32, to: i32, t: f64) -> i32 {
|
|
||||||
let eased = spring_ease(t);
|
|
||||||
let value = from as f64 + (to - from) as f64 * eased;
|
|
||||||
let lo = from.min(to) as f64;
|
|
||||||
let hi = from.max(to) as f64;
|
|
||||||
value.clamp(lo, hi).round() as i32
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Interpolates from `from` to `to` over `duration_ms`, calling `on_frame`
|
|
||||||
/// with each intermediate value (and, on the final tick, the exact `to` —
|
|
||||||
/// never an off-by-rounding near-miss) via `widget`'s frame clock.
|
|
||||||
///
|
|
||||||
/// Returns the [`gtk4::TickCallbackId`] so a caller that might need to
|
|
||||||
/// interrupt an in-flight run (e.g. the drawer re-opening before its close
|
|
||||||
/// animation finished) can `.remove()` it early; a run left to finish on its
|
|
||||||
/// own needs no cleanup — the callback self-terminates by returning
|
|
||||||
/// [`ControlFlow::Break`] once `duration_ms` has elapsed, same as
|
|
||||||
/// `WorkspaceTrail`'s own tick callbacks.
|
|
||||||
pub fn spring_to(
|
|
||||||
widget: &impl IsA<gtk4::Widget>,
|
|
||||||
from: i32,
|
|
||||||
to: i32,
|
|
||||||
duration_ms: f64,
|
|
||||||
on_frame: impl FnMut(i32) + 'static,
|
|
||||||
) -> gtk4::TickCallbackId {
|
|
||||||
let started = std::time::Instant::now();
|
|
||||||
// `add_tick_callback` requires `Fn`, not `FnMut` — the caller's frame
|
|
||||||
// closure almost always needs to mutate captured state (a widget's size
|
|
||||||
// request, an `Rc<Cell<..>>` flag), so it's boxed behind a `RefCell`
|
|
||||||
// here rather than pushing `Cell`/`RefCell` plumbing onto every call
|
|
||||||
// site (every current and future caller wants `FnMut`, none want `Fn`).
|
|
||||||
let on_frame = std::cell::RefCell::new(on_frame);
|
|
||||||
widget.add_tick_callback(move |_, _| {
|
|
||||||
let elapsed = started.elapsed().as_secs_f64() * 1000.0;
|
|
||||||
let mut on_frame = on_frame.borrow_mut();
|
|
||||||
if elapsed >= duration_ms {
|
|
||||||
on_frame(to);
|
|
||||||
return ControlFlow::Break;
|
|
||||||
}
|
|
||||||
on_frame(frame_value(from, to, elapsed / duration_ms));
|
|
||||||
ControlFlow::Continue
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Samples [`frame_value`] — the exact function `spring_to`'s tick
|
|
||||||
/// callback calls every frame — across the full `t` timeline at fine
|
|
||||||
/// granularity, bypassing the real frame clock (which needs a running
|
|
||||||
/// main loop the test environment doesn't have).
|
|
||||||
fn sample_all_frames(from: i32, to: i32) -> Vec<i32> {
|
|
||||||
let steps = 2000;
|
|
||||||
(0..=steps)
|
|
||||||
.map(|i| frame_value(from, to, i as f64 / steps as f64))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spring_ease_overshoots_past_one_for_a_backout_curve() {
|
|
||||||
// Sanity check on the premise: without a clamp, a shrink would
|
|
||||||
// legitimately go negative around this point in the curve.
|
|
||||||
assert!(spring_ease(0.8) > 1.0, "expected overshoot past 1.0");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shrink_never_emits_a_value_outside_the_from_to_range() {
|
|
||||||
// from > to (a drawer collapsing to 0) is exactly the case the
|
|
||||||
// unclamped overshoot could drive negative.
|
|
||||||
for value in sample_all_frames(480, 0) {
|
|
||||||
assert!(
|
|
||||||
(0..=480).contains(&value),
|
|
||||||
"shrink emitted {value}, outside [0, 480]"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn grow_never_emits_a_value_outside_the_from_to_range() {
|
|
||||||
for value in sample_all_frames(0, 480) {
|
|
||||||
assert!(
|
|
||||||
(0..=480).contains(&value),
|
|
||||||
"grow emitted {value}, outside [0, 480]"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zero_span_never_clamps_outside_the_single_point() {
|
|
||||||
// from == to: lo == hi == that point for every t, including past
|
|
||||||
// the overshoot peak.
|
|
||||||
for value in sample_all_frames(120, 120) {
|
|
||||||
assert_eq!(value, 120);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,10 +9,6 @@
|
||||||
//! # signal every running bread GUI to recolour
|
//! # signal every running bread GUI to recolour
|
||||||
//! bread-theme path # print the stylesheet path
|
//! bread-theme path # print the stylesheet path
|
||||||
//! bread-theme print # render to stdout (no write)
|
//! bread-theme print # render to stdout (no write)
|
||||||
//! bread-theme generate-output <OUTPUT> --image <PATH> [--shared]
|
|
||||||
//! bread-theme generate-output <OUTPUT> --from-json <PATH> [--shared]
|
|
||||||
//! bread-theme layerrules # write the active theme's [compositor] table
|
|
||||||
//! # to ~/.config/hypr/layerrules.json (plan §9)
|
|
||||||
|
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
|
@ -29,179 +25,6 @@ fn write_and_report(verb: &str) -> ExitCode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_help_to(mut w: impl std::io::Write) {
|
|
||||||
let _ = write!(
|
|
||||||
&mut w,
|
|
||||||
"bread-theme — shared stylesheet generator\n\n\
|
|
||||||
USAGE:\n\
|
|
||||||
\x20 bread-theme [generate|reload|path|print|layerrules]\n\
|
|
||||||
\x20 bread-theme generate-output <OUTPUT> --image <PATH> [--shared]\n\
|
|
||||||
\x20 bread-theme generate-output <OUTPUT> --from-json <WAL-OR-PALETTE.json> [--shared]\n\n\
|
|
||||||
generate render the pywal palette to the shared stylesheet (default)\n\
|
|
||||||
reload re-render and signal running bread GUIs to recolour live\n\
|
|
||||||
path print the stylesheet path ({})\n\
|
|
||||||
print render to stdout without writing\n\
|
|
||||||
generate-output write palettes/<OUTPUT>.json and themes/<OUTPUT>.css\n\
|
|
||||||
\x20 --image isolated `wal -i` (does not touch ~/.cache/wal)\n\
|
|
||||||
\x20 --from-json wal colors.json or a color1-6 object\n\
|
|
||||||
\x20 --shared also write the session-global theme.css\n\
|
|
||||||
layerrules write the active shell theme's [compositor] table to\n\
|
|
||||||
\x20 {} —\n\
|
|
||||||
\x20 scripts/ui/rules.lua reads it for per-namespace blur/\n\
|
|
||||||
\x20 animation, falling back to its hardcoded rules if this\n\
|
|
||||||
\x20 is missing or malformed",
|
|
||||||
bread_theme::shared_css_path().display(),
|
|
||||||
bread_theme::layerrules_path().display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Usage/help text. Explicitly requested help (`--help`/`-h`) goes to
|
|
||||||
/// **stdout** so it can be piped/grepped; the same text on an error path
|
|
||||||
/// (e.g. `generate-output` with no args) goes to stderr via
|
|
||||||
/// [`print_help_err`].
|
|
||||||
fn print_help() {
|
|
||||||
print_help_to(std::io::stdout());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_help_err() {
|
|
||||||
print_help_to(std::io::stderr());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_output_cmd() -> ExitCode {
|
|
||||||
let args: Vec<String> = std::env::args().skip(2).collect();
|
|
||||||
if args.iter().any(|a| matches!(a.as_str(), "-h" | "--help" | "help")) {
|
|
||||||
print_help();
|
|
||||||
return ExitCode::SUCCESS;
|
|
||||||
}
|
|
||||||
if args.is_empty() {
|
|
||||||
// Missing arguments is an error, not a help request — usage goes to
|
|
||||||
// stderr.
|
|
||||||
print_help_err();
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = args[0].as_str();
|
|
||||||
if output.starts_with('-') {
|
|
||||||
eprintln!("bread-theme: generate-output requires an OUTPUT name (got '{output}')");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut image: Option<&str> = None;
|
|
||||||
let mut from_json: Option<&str> = None;
|
|
||||||
let mut shared = false;
|
|
||||||
let mut i = 1;
|
|
||||||
while i < args.len() {
|
|
||||||
match args[i].as_str() {
|
|
||||||
"--shared" => shared = true,
|
|
||||||
"--image" => {
|
|
||||||
i += 1;
|
|
||||||
match args.get(i) {
|
|
||||||
Some(p) => image = Some(p.as_str()),
|
|
||||||
None => {
|
|
||||||
eprintln!("bread-theme: --image requires a path");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"--from-json" => {
|
|
||||||
i += 1;
|
|
||||||
match args.get(i) {
|
|
||||||
Some(p) => from_json = Some(p.as_str()),
|
|
||||||
None => {
|
|
||||||
eprintln!("bread-theme: --from-json requires a path");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
eprintln!("bread-theme: unknown generate-output flag '{other}'");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
match (image, from_json) {
|
|
||||||
(Some(_), Some(_)) => {
|
|
||||||
eprintln!("bread-theme: pass only one of --image or --from-json");
|
|
||||||
ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
(None, None) => {
|
|
||||||
eprintln!("bread-theme: generate-output needs --image <PATH> or --from-json <PATH>");
|
|
||||||
ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
(Some(path), None) => {
|
|
||||||
match bread_theme::generate_output(output, std::path::Path::new(path)) {
|
|
||||||
Ok(css) => finish_generate_output(output, css, shared),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("bread-theme: generate-output failed: {e}");
|
|
||||||
ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(None, Some(path)) => match write_output_from_json(output, path, shared) {
|
|
||||||
Ok(()) => ExitCode::SUCCESS,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("bread-theme: generate-output failed: {e}");
|
|
||||||
ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_output_from_json(output: &str, json_path: &str, shared: bool) -> std::io::Result<()> {
|
|
||||||
let json = std::fs::read_to_string(json_path)?;
|
|
||||||
let palette = bread_theme::palette_from_json(&json).ok_or_else(|| {
|
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
format!("could not parse palette JSON: {json_path}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let pal_path = bread_theme::write_output_palette(output, &palette)?;
|
|
||||||
let css_path = bread_theme::write_output_css(output, &palette)?;
|
|
||||||
eprintln!(
|
|
||||||
"bread-theme: wrote {} and {}",
|
|
||||||
pal_path.display(),
|
|
||||||
css_path.display()
|
|
||||||
);
|
|
||||||
if shared {
|
|
||||||
let shared_path = bread_theme::write_shared_css_from(&palette)?;
|
|
||||||
eprintln!("bread-theme: wrote shared {}", shared_path.display());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish_generate_output(output: &str, css: std::path::PathBuf, shared: bool) -> ExitCode {
|
|
||||||
eprintln!("bread-theme: wrote {}", css.display());
|
|
||||||
if shared {
|
|
||||||
match bread_theme::write_shared_css_from(&bread_theme::load_palette_for(output)) {
|
|
||||||
Ok(path) => {
|
|
||||||
eprintln!("bread-theme: wrote shared {}", path.display());
|
|
||||||
ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("bread-theme: failed to write shared stylesheet: {e}");
|
|
||||||
ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layerrules_cmd() -> ExitCode {
|
|
||||||
match bread_theme::write_layerrules_active() {
|
|
||||||
Ok(path) => {
|
|
||||||
eprintln!("bread-theme: wrote {}", path.display());
|
|
||||||
ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("bread-theme: failed to write layer rules: {e}");
|
|
||||||
ExitCode::FAILURE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
|
let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into());
|
||||||
match cmd.as_str() {
|
match cmd.as_str() {
|
||||||
|
|
@ -219,16 +42,20 @@ fn main() -> ExitCode {
|
||||||
// the file monitor in every running bread GUI, so they all re-read the
|
// the file monitor in every running bread GUI, so they all re-read the
|
||||||
// palette and recolour live — shared widgets *and* each app's own rules.
|
// palette and recolour live — shared widgets *and* each app's own rules.
|
||||||
"reload" => write_and_report("reloaded"),
|
"reload" => write_and_report("reloaded"),
|
||||||
"generate-output" => generate_output_cmd(),
|
|
||||||
"layerrules" => layerrules_cmd(),
|
|
||||||
"-h" | "--help" | "help" => {
|
"-h" | "--help" | "help" => {
|
||||||
print_help();
|
eprintln!(
|
||||||
|
"bread-theme — shared stylesheet generator\n\n\
|
||||||
|
USAGE:\n bread-theme [generate|reload|path|print]\n\n\
|
||||||
|
generate render the pywal palette to the shared stylesheet (default)\n\
|
||||||
|
reload re-render and signal running bread GUIs to recolour live\n\
|
||||||
|
path print the stylesheet path ({})\n\
|
||||||
|
print render to stdout without writing",
|
||||||
|
bread_theme::shared_css_path().display()
|
||||||
|
);
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
eprintln!(
|
eprintln!("bread-theme: unknown command '{other}' (try generate|reload|path|print)");
|
||||||
"bread-theme: unknown command '{other}' (try generate|reload|path|print|generate-output|layerrules)"
|
|
||||||
);
|
|
||||||
ExitCode::FAILURE
|
ExitCode::FAILURE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,8 @@
|
||||||
use gtk4::gdk::prelude::*;
|
|
||||||
use gtk4::gio;
|
use gtk4::gio;
|
||||||
use gtk4::glib::object::ObjectType;
|
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4::CssProvider;
|
use gtk4::CssProvider;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use crate::Palette;
|
|
||||||
|
|
||||||
/// Per-widget app-CSS builder: given the resolved palette for the widget's
|
|
||||||
/// monitor, produce the app stylesheet to layer on top of the theme CSS.
|
|
||||||
type AppCssBuilder = Rc<dyn Fn(&Palette) -> String>;
|
|
||||||
|
|
||||||
/// Above APPLICATION (600) so we beat [`apply_shared`], below USER (800)
|
|
||||||
/// so `apply_user_css` still wins.
|
|
||||||
const BIND_PRIORITY: u32 = gtk4::STYLE_PROVIDER_PRIORITY_USER - 10;
|
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
static SHARED_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
|
||||||
|
|
@ -28,7 +14,8 @@ thread_local! {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reload_shared() {
|
fn reload_shared() {
|
||||||
let css = std::fs::read_to_string(crate::shared_css_path()).unwrap_or_else(|_| crate::render());
|
let css = std::fs::read_to_string(crate::shared_css_path())
|
||||||
|
.unwrap_or_else(|_| crate::render());
|
||||||
SHARED_PROVIDER.with(|cell| apply_css(&css, cell));
|
SHARED_PROVIDER.with(|cell| apply_css(&css, cell));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,10 +121,7 @@ pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) {
|
||||||
/// breadpad, and breadman (both cream), none of which agreed with each
|
/// breadpad, and breadman (both cream), none of which agreed with each
|
||||||
/// other or with the shared token.
|
/// other or with the shared token.
|
||||||
pub fn chip(label: &str) -> gtk4::Button {
|
pub fn chip(label: &str) -> gtk4::Button {
|
||||||
gtk4::Button::builder()
|
gtk4::Button::builder().label(label).css_classes(["chip"]).build()
|
||||||
.label(label)
|
|
||||||
.css_classes(["chip"])
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggles a chip's (or any widget's) `active` CSS class — the `.chip.active`
|
/// Toggles a chip's (or any widget's) `active` CSS class — the `.chip.active`
|
||||||
|
|
@ -153,319 +137,6 @@ pub fn set_chip_active(chip: &impl IsA<gtk4::Widget>, active: bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gdk connector for the monitor currently showing this widget, if any.
|
|
||||||
pub fn output_for_widget(widget: &impl IsA<gtk4::Widget>) -> Option<String> {
|
|
||||||
let widget = widget.as_ref();
|
|
||||||
let native = widget.native()?;
|
|
||||||
let surface = NativeExt::surface(&native)?;
|
|
||||||
let monitor = widget.display().monitor_at_surface(&surface)?;
|
|
||||||
monitor.connector().map(|c| c.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WidgetBind {
|
|
||||||
output: String,
|
|
||||||
theme: CssProvider,
|
|
||||||
app: Option<CssProvider>,
|
|
||||||
app_build: Option<AppCssBuilder>,
|
|
||||||
/// Keep the directory monitor + child model alive for this widget.
|
|
||||||
_watch: Option<gio::ListModel>,
|
|
||||||
}
|
|
||||||
|
|
||||||
thread_local! {
|
|
||||||
static BINDS: RefCell<HashMap<usize, WidgetBind>> = RefCell::new(HashMap::new());
|
|
||||||
static THEMES_MONITOR: RefCell<Option<gio::FileMonitor>> = const { RefCell::new(None) };
|
|
||||||
static DESTROY_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
|
||||||
static AUTO_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
|
||||||
static ENTER_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn widget_key(widget: >k4::Widget) -> usize {
|
|
||||||
widget.as_ptr() as usize
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(deprecated)]
|
|
||||||
fn add_widget_provider(widget: >k4::Widget, provider: &CssProvider, prio: u32) {
|
|
||||||
widget.style_context().add_provider(provider, prio);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Same `CssProvider` on the widget and its current descendants so component
|
|
||||||
/// rules actually reach buttons/labels (a style-context provider is not
|
|
||||||
/// inherited by children).
|
|
||||||
fn attach_tree(widget: >k4::Widget, theme: &CssProvider, app: Option<&CssProvider>) {
|
|
||||||
add_widget_provider(widget, theme, BIND_PRIORITY);
|
|
||||||
if let Some(app) = app {
|
|
||||||
add_widget_provider(widget, app, BIND_PRIORITY + 1);
|
|
||||||
}
|
|
||||||
let mut child = widget.first_child();
|
|
||||||
while let Some(c) = child {
|
|
||||||
attach_tree(&c, theme, app);
|
|
||||||
child = c.next_sibling();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_destroy_cleanup(widget: >k4::Widget) {
|
|
||||||
let key = widget_key(widget);
|
|
||||||
let inserted = DESTROY_HOOKED.with(|s| s.borrow_mut().insert(key));
|
|
||||||
if !inserted {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
widget.connect_destroy(move |w| {
|
|
||||||
let key = widget_key(w);
|
|
||||||
BINDS.with(|b| {
|
|
||||||
b.borrow_mut().remove(&key);
|
|
||||||
});
|
|
||||||
DESTROY_HOOKED.with(|s| {
|
|
||||||
s.borrow_mut().remove(&key);
|
|
||||||
});
|
|
||||||
AUTO_HOOKED.with(|s| {
|
|
||||||
s.borrow_mut().remove(&key);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_themes_watch() {
|
|
||||||
THEMES_MONITOR.with(|cell| {
|
|
||||||
if cell.borrow().is_some() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let dir = crate::themes_dir();
|
|
||||||
let _ = std::fs::create_dir_all(&dir);
|
|
||||||
let monitor = gio::File::for_path(&dir)
|
|
||||||
.monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE)
|
|
||||||
.ok();
|
|
||||||
if let Some(ref m) = monitor {
|
|
||||||
m.connect_changed(move |_, file, other, _event| {
|
|
||||||
let path = file.path().or_else(|| other.and_then(|f| f.path()));
|
|
||||||
let Some(path) = path else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("css") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
reload_binds_for_sanitized(stem);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
*cell.borrow_mut() = monitor;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reload_binds_for_sanitized(sanitized: &str) {
|
|
||||||
BINDS.with(|binds| {
|
|
||||||
for bind in binds.borrow_mut().values_mut() {
|
|
||||||
if crate::sanitize_output(&bind.output) != sanitized {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let palette = crate::load_palette_for(&bind.output);
|
|
||||||
bind.theme
|
|
||||||
.load_from_string(&crate::stylesheet_resolved(&palette));
|
|
||||||
if let (Some(build), Some(provider)) = (&bind.app_build, &bind.app) {
|
|
||||||
provider.load_from_string(&crate::resolve_color_names(&build(&palette), &palette));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn watch_root_children(widget: >k4::Widget) -> gio::ListModel {
|
|
||||||
let model = widget.observe_children();
|
|
||||||
let root = widget.downgrade();
|
|
||||||
model.connect_items_changed(move |_, _, _, _| {
|
|
||||||
let Some(root) = root.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let key = widget_key(&root);
|
|
||||||
BINDS.with(|binds| {
|
|
||||||
if let Some(bind) = binds.borrow().get(&key) {
|
|
||||||
attach_tree(&root, &bind.theme, bind.app.as_ref());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
model
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bind_window_inner(
|
|
||||||
widget: >k4::Widget,
|
|
||||||
output: &str,
|
|
||||||
app_build: Option<AppCssBuilder>,
|
|
||||||
) {
|
|
||||||
let key = widget_key(widget);
|
|
||||||
let palette = crate::load_palette_for(output);
|
|
||||||
let theme_css = crate::stylesheet_resolved(&palette);
|
|
||||||
let app_css = app_build
|
|
||||||
.as_ref()
|
|
||||||
.map(|build| crate::resolve_color_names(&build(&palette), &palette));
|
|
||||||
|
|
||||||
BINDS.with(|binds| {
|
|
||||||
let mut map = binds.borrow_mut();
|
|
||||||
if let Some(existing) = map.get_mut(&key) {
|
|
||||||
existing.output = output.to_string();
|
|
||||||
existing.theme.load_from_string(&theme_css);
|
|
||||||
existing.app_build = app_build.clone();
|
|
||||||
match (&app_css, existing.app.as_ref()) {
|
|
||||||
(Some(css), Some(p)) => p.load_from_string(css),
|
|
||||||
(Some(css), None) => {
|
|
||||||
let p = CssProvider::new();
|
|
||||||
p.load_from_string(css);
|
|
||||||
add_widget_provider(widget, &p, BIND_PRIORITY + 1);
|
|
||||||
existing.app = Some(p);
|
|
||||||
}
|
|
||||||
(None, Some(p)) => p.load_from_string(""),
|
|
||||||
(None, None) => {}
|
|
||||||
}
|
|
||||||
attach_tree(widget, &existing.theme, existing.app.as_ref());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let theme = CssProvider::new();
|
|
||||||
theme.load_from_string(&theme_css);
|
|
||||||
add_widget_provider(widget, &theme, BIND_PRIORITY);
|
|
||||||
|
|
||||||
let app = app_css.map(|css| {
|
|
||||||
let p = CssProvider::new();
|
|
||||||
p.load_from_string(&css);
|
|
||||||
add_widget_provider(widget, &p, BIND_PRIORITY + 1);
|
|
||||||
p
|
|
||||||
});
|
|
||||||
|
|
||||||
attach_tree(widget, &theme, app.as_ref());
|
|
||||||
|
|
||||||
let child_model = watch_root_children(widget);
|
|
||||||
map.insert(
|
|
||||||
key,
|
|
||||||
WidgetBind {
|
|
||||||
output: output.to_string(),
|
|
||||||
theme,
|
|
||||||
app,
|
|
||||||
app_build,
|
|
||||||
_watch: Some(child_model),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ensure_destroy_cleanup(widget);
|
|
||||||
ensure_themes_watch();
|
|
||||||
ensure_map_reattach(widget);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_map_reattach(widget: >k4::Widget) {
|
|
||||||
// `connect_map` once per widget — re-bind already lives in BINDS.
|
|
||||||
thread_local! {
|
|
||||||
static MAP_HOOKED: RefCell<HashSet<usize>> = RefCell::new(HashSet::new());
|
|
||||||
}
|
|
||||||
let key = widget_key(widget);
|
|
||||||
let inserted = MAP_HOOKED.with(|s| s.borrow_mut().insert(key));
|
|
||||||
if !inserted {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
widget.connect_map(|w| {
|
|
||||||
BINDS.with(|binds| {
|
|
||||||
if let Some(bind) = binds.borrow().get(&widget_key(w)) {
|
|
||||||
attach_tree(w, &bind.theme, bind.app.as_ref());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
widget.connect_destroy(move |_| {
|
|
||||||
MAP_HOOKED.with(|s| {
|
|
||||||
s.borrow_mut().remove(&key);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attach a widget-level `CssProvider` with
|
|
||||||
/// `stylesheet_resolved(load_palette_for(output))` above APPLICATION so it
|
|
||||||
/// beats [`apply_shared`] for this widget tree. User CSS still wins.
|
|
||||||
/// Calling again on the same widget replaces the provider; it does not stack.
|
|
||||||
pub fn bind_window(widget: &impl IsA<gtk4::Widget>, output: &str) {
|
|
||||||
bind_window_inner(widget.as_ref(), output, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`bind_window`], then also apply `build(&palette)` on the same widget.
|
|
||||||
/// App CSS may still use `@accent` etc.; those names are inlined against
|
|
||||||
/// the same palette before loading.
|
|
||||||
pub fn bind_window_with_app_css<F>(widget: &impl IsA<gtk4::Widget>, output: &str, build: F)
|
|
||||||
where
|
|
||||||
F: Fn(&Palette) -> String + 'static,
|
|
||||||
{
|
|
||||||
bind_window_inner(widget.as_ref(), output, Some(Rc::new(build)));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn attach_enter_monitor(widget: >k4::Widget, build: Option<AppCssBuilder>) {
|
|
||||||
let Some(native) = widget.native() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(surface) = NativeExt::surface(&native) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let surf_key = surface.as_ptr() as usize;
|
|
||||||
let already = ENTER_HOOKED.with(|s| !s.borrow_mut().insert(surf_key));
|
|
||||||
if already {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let widget = widget.clone();
|
|
||||||
surface.connect_enter_monitor(move |_, monitor| {
|
|
||||||
let Some(conn) = monitor.connector() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
bind_window_inner(&widget, conn.as_str(), build.clone());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bind_auto(native: >k4::Native, build: Option<AppCssBuilder>) {
|
|
||||||
let widget = native.upcast_ref::<gtk4::Widget>().clone();
|
|
||||||
|
|
||||||
let apply = {
|
|
||||||
let widget = widget.clone();
|
|
||||||
let build = build.clone();
|
|
||||||
Rc::new(move || {
|
|
||||||
if let Some(output) = output_for_widget(&widget) {
|
|
||||||
bind_window_inner(&widget, &output, build.clone());
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
apply();
|
|
||||||
|
|
||||||
let key = widget_key(&widget);
|
|
||||||
let inserted = AUTO_HOOKED.with(|s| s.borrow_mut().insert(key));
|
|
||||||
if inserted {
|
|
||||||
widget.connect_realize({
|
|
||||||
let apply = apply.clone();
|
|
||||||
let widget = widget.clone();
|
|
||||||
let build = build.clone();
|
|
||||||
move |_| {
|
|
||||||
apply();
|
|
||||||
attach_enter_monitor(&widget, build.clone());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
widget.connect_map({
|
|
||||||
let apply = apply.clone();
|
|
||||||
move |_| apply()
|
|
||||||
});
|
|
||||||
ensure_destroy_cleanup(&widget);
|
|
||||||
}
|
|
||||||
|
|
||||||
if widget.is_realized() {
|
|
||||||
attach_enter_monitor(&widget, build);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Realize + `GdkSurface::enter-monitor`: rebind when the window moves
|
|
||||||
/// outputs. If the connector is unknown, leave unbound (display fallback)
|
|
||||||
/// rather than guessing the wrong monitor.
|
|
||||||
pub fn bind_window_auto(window: &impl IsA<gtk4::Native>) {
|
|
||||||
bind_auto(window.as_ref(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`bind_window_auto`] plus per-output app CSS, resolved to hex.
|
|
||||||
pub fn bind_window_auto_with_app_css<F>(window: &impl IsA<gtk4::Native>, build: F)
|
|
||||||
where
|
|
||||||
F: Fn(&Palette) -> String + 'static,
|
|
||||||
{
|
|
||||||
bind_auto(window.as_ref(), Some(Rc::new(build)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply a user CSS override file at USER priority. Clears the provider if the
|
/// Apply a user CSS override file at USER priority. Clears the provider if the
|
||||||
/// file is absent so stale overrides don't persist across SIGHUP reloads.
|
/// file is absent so stale overrides don't persist across SIGHUP reloads.
|
||||||
pub fn apply_user_css(path: &Path, provider: &RefCell<Option<CssProvider>>) {
|
pub fn apply_user_css(path: &Path, provider: &RefCell<Option<CssProvider>>) {
|
||||||
|
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
//! Generates `~/.config/hypr/layerrules.json` from the active shell theme's
|
|
||||||
//! `[compositor]` table (`THEME_SYSTEM_PLAN.md` §9). `scripts/ui/rules.lua`
|
|
||||||
//! reads this file and emits `hl.layer_rule` calls from it, keeping its own
|
|
||||||
//! hardcoded rules as a pcall-guarded fallback for when this file is missing
|
|
||||||
//! or malformed — so generation here never has to be perfect, only present.
|
|
||||||
//!
|
|
||||||
//! Scope (plan §9's appearance/placement boundary): a theme's `[compositor]`
|
|
||||||
//! table owns per-namespace *appearance* only — blur, ignore_alpha,
|
|
||||||
//! blur_popups, animation, no_anim (the [`crate::shell::LayerRule`] field
|
|
||||||
//! set). It never owns placement, workspace-assignment, or focus rules —
|
|
||||||
//! those stay Lua-side user policy (`rules.lua`'s `hl.window_rule` calls)
|
|
||||||
//! that this generator does not touch.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use crate::shell::ShellTheme;
|
|
||||||
|
|
||||||
fn config_home() -> PathBuf {
|
|
||||||
// XDG spec: `XDG_CONFIG_HOME` is only honored when it's an *absolute*
|
|
||||||
// path; a relative value must be ignored (matches `bread_utils::xdg`).
|
|
||||||
if let Ok(v) = std::env::var("XDG_CONFIG_HOME") {
|
|
||||||
let p = PathBuf::from(&v);
|
|
||||||
if p.is_absolute() {
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dirs::home_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
|
||||||
.join(".config")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `~/.config/hypr/layerrules.json` (or under `$XDG_CONFIG_HOME` if set) —
|
|
||||||
/// alongside `binds.json`, `settings.json`, `monitors.json`, and
|
|
||||||
/// `autostart.json`, the established flat-JSON-under-`hypr/` convention
|
|
||||||
/// those files already use (see `~/.config/hypr/scripts/input/binds.lua` for
|
|
||||||
/// the read side of that pattern, which `scripts/ui/rules.lua` now mirrors).
|
|
||||||
pub fn layerrules_path() -> PathBuf {
|
|
||||||
config_home().join("hypr").join("layerrules.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render `theme`'s `[compositor]` table as the JSON object
|
|
||||||
/// `scripts/ui/rules.lua` expects: keyed by layer-shell namespace (e.g.
|
|
||||||
/// `"breadbar"`, `"breadbox"`), each value the namespace's
|
|
||||||
/// [`crate::shell::LayerRule`] fields. `compositor_rules()` returns a
|
|
||||||
/// `BTreeMap`, so namespace order is stable (alphabetical) across runs and a
|
|
||||||
/// rewritten file diffs cleanly.
|
|
||||||
pub fn layerrules_json(theme: &ShellTheme) -> String {
|
|
||||||
serde_json::to_string_pretty(theme.compositor_rules())
|
|
||||||
.expect("LayerRule serialization is infallible (no maps/floats that can fail)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`layerrules_json`] + atomic write (tmp + rename), the same durability
|
|
||||||
/// [`crate::write_shared_css_from`] uses so a reload can never observe a
|
|
||||||
/// half-written file.
|
|
||||||
pub fn write_layerrules(theme: &ShellTheme) -> std::io::Result<PathBuf> {
|
|
||||||
let path = layerrules_path();
|
|
||||||
let json = layerrules_json(theme);
|
|
||||||
crate::output::atomic_write(&path, &json)?;
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`write_layerrules`] from the active theme ([`crate::shell::load`], which
|
|
||||||
/// never fails — a broken active theme falls back to the builtin). Used by
|
|
||||||
/// the `bread-theme layerrules` CLI subcommand.
|
|
||||||
pub fn write_layerrules_active() -> std::io::Result<PathBuf> {
|
|
||||||
write_layerrules(&crate::shell::load())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
fn lock_xdg() -> std::sync::MutexGuard<'static, ()> {
|
|
||||||
// Shared with `shell::tests::isolated_xdg`, which also mutates
|
|
||||||
// XDG_CONFIG_HOME — must be the *same* lock, not a look-alike one,
|
|
||||||
// or the two modules' parallel tests race each other's env var
|
|
||||||
// reads (see `crate::test_support`'s doc comment).
|
|
||||||
crate::test_support::XDG_CONFIG_HOME_LOCK
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_config_home<T>(f: impl FnOnce(&Path) -> T) -> T {
|
|
||||||
let _lock = lock_xdg();
|
|
||||||
let dir = std::env::temp_dir().join(format!(
|
|
||||||
"bread-theme-layerrules-test-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_nanos()
|
|
||||||
));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
let old = std::env::var("XDG_CONFIG_HOME").ok();
|
|
||||||
std::env::set_var("XDG_CONFIG_HOME", &dir);
|
|
||||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&dir)));
|
|
||||||
match old {
|
|
||||||
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
|
|
||||||
None => std::env::remove_var("XDG_CONFIG_HOME"),
|
|
||||||
}
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
match result {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => std::panic::resume_unwind(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn layerrules_path_sits_under_config_hypr() {
|
|
||||||
with_config_home(|dir| {
|
|
||||||
assert_eq!(layerrules_path(), dir.join("hypr").join("layerrules.json"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True if any file under `dir` has a name containing `.tmp.` (a
|
|
||||||
/// leftover from `output::atomic_write`'s pid-suffixed temp files).
|
|
||||||
fn has_leftover_tmp(dir: &Path) -> bool {
|
|
||||||
fn walk(p: &Path) -> bool {
|
|
||||||
let Ok(entries) = std::fs::read_dir(p) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
if walk(&path) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.is_some_and(|n| n.contains(".tmp."))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
walk(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `load_named("liquid-motion")` resolves through discovery (user dir,
|
|
||||||
/// then system dir, then the compiled-in builtin) — isolate
|
|
||||||
/// `XDG_CONFIG_HOME` to an empty dir so this can't pick up a real
|
|
||||||
/// `~/.config/bread/themes/liquid-motion/theme.toml` override and land
|
|
||||||
/// on a different `[compositor]` table than the builtin's.
|
|
||||||
fn builtin_theme() -> ShellTheme {
|
|
||||||
with_config_home(|_| crate::shell::load_named("liquid-motion").unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn layerrules_json_covers_all_six_builtin_namespaces() {
|
|
||||||
let theme = builtin_theme();
|
|
||||||
let json = layerrules_json(&theme);
|
|
||||||
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
||||||
let obj = value.as_object().expect("top-level object");
|
|
||||||
for ns in [
|
|
||||||
"breadbar",
|
|
||||||
"breadbar-osd",
|
|
||||||
"breadbar-notif",
|
|
||||||
"breadbar-panel",
|
|
||||||
"breadbar-dismiss",
|
|
||||||
"breadbox",
|
|
||||||
] {
|
|
||||||
assert!(obj.contains_key(ns), "missing namespace {ns} in JSON");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn layerrules_json_shape_matches_breadbar_rule() {
|
|
||||||
let theme = builtin_theme();
|
|
||||||
let json = layerrules_json(&theme);
|
|
||||||
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
||||||
let bar = &value["breadbar"];
|
|
||||||
assert_eq!(bar["blur"], true);
|
|
||||||
assert_eq!(bar["ignore_alpha"], 0.2);
|
|
||||||
assert_eq!(bar["blur_popups"], true);
|
|
||||||
assert_eq!(bar["animation"], "slide top");
|
|
||||||
// no_anim is a plain bool (not Option), so it's always present, even
|
|
||||||
// when false — unlike ignore_alpha/animation which are omitted.
|
|
||||||
assert_eq!(bar["no_anim"], false);
|
|
||||||
|
|
||||||
let dismiss = &value["breadbar-dismiss"];
|
|
||||||
assert_eq!(dismiss["no_anim"], true);
|
|
||||||
// ignore_alpha/animation are unset for breadbar-dismiss, so the
|
|
||||||
// skip_serializing_if omits them entirely rather than writing null.
|
|
||||||
assert!(dismiss.get("ignore_alpha").is_none());
|
|
||||||
assert!(dismiss.get("animation").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn write_layerrules_active_writes_atomically_and_is_reloadable() {
|
|
||||||
with_config_home(|dir| {
|
|
||||||
let path = write_layerrules_active().unwrap();
|
|
||||||
assert_eq!(path, dir.join("hypr").join("layerrules.json"));
|
|
||||||
assert!(path.is_file());
|
|
||||||
// No leftover .tmp file after the atomic rename.
|
|
||||||
assert!(!has_leftover_tmp(dir));
|
|
||||||
|
|
||||||
let contents = std::fs::read_to_string(&path).unwrap();
|
|
||||||
let value: serde_json::Value = serde_json::from_str(&contents).unwrap();
|
|
||||||
assert!(value.as_object().unwrap().contains_key("breadbox"));
|
|
||||||
|
|
||||||
// Rewriting (theme switch, pywal hook, etc.) must not fail or
|
|
||||||
// leave a stale temp file behind.
|
|
||||||
let path2 = write_layerrules_active().unwrap();
|
|
||||||
assert_eq!(path, path2);
|
|
||||||
assert!(!has_leftover_tmp(dir));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +1,11 @@
|
||||||
#[cfg(feature = "adw")]
|
pub mod palette;
|
||||||
pub mod adw;
|
|
||||||
#[cfg(feature = "gtk")]
|
|
||||||
pub mod anim;
|
|
||||||
#[cfg(feature = "gtk")]
|
#[cfg(feature = "gtk")]
|
||||||
pub mod gtk;
|
pub mod gtk;
|
||||||
mod layerrules;
|
#[cfg(feature = "adw")]
|
||||||
mod output;
|
pub mod adw;
|
||||||
pub mod palette;
|
|
||||||
pub mod shell;
|
|
||||||
|
|
||||||
pub use layerrules::{layerrules_json, layerrules_path, write_layerrules, write_layerrules_active};
|
|
||||||
pub use output::{
|
|
||||||
generate_output, load_palette_for, output_css_path, output_palette_path, palette_from_image,
|
|
||||||
palette_from_json, palettes_dir, sanitize_output, themes_dir, write_output_css,
|
|
||||||
write_output_palette, write_shared_css_from,
|
|
||||||
};
|
|
||||||
pub use palette::{load_palette, Palette};
|
pub use palette::{load_palette, Palette};
|
||||||
|
|
||||||
/// Env-var locks shared by any test module that mutates process-global
|
|
||||||
/// state (`std::env::set_var`) — `cargo test` runs a crate's tests in
|
|
||||||
/// parallel by default, so every module touching the *same* env var must
|
|
||||||
/// serialize through the *same* lock or their mutations race each other's
|
|
||||||
/// reads. `bread_theme::output`'s own `XDG_ENV_LOCK` guards `XDG_RUNTIME_DIR`
|
|
||||||
/// specifically and stays where it is; `XDG_CONFIG_HOME_LOCK` here is the
|
|
||||||
/// one shared by `shell::tests` and `layerrules::tests`, which both point
|
|
||||||
/// `XDG_CONFIG_HOME` at an isolated temp dir.
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) mod test_support {
|
|
||||||
pub(crate) static XDG_CONFIG_HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Design tokens from BREAD_DESIGN_SYSTEM.md.
|
/// Design tokens from BREAD_DESIGN_SYSTEM.md.
|
||||||
pub mod tokens {
|
pub mod tokens {
|
||||||
pub const FONT_FAMILY: &str = "Varela Round, sans-serif";
|
pub const FONT_FAMILY: &str = "Varela Round, sans-serif";
|
||||||
|
|
@ -50,13 +26,6 @@ pub mod tokens {
|
||||||
pub const RADIUS_PILL: u16 = 999;
|
pub const RADIUS_PILL: u16 = 999;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CSS `font-family` list: quote the named face, leave the generic fallback
|
|
||||||
/// unquoted. Wrapping [`tokens::FONT_FAMILY`] in one pair of quotes would
|
|
||||||
/// make a single family named "Varela Round, sans-serif" and drop sans-serif.
|
|
||||||
fn css_font_family() -> &'static str {
|
|
||||||
"'Varela Round', sans-serif"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emit the `@define-color` block that all bread apps use, plus the shared
|
/// Emit the `@define-color` block that all bread apps use, plus the shared
|
||||||
/// font rule.
|
/// font rule.
|
||||||
///
|
///
|
||||||
|
|
@ -72,9 +41,9 @@ fn css_font_family() -> &'static str {
|
||||||
/// one color-block implementation and it cannot drift again.
|
/// one color-block implementation and it cannot drift again.
|
||||||
pub fn css_vars(p: &Palette) -> String {
|
pub fn css_vars(p: &Palette) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{vars}* {{ font-family: {font}; font-size: {size}px; }}\n",
|
"{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n",
|
||||||
vars = define_colors(p),
|
vars = define_colors(p),
|
||||||
font = css_font_family(),
|
font = tokens::FONT_FAMILY,
|
||||||
size = tokens::FONT_SIZE_BASE,
|
size = tokens::FONT_SIZE_BASE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -84,11 +53,7 @@ pub fn luminance(hex: &str) -> f32 {
|
||||||
let h = hex.trim_start_matches('#');
|
let h = hex.trim_start_matches('#');
|
||||||
let lin = |i: usize| -> f32 {
|
let lin = |i: usize| -> f32 {
|
||||||
let c = u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f32 / 255.0;
|
let c = u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f32 / 255.0;
|
||||||
if c <= 0.04045 {
|
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||||
c / 12.92
|
|
||||||
} else {
|
|
||||||
((c + 0.055) / 1.055).powf(2.4)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4)
|
0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4)
|
||||||
}
|
}
|
||||||
|
|
@ -99,11 +64,7 @@ pub fn luminance(hex: &str) -> f32 {
|
||||||
/// text readable no matter how light or dark pywal makes a given palette slot,
|
/// text readable no matter how light or dark pywal makes a given palette slot,
|
||||||
/// without altering the palette colours themselves.
|
/// without altering the palette colours themselves.
|
||||||
pub fn ink_on(hex: &str) -> &'static str {
|
pub fn ink_on(hex: &str) -> &'static str {
|
||||||
if luminance(hex) > 0.179 {
|
if luminance(hex) > 0.179 { "#11111b" } else { "#f5f5f5" }
|
||||||
"#11111b"
|
|
||||||
} else {
|
|
||||||
"#f5f5f5"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonical (name, value) list: the single naming all bread apps share.
|
/// Canonical (name, value) list: the single naming all bread apps share.
|
||||||
|
|
@ -169,7 +130,7 @@ pub fn css_tokens() -> String {
|
||||||
use tokens::*;
|
use tokens::*;
|
||||||
format!(
|
format!(
|
||||||
":root {{\n\
|
":root {{\n\
|
||||||
\x20\x20--font-family: {font};\n\
|
\x20\x20--font-family: '{font}';\n\
|
||||||
\x20\x20--font-size-base: {base}px;\n\
|
\x20\x20--font-size-base: {base}px;\n\
|
||||||
\x20\x20--font-size-secondary: {sec}px;\n\
|
\x20\x20--font-size-secondary: {sec}px;\n\
|
||||||
\x20\x20--space-xs: {xs}px;\n\
|
\x20\x20--space-xs: {xs}px;\n\
|
||||||
|
|
@ -182,18 +143,9 @@ pub fn css_tokens() -> String {
|
||||||
\x20\x20--radius-tertiary: {r3}px;\n\
|
\x20\x20--radius-tertiary: {r3}px;\n\
|
||||||
\x20\x20--radius-pill: {pill}px;\n\
|
\x20\x20--radius-pill: {pill}px;\n\
|
||||||
}}\n",
|
}}\n",
|
||||||
font = css_font_family(),
|
font = FONT_FAMILY, base = FONT_SIZE_BASE, sec = FONT_SIZE_SECONDARY,
|
||||||
base = FONT_SIZE_BASE,
|
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, xl = SPACE_XL,
|
||||||
sec = FONT_SIZE_SECONDARY,
|
r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, r3 = RADIUS_TERTIARY, pill = RADIUS_PILL,
|
||||||
xs = SPACE_XS,
|
|
||||||
sm = SPACE_SM,
|
|
||||||
md = SPACE_MD,
|
|
||||||
lg = SPACE_LG,
|
|
||||||
xl = SPACE_XL,
|
|
||||||
r1 = RADIUS_PRIMARY,
|
|
||||||
r2 = RADIUS_SECONDARY,
|
|
||||||
r3 = RADIUS_TERTIARY,
|
|
||||||
pill = RADIUS_PILL,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,7 +159,7 @@ pub fn stylesheet(p: &Palette) -> String {
|
||||||
use tokens::*;
|
use tokens::*;
|
||||||
format!(
|
format!(
|
||||||
"{vars}\
|
"{vars}\
|
||||||
* {{ font-family: {font}; font-size: {base}px; }}\n\
|
* {{ font-family: '{font}'; font-size: {base}px; }}\n\
|
||||||
/* Colour is set on containers; labels inherit it, so text on any panel,\
|
/* Colour is set on containers; labels inherit it, so text on any panel,\
|
||||||
button, or accent is always the legible ink for that background. Bare\
|
button, or accent is always the legible ink for that background. Bare\
|
||||||
`label {{ color }}` is deliberately avoided — as a type selector it\
|
`label {{ color }}` is deliberately avoided — as a type selector it\
|
||||||
|
|
@ -290,7 +242,7 @@ pub fn stylesheet(p: &Palette) -> String {
|
||||||
textview, .mono {{ font-family: monospace; }}\n\
|
textview, .mono {{ font-family: monospace; }}\n\
|
||||||
textview text {{ background-color: @surface; color: @on-surface; }}\n",
|
textview text {{ background-color: @surface; color: @on-surface; }}\n",
|
||||||
vars = define_colors(p),
|
vars = define_colors(p),
|
||||||
font = css_font_family(),
|
font = FONT_FAMILY,
|
||||||
base = FONT_SIZE_BASE,
|
base = FONT_SIZE_BASE,
|
||||||
sec = FONT_SIZE_SECONDARY,
|
sec = FONT_SIZE_SECONDARY,
|
||||||
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG,
|
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG,
|
||||||
|
|
@ -309,34 +261,28 @@ pub fn render() -> String {
|
||||||
/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`,
|
/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`,
|
||||||
/// falling back to the cache dir.
|
/// falling back to the cache dir.
|
||||||
pub fn shared_css_path() -> std::path::PathBuf {
|
pub fn shared_css_path() -> std::path::PathBuf {
|
||||||
output::runtime_bread_dir().join("theme.css")
|
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
|
||||||
|
if !rt.is_empty() {
|
||||||
|
return std::path::PathBuf::from(rt).join("bread").join("theme.css");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirs::cache_dir()
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
|
||||||
|
.join("bread")
|
||||||
|
.join("theme.css")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
|
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
|
||||||
/// the path written. Used by the `bread-theme` CLI.
|
/// the path written. Used by the `bread-theme` CLI.
|
||||||
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
|
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
|
||||||
write_shared_css_from(&load_palette())
|
let path = shared_css_path();
|
||||||
}
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
/// `stylesheet()` with `@name` references in rule bodies replaced by hex.
|
|
||||||
/// Longer names first (`on-surface` before `surface`, `on-bg` before `bg`)
|
|
||||||
/// so a prefix match cannot half-replace `@on-bg`.
|
|
||||||
pub fn stylesheet_resolved(p: &Palette) -> String {
|
|
||||||
resolve_color_names(&stylesheet(p), p)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace `@define-color` names (`@accent`, `@on-bg`, …) with hex values.
|
|
||||||
/// Used by [`stylesheet_resolved`] and by GTK `bind_window` so display-global
|
|
||||||
/// named colors cannot leak the wrong monitor's accent.
|
|
||||||
pub(crate) fn resolve_color_names(css: &str, p: &Palette) -> String {
|
|
||||||
let mut pairs: Vec<(&str, String)> = color_pairs(p).into_iter().collect();
|
|
||||||
// Longest name first, so `@on-bg` is replaced before `@bg` can match its tail.
|
|
||||||
pairs.sort_by_key(|(name, _)| std::cmp::Reverse(name.len()));
|
|
||||||
let mut out = css.to_string();
|
|
||||||
for (name, value) in pairs {
|
|
||||||
out = out.replace(&format!("@{name}"), &value);
|
|
||||||
}
|
}
|
||||||
out
|
let tmp = path.with_extension("css.tmp");
|
||||||
|
std::fs::write(&tmp, render())?;
|
||||||
|
std::fs::rename(&tmp, &path)?;
|
||||||
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
|
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
|
||||||
|
|
@ -355,24 +301,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn css_vars_contains_all_define_color_names() {
|
fn css_vars_contains_all_define_color_names() {
|
||||||
let css = css_vars(&Palette::default());
|
let css = css_vars(&Palette::default());
|
||||||
for name in &[
|
for name in &["bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
|
||||||
"bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay",
|
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
css.contains(&format!("@define-color {name} ")),
|
|
||||||
"missing @define-color {name}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn css_vars_contains_font_rule() {
|
fn css_vars_contains_font_rule() {
|
||||||
let css = css_vars(&Palette::default());
|
let css = css_vars(&Palette::default());
|
||||||
assert!(css.contains("font-family: 'Varela Round', sans-serif;"));
|
assert!(css.contains("Varela Round"));
|
||||||
assert!(
|
|
||||||
!css.contains("font-family: 'Varela Round, sans-serif'"),
|
|
||||||
"named face and generic fallback must not be one quoted family"
|
|
||||||
);
|
|
||||||
assert!(css.contains("14px"));
|
assert!(css.contains("14px"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,18 +322,8 @@ mod tests {
|
||||||
// color name — the illegible-text bug. css_vars() must now emit
|
// color name — the illegible-text bug. css_vars() must now emit
|
||||||
// exactly the same color set as the full stylesheet.
|
// exactly the same color set as the full stylesheet.
|
||||||
let css = css_vars(&Palette::default());
|
let css = css_vars(&Palette::default());
|
||||||
for name in &[
|
for name in &["accent", "on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
|
||||||
"accent",
|
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||||
"on-bg",
|
|
||||||
"on-surface",
|
|
||||||
"on-accent",
|
|
||||||
"on-red",
|
|
||||||
"on-overlay",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
css.contains(&format!("@define-color {name} ")),
|
|
||||||
"missing @define-color {name}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -407,16 +334,7 @@ mod tests {
|
||||||
let p = Palette::default();
|
let p = Palette::default();
|
||||||
let vars = css_vars(&p);
|
let vars = css_vars(&p);
|
||||||
let sheet = stylesheet(&p);
|
let sheet = stylesheet(&p);
|
||||||
for name in &[
|
for name in &["bg", "fg", "surface", "overlay", "accent", "on-bg", "on-surface", "on-accent"] {
|
||||||
"bg",
|
|
||||||
"fg",
|
|
||||||
"surface",
|
|
||||||
"overlay",
|
|
||||||
"accent",
|
|
||||||
"on-bg",
|
|
||||||
"on-surface",
|
|
||||||
"on-accent",
|
|
||||||
] {
|
|
||||||
let needle = format!("@define-color {name} ");
|
let needle = format!("@define-color {name} ");
|
||||||
assert!(vars.contains(&needle) && sheet.contains(&needle));
|
assert!(vars.contains(&needle) && sheet.contains(&needle));
|
||||||
}
|
}
|
||||||
|
|
@ -426,28 +344,13 @@ mod tests {
|
||||||
fn stylesheet_defines_canonical_colors_and_components() {
|
fn stylesheet_defines_canonical_colors_and_components() {
|
||||||
let css = stylesheet(&Palette::default());
|
let css = stylesheet(&Palette::default());
|
||||||
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
|
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
|
||||||
assert!(
|
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||||
css.contains(&format!("@define-color {name} ")),
|
|
||||||
"missing @define-color {name}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// a representative spread of the shared component selectors
|
// a representative spread of the shared component selectors
|
||||||
for sel in &[
|
for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".page-title"] {
|
||||||
"button",
|
|
||||||
"entry",
|
|
||||||
"switch:checked",
|
|
||||||
".card",
|
|
||||||
".sidebar",
|
|
||||||
"scrollbar slider",
|
|
||||||
".page-title",
|
|
||||||
] {
|
|
||||||
assert!(css.contains(sel), "stylesheet missing selector: {sel}");
|
assert!(css.contains(sel), "stylesheet missing selector: {sel}");
|
||||||
}
|
}
|
||||||
assert!(css.contains("font-family: 'Varela Round', sans-serif;"));
|
assert!(css.contains("Varela Round"));
|
||||||
assert!(
|
|
||||||
!css.contains("font-family: 'Varela Round, sans-serif'"),
|
|
||||||
"named face and generic fallback must not be one quoted family"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -459,10 +362,7 @@ mod tests {
|
||||||
let gtk = define_colors(&p);
|
let gtk = define_colors(&p);
|
||||||
let web = css_custom_properties(&p);
|
let web = css_custom_properties(&p);
|
||||||
for (name, _) in color_pairs(&p) {
|
for (name, _) in color_pairs(&p) {
|
||||||
assert!(
|
assert!(gtk.contains(&format!("@define-color {name} ")), "gtk missing {name}");
|
||||||
gtk.contains(&format!("@define-color {name} ")),
|
|
||||||
"gtk missing {name}"
|
|
||||||
);
|
|
||||||
assert!(web.contains(&format!("--{name}: ")), "web missing {name}");
|
assert!(web.contains(&format!("--{name}: ")), "web missing {name}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -479,11 +379,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn css_tokens_contains_font_and_spacing_vars() {
|
fn css_tokens_contains_font_and_spacing_vars() {
|
||||||
let css = css_tokens();
|
let css = css_tokens();
|
||||||
assert!(css.contains("--font-family: 'Varela Round', sans-serif;"));
|
assert!(css.contains("--font-family: 'Varela Round, sans-serif';"));
|
||||||
assert!(
|
|
||||||
!css.contains("--font-family: 'Varela Round, sans-serif'"),
|
|
||||||
"named face and generic fallback must not be one quoted family"
|
|
||||||
);
|
|
||||||
assert!(css.contains("--font-size-base: 14px;"));
|
assert!(css.contains("--font-size-base: 14px;"));
|
||||||
assert!(css.contains("--space-md: 12px;"));
|
assert!(css.contains("--space-md: 12px;"));
|
||||||
assert!(css.contains("--radius-pill: 999px;"));
|
assert!(css.contains("--radius-pill: 999px;"));
|
||||||
|
|
@ -513,10 +409,7 @@ mod tests {
|
||||||
fn stylesheet_defines_on_colors() {
|
fn stylesheet_defines_on_colors() {
|
||||||
let css = stylesheet(&Palette::default());
|
let css = stylesheet(&Palette::default());
|
||||||
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
|
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
|
||||||
assert!(
|
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||||
css.contains(&format!("@define-color {name} ")),
|
|
||||||
"missing @define-color {name}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -525,60 +418,13 @@ mod tests {
|
||||||
// A bare `label { color: ... }` would override container colours on child
|
// A bare `label { color: ... }` would override container colours on child
|
||||||
// labels — the bug that made coloured-background text illegible.
|
// labels — the bug that made coloured-background text illegible.
|
||||||
let css = stylesheet(&Palette::default());
|
let css = stylesheet(&Palette::default());
|
||||||
assert!(
|
assert!(!css.contains("label { color:"), "blanket label colour rule reintroduced");
|
||||||
!css.contains("label { color:"),
|
|
||||||
"blanket label colour rule reintroduced"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shared_css_path_uses_runtime_dir() {
|
fn shared_css_path_uses_runtime_dir() {
|
||||||
let _lock = crate::output::XDG_ENV_LOCK
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
|
||||||
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
|
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
|
||||||
assert_eq!(
|
assert_eq!(shared_css_path(), std::path::PathBuf::from("/run/user/1234/bread/theme.css"));
|
||||||
shared_css_path(),
|
|
||||||
std::path::PathBuf::from("/run/user/1234/bread/theme.css")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stylesheet_resolved_inlines_color4_and_drops_named_refs_in_rules() {
|
|
||||||
let p = Palette {
|
|
||||||
color4: "#7aa2f7".into(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let css = stylesheet_resolved(&p);
|
|
||||||
assert!(css.contains("#7aa2f7"), "color4 must appear as hex: {css}");
|
|
||||||
// Rule bodies must not keep named colors — GTK display-global
|
|
||||||
// @define-color would otherwise leak the wrong monitor's accent.
|
|
||||||
let rules = css
|
|
||||||
.lines()
|
|
||||||
.filter(|l| !l.trim_start().starts_with("@define-color"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
assert!(
|
|
||||||
!rules.contains("@accent"),
|
|
||||||
"leftover @accent in rules:\n{rules}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!rules.contains("@on-bg"),
|
|
||||||
"leftover @on-bg in rules:\n{rules}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!rules.contains("@on-surface"),
|
|
||||||
"leftover @on-surface in rules:\n{rules}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!rules.contains("@on-accent"),
|
|
||||||
"leftover @on-accent in rules:\n{rules}"
|
|
||||||
);
|
|
||||||
// Longer names first: @on-bg must not become @on-#...
|
|
||||||
assert!(
|
|
||||||
!rules.contains("@on-#"),
|
|
||||||
"half-replaced on-* name:\n{rules}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,344 +0,0 @@
|
||||||
//! Per-output (per-monitor) palette and stylesheet paths under
|
|
||||||
//! `$XDG_RUNTIME_DIR/bread/{palettes,themes}/`.
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use crate::palette::{from_wal_json, Palette};
|
|
||||||
use crate::{load_palette, stylesheet};
|
|
||||||
|
|
||||||
/// Session-scoped `$XDG_RUNTIME_DIR/bread`, same fallback as [`crate::shared_css_path`].
|
|
||||||
pub(crate) fn runtime_bread_dir() -> PathBuf {
|
|
||||||
// XDG spec: `XDG_RUNTIME_DIR` is only honored when set to a non-empty,
|
|
||||||
// *absolute* path; a relative value must be ignored.
|
|
||||||
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
|
|
||||||
let p = PathBuf::from(&rt);
|
|
||||||
if !rt.is_empty() && p.is_absolute() {
|
|
||||||
return p.join("bread");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dirs::cache_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
|
||||||
.join("bread")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keep `[A-Za-z0-9._-]`; replace everything else with `_`.
|
|
||||||
pub fn sanitize_output(output: &str) -> String {
|
|
||||||
let s: String = output
|
|
||||||
.chars()
|
|
||||||
.map(|c| {
|
|
||||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
|
|
||||||
c
|
|
||||||
} else {
|
|
||||||
'_'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if s.is_empty() {
|
|
||||||
"_".into()
|
|
||||||
} else {
|
|
||||||
s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn themes_dir() -> PathBuf {
|
|
||||||
runtime_bread_dir().join("themes")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn palettes_dir() -> PathBuf {
|
|
||||||
runtime_bread_dir().join("palettes")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn output_css_path(output: &str) -> PathBuf {
|
|
||||||
themes_dir().join(format!("{}.css", sanitize_output(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn output_palette_path(output: &str) -> PathBuf {
|
|
||||||
palettes_dir().join(format!("{}.json", sanitize_output(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
// Pad the temp name with the pid (matching `bread_utils::atomic`) so two
|
|
||||||
// concurrent writers for the same target can't race on one shared `.tmp`
|
|
||||||
// file — e.g. two `bread-theme generate-output` runs writing the same
|
|
||||||
// `themes/<output>.css` at once.
|
|
||||||
let tmp = match path.file_name().and_then(|n| n.to_str()) {
|
|
||||||
Some(name) => path.with_file_name(format!(".{name}.tmp.{}", std::process::id())),
|
|
||||||
None => path.with_extension(format!("tmp.{}", std::process::id())),
|
|
||||||
};
|
|
||||||
std::fs::write(&tmp, contents)?;
|
|
||||||
std::fs::rename(&tmp, path)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Accents only — never persist pywal's light background/surface/overlay/fg.
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct StoredColors {
|
|
||||||
color1: String,
|
|
||||||
color2: String,
|
|
||||||
color3: String,
|
|
||||||
color4: String,
|
|
||||||
color5: String,
|
|
||||||
color6: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct StoredPalette {
|
|
||||||
colors: StoredColors,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse on-disk JSON: wal `colors.json` shape, or a flat `{color1..color6}` object.
|
|
||||||
/// Always forces FIXED background/foreground/color0/color7 via [`from_wal_json`].
|
|
||||||
pub fn palette_from_json(json: &str) -> Option<Palette> {
|
|
||||||
let value: serde_json::Value = serde_json::from_str(json).ok()?;
|
|
||||||
if value
|
|
||||||
.get("colors")
|
|
||||||
.and_then(|c| c.as_object())
|
|
||||||
.is_some_and(|o| !o.is_empty())
|
|
||||||
{
|
|
||||||
return from_wal_json(json);
|
|
||||||
}
|
|
||||||
if value.get("color1").is_some()
|
|
||||||
|| value.get("color2").is_some()
|
|
||||||
|| value.get("color3").is_some()
|
|
||||||
|| value.get("color4").is_some()
|
|
||||||
|| value.get("color5").is_some()
|
|
||||||
|| value.get("color6").is_some()
|
|
||||||
{
|
|
||||||
let wrapped = serde_json::json!({ "colors": value });
|
|
||||||
return from_wal_json(&wrapped.to_string());
|
|
||||||
}
|
|
||||||
from_wal_json(json)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load `palettes/<output>.json`; fall back to [`load_palette`].
|
|
||||||
pub fn load_palette_for(output: &str) -> Palette {
|
|
||||||
std::fs::read_to_string(output_palette_path(output))
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| palette_from_json(&s))
|
|
||||||
.unwrap_or_else(load_palette)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_output_palette(output: &str, palette: &Palette) -> std::io::Result<PathBuf> {
|
|
||||||
let path = output_palette_path(output);
|
|
||||||
let stored = StoredPalette {
|
|
||||||
colors: StoredColors {
|
|
||||||
color1: palette.color1.clone(),
|
|
||||||
color2: palette.color2.clone(),
|
|
||||||
color3: palette.color3.clone(),
|
|
||||||
color4: palette.color4.clone(),
|
|
||||||
color5: palette.color5.clone(),
|
|
||||||
color6: palette.color6.clone(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string_pretty(&stored)
|
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
|
||||||
atomic_write(&path, &json)?;
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_output_css(output: &str, palette: &Palette) -> std::io::Result<PathBuf> {
|
|
||||||
let path = output_css_path(output);
|
|
||||||
atomic_write(&path, &stylesheet(palette))?;
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [`crate::write_shared_css`] but from an explicit palette.
|
|
||||||
pub fn write_shared_css_from(palette: &Palette) -> std::io::Result<PathBuf> {
|
|
||||||
let path = crate::shared_css_path();
|
|
||||||
atomic_write(&path, &stylesheet(palette))?;
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Isolated `wal -i <image> -n -q` with `XDG_CACHE_HOME` set to a unique temp
|
|
||||||
/// dir so the user's `~/.cache/wal` is not clobbered.
|
|
||||||
pub fn palette_from_image(path: &Path) -> std::io::Result<Palette> {
|
|
||||||
let pid = std::process::id();
|
|
||||||
let nanos = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_nanos();
|
|
||||||
let tmp = std::env::temp_dir().join(format!("bread-theme-wal-{pid}-{nanos}"));
|
|
||||||
std::fs::create_dir_all(&tmp)?;
|
|
||||||
struct Rm(PathBuf);
|
|
||||||
impl Drop for Rm {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = std::fs::remove_dir_all(&self.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _guard = Rm(tmp.clone());
|
|
||||||
|
|
||||||
// Classic pywal ignores XDG_CACHE_HOME and writes $HOME/.cache/wal.
|
|
||||||
// Point HOME at the temp dir so a per-output extract cannot clobber
|
|
||||||
// the session cache (or the other monitor's last `wal -i`).
|
|
||||||
let status = match std::process::Command::new("wal")
|
|
||||||
.arg("-i")
|
|
||||||
.arg(path)
|
|
||||||
.args(["-n", "-q"])
|
|
||||||
.env("HOME", &tmp)
|
|
||||||
.env("XDG_CACHE_HOME", tmp.join(".cache"))
|
|
||||||
.status()
|
|
||||||
{
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::NotFound,
|
|
||||||
"wal is not installed",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
Ok(s) => s,
|
|
||||||
};
|
|
||||||
if !status.success() {
|
|
||||||
return Err(std::io::Error::other(format!("wal failed with {status}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
let json_path = [
|
|
||||||
tmp.join(".cache").join("wal").join("colors.json"),
|
|
||||||
tmp.join("wal").join("colors.json"),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.find(|p| p.is_file())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::NotFound,
|
|
||||||
"wal did not write colors.json under the isolated cache",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let json = std::fs::read_to_string(&json_path)?;
|
|
||||||
from_wal_json(&json).ok_or_else(|| {
|
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"wal produced unparseable colors.json",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`palette_from_image`] + [`write_output_palette`] + [`write_output_css`].
|
|
||||||
pub fn generate_output(output: &str, image: &Path) -> std::io::Result<PathBuf> {
|
|
||||||
let palette = palette_from_image(image)?;
|
|
||||||
write_output_palette(output, &palette)?;
|
|
||||||
write_output_css(output, &palette)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) static XDG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::palette::{FIXED_BACKGROUND, FIXED_FOREGROUND, FIXED_OVERLAY, FIXED_SURFACE};
|
|
||||||
|
|
||||||
fn lock_xdg() -> std::sync::MutexGuard<'static, ()> {
|
|
||||||
XDG_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_runtime_dir<T>(f: impl FnOnce(&Path) -> T) -> T {
|
|
||||||
let _lock = lock_xdg();
|
|
||||||
let dir = std::env::temp_dir().join(format!(
|
|
||||||
"bread-theme-test-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_nanos()
|
|
||||||
));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
let old = std::env::var("XDG_RUNTIME_DIR").ok();
|
|
||||||
std::env::set_var("XDG_RUNTIME_DIR", &dir);
|
|
||||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&dir)));
|
|
||||||
match old {
|
|
||||||
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
|
|
||||||
None => std::env::remove_var("XDG_RUNTIME_DIR"),
|
|
||||||
}
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
match result {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => std::panic::resume_unwind(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sanitize_output_keeps_hyprland_connectors() {
|
|
||||||
assert_eq!(sanitize_output("HDMI-A-1"), "HDMI-A-1");
|
|
||||||
assert_eq!(sanitize_output("eDP-1"), "eDP-1");
|
|
||||||
assert_eq!(sanitize_output("DP-2"), "DP-2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sanitize_output_replaces_unsafe_chars() {
|
|
||||||
assert_eq!(sanitize_output("HDMI A:1"), "HDMI_A_1");
|
|
||||||
assert_eq!(sanitize_output("foo/bar"), "foo_bar");
|
|
||||||
assert_eq!(sanitize_output(""), "_");
|
|
||||||
assert_eq!(sanitize_output("..ok_name-1"), "..ok_name-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn output_paths_use_sanitize_and_sit_under_dirs() {
|
|
||||||
let _lock = lock_xdg();
|
|
||||||
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
|
|
||||||
let css = output_css_path("HDMI A:1");
|
|
||||||
let pal = output_palette_path("HDMI A:1");
|
|
||||||
assert_eq!(css, themes_dir().join("HDMI_A_1.css"));
|
|
||||||
assert_eq!(pal, palettes_dir().join("HDMI_A_1.json"));
|
|
||||||
assert!(css.starts_with(themes_dir()));
|
|
||||||
assert!(pal.starts_with(palettes_dir()));
|
|
||||||
assert_eq!(
|
|
||||||
output_css_path("eDP-1"),
|
|
||||||
PathBuf::from("/run/user/1234/bread/themes/eDP-1.css")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_palette_for_missing_file_has_fixed_bg() {
|
|
||||||
with_runtime_dir(|_| {
|
|
||||||
let p = load_palette_for("no-such-output");
|
|
||||||
assert_eq!(p.background, FIXED_BACKGROUND);
|
|
||||||
assert!(p.color4.starts_with('#'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn write_output_palette_roundtrips_color4() {
|
|
||||||
with_runtime_dir(|_| {
|
|
||||||
let p = Palette {
|
|
||||||
color4: "#7aa2f7".into(),
|
|
||||||
background: "#ffffff".into(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
write_output_palette("HDMI-A-1", &p).unwrap();
|
|
||||||
let loaded = load_palette_for("HDMI-A-1");
|
|
||||||
assert_eq!(loaded.color4, "#7aa2f7");
|
|
||||||
assert_eq!(loaded.background, FIXED_BACKGROUND);
|
|
||||||
assert_eq!(loaded.foreground, FIXED_FOREGROUND);
|
|
||||||
assert_eq!(loaded.color0, FIXED_SURFACE);
|
|
||||||
assert_eq!(loaded.color7, FIXED_OVERLAY);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn write_shared_css_from_writes_shared_css_path() {
|
|
||||||
with_runtime_dir(|rt| {
|
|
||||||
let path = write_shared_css_from(&Palette::default()).unwrap();
|
|
||||||
assert_eq!(path, crate::shared_css_path());
|
|
||||||
assert_eq!(path, rt.join("bread").join("theme.css"));
|
|
||||||
let css = std::fs::read_to_string(&path).unwrap();
|
|
||||||
assert!(css.contains("@define-color accent "));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_palette_for_accepts_flat_color_object() {
|
|
||||||
with_runtime_dir(|_| {
|
|
||||||
let path = output_palette_path("DP-1");
|
|
||||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
||||||
std::fs::write(&path, r##"{"color4":"#112233","color1":"#abcdef"}"##).unwrap();
|
|
||||||
let p = load_palette_for("DP-1");
|
|
||||||
assert_eq!(p.color4, "#112233");
|
|
||||||
assert_eq!(p.color1, "#abcdef");
|
|
||||||
assert_eq!(p.background, FIXED_BACKGROUND);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,10 +9,10 @@ use std::path::PathBuf;
|
||||||
/// off-hue background, and every bread GUI's panels inherit it — the app
|
/// off-hue background, and every bread GUI's panels inherit it — the app
|
||||||
/// stops looking like a dark BOS tool and starts looking like whatever colour
|
/// stops looking like a dark BOS tool and starts looking like whatever colour
|
||||||
/// the wallpaper happened to be.
|
/// the wallpaper happened to be.
|
||||||
pub(crate) const FIXED_BACKGROUND: &str = "#0c0c0c";
|
const FIXED_BACKGROUND: &str = "#0c0c0c";
|
||||||
pub(crate) const FIXED_FOREGROUND: &str = "#e8e8e8";
|
const FIXED_FOREGROUND: &str = "#e8e8e8";
|
||||||
pub(crate) const FIXED_SURFACE: &str = "#1a1a1a";
|
const FIXED_SURFACE: &str = "#1a1a1a";
|
||||||
pub(crate) const FIXED_OVERLAY: &str = "#d8d8d8";
|
const FIXED_OVERLAY: &str = "#d8d8d8";
|
||||||
|
|
||||||
/// Accent fallback when no pywal palette exists yet (fresh install, before
|
/// Accent fallback when no pywal palette exists yet (fresh install, before
|
||||||
/// any wallpaper has been set for real) — BOS's own bread-toned accents,
|
/// any wallpaper has been set for real) — BOS's own bread-toned accents,
|
||||||
|
|
@ -84,10 +84,7 @@ pub fn load_palette() -> Palette {
|
||||||
pub(crate) fn from_wal_json(json: &str) -> Option<Palette> {
|
pub(crate) fn from_wal_json(json: &str) -> Option<Palette> {
|
||||||
let wal: WalColors = serde_json::from_str(json).ok()?;
|
let wal: WalColors = serde_json::from_str(json).ok()?;
|
||||||
let c = |k: &str, fallback: &str| -> String {
|
let c = |k: &str, fallback: &str| -> String {
|
||||||
wal.colors
|
wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into())
|
||||||
.get(k)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| fallback.into())
|
|
||||||
};
|
};
|
||||||
Some(Palette {
|
Some(Palette {
|
||||||
background: FIXED_BACKGROUND.into(),
|
background: FIXED_BACKGROUND.into(),
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
//! The compiled-in themes (plan §11 phase 1/5: "**One** built-in manifest
|
|
||||||
//! (`liquid-motion`) describing the bar as it exists today", extended in
|
|
||||||
//! Phase 5 with `glass-workbench`, demo 02). Every file here is plain data,
|
|
||||||
//! not Rust — each `theme.toml` is the manifest text a user override would
|
|
||||||
//! otherwise supply, and each `<id>.css` is the CSS template `ShellTheme::css`
|
|
||||||
//! substitutes tokens into (see that method's doc comment for why this
|
|
||||||
//! template is a representative subset of `breadbar::theme::load_css`'s
|
|
||||||
//! full stylesheet rather than a byte-for-byte copy of it).
|
|
||||||
//!
|
|
||||||
//! All are read with `include_str!` so a broken build can't ship without
|
|
||||||
//! them, and so [`super`] never touches the filesystem for a builtin — it
|
|
||||||
//! must work identically whether or not `$XDG_CONFIG_HOME` exists at all.
|
|
||||||
|
|
||||||
pub const LIQUID_MOTION_ID: &str = "liquid-motion";
|
|
||||||
|
|
||||||
const LIQUID_MOTION_TOML: &str = include_str!(concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/assets/shell/liquid-motion/theme.toml"
|
|
||||||
));
|
|
||||||
|
|
||||||
const LIQUID_MOTION_CSS: &str = include_str!(concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/assets/shell/liquid-motion/liquid-motion.css"
|
|
||||||
));
|
|
||||||
|
|
||||||
pub const GLASS_WORKBENCH_ID: &str = "glass-workbench";
|
|
||||||
|
|
||||||
const GLASS_WORKBENCH_TOML: &str = include_str!(concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/assets/shell/glass-workbench/theme.toml"
|
|
||||||
));
|
|
||||||
|
|
||||||
const GLASS_WORKBENCH_CSS: &str = include_str!(concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/assets/shell/glass-workbench/glass-workbench.css"
|
|
||||||
));
|
|
||||||
|
|
||||||
pub const SPOTLIGHT_ID: &str = "spotlight";
|
|
||||||
|
|
||||||
const SPOTLIGHT_TOML: &str = include_str!(concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/assets/shell/spotlight/theme.toml"
|
|
||||||
));
|
|
||||||
|
|
||||||
const SPOTLIGHT_CSS: &str = include_str!(concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/assets/shell/spotlight/spotlight.css"
|
|
||||||
));
|
|
||||||
|
|
||||||
pub const DAYLIGHT_ID: &str = "daylight";
|
|
||||||
|
|
||||||
const DAYLIGHT_TOML: &str = include_str!(concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/assets/shell/daylight/theme.toml"
|
|
||||||
));
|
|
||||||
|
|
||||||
const DAYLIGHT_CSS: &str = include_str!(concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/assets/shell/daylight/daylight.css"
|
|
||||||
));
|
|
||||||
|
|
||||||
/// One compiled-in theme's identity plus its two `include_str!`ed assets.
|
|
||||||
/// `id`/`name` are also duplicated inside `toml`'s own `id =`/`name =`
|
|
||||||
/// fields — kept here too so [`all`]/[`find`] can list/look up a builtin
|
|
||||||
/// without parsing TOML first (`super::list`'s builtin fallback entry, and
|
|
||||||
/// `super::find_source`'s existence check, both run before any manifest
|
|
||||||
/// parsing happens).
|
|
||||||
pub struct BuiltinTheme {
|
|
||||||
pub id: &'static str,
|
|
||||||
pub name: &'static str,
|
|
||||||
pub toml: &'static str,
|
|
||||||
pub css: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every compiled-in theme, in the order [`super::list`] should present
|
|
||||||
/// them. Adding a third builtin is one entry here plus its two asset files
|
|
||||||
/// — nothing else in `mod.rs` names a specific builtin id except the always-
|
|
||||||
/// -safe fallback ([`LIQUID_MOTION_ID`], deliberately still hardcoded at
|
|
||||||
/// its one call site in `super::resolve_builtin` — see that function's doc
|
|
||||||
/// comment for why that one reference must NOT become "whichever builtin is
|
|
||||||
/// listed first").
|
|
||||||
pub const ALL: &[BuiltinTheme] = &[
|
|
||||||
BuiltinTheme {
|
|
||||||
id: LIQUID_MOTION_ID,
|
|
||||||
name: "Liquid Motion",
|
|
||||||
toml: LIQUID_MOTION_TOML,
|
|
||||||
css: LIQUID_MOTION_CSS,
|
|
||||||
},
|
|
||||||
BuiltinTheme {
|
|
||||||
id: GLASS_WORKBENCH_ID,
|
|
||||||
name: "Glass Workbench",
|
|
||||||
toml: GLASS_WORKBENCH_TOML,
|
|
||||||
css: GLASS_WORKBENCH_CSS,
|
|
||||||
},
|
|
||||||
BuiltinTheme {
|
|
||||||
id: SPOTLIGHT_ID,
|
|
||||||
name: "Spotlight",
|
|
||||||
toml: SPOTLIGHT_TOML,
|
|
||||||
css: SPOTLIGHT_CSS,
|
|
||||||
},
|
|
||||||
BuiltinTheme {
|
|
||||||
id: DAYLIGHT_ID,
|
|
||||||
name: "Daylight",
|
|
||||||
toml: DAYLIGHT_TOML,
|
|
||||||
css: DAYLIGHT_CSS,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Looks up a compiled-in theme by id — `None` means "not a builtin",
|
|
||||||
/// exactly like a miss in the user/system theme directories.
|
|
||||||
pub fn find(id: &str) -> Option<&'static BuiltinTheme> {
|
|
||||||
ALL.iter().find(|t| t.id == id)
|
|
||||||
}
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
//! `watch()` — only compiled under the `gtk` feature, since
|
|
||||||
//! `gio::FileMonitor` is a gtk4 dependency and the rest of `shell` is
|
|
||||||
//! deliberately gtk-free (`bread`/`breadcrumbs` link this crate without the
|
|
||||||
//! `gtk` feature at all).
|
|
||||||
|
|
||||||
use gtk4::gio;
|
|
||||||
use gtk4::prelude::*;
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
/// The handle [`watch`] returns. Keep it alive — dropping it disarms both
|
|
||||||
/// the config-file watch and whichever theme directory is currently armed;
|
|
||||||
/// there is nothing else to call on it.
|
|
||||||
///
|
|
||||||
/// Two independent [`gio::FileMonitor`]s live behind this, not one:
|
|
||||||
/// - a fixed watch on `~/.config/bread/` (or `$XDG_CONFIG_HOME/bread/`)
|
|
||||||
/// for `shell.toml` itself, so a change to the *active* theme id is
|
|
||||||
/// noticed at all;
|
|
||||||
/// - a swappable watch on whichever theme directory is currently active,
|
|
||||||
/// re-armed onto the new directory whenever the config watch observes
|
|
||||||
/// `active` changing (see [`watch`]'s doc comment for what this does and
|
|
||||||
/// does not cover).
|
|
||||||
pub struct ThemeWatch {
|
|
||||||
_config_monitor: Option<gio::FileMonitor>,
|
|
||||||
// `Rc<RefCell<..>>` (rather than a plain field) because the config
|
|
||||||
// watch's own callback needs to replace this monitor in place when the
|
|
||||||
// active theme changes, while this struct is what keeps it alive for
|
|
||||||
// the caller.
|
|
||||||
_theme_monitor: Rc<RefCell<Option<gio::FileMonitor>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort: `monitor_directory` on `dir` can fail (e.g.
|
|
||||||
/// `fs.inotify.max_user_watches` exhausted) — that must never be fatal, per
|
|
||||||
/// this crate's "the shell must never fail to start because a theme file is
|
|
||||||
/// malformed" stance (`shell` module doc). Logs once per call site and
|
|
||||||
/// returns `None` rather than panicking; the caller simply runs without a
|
|
||||||
/// live watch for whatever this was meant to cover, same as
|
|
||||||
/// `bread_theme::gtk::watch_theme_file`'s existing `.ok()?` stance for the
|
|
||||||
/// shared-stylesheet watch.
|
|
||||||
fn monitor_dir(dir: &std::path::Path, what: &str) -> Option<gio::FileMonitor> {
|
|
||||||
let _ = std::fs::create_dir_all(dir);
|
|
||||||
match gio::File::for_path(dir).monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE) {
|
|
||||||
Ok(m) => Some(m),
|
|
||||||
Err(err) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"bread-theme: could not create a file monitor for {what} ({}); \
|
|
||||||
continuing without live reload for it",
|
|
||||||
err
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn theme_dir(id: &str) -> PathBuf {
|
|
||||||
super::user_theme_path(id)
|
|
||||||
.parent()
|
|
||||||
.expect("user_theme_path always has a parent")
|
|
||||||
.to_path_buf()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// (Re-)arms `theme_monitor` on `id`'s own directory, replacing whatever was
|
|
||||||
/// armed before (dropping the old [`gio::FileMonitor`] disarms it). Returns
|
|
||||||
/// the id actually armed, so the caller can remember it for the "did the
|
|
||||||
/// active id change" comparison in [`watch`]'s config-watch callback.
|
|
||||||
fn arm_theme_watch(theme_monitor: &Rc<RefCell<Option<gio::FileMonitor>>>, id: &str, f: &Rc<dyn Fn(super::ShellTheme)>) {
|
|
||||||
let dir = theme_dir(id);
|
|
||||||
let monitor = monitor_dir(&dir, &format!("theme '{id}'s directory ({})", dir.display()));
|
|
||||||
if let Some(m) = &monitor {
|
|
||||||
let f = f.clone();
|
|
||||||
m.connect_changed(move |_, _file, _other, _event| {
|
|
||||||
f(super::load());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
*theme_monitor.borrow_mut() = monitor;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fires `f` with a freshly-[`super::load`]ed [`super::ShellTheme`] whenever
|
|
||||||
/// the active theme's own directory changes on disk, AND re-arms itself onto
|
|
||||||
/// a new theme's directory when `~/.config/bread/shell.toml`'s `active` key
|
|
||||||
/// changes — so switching themes while a host is already running (e.g.
|
|
||||||
/// bos-settings rewriting `active =`) picks up live edits to the *new*
|
|
||||||
/// theme without a restart, not just edits to whichever theme happened to
|
|
||||||
/// be active when `watch()` was first called.
|
|
||||||
///
|
|
||||||
/// Watches directories, not the files themselves, for the same reason
|
|
||||||
/// `bread_theme::gtk::watch_theme_file` does (see that function's doc
|
|
||||||
/// comment): an editor or `bread-theme` doing an atomic write-tmp-then-
|
|
||||||
/// rename replaces the inode, and a monitor on the file itself dies after
|
|
||||||
/// the first replace (inotify reports `DELETE_SELF` and never re-arms).
|
|
||||||
///
|
|
||||||
/// # What this does NOT cover
|
|
||||||
///
|
|
||||||
/// `$BREAD_SHELL_THEME` is a **single-process override for testing**, not
|
|
||||||
/// the primary theme selector (`shell.toml`'s `active` key is — see the
|
|
||||||
/// `shell` module doc's "Discovery" section) — precisely because an env var
|
|
||||||
/// set for one process is invisible to any other, it cannot coordinate two
|
|
||||||
/// already-running hosts (breadbar/breadbox) onto the same theme, and this
|
|
||||||
/// watch cannot observe it changing either: it's read once per call to
|
|
||||||
/// [`super::active_theme_id`] (i.e. once per `load()`/re-arm here), and
|
|
||||||
/// there is no filesystem event to watch for a process's own environment
|
|
||||||
/// changing out from under it. A host that wants to pick up a new
|
|
||||||
/// `$BREAD_SHELL_THEME` value has to restart, same as before this fix.
|
|
||||||
pub fn watch<F: Fn(super::ShellTheme) + 'static>(f: F) -> ThemeWatch {
|
|
||||||
let f: Rc<dyn Fn(super::ShellTheme)> = Rc::new(f);
|
|
||||||
|
|
||||||
let watched_id = Rc::new(RefCell::new(super::active_theme_id()));
|
|
||||||
let theme_monitor: Rc<RefCell<Option<gio::FileMonitor>>> = Rc::new(RefCell::new(None));
|
|
||||||
arm_theme_watch(&theme_monitor, &watched_id.borrow(), &f);
|
|
||||||
|
|
||||||
let config_dir = super::config_home().join("bread");
|
|
||||||
let config_monitor = monitor_dir(
|
|
||||||
&config_dir,
|
|
||||||
&format!("the shell config directory ({})", config_dir.display()),
|
|
||||||
);
|
|
||||||
if let Some(cm) = &config_monitor {
|
|
||||||
let theme_monitor = theme_monitor.clone();
|
|
||||||
let watched_id = watched_id.clone();
|
|
||||||
let f = f.clone();
|
|
||||||
cm.connect_changed(move |_, file, other, _event| {
|
|
||||||
// Only `shell.toml` changing can move `active` — ignore any
|
|
||||||
// other file (e.g. a theme directory that happens to also live
|
|
||||||
// under this same parent) so this doesn't re-check on every
|
|
||||||
// unrelated write in ~/.config/bread/.
|
|
||||||
let is_shell_toml = |f: &gio::File| {
|
|
||||||
f.basename().and_then(|b| b.to_str().map(str::to_string)) == Some("shell.toml".to_string())
|
|
||||||
};
|
|
||||||
if !is_shell_toml(file) && !other.is_some_and(is_shell_toml) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let new_id = super::active_theme_id();
|
|
||||||
if new_id == *watched_id.borrow() {
|
|
||||||
// shell.toml changed but `active` didn't (or moved to the
|
|
||||||
// same value) — the already-armed theme watch still covers
|
|
||||||
// whatever changed.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
*watched_id.borrow_mut() = new_id.clone();
|
|
||||||
arm_theme_watch(&theme_monitor, &new_id, &f);
|
|
||||||
f(super::load());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ThemeWatch {
|
|
||||||
_config_monitor: config_monitor,
|
|
||||||
_theme_monitor: theme_monitor,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
/// Isolated `$XDG_CONFIG_HOME`, mirroring `shell::tests::isolated_xdg`
|
|
||||||
/// (can't reuse it directly — it's private to the parent module's own
|
|
||||||
/// `tests` submodule — but shares the same lock so the two test files
|
|
||||||
/// never race the same env vars).
|
|
||||||
struct EnvGuard {
|
|
||||||
_lock: std::sync::MutexGuard<'static, ()>,
|
|
||||||
dir: PathBuf,
|
|
||||||
old_xdg: Option<String>,
|
|
||||||
old_theme_var: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for EnvGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
match &self.old_xdg {
|
|
||||||
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
|
|
||||||
None => std::env::remove_var("XDG_CONFIG_HOME"),
|
|
||||||
}
|
|
||||||
match &self.old_theme_var {
|
|
||||||
Some(v) => std::env::set_var("BREAD_SHELL_THEME", v),
|
|
||||||
None => std::env::remove_var("BREAD_SHELL_THEME"),
|
|
||||||
}
|
|
||||||
let _ = std::fs::remove_dir_all(&self.dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn isolated_xdg() -> EnvGuard {
|
|
||||||
let lock = crate::test_support::XDG_CONFIG_HOME_LOCK
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
|
||||||
let dir = std::env::temp_dir().join(format!(
|
|
||||||
"bread-theme-hotreload-test-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_nanos()
|
|
||||||
));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
let old_xdg = std::env::var("XDG_CONFIG_HOME").ok();
|
|
||||||
let old_theme_var = std::env::var("BREAD_SHELL_THEME").ok();
|
|
||||||
std::env::set_var("XDG_CONFIG_HOME", &dir);
|
|
||||||
std::env::remove_var("BREAD_SHELL_THEME");
|
|
||||||
EnvGuard {
|
|
||||||
_lock: lock,
|
|
||||||
dir,
|
|
||||||
old_xdg,
|
|
||||||
old_theme_var,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_theme(xdg: &EnvGuard, id: &str, toml_body: &str) {
|
|
||||||
let dir = xdg.dir.join("bread/themes").join(id);
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
std::fs::write(dir.join("theme.toml"), toml_body).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_shell_toml(xdg: &EnvGuard, active: &str) {
|
|
||||||
let dir = xdg.dir.join("bread");
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
std::fs::write(dir.join("shell.toml"), format!("active = \"{active}\"\n")).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pumps the default `glib::MainContext` (where `gio::FileMonitor`
|
|
||||||
/// dispatches its `connect_changed` signal) until `done()` returns
|
|
||||||
/// true or `timeout` elapses. Real inotify events go through the OS,
|
|
||||||
/// so this polls rather than blocking on a single iteration.
|
|
||||||
fn pump_until(timeout: std::time::Duration, mut done: impl FnMut() -> bool) {
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let ctx = gtk4::glib::MainContext::default();
|
|
||||||
while !done() && start.elapsed() < timeout {
|
|
||||||
while ctx.iteration(false) {}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn watch_arms_a_monitor_for_the_currently_active_theme() {
|
|
||||||
let xdg = isolated_xdg();
|
|
||||||
write_theme(&xdg, "liquid-motion", "id = \"liquid-motion\"\n");
|
|
||||||
let watch = watch(|_| {});
|
|
||||||
assert!(
|
|
||||||
watch._theme_monitor.borrow().is_some(),
|
|
||||||
"watch() should arm a monitor for the active theme's directory"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
watch._config_monitor.is_some(),
|
|
||||||
"watch() should arm a monitor for the shell config directory too"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editing_the_active_theme_directory_fires_the_callback() {
|
|
||||||
let xdg = isolated_xdg();
|
|
||||||
write_theme(&xdg, "custom", "id = \"custom\"\n[tokens]\npad = 1\n");
|
|
||||||
write_shell_toml(&xdg, "custom");
|
|
||||||
|
|
||||||
let calls = Rc::new(RefCell::new(0));
|
|
||||||
let calls2 = calls.clone();
|
|
||||||
let _watch = watch(move |_| *calls2.borrow_mut() += 1);
|
|
||||||
|
|
||||||
write_theme(&xdg, "custom", "id = \"custom\"\n[tokens]\npad = 2\n");
|
|
||||||
pump_until(std::time::Duration::from_secs(3), || *calls.borrow() > 0);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
*calls.borrow() > 0,
|
|
||||||
"editing the active theme's own directory should fire the watch callback"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn switching_the_active_theme_re_arms_onto_the_new_directory() {
|
|
||||||
// This is the coordination gap the fix closes: before it, `watch()`
|
|
||||||
// resolved the active theme's directory once and pinned a monitor
|
|
||||||
// there forever, so a `shell.toml` `active` switch left the OLD
|
|
||||||
// theme's edits observed and the NEW theme's edits invisible until
|
|
||||||
// a restart.
|
|
||||||
let xdg = isolated_xdg();
|
|
||||||
write_theme(&xdg, "theme-a", "id = \"theme-a\"\n[tokens]\npad = 1\n");
|
|
||||||
write_theme(&xdg, "theme-b", "id = \"theme-b\"\n[tokens]\npad = 1\n");
|
|
||||||
write_shell_toml(&xdg, "theme-a");
|
|
||||||
|
|
||||||
let seen_ids = Rc::new(RefCell::new(Vec::<String>::new()));
|
|
||||||
let seen_ids2 = seen_ids.clone();
|
|
||||||
let _watch = watch(move |theme| seen_ids2.borrow_mut().push(theme.id().to_string()));
|
|
||||||
|
|
||||||
// Switch active to theme-b.
|
|
||||||
write_shell_toml(&xdg, "theme-b");
|
|
||||||
pump_until(std::time::Duration::from_secs(3), || {
|
|
||||||
seen_ids.borrow().iter().any(|id| id == "theme-b")
|
|
||||||
});
|
|
||||||
assert!(
|
|
||||||
seen_ids.borrow().iter().any(|id| id == "theme-b"),
|
|
||||||
"switching shell.toml's active id should fire the callback with the new theme: {:?}",
|
|
||||||
seen_ids.borrow()
|
|
||||||
);
|
|
||||||
|
|
||||||
seen_ids.borrow_mut().clear();
|
|
||||||
|
|
||||||
// Now edit theme-b's own directory — the re-armed watch must see it.
|
|
||||||
write_theme(&xdg, "theme-b", "id = \"theme-b\"\n[tokens]\npad = 2\n");
|
|
||||||
pump_until(std::time::Duration::from_secs(3), || !seen_ids.borrow().is_empty());
|
|
||||||
assert!(
|
|
||||||
!seen_ids.borrow().is_empty(),
|
|
||||||
"after switching, editing the NEW active theme's directory must fire the callback"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn monitor_dir_degrades_gracefully_instead_of_panicking() {
|
|
||||||
// A directory that cannot possibly be created (its parent is a
|
|
||||||
// plain file, not a directory) — `create_dir_all` fails, and this
|
|
||||||
// must not panic either way `monitor_directory` then behaves.
|
|
||||||
let xdg = isolated_xdg();
|
|
||||||
let blocker = xdg.dir.join("not-a-directory");
|
|
||||||
std::fs::write(&blocker, b"x").unwrap();
|
|
||||||
let impossible = blocker.join("child").join("grandchild");
|
|
||||||
|
|
||||||
// Must not panic; may or may not return a monitor depending on
|
|
||||||
// glib's own behavior for a directory that doesn't exist yet, but
|
|
||||||
// this call itself is infallible from the caller's point of view.
|
|
||||||
let _ = monitor_dir(&impossible, "test");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,601 +0,0 @@
|
||||||
//! `theme.toml` deserialization, validation, and resolution into
|
|
||||||
//! [`crate::shell::ShellTheme`].
|
|
||||||
//!
|
|
||||||
//! Two layers on purpose: `Raw*` types mirror the TOML shape exactly (every
|
|
||||||
//! field optional, `deny_unknown_fields` everywhere so a typo'd key is a
|
|
||||||
//! hard error naming that key rather than a silent no-op) and know nothing
|
|
||||||
//! about defaults; [`RawManifest::resolve`] is the one place defaults get
|
|
||||||
//! filled and string enums get validated, producing the fully-resolved
|
|
||||||
//! types in `types.rs`.
|
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Context};
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
|
||||||
|
|
||||||
use super::types::*;
|
|
||||||
|
|
||||||
/// Module names a slot entry may reference without recompiling anything —
|
|
||||||
/// plan §2 tier 1/2 (declarative slots) plus the `widget:*` escape hatch
|
|
||||||
/// (tier 3, validated separately since its suffix is open-ended). This is
|
|
||||||
/// intentionally the set the *current* theme and the plan's own schema
|
|
||||||
/// example use; Phase 3 (breadbar's module registry) is the place a new
|
|
||||||
/// built-in module name gets added for real.
|
|
||||||
const KNOWN_MODULES: &[&str] = &[
|
|
||||||
"workspaces",
|
|
||||||
"media",
|
|
||||||
"clock",
|
|
||||||
"volume",
|
|
||||||
"wifi",
|
|
||||||
"battery",
|
|
||||||
"control",
|
|
||||||
"launcher_entry",
|
|
||||||
"launcher_results",
|
|
||||||
// `02-glass-workbench` (plan §11 phase 5): plain right-side stat chips,
|
|
||||||
// reusing the same `AppInput::StatsUpdate` data the control panel's
|
|
||||||
// sys-grid already receives — see breadbar's `bar::slots` module docs.
|
|
||||||
"cpu",
|
|
||||||
"ram",
|
|
||||||
];
|
|
||||||
|
|
||||||
pub(super) fn validate_module_name(theme_id: &str, slot: &str, module: &str) -> anyhow::Result<()> {
|
|
||||||
if module.starts_with("widget:") || KNOWN_MODULES.contains(&module) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
bail!(
|
|
||||||
"theme '{theme_id}': slot \"{slot}\" references unknown module \"{module}\" \
|
|
||||||
(known modules: {}, or widget:<lua-module-name>)",
|
|
||||||
KNOWN_MODULES.join(", ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn validate_slots(raw: &RawManifest, theme_id: &str) -> anyhow::Result<()> {
|
|
||||||
let Some(bar) = &raw.bar else { return Ok(()) };
|
|
||||||
let Some(slots) = &bar.slots else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
for (slot_name, list) in [
|
|
||||||
("left", &slots.left),
|
|
||||||
("centre", &slots.centre),
|
|
||||||
("right", &slots.right),
|
|
||||||
("drawer", &slots.drawer),
|
|
||||||
] {
|
|
||||||
for module in list {
|
|
||||||
validate_module_name(theme_id, slot_name, module)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub(super) struct RawManifest {
|
|
||||||
pub(super) name: Option<String>,
|
|
||||||
pub(super) id: Option<String>,
|
|
||||||
/// Present only so `extends` deserializes as a *known* field (otherwise
|
|
||||||
/// `deny_unknown_fields` would reject every theme that sets it). The
|
|
||||||
/// value itself is read straight off the raw `toml::Value` in
|
|
||||||
/// `mod.rs::resolve_theme` — before this struct exists — since the
|
|
||||||
/// merge has to happen ahead of (and separately from) deserialization.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(super) extends: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub(super) tokens: HashMap<String, toml::Value>,
|
|
||||||
pub(super) bar: Option<RawBar>,
|
|
||||||
pub(super) modules: Option<RawModules>,
|
|
||||||
pub(super) launcher: Option<RawLauncher>,
|
|
||||||
pub(super) surfaces: Option<HashMap<String, RawSurface>>,
|
|
||||||
pub(super) compositor: Option<HashMap<String, RawLayerRule>>,
|
|
||||||
/// Overlay CSS path, resolved relative to the theme file's own
|
|
||||||
/// directory, appended last by `ShellTheme::css`. Declared-but-not-yet-
|
|
||||||
/// consumed in production: `ShellTheme::css` (the only reader of this
|
|
||||||
/// field's resolved `extra_css`) is not called by breadbar or breadbox
|
|
||||||
/// today — see that method's doc comment. Setting this key is currently
|
|
||||||
/// a no-op for a real running shell (it IS exercised by this crate's own
|
|
||||||
/// tests). (Schema note: plan §4
|
|
||||||
/// shows `css = "extra.css"` textually after the `[compositor]` table
|
|
||||||
/// with no table header of its own between them, which in real TOML
|
|
||||||
/// would nest it *inside* `[compositor]`. Treated here as a top-level
|
|
||||||
/// field per §5's `css()` doc — see this crate's implementation notes.)
|
|
||||||
pub(super) css: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub(super) struct RawBar {
|
|
||||||
pub(super) window: Option<RawWindow>,
|
|
||||||
pub(super) slots: Option<RawSlots>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub(super) struct RawWindow {
|
|
||||||
pub(super) anchors: Option<Vec<String>>,
|
|
||||||
pub(super) width: Option<RawSize>,
|
|
||||||
pub(super) height: Option<i64>,
|
|
||||||
pub(super) margin: Option<RawMargin>,
|
|
||||||
pub(super) exclusive: Option<RawExclusive>,
|
|
||||||
pub(super) keyboard: Option<String>,
|
|
||||||
pub(super) layer: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub(super) enum RawSize {
|
|
||||||
Named(String),
|
|
||||||
Px(i64),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub(super) enum RawExclusive {
|
|
||||||
Named(String),
|
|
||||||
Px(i64),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, serde::Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields, default)]
|
|
||||||
pub(super) struct RawMargin {
|
|
||||||
pub(super) top: i64,
|
|
||||||
pub(super) left: i64,
|
|
||||||
pub(super) right: i64,
|
|
||||||
/// See [`super::types::Margin::bottom`] — accepted and resolved, but
|
|
||||||
/// breadbar never applies it.
|
|
||||||
pub(super) bottom: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, serde::Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields, default)]
|
|
||||||
pub(super) struct RawSlots {
|
|
||||||
pub(super) left: Vec<String>,
|
|
||||||
pub(super) centre: Vec<String>,
|
|
||||||
pub(super) right: Vec<String>,
|
|
||||||
pub(super) drawer: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub(super) struct RawModules {
|
|
||||||
pub(super) workspaces: Option<RawWorkspacesModule>,
|
|
||||||
pub(super) clock: Option<RawClockModule>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub(super) struct RawWorkspacesModule {
|
|
||||||
pub(super) style: Option<String>,
|
|
||||||
pub(super) show_empty: Option<bool>,
|
|
||||||
/// `[6, 10, 14, 18]`-shaped — see [`DotWidths`]. A length other than 4
|
|
||||||
/// is a hard error (validated in [`resolve_modules`]) rather than
|
|
||||||
/// silently truncated/padded, matching this file's "typo'd key is a
|
|
||||||
/// hard error" policy for enum-ish values.
|
|
||||||
pub(super) dot_widths: Option<Vec<i64>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub(super) struct RawClockModule {
|
|
||||||
pub(super) style: Option<String>,
|
|
||||||
pub(super) format: Option<String>,
|
|
||||||
pub(super) show_date: Option<bool>,
|
|
||||||
pub(super) placeholder_clock: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub(super) struct RawLauncher {
|
|
||||||
pub(super) mode: Option<String>,
|
|
||||||
pub(super) width: Option<i64>,
|
|
||||||
pub(super) top: Option<String>,
|
|
||||||
pub(super) radius: Option<i64>,
|
|
||||||
pub(super) icon_px: Option<i64>,
|
|
||||||
pub(super) row_anim: Option<String>,
|
|
||||||
pub(super) rule: Option<String>,
|
|
||||||
pub(super) footer: Option<String>,
|
|
||||||
pub(super) sections: Option<bool>,
|
|
||||||
pub(super) modes: Option<Vec<String>>,
|
|
||||||
pub(super) search_width: Option<i64>,
|
|
||||||
pub(super) search_radius: Option<i64>,
|
|
||||||
pub(super) row_radius: Option<i64>,
|
|
||||||
pub(super) row_inset: Option<i64>,
|
|
||||||
pub(super) row_padding_v: Option<i64>,
|
|
||||||
pub(super) row_padding_h: Option<i64>,
|
|
||||||
pub(super) icon_radius: Option<i64>,
|
|
||||||
pub(super) search_font_size: Option<i64>,
|
|
||||||
pub(super) search_padding_v: Option<i64>,
|
|
||||||
pub(super) search_padding_h: Option<i64>,
|
|
||||||
pub(super) panel_alpha: Option<f64>,
|
|
||||||
pub(super) selection_alpha: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub(super) struct RawSurface {
|
|
||||||
pub(super) anchor: Option<String>,
|
|
||||||
pub(super) offset: Option<RawOffset>,
|
|
||||||
pub(super) width: Option<RawSurfaceWidth>,
|
|
||||||
pub(super) layer: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub(super) enum RawOffset {
|
|
||||||
Single(f64),
|
|
||||||
Pair([f64; 2]),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub(super) enum RawSurfaceWidth {
|
|
||||||
Named(String),
|
|
||||||
Px(i64),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, serde::Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields, default)]
|
|
||||||
pub(super) struct RawLayerRule {
|
|
||||||
pub(super) blur: Option<bool>,
|
|
||||||
pub(super) ignore_alpha: Option<f64>,
|
|
||||||
pub(super) blur_popups: Option<bool>,
|
|
||||||
pub(super) animation: Option<String>,
|
|
||||||
pub(super) no_anim: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn token_value(v: &toml::Value) -> anyhow::Result<TokenValue> {
|
|
||||||
match v {
|
|
||||||
toml::Value::String(s) => Ok(TokenValue::Str(s.clone())),
|
|
||||||
toml::Value::Integer(i) => Ok(TokenValue::Int(*i)),
|
|
||||||
toml::Value::Float(f) => Ok(TokenValue::Float(*f)),
|
|
||||||
toml::Value::Boolean(b) => Ok(TokenValue::Bool(*b)),
|
|
||||||
other => Err(anyhow!("must be a string, number, or bool, got {other:?}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_window(theme_id: &str, w: &RawWindow) -> anyhow::Result<WindowSpec> {
|
|
||||||
let default = WindowSpec::default();
|
|
||||||
|
|
||||||
let anchors = match &w.anchors {
|
|
||||||
Some(list) => {
|
|
||||||
for a in list {
|
|
||||||
if !matches!(a.as_str(), "top" | "bottom" | "left" | "right") {
|
|
||||||
bail!(
|
|
||||||
"theme '{theme_id}': bar.window.anchors contains unknown anchor \"{a}\" \
|
|
||||||
(expected top|bottom|left|right)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
list.clone()
|
|
||||||
}
|
|
||||||
None => default.anchors,
|
|
||||||
};
|
|
||||||
|
|
||||||
let width = match &w.width {
|
|
||||||
Some(RawSize::Named(s)) if s == "fill" => Width::Fill,
|
|
||||||
Some(RawSize::Named(other)) => bail!(
|
|
||||||
"theme '{theme_id}': bar.window.width = \"{other}\" is not \"fill\" \
|
|
||||||
(use a bare number for a fixed width)"
|
|
||||||
),
|
|
||||||
Some(RawSize::Px(n)) => Width::Px(*n as i32),
|
|
||||||
None => default.width,
|
|
||||||
};
|
|
||||||
|
|
||||||
let height = w.height.map(|h| h as i32).unwrap_or(default.height);
|
|
||||||
|
|
||||||
let margin = w
|
|
||||||
.margin
|
|
||||||
.as_ref()
|
|
||||||
.map(|m| Margin {
|
|
||||||
top: m.top as i32,
|
|
||||||
left: m.left as i32,
|
|
||||||
right: m.right as i32,
|
|
||||||
bottom: m.bottom as i32,
|
|
||||||
})
|
|
||||||
.unwrap_or(default.margin);
|
|
||||||
|
|
||||||
let exclusive = match &w.exclusive {
|
|
||||||
Some(RawExclusive::Named(s)) if s == "auto" => Exclusive::Auto,
|
|
||||||
Some(RawExclusive::Named(s)) if s == "none" => Exclusive::None,
|
|
||||||
Some(RawExclusive::Named(other)) => bail!(
|
|
||||||
"theme '{theme_id}': bar.window.exclusive = \"{other}\" is not \"auto\" or \"none\" \
|
|
||||||
(use a bare number for a fixed exclusive zone)"
|
|
||||||
),
|
|
||||||
Some(RawExclusive::Px(n)) => Exclusive::Px(*n as i32),
|
|
||||||
None => default.exclusive,
|
|
||||||
};
|
|
||||||
|
|
||||||
let keyboard = match w.keyboard.as_deref() {
|
|
||||||
None => default.keyboard,
|
|
||||||
Some("none") => Keyboard::None,
|
|
||||||
Some("on_demand") => Keyboard::OnDemand,
|
|
||||||
Some("exclusive") => Keyboard::Exclusive,
|
|
||||||
Some(other) => bail!(
|
|
||||||
"theme '{theme_id}': bar.window.keyboard = \"{other}\" is not none|on_demand|exclusive"
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
let layer = match w.layer.as_deref() {
|
|
||||||
None => default.layer,
|
|
||||||
Some("top") => "top".to_string(),
|
|
||||||
Some("overlay") => "overlay".to_string(),
|
|
||||||
Some(other) => {
|
|
||||||
bail!("theme '{theme_id}': bar.window.layer = \"{other}\" is not top|overlay")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(WindowSpec {
|
|
||||||
anchors,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
margin,
|
|
||||||
exclusive,
|
|
||||||
keyboard,
|
|
||||||
layer,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_modules(theme_id: &str, m: Option<&RawModules>) -> anyhow::Result<Modules> {
|
|
||||||
let ws = m.and_then(|m| m.workspaces.as_ref());
|
|
||||||
let style = match ws.and_then(|w| w.style.as_deref()) {
|
|
||||||
None => WorkspaceStyle::Trail,
|
|
||||||
Some("trail") => WorkspaceStyle::Trail,
|
|
||||||
Some("pill") => WorkspaceStyle::Pill,
|
|
||||||
Some("dots") => WorkspaceStyle::Dots,
|
|
||||||
Some(other) => bail!(
|
|
||||||
"theme '{theme_id}': modules.workspaces.style = \"{other}\" is not trail|pill|dots"
|
|
||||||
),
|
|
||||||
};
|
|
||||||
let show_empty = ws.and_then(|w| w.show_empty).unwrap_or(true);
|
|
||||||
let dot_widths = match ws.and_then(|w| w.dot_widths.as_ref()) {
|
|
||||||
None => DEFAULT_DOT_WIDTHS,
|
|
||||||
Some(v) if v.len() == 4 => [v[0] as i32, v[1] as i32, v[2] as i32, v[3] as i32],
|
|
||||||
Some(v) => bail!(
|
|
||||||
"theme '{theme_id}': modules.workspaces.dot_widths has {} entries, expected 4 \
|
|
||||||
(0/1/2/3-or-more open windows)",
|
|
||||||
v.len()
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
let ck = m.and_then(|m| m.clock.as_ref());
|
|
||||||
let cstyle = match ck.and_then(|c| c.style.as_deref()) {
|
|
||||||
None => ClockStyle::Flip,
|
|
||||||
Some("flip") => ClockStyle::Flip,
|
|
||||||
Some("plain") => ClockStyle::Plain,
|
|
||||||
Some("none") => ClockStyle::None,
|
|
||||||
Some(other) => {
|
|
||||||
bail!("theme '{theme_id}': modules.clock.style = \"{other}\" is not flip|plain|none")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let format = ck
|
|
||||||
.and_then(|c| c.format.clone())
|
|
||||||
.unwrap_or_else(|| "%H:%M".to_string());
|
|
||||||
let show_date = ck.and_then(|c| c.show_date).unwrap_or(false);
|
|
||||||
let placeholder_clock = ck.and_then(|c| c.placeholder_clock).unwrap_or(false);
|
|
||||||
|
|
||||||
Ok(Modules {
|
|
||||||
workspaces: WorkspacesModule {
|
|
||||||
style,
|
|
||||||
show_empty,
|
|
||||||
dot_widths,
|
|
||||||
},
|
|
||||||
clock: ClockModule {
|
|
||||||
style: cstyle,
|
|
||||||
format,
|
|
||||||
show_date,
|
|
||||||
placeholder_clock,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_launcher(theme_id: &str, l: Option<&RawLauncher>) -> anyhow::Result<Launcher> {
|
|
||||||
let mode = match l.and_then(|l| l.mode.as_deref()) {
|
|
||||||
None => LauncherMode::Overlay,
|
|
||||||
Some("overlay") => LauncherMode::Overlay,
|
|
||||||
Some("embedded") => LauncherMode::Embedded,
|
|
||||||
Some(other) => {
|
|
||||||
bail!("theme '{theme_id}': launcher.mode = \"{other}\" is not overlay|embedded")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let width = l.and_then(|l| l.width).unwrap_or(540) as i32;
|
|
||||||
let radius = l.and_then(|l| l.radius).unwrap_or(20) as i32;
|
|
||||||
Ok(Launcher {
|
|
||||||
mode,
|
|
||||||
width,
|
|
||||||
top: l
|
|
||||||
.and_then(|l| l.top.clone())
|
|
||||||
.unwrap_or_else(|| "16%".to_string()),
|
|
||||||
radius,
|
|
||||||
icon_px: l.and_then(|l| l.icon_px).unwrap_or(36) as i32,
|
|
||||||
row_anim: l
|
|
||||||
.and_then(|l| l.row_anim.clone())
|
|
||||||
.unwrap_or_else(|| "flip".to_string()),
|
|
||||||
rule: l
|
|
||||||
.and_then(|l| l.rule.clone())
|
|
||||||
.unwrap_or_else(|| "gradient".to_string()),
|
|
||||||
footer: l
|
|
||||||
.and_then(|l| l.footer.clone())
|
|
||||||
.unwrap_or_else(|| "count_apps".to_string()),
|
|
||||||
sections: l.and_then(|l| l.sections).unwrap_or(false),
|
|
||||||
modes: l
|
|
||||||
.and_then(|l| l.modes.clone())
|
|
||||||
.unwrap_or_else(|| vec!["apps".to_string()]),
|
|
||||||
// Default to the idle value — a theme that never sets these gets
|
|
||||||
// "no change while searching", not a hardcoded widen/shrink it
|
|
||||||
// never asked for (see the field docs on `Launcher`).
|
|
||||||
search_width: l.and_then(|l| l.search_width).map(|v| v as i32).unwrap_or(width),
|
|
||||||
search_radius: l.and_then(|l| l.search_radius).map(|v| v as i32).unwrap_or(radius),
|
|
||||||
// Defaults below reproduce breadbox's pre-redesign hardcoded CSS
|
|
||||||
// literals (`row { padding: 8px 12px; border-radius: 6px; }`,
|
|
||||||
// `listbox { padding: 4px; }`, no icon swatch, a solid — not
|
|
||||||
// alpha-blended — selection fill), so a theme that omits this whole
|
|
||||||
// block of new keys (spotlight does; its capsule doesn't read them
|
|
||||||
// at all) renders the same as before this change, not a jump to
|
|
||||||
// either built-in's specific numbers.
|
|
||||||
row_radius: l.and_then(|l| l.row_radius).unwrap_or(6) as i32,
|
|
||||||
row_inset: l.and_then(|l| l.row_inset).unwrap_or(4) as i32,
|
|
||||||
row_padding_v: l.and_then(|l| l.row_padding_v).unwrap_or(8) as i32,
|
|
||||||
row_padding_h: l.and_then(|l| l.row_padding_h).unwrap_or(12) as i32,
|
|
||||||
icon_radius: l.and_then(|l| l.icon_radius).unwrap_or(0) as i32,
|
|
||||||
search_font_size: l.and_then(|l| l.search_font_size).unwrap_or(16) as i32,
|
|
||||||
search_padding_v: l.and_then(|l| l.search_padding_v).unwrap_or(12) as i32,
|
|
||||||
search_padding_h: l.and_then(|l| l.search_padding_h).unwrap_or(16) as i32,
|
|
||||||
// 0.93 default, not breadbox's historical 0.60: a launcher panel needs
|
|
||||||
// near-opacity to stay legible over a bright wallpaper.
|
|
||||||
panel_alpha: l.and_then(|l| l.panel_alpha).unwrap_or(0.93),
|
|
||||||
selection_alpha: l.and_then(|l| l.selection_alpha).unwrap_or(0.24),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_surfaces(
|
|
||||||
theme_id: &str,
|
|
||||||
raw: Option<&HashMap<String, RawSurface>>,
|
|
||||||
) -> anyhow::Result<BTreeMap<String, Surface>> {
|
|
||||||
let mut out = BTreeMap::new();
|
|
||||||
let Some(raw) = raw else { return Ok(out) };
|
|
||||||
for (namespace, s) in raw {
|
|
||||||
let anchor = match s.anchor.as_deref() {
|
|
||||||
Some(a @ ("top_right" | "bottom_right" | "bottom_centre" | "fill")) => a.to_string(),
|
|
||||||
Some(other) => bail!(
|
|
||||||
"theme '{theme_id}': surfaces.{namespace}.anchor = \"{other}\" is not \
|
|
||||||
top_right|bottom_right|bottom_centre|fill (the only shapes breadbar's \
|
|
||||||
satellite windows implement — see breadbar/src/surface.rs)"
|
|
||||||
),
|
|
||||||
None => bail!(
|
|
||||||
"theme '{theme_id}': surfaces.{namespace} has no anchor set \
|
|
||||||
(expected one of top_right|bottom_centre|fill)"
|
|
||||||
),
|
|
||||||
};
|
|
||||||
let offset = match &s.offset {
|
|
||||||
None => vec![],
|
|
||||||
Some(RawOffset::Single(v)) => vec![*v],
|
|
||||||
Some(RawOffset::Pair(v)) => v.to_vec(),
|
|
||||||
};
|
|
||||||
let width = match &s.width {
|
|
||||||
None => SurfaceWidth::Auto,
|
|
||||||
Some(RawSurfaceWidth::Named(n)) if n == "fill" => SurfaceWidth::Fill,
|
|
||||||
Some(RawSurfaceWidth::Named(n)) if n == "auto" => SurfaceWidth::Auto,
|
|
||||||
Some(RawSurfaceWidth::Named(other)) => bail!(
|
|
||||||
"theme '{theme_id}': surfaces.{namespace}.width = \"{other}\" is not \"fill\" or \"auto\" \
|
|
||||||
(use a bare number for a fixed width)"
|
|
||||||
),
|
|
||||||
Some(RawSurfaceWidth::Px(n)) => SurfaceWidth::Px(*n as i32),
|
|
||||||
};
|
|
||||||
let layer = match s.layer.as_deref() {
|
|
||||||
None | Some("overlay") => "overlay".to_string(),
|
|
||||||
Some("top") => "top".to_string(),
|
|
||||||
Some(other) => bail!(
|
|
||||||
"theme '{theme_id}': surfaces.{namespace}.layer = \"{other}\" is not top|overlay"
|
|
||||||
),
|
|
||||||
};
|
|
||||||
out.insert(
|
|
||||||
namespace.clone(),
|
|
||||||
Surface {
|
|
||||||
anchor,
|
|
||||||
offset,
|
|
||||||
width,
|
|
||||||
layer,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_compositor(raw: Option<&HashMap<String, RawLayerRule>>) -> BTreeMap<String, LayerRule> {
|
|
||||||
let mut out = BTreeMap::new();
|
|
||||||
let Some(raw) = raw else { return out };
|
|
||||||
for (namespace, r) in raw {
|
|
||||||
out.insert(
|
|
||||||
namespace.clone(),
|
|
||||||
LayerRule {
|
|
||||||
blur: r.blur.unwrap_or(false),
|
|
||||||
ignore_alpha: r.ignore_alpha,
|
|
||||||
blur_popups: r.blur_popups.unwrap_or(false),
|
|
||||||
animation: r.animation.clone(),
|
|
||||||
no_anim: r.no_anim.unwrap_or(false),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RawManifest {
|
|
||||||
/// Fill every default and validate every enum-ish string, producing a
|
|
||||||
/// fully-resolved [`super::ShellTheme`]. `requested_id` is the id this
|
|
||||||
/// manifest was looked up under (used as the id/name fallback when the
|
|
||||||
/// TOML omits `id`/`name`); `css_template` and `extra_css` are threaded
|
|
||||||
/// in by the discovery/extends logic in `mod.rs` since neither is a
|
|
||||||
/// plain TOML field (extra_css is *read from* a TOML field, `css`, but
|
|
||||||
/// resolving that path against the theme's own directory happens in the
|
|
||||||
/// caller, which is the only place that still has the directory handy).
|
|
||||||
pub(super) fn resolve(
|
|
||||||
&self,
|
|
||||||
requested_id: &str,
|
|
||||||
css_template: String,
|
|
||||||
extra_css: Option<String>,
|
|
||||||
) -> anyhow::Result<super::ShellTheme> {
|
|
||||||
let id = self.id.clone().unwrap_or_else(|| requested_id.to_string());
|
|
||||||
let name = self.name.clone().unwrap_or_else(|| id.clone());
|
|
||||||
|
|
||||||
let mut tokens_map = BTreeMap::new();
|
|
||||||
for (k, v) in &self.tokens {
|
|
||||||
let tv = token_value(v).with_context(|| format!("theme '{id}': tokens.{k}"))?;
|
|
||||||
tokens_map.insert(k.clone(), tv);
|
|
||||||
}
|
|
||||||
let tokens = Tokens::from_map(tokens_map);
|
|
||||||
|
|
||||||
let window = match self.bar.as_ref().and_then(|b| b.window.as_ref()) {
|
|
||||||
Some(w) => resolve_window(&id, w)?,
|
|
||||||
None => WindowSpec::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let slots = self
|
|
||||||
.bar
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|b| b.slots.as_ref())
|
|
||||||
.map(|s| Slots {
|
|
||||||
left: s.left.clone(),
|
|
||||||
centre: s.centre.clone(),
|
|
||||||
right: s.right.clone(),
|
|
||||||
drawer: s.drawer.clone(),
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let modules = resolve_modules(&id, self.modules.as_ref())?;
|
|
||||||
let launcher = resolve_launcher(&id, self.launcher.as_ref())?;
|
|
||||||
let surfaces = resolve_surfaces(&id, self.surfaces.as_ref())?;
|
|
||||||
let compositor = resolve_compositor(self.compositor.as_ref());
|
|
||||||
|
|
||||||
Ok(super::ShellTheme {
|
|
||||||
name,
|
|
||||||
id,
|
|
||||||
tokens,
|
|
||||||
window,
|
|
||||||
slots,
|
|
||||||
modules,
|
|
||||||
launcher,
|
|
||||||
surfaces,
|
|
||||||
compositor,
|
|
||||||
css_template,
|
|
||||||
extra_css,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deep-merge `over` onto `base`: tables merge key-by-key recursively;
|
|
||||||
/// anything else (scalars, arrays — including slot lists) is a full
|
|
||||||
/// replacement. This is `extends`'s one-level merge (plan §4/§11):
|
|
||||||
/// `mod.rs` calls this exactly once per `load_named`, with the base's own
|
|
||||||
/// `extends` key already stripped by the caller so a chain can't go deeper
|
|
||||||
/// than one level.
|
|
||||||
pub(super) fn merge_values(base: toml::Value, over: toml::Value) -> toml::Value {
|
|
||||||
match (base, over) {
|
|
||||||
(toml::Value::Table(mut base_t), toml::Value::Table(over_t)) => {
|
|
||||||
for (k, v) in over_t {
|
|
||||||
let merged = match base_t.remove(&k) {
|
|
||||||
Some(existing) => merge_values(existing, v),
|
|
||||||
None => v,
|
|
||||||
};
|
|
||||||
base_t.insert(k, merged);
|
|
||||||
}
|
|
||||||
toml::Value::Table(base_t)
|
|
||||||
}
|
|
||||||
(_, over) => over,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,590 +0,0 @@
|
||||||
//! Fully-resolved shell theme types — see `bread-theme/src/shell/mod.rs` for
|
|
||||||
//! the module overview and `manifest.rs` for how these are built from TOML.
|
|
||||||
//!
|
|
||||||
//! Every type here has all defaults filled in; there is no further "is this
|
|
||||||
//! set" branching once a `ShellTheme` exists. That resolution work happens
|
|
||||||
//! once, in `manifest.rs`, so consumers (breadbar, breadbox, bos-settings)
|
|
||||||
//! never have to know the manifest format at all.
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
/// Workspace strip rendering. Phase 1 ships only `Trail` (what breadbar draws
|
|
||||||
/// today); `Pill`/`Dots` exist now so 02/04 (plan §11 phases 5-6) are additive.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum WorkspaceStyle {
|
|
||||||
Trail,
|
|
||||||
Pill,
|
|
||||||
Dots,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clock rendering. Phase 1 ships only `Flip` (today's per-digit flip clock).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ClockStyle {
|
|
||||||
Flip,
|
|
||||||
Plain,
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dot pill widths (px) for 0/1/2/3-or-more open windows, `style = "dots"`
|
|
||||||
/// (theme 04/spotlight). Index 3 covers "3 or more" — the demo's own dots
|
|
||||||
/// never grow past that fourth width. Unused by `Trail`/`Pill`.
|
|
||||||
pub type DotWidths = [i32; 4];
|
|
||||||
|
|
||||||
/// The demo's own numbers (`04-spotlight.html`'s `.dots button[data-n="N"]`
|
|
||||||
/// rules) — the default a theme gets if it sets `style = "dots"` but omits
|
|
||||||
/// `dot_widths`.
|
|
||||||
pub const DEFAULT_DOT_WIDTHS: DotWidths = [6, 10, 14, 18];
|
|
||||||
|
|
||||||
/// How the launcher attaches to the shell. Phase 1 ships only `Overlay`
|
|
||||||
/// (breadbox's own window); `Embedded` is theme 04's bar-drawer launcher.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum LauncherMode {
|
|
||||||
Overlay,
|
|
||||||
Embedded,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `gtk4_layer_shell::KeyboardMode` mirror, kept independent of the `gtk`
|
|
||||||
/// feature so the manifest types stay usable without GTK linked in (bread,
|
|
||||||
/// breadcrumbs). Values map 1:1 onto `KeyboardMode::{None,Exclusive,OnDemand}`
|
|
||||||
/// (verified against gtk4-layer-shell 0.8.1's `src/auto/enums.rs`).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Keyboard {
|
|
||||||
None,
|
|
||||||
OnDemand,
|
|
||||||
Exclusive,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `bar.window.width` / a surface's `width`: `"fill"` spans the anchored
|
|
||||||
/// edges, a bare number is a fixed/centred/hug width.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum Width {
|
|
||||||
Fill,
|
|
||||||
Px(i32),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `bar.window.exclusive`: `"auto"` reserves `height + margin.top`, `"none"`
|
|
||||||
/// reserves nothing (theme 04's capsule), or a literal pixel override.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum Exclusive {
|
|
||||||
Auto,
|
|
||||||
None,
|
|
||||||
Px(i32),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A satellite surface's width: unlike the bar window, a satellite can also
|
|
||||||
/// be `Auto` — sized by its own content/CSS with no `set_default_width` call
|
|
||||||
/// at all. `breadbar-panel` is exactly this today (popover content decides
|
|
||||||
/// its width via `.control-panel-inner`/`.wifi-popover-inner` min-width, not
|
|
||||||
/// the layer-shell window).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum SurfaceWidth {
|
|
||||||
Fill,
|
|
||||||
Auto,
|
|
||||||
Px(i32),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub struct Margin {
|
|
||||||
pub top: i32,
|
|
||||||
pub left: i32,
|
|
||||||
pub right: i32,
|
|
||||||
/// Declared-but-not-yet-consumed: parsed and carried all the way
|
|
||||||
/// through resolution, but breadbar only calls
|
|
||||||
/// `gtk4_layer_shell::LayerShell::set_margin` for
|
|
||||||
/// `Edge::{Top,Left,Right}` (`breadbar/src/main.rs`) — there is no
|
|
||||||
/// `Edge::Bottom` call anywhere in that crate. All three built-in
|
|
||||||
/// themes happen to omit `margin.bottom` (defaulting to 0, this
|
|
||||||
/// struct's own `Default`), which is exactly why the gap has stayed
|
|
||||||
/// invisible: a theme author who *does* set it would see no effect.
|
|
||||||
pub bottom: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `bar.window` — plan §2: window shape is data, not a closed layout enum.
|
|
||||||
/// Island/Edge/Capsule are three *values* of this struct, not three code
|
|
||||||
/// paths.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct WindowSpec {
|
|
||||||
pub anchors: Vec<String>,
|
|
||||||
pub width: Width,
|
|
||||||
pub height: i32,
|
|
||||||
pub margin: Margin,
|
|
||||||
pub exclusive: Exclusive,
|
|
||||||
pub keyboard: Keyboard,
|
|
||||||
pub layer: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WindowSpec {
|
|
||||||
/// Generic baseline for a theme that omits `[bar.window]` entirely —
|
|
||||||
/// deliberately the plan §4 schema example's numbers, not necessarily
|
|
||||||
/// any particular shipped theme's. `liquid-motion` sets every field
|
|
||||||
/// explicitly, so it never falls through to this.
|
|
||||||
fn default() -> Self {
|
|
||||||
WindowSpec {
|
|
||||||
anchors: vec!["top".into(), "left".into(), "right".into()],
|
|
||||||
width: Width::Fill,
|
|
||||||
height: 44,
|
|
||||||
margin: Margin {
|
|
||||||
top: 12,
|
|
||||||
left: 14,
|
|
||||||
right: 14,
|
|
||||||
bottom: 0,
|
|
||||||
},
|
|
||||||
exclusive: Exclusive::Auto,
|
|
||||||
keyboard: Keyboard::None,
|
|
||||||
layer: "top".into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `bar.slots` — plan §2: structure is slots, not layout code. `drawer` is
|
|
||||||
/// the only thing Capsule/theme-04 adds over Island, and it's just an
|
|
||||||
/// (empty, for now) slot list, not a code path.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
||||||
pub struct Slots {
|
|
||||||
pub left: Vec<String>,
|
|
||||||
pub centre: Vec<String>,
|
|
||||||
pub right: Vec<String>,
|
|
||||||
pub drawer: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct WorkspacesModule {
|
|
||||||
pub style: WorkspaceStyle,
|
|
||||||
pub show_empty: bool,
|
|
||||||
/// `style = "dots"` only — see [`DotWidths`]. Trail/Pill ignore this.
|
|
||||||
pub dot_widths: DotWidths,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ClockModule {
|
|
||||||
pub style: ClockStyle,
|
|
||||||
/// Consumed only by `ClockStyle::Plain` (`breadbar::bar::clock`'s
|
|
||||||
/// `formatted()`, called from `main.rs` only in the `Plain` arm) —
|
|
||||||
/// `Flip` and `None` never read it; `Flip`'s own `time()` hardcodes a
|
|
||||||
/// 24h `HH:MM` layout regardless of this field. All three built-in
|
|
||||||
/// themes set `format = "%H:%M"` (which happens to match `Flip`'s
|
|
||||||
/// hardcoded layout, masking the gap for two of the three styles); see
|
|
||||||
/// each `theme.toml`'s own note next to it for which styles actually
|
|
||||||
/// honour this.
|
|
||||||
pub format: String,
|
|
||||||
/// Consumed only by `ClockStyle::Plain`, same scoping as
|
|
||||||
/// [`Self::format`] — `date_lbl` is only ever attached under the
|
|
||||||
/// `Plain` arm's box, so this value can never be observed under
|
|
||||||
/// `Flip`/`None`. Currently harmless in practice (every built-in theme
|
|
||||||
/// that isn't `Plain` sets this `false`, so there's nothing to ignore),
|
|
||||||
/// but the same "declared, scoped to one style" caveat as `format`
|
|
||||||
/// applies.
|
|
||||||
pub show_date: bool,
|
|
||||||
/// `style = "none"` + this `true`: no module renders a clock label of
|
|
||||||
/// its own — `launcher_entry`'s placeholder text becomes the time
|
|
||||||
/// instead (theme 04/spotlight: the capsule's entry IS the clock until
|
|
||||||
/// focused, per `04-spotlight.html`'s `q.placeholder = t`). Meaningless
|
|
||||||
/// for `Flip`/`Plain`, which already show a time some other way.
|
|
||||||
pub placeholder_clock: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Modules {
|
|
||||||
pub workspaces: WorkspacesModule,
|
|
||||||
pub clock: ClockModule,
|
|
||||||
}
|
|
||||||
|
|
||||||
// No `Eq`: `selection_alpha` is an `f64`, which doesn't implement it.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct Launcher {
|
|
||||||
pub mode: LauncherMode,
|
|
||||||
pub width: i32,
|
|
||||||
pub top: String,
|
|
||||||
pub radius: i32,
|
|
||||||
pub icon_px: i32,
|
|
||||||
pub row_anim: String,
|
|
||||||
pub rule: String,
|
|
||||||
/// Selects the footer label's noun — `"count_apps"` renders
|
|
||||||
/// `"{n} applications"`, anything else (`"count_results"` included)
|
|
||||||
/// renders `"{n} results"`. Consumed by `breadbox::main`'s footer label
|
|
||||||
/// (appended below `ResultsList::scroller`, updated on every
|
|
||||||
/// `set_query` call alongside the visible-row count).
|
|
||||||
pub footer: String,
|
|
||||||
/// Consumed by breadbar's embedded capsule (`breadbar::main::run_ui`,
|
|
||||||
/// passed straight to `ResultsList::new`) for every theme, and by
|
|
||||||
/// breadbox's own overlay (`breadbox::main::run_ui`) as of the
|
|
||||||
/// liquid-motion/glass-workbench redesign — `true` groups the idle
|
|
||||||
/// (empty-query) view into "Recent"/"Apps" headers via
|
|
||||||
/// `bread_launcher::gtk::split_sections`, `false` reproduces the flat
|
|
||||||
/// list. breadbox no longer hardcodes `false` at its `ResultsList::new`
|
|
||||||
/// call site; it reads this field.
|
|
||||||
pub sections: bool,
|
|
||||||
pub modes: Vec<String>,
|
|
||||||
/// `LauncherMode::Embedded` only (theme 04/spotlight, plan §7 phase 6c):
|
|
||||||
/// the capsule's own width while a search is in progress
|
|
||||||
/// (`04-spotlight.html`: `.searching .capsule { width: 520px }` vs the
|
|
||||||
/// idle 480px). Defaults to `width` (no widen) for a theme that omits
|
|
||||||
/// it, so an `Overlay`-mode theme — which never reads this field at all
|
|
||||||
/// — and an `Embedded` theme that just doesn't want the widen both fall
|
|
||||||
/// back to "no change" rather than a hardcoded magic number.
|
|
||||||
pub search_width: i32,
|
|
||||||
/// `LauncherMode::Embedded` only: `border-radius` while searching
|
|
||||||
/// (`04-spotlight.html`: `.searching .capsule { border-radius: 20px }`
|
|
||||||
/// vs the collapsed 22px `radius`). Same default-to-`radius` fallback
|
|
||||||
/// reasoning as `search_width`.
|
|
||||||
pub search_radius: i32,
|
|
||||||
/// Result row `border-radius` (px) — `breadbox::main::build_css`'s
|
|
||||||
/// `row { border-radius: ... }`. Liquid Motion's soft 12px vs Glass
|
|
||||||
/// Workbench's dense 6px is the clearest single signal that the two
|
|
||||||
/// themes are different instruments, not one launcher recoloured.
|
|
||||||
pub row_radius: i32,
|
|
||||||
/// Result row horizontal inset (px) from the panel's edge —
|
|
||||||
/// `row { margin: 0 {row_inset}px; }`. Mirrors the demos' `.bx .r`
|
|
||||||
/// `margin: 0 Npx` rule (liquid-motion 8px, glass-workbench 6px).
|
|
||||||
pub row_inset: i32,
|
|
||||||
/// Result row vertical padding (px) — `row { padding: {row_padding_v}px
|
|
||||||
/// {row_padding_h}px; }`'s first component.
|
|
||||||
pub row_padding_v: i32,
|
|
||||||
/// Result row horizontal padding (px) — same rule's second component.
|
|
||||||
pub row_padding_h: i32,
|
|
||||||
/// Row icon `border-radius` (px) — `breadbox::main::build_css`'s
|
|
||||||
/// `image { border-radius: ...; }`, paired with `icon_px` for the
|
|
||||||
/// swatch's size. Liquid Motion's rounder 9px vs Glass Workbench's
|
|
||||||
/// tighter 5px.
|
|
||||||
pub icon_radius: i32,
|
|
||||||
/// Search entry font-size (px) — distinct from `tokens.font_size_base`
|
|
||||||
/// (which sizes the result rows): both demos give the search field a
|
|
||||||
/// larger face than its rows (liquid-motion 16 vs its rows' 14,
|
|
||||||
/// glass-workbench 14 vs its rows' 13).
|
|
||||||
pub search_font_size: i32,
|
|
||||||
/// Search entry vertical padding (px).
|
|
||||||
pub search_padding_v: i32,
|
|
||||||
/// Search entry horizontal padding (px).
|
|
||||||
pub search_padding_h: i32,
|
|
||||||
/// Opacity of the launcher PANEL itself, distinct from `tokens.bg_alpha`
|
|
||||||
/// (which governs the thin bar). A bar can be very translucent and stay
|
|
||||||
/// readable because it is 36-44px tall over a small slice of wallpaper; a
|
|
||||||
/// full launcher panel at the same alpha washes out badly over a bright
|
|
||||||
/// wallpaper and its text becomes hard to read. The approved reference
|
|
||||||
/// uses 0.95 (glass-workbench) and 0.93 (liquid-motion); breadbox
|
|
||||||
/// previously hardcoded 0.60, which is what made it look washed out.
|
|
||||||
pub panel_alpha: f64,
|
|
||||||
/// Selected/hovered row background: `alpha(@accent, selection_alpha)`.
|
|
||||||
/// Liquid Motion's softer 0.22 vs Glass Workbench's denser 0.28 — see
|
|
||||||
/// each demo's `.bx .r.sel` rule.
|
|
||||||
pub selection_alpha: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A satellite surface, keyed by layer-shell namespace in `[surfaces.*]` —
|
|
||||||
/// deliberately the same keyspace as `[compositor.*]` (see module docs)
|
|
||||||
/// rather than a role name, so the two tables can be validated against each
|
|
||||||
/// other and a namespace's positioning and compositor treatment live under
|
|
||||||
/// one lookup.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct Surface {
|
|
||||||
/// One of `top_right`/`bottom_right`/`bottom_centre`/`fill` — validated
|
|
||||||
/// in `manifest.rs::resolve_surfaces` against the shapes
|
|
||||||
/// `breadbar/src/surface.rs::apply` actually implements, the same
|
|
||||||
/// "typo'd key is a hard error" policy this field's siblings
|
|
||||||
/// (`width`, `layer`) already got. Required, not defaulted: a surface
|
|
||||||
/// entry with no anchor at all is as much a hard `theme.toml` error as
|
|
||||||
/// an unrecognized one.
|
|
||||||
///
|
|
||||||
/// `bottom_right` (daylight, plan §11 phase 7) added alongside the
|
|
||||||
/// original three: every built-in theme before it anchored its bar to
|
|
||||||
/// the TOP, so `breadbar-notif`/`breadbar-panel` popping up from
|
|
||||||
/// `top_right` always sat naturally close to the bar. A bottom-anchored
|
|
||||||
/// bar has no shape in the original three that keeps those satellites
|
|
||||||
/// near it — `top_right` would put them at the opposite corner of the
|
|
||||||
/// screen from the dock they visually belong to. `offset` is
|
|
||||||
/// `[right, bottom]` for this anchor, the same two-element convention
|
|
||||||
/// `top_right`'s `[right, top]` already uses.
|
|
||||||
pub anchor: String,
|
|
||||||
pub offset: Vec<f64>,
|
|
||||||
pub width: SurfaceWidth,
|
|
||||||
pub layer: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One `[compositor.*]` entry — plan §9: the per-namespace layer-shell rule
|
|
||||||
/// an app ships as its default and a theme may override. Mirrors the field
|
|
||||||
/// set `hl.layer_rule` actually accepts in `scripts/ui/rules.lua` (blur,
|
|
||||||
/// ignore_alpha, blur_popups, animation, no_anim) — that Lua API isn't in
|
|
||||||
/// hyprland-api.lua's type annotations, so this field set is evidenced by
|
|
||||||
/// working usage, not documentation (plan §12 risk 3).
|
|
||||||
///
|
|
||||||
/// Also `Serialize`: this is the per-namespace shape written to
|
|
||||||
/// `~/.config/hypr/layerrules.json` by `bread_theme::layerrules` (plan §9
|
|
||||||
/// step 3-4), which `scripts/ui/rules.lua` parses back into `hl.layer_rule`
|
|
||||||
/// calls. `Option::None` fields are omitted rather than emitted as `null` —
|
|
||||||
/// the Lua JSON reader treats a missing key and a `null` value identically
|
|
||||||
/// (assigning `nil` into a table key is a no-op), so either encoding is
|
|
||||||
/// correct, but omitting keeps the file legible for hand inspection.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize)]
|
|
||||||
pub struct LayerRule {
|
|
||||||
pub blur: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub ignore_alpha: Option<f64>,
|
|
||||||
pub blur_popups: bool,
|
|
||||||
/// Passed through verbatim to `hl.layer_rule`'s `animation` field
|
|
||||||
/// (`"slide top"`, `"slide bottom"`, …) — kept as a plain string rather
|
|
||||||
/// than a closed enum since `hl.layer_rule`'s own field set is only
|
|
||||||
/// evidenced by working usage in `rules.lua`, not documented (plan §12
|
|
||||||
/// risk 3); a closed Rust enum here would need updating in lockstep
|
|
||||||
/// with Hyprland additions this crate has no way to know about.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub animation: Option<String>,
|
|
||||||
pub no_anim: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A raw TOML scalar carried through to [`Tokens`] for `{name}` substitution
|
|
||||||
/// in [`crate::shell::ShellTheme::css`]. Kept untyped (rather than forcing
|
|
||||||
/// every token into a `String`) so `css()` can format a number without a
|
|
||||||
/// theme author having to quote it, while `bg_alpha = 0.72` etc. still round
|
|
||||||
/// -trips as a real float for any future non-string consumer.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum TokenValue {
|
|
||||||
Str(String),
|
|
||||||
Int(i64),
|
|
||||||
Float(f64),
|
|
||||||
Bool(bool),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TokenValue {
|
|
||||||
/// Textual form used both for `{name}` substitution in CSS and for the
|
|
||||||
/// typed accessors' fallback formatting.
|
|
||||||
pub fn as_css(&self) -> String {
|
|
||||||
match self {
|
|
||||||
TokenValue::Str(s) => s.clone(),
|
|
||||||
TokenValue::Int(i) => i.to_string(),
|
|
||||||
TokenValue::Float(f) => {
|
|
||||||
if f.fract() == 0.0 {
|
|
||||||
format!("{f:.0}")
|
|
||||||
} else {
|
|
||||||
f.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TokenValue::Bool(b) => b.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `[tokens]`, resolved. Deliberately an open bag (`BTreeMap`), not a fixed
|
|
||||||
/// struct: the schema (plan §4) names eleven fields, but a theme may define
|
|
||||||
/// arbitrary extra keys purely for `{name}` substitution in `extra.css`
|
|
||||||
/// (`radius_pill`, `chip_height`, `icon_px`, `spring_settle` below are all
|
|
||||||
/// exactly this — real values `theme.rs::load_css` uses today that the plan
|
|
||||||
/// text's `[tokens]` example didn't list). The named accessors below give
|
|
||||||
/// the documented fields typed access with sensible defaults; [`Tokens::get`]
|
|
||||||
/// and [`Tokens::substitute`] cover everything else.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Default)]
|
|
||||||
pub struct Tokens {
|
|
||||||
pub(crate) map: BTreeMap<String, TokenValue>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Tokens {
|
|
||||||
pub fn from_map(map: BTreeMap<String, TokenValue>) -> Self {
|
|
||||||
Tokens { map }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, key: &str) -> Option<&TokenValue> {
|
|
||||||
self.map.get(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn keys(&self) -> impl Iterator<Item = &str> {
|
|
||||||
self.map.keys().map(|s| s.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn str_or(&self, key: &str, default: &str) -> String {
|
|
||||||
match self.map.get(key) {
|
|
||||||
Some(v) => v.as_css(),
|
|
||||||
None => default.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn int_or(&self, key: &str, default: i64) -> i64 {
|
|
||||||
match self.map.get(key) {
|
|
||||||
Some(TokenValue::Int(i)) => *i,
|
|
||||||
Some(TokenValue::Float(f)) => *f as i64,
|
|
||||||
Some(TokenValue::Str(s)) => s.parse().unwrap_or(default),
|
|
||||||
_ => default,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn float_or(&self, key: &str, default: f64) -> f64 {
|
|
||||||
match self.map.get(key) {
|
|
||||||
Some(TokenValue::Float(f)) => *f,
|
|
||||||
Some(TokenValue::Int(i)) => *i as f64,
|
|
||||||
Some(TokenValue::Str(s)) => s.parse().unwrap_or(default),
|
|
||||||
_ => default,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Same fallback shape as [`Self::str_or`]/[`Self::int_or`]/
|
|
||||||
/// [`Self::float_or`] for a `TokenValue::Bool`. First consumer: `light`
|
|
||||||
/// below (theme 05/daylight, plan §11 phase 7) — every existing token is
|
|
||||||
/// a string/number, this is the first bool-shaped one, hence the new
|
|
||||||
/// helper rather than reusing one of the three above.
|
|
||||||
fn bool_or(&self, key: &str, default: bool) -> bool {
|
|
||||||
match self.map.get(key) {
|
|
||||||
Some(TokenValue::Bool(b)) => *b,
|
|
||||||
Some(TokenValue::Str(s)) => s.parse().unwrap_or(default),
|
|
||||||
_ => default,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Consumed by `breadbox::main::build_css` for the launcher panel's
|
|
||||||
/// `font-family` (the `entry.search`/`row` rule) as of the
|
|
||||||
/// liquid-motion/glass-workbench redesign — combined with
|
|
||||||
/// [`Self::font_fallback`] into a CSS font stack. Still NOT consumed by
|
|
||||||
/// `breadbar` (bar/chip/clock text) or by [`crate::stylesheet`]'s
|
|
||||||
/// ecosystem-wide base rule, which still hardcodes
|
|
||||||
/// `crate::tokens::FONT_FAMILY` for every non-launcher widget — this is
|
|
||||||
/// a scoped, launcher-only wire-up, not the full "per-shell-theme font
|
|
||||||
/// everywhere" replacement a `stylesheet()` owner would need to decide
|
|
||||||
/// on separately.
|
|
||||||
pub fn font_family(&self) -> String {
|
|
||||||
self.str_or("font_family", crate::tokens::FONT_FAMILY)
|
|
||||||
}
|
|
||||||
/// See [`Self::font_family`] — same scoped (launcher-only) consumption,
|
|
||||||
/// appended after `font_family` in the CSS font stack breadbox builds.
|
|
||||||
pub fn font_fallback(&self) -> String {
|
|
||||||
self.str_or("font_fallback", "sans-serif")
|
|
||||||
}
|
|
||||||
/// Consumed by `breadbox::main::build_css` for the result rows'
|
|
||||||
/// `font-size` (the search entry uses `[launcher].search_font_size`
|
|
||||||
/// instead — both demos give the search field a larger face than its
|
|
||||||
/// rows). Still not read by `breadbar`.
|
|
||||||
pub fn font_size_base(&self) -> i64 {
|
|
||||||
self.int_or("font_size_base", crate::tokens::FONT_SIZE_BASE as i64)
|
|
||||||
}
|
|
||||||
pub fn radius_bar(&self) -> i64 {
|
|
||||||
self.int_or("radius_bar", crate::tokens::RADIUS_PRIMARY as i64)
|
|
||||||
}
|
|
||||||
pub fn radius_card(&self) -> i64 {
|
|
||||||
self.int_or("radius_card", crate::tokens::RADIUS_PRIMARY as i64)
|
|
||||||
}
|
|
||||||
pub fn radius_sm(&self) -> i64 {
|
|
||||||
self.int_or("radius_sm", crate::tokens::RADIUS_SECONDARY as i64)
|
|
||||||
}
|
|
||||||
/// Not in the plan §4 schema list, but a named local in
|
|
||||||
/// `theme.rs::load_css` (`radius_pill = "999px"`) alongside the three
|
|
||||||
/// siblings that are. See the [`Tokens`] doc comment.
|
|
||||||
pub fn radius_pill(&self) -> i64 {
|
|
||||||
self.int_or("radius_pill", crate::tokens::RADIUS_PILL as i64)
|
|
||||||
}
|
|
||||||
pub fn pad(&self) -> i64 {
|
|
||||||
self.int_or("pad", crate::tokens::SPACE_MD as i64)
|
|
||||||
}
|
|
||||||
pub fn bg_alpha(&self) -> f64 {
|
|
||||||
self.float_or("bg_alpha", 0.72)
|
|
||||||
}
|
|
||||||
/// The overshoot/bounce curve (`0.22, 1.35, 0.36, 1`) — clock flips,
|
|
||||||
/// pop-ins, the workspace caret draw.
|
|
||||||
pub fn spring(&self) -> String {
|
|
||||||
self.str_or("spring", "cubic-bezier(0.22, 1.35, 0.36, 1)")
|
|
||||||
}
|
|
||||||
/// The settle curve (`0.22, 1.2, 0.36, 1`) — hovers, workspace-btn
|
|
||||||
/// opacity/background transitions, OSD/notification slide-ins. Not in
|
|
||||||
/// the plan §4 schema (which names only `spring`), but `theme.rs` uses
|
|
||||||
/// it just as pervasively as the overshoot curve. See [`Tokens`] doc.
|
|
||||||
pub fn spring_settle(&self) -> String {
|
|
||||||
self.str_or("spring_settle", "cubic-bezier(0.22, 1.2, 0.36, 1)")
|
|
||||||
}
|
|
||||||
pub fn accent_from(&self) -> String {
|
|
||||||
self.str_or("accent_from", "accent")
|
|
||||||
}
|
|
||||||
/// Was declared-but-not-yet-consumed in production through theme 04
|
|
||||||
/// (spotlight): breadbar's hand-rolled Trail CSS hardcoded the literal
|
|
||||||
/// gradient `linear-gradient(90deg, @accent, @teal)` instead of
|
|
||||||
/// substituting `accent_from`/`accent_to`, silently correct only because
|
|
||||||
/// liquid-motion's own `accent_from`/`accent_to` happened to be
|
|
||||||
/// `"accent"`/`"teal"` — the exact two names the hardcode already spelled
|
|
||||||
/// out. Theme 05 (daylight, plan §11 phase 7) is the first Trail-style
|
|
||||||
/// theme to set a *different* pair (`accent_from = accent_to = "teal"`,
|
|
||||||
/// a flat fill, not a gradient), which is what finally forced
|
|
||||||
/// `breadbar::theme::load_css`'s Trail branch to read this method for
|
|
||||||
/// real instead of the two literal names — see that function's own note
|
|
||||||
/// next to `.workspace-trail`.
|
|
||||||
pub fn accent_to(&self) -> String {
|
|
||||||
let from = self.accent_from();
|
|
||||||
self.str_or("accent_to", &from)
|
|
||||||
}
|
|
||||||
/// A second, independent accent for chrome that shouldn't track the
|
|
||||||
/// primary `accent_from`/`accent_to` pair — daylight (plan §11 phase 7)
|
|
||||||
/// is the first theme that needs one: its media-widget equaliser bars
|
|
||||||
/// are warm amber while the workspace trail/active-fill accent is deep
|
|
||||||
/// teal, so one `accent_from` value can no longer describe both.
|
|
||||||
/// Defaults to `accent_from` itself, which reproduces every earlier
|
|
||||||
/// theme's actual rendering byte-for-byte (liquid-motion/glass-
|
|
||||||
/// workbench/spotlight all paint their equaliser with the same accent
|
|
||||||
/// as everything else, and none of them sets this key). Consumed by
|
|
||||||
/// `breadbar::theme::load_css`'s `.media-eq-bar` rule.
|
|
||||||
pub fn accent2(&self) -> String {
|
|
||||||
let from = self.accent_from();
|
|
||||||
self.str_or("accent2", &from)
|
|
||||||
}
|
|
||||||
/// Whether this theme's surfaces are painted ink-on-paper (near-opaque
|
|
||||||
/// LIGHT fills with dark ink) rather than the glass-on-dark look every
|
|
||||||
/// earlier theme assumed — daylight (plan §11 phase 7) is the first
|
|
||||||
/// theme to set this `true`. Defaults `false`, reproducing every
|
|
||||||
/// existing theme's rendering unchanged.
|
|
||||||
///
|
|
||||||
/// Exists because the palette's `bg`/`surface`/`overlay`/`fg` slots are
|
|
||||||
/// NEVER pywal-derived (`bread_theme::palette`'s `FIXED_*` constants —
|
|
||||||
/// deliberately, so a bright wallpaper can't turn the whole UI an
|
|
||||||
/// unreadable light colour by accident) — only the six accent slots
|
|
||||||
/// vary. That means there is no palette token whose value is a light
|
|
||||||
/// "paper" surface for a theme to reference by name; `@bg` is always
|
|
||||||
/// dark, `@on-bg` is always its computed-legible near-white ink.
|
|
||||||
/// `breadbar::theme::load_css` reads this flag to swap which of those
|
|
||||||
/// two *fixed, anti-correlated* tokens plays "surface fill" versus
|
|
||||||
/// "ink" for every translucent card/panel/hover-wash in the stylesheet
|
|
||||||
/// (`@on-bg` as the near-white fill, `@bg` as the near-black ink,
|
|
||||||
/// otherwise the reverse) — see that function's own `panel`/`ink`
|
|
||||||
/// locals. This works only because `@bg`/`@on-bg` are pinned opposite
|
|
||||||
/// constants by construction; it is not a general "pick any light
|
|
||||||
/// surface colour" mechanism, and this doc comment is the canonical
|
|
||||||
/// place that fact is written down (see the task report for the full
|
|
||||||
/// reasoning: the palette schema has no dedicated light-surface token).
|
|
||||||
pub fn light(&self) -> bool {
|
|
||||||
self.bool_or("light", false)
|
|
||||||
}
|
|
||||||
/// Workspace-pill / chip height. Not in the plan §4 schema, but
|
|
||||||
/// `breadbar::CHIP_HEIGHT` (32) today. See [`Tokens`] doc.
|
|
||||||
pub fn chip_height(&self) -> i64 {
|
|
||||||
self.int_or("chip_height", 32)
|
|
||||||
}
|
|
||||||
/// Not in the plan §4 schema, but `breadbar::ICON_PX` (24) today. See
|
|
||||||
/// [`Tokens`] doc.
|
|
||||||
pub fn icon_px(&self) -> i64 {
|
|
||||||
self.int_or("icon_px", 24)
|
|
||||||
}
|
|
||||||
/// Not in the plan §4 schema. `"full"` (default, liquid-motion's island)
|
|
||||||
/// draws a border on all four edges; `"bottom"` (glass-workbench's flush
|
|
||||||
/// edge-to-edge bar, plan §1) draws only the bottom hairline the demo's
|
|
||||||
/// `.bar { border-bottom: 1px solid #ffffff12 }` calls for — a floating
|
|
||||||
/// island's full border would otherwise render as a stray top/side line
|
|
||||||
/// flush against the screen edge. `"segmented"` (daylight, plan §11
|
|
||||||
/// phase 7) is a third value: `window.breadbar` itself gets NO fill,
|
|
||||||
/// border, or radius at all (fully transparent), and the bar's three
|
|
||||||
/// slot-group containers each carry their own pill surface instead (see
|
|
||||||
/// `breadbar::theme::load_css`'s `segmented`/`.bar-segment` locals) —
|
|
||||||
/// this is what lets one bar surface look like three detached floating
|
|
||||||
/// pills rather than one continuous strip. See [`Tokens`] doc; consumed
|
|
||||||
/// by `breadbar::theme::load_css`, not by [`crate::shell::ShellTheme::css`].
|
|
||||||
pub fn bar_border(&self) -> String {
|
|
||||||
self.str_or("bar_border", "full")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace every `{name}` occurrence in `template` with that token's
|
|
||||||
/// [`TokenValue::as_css`] form. Longest names are substituted first
|
|
||||||
/// (mirrors [`crate::resolve_color_names`]) so `{radius}` cannot
|
|
||||||
/// half-consume `{radius_bar}` if a theme happens to define both.
|
|
||||||
/// `@name` palette references are untouched — this only ever looks at
|
|
||||||
/// `{...}` tokens.
|
|
||||||
pub fn substitute(&self, template: &str) -> String {
|
|
||||||
let mut keys: Vec<&String> = self.map.keys().collect();
|
|
||||||
keys.sort_by_key(|k| std::cmp::Reverse(k.len()));
|
|
||||||
let mut out = template.to_string();
|
|
||||||
for k in keys {
|
|
||||||
let value = self.map[k].as_css();
|
|
||||||
out = out.replace(&format!("{{{k}}}"), &value);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -15,7 +15,7 @@ dirs = { workspace = true }
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
|
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
|
||||||
gtk4-layer-shell = { version = "0.8", optional = true }
|
gtk4-layer-shell = { version = "0.8", optional = true }
|
||||||
toml_edit = { version = "0.22", optional = true }
|
toml_edit = { version = "0.22", optional = true }
|
||||||
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0", optional = true }
|
bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional
|
# Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional
|
||||||
|
|
|
||||||
|
|
@ -36,17 +36,10 @@ pub enum Socket {
|
||||||
/// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if
|
/// `HYPRLAND_INSTANCE_SIGNATURE` + `XDG_RUNTIME_DIR`. Returns `None` if
|
||||||
/// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're
|
/// `HYPRLAND_INSTANCE_SIGNATURE` isn't set (Hyprland isn't running, or we're
|
||||||
/// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to
|
/// not inside a Hyprland session) — `XDG_RUNTIME_DIR` falls back to
|
||||||
/// `/run/user/<uid>` (this process's real uid, or the historical
|
/// `/run/user/1000` if unset, matching `breadmon`'s existing fallback.
|
||||||
/// `/run/user/1000` if that can't be read) when unset.
|
|
||||||
pub fn socket_path(kind: Socket) -> Option<PathBuf> {
|
pub fn socket_path(kind: Socket) -> Option<PathBuf> {
|
||||||
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?;
|
||||||
// `XDG_RUNTIME_DIR` is normally `/run/user/<uid>`; when it's unset,
|
let rt = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
|
||||||
// reconstruct that from the process's real uid instead of assuming uid
|
|
||||||
// 1000, so the socket path is right for any account.
|
|
||||||
let rt = env::var("XDG_RUNTIME_DIR")
|
|
||||||
.ok()
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or_else(fallback_runtime_dir);
|
|
||||||
let file = match kind {
|
let file = match kind {
|
||||||
Socket::Request => ".socket.sock",
|
Socket::Request => ".socket.sock",
|
||||||
Socket::Events => ".socket2.sock",
|
Socket::Events => ".socket2.sock",
|
||||||
|
|
@ -54,21 +47,6 @@ pub fn socket_path(kind: Socket) -> Option<PathBuf> {
|
||||||
Some(PathBuf::from(format!("{rt}/hypr/{sig}/{file}")))
|
Some(PathBuf::from(format!("{rt}/hypr/{sig}/{file}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `XDG_RUNTIME_DIR`'s conventional `/run/user/<uid>` value, derived from
|
|
||||||
/// this process's real uid (`Uid:` line in `/proc/self/status`). Falls back
|
|
||||||
/// to the historical `/run/user/1000` if that can't be read.
|
|
||||||
fn fallback_runtime_dir() -> String {
|
|
||||||
let status = std::fs::read_to_string("/proc/self/status").unwrap_or_default();
|
|
||||||
for line in status.lines() {
|
|
||||||
if let Some(rest) = line.strip_prefix("Uid:") {
|
|
||||||
if let Some(uid) = rest.split_whitespace().next() {
|
|
||||||
return format!("/run/user/{uid}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"/run/user/1000".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send `request` (e.g. `"j/activewindow"`, `"j/monitors"`) to the socket1
|
/// Send `request` (e.g. `"j/activewindow"`, `"j/monitors"`) to the socket1
|
||||||
/// IPC socket and return the raw response body. Blocking/synchronous — this
|
/// IPC socket and return the raw response body. Blocking/synchronous — this
|
||||||
/// matches every current consumer (breadbox, breadclip), which call it from
|
/// matches every current consumer (breadbox, breadclip), which call it from
|
||||||
|
|
@ -244,17 +222,6 @@ mod tests {
|
||||||
assert!(win.fullscreen.is_fullscreen());
|
assert!(win.fullscreen.is_fullscreen());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fallback_runtime_dir_is_user_uid_shaped() {
|
|
||||||
let dir = fallback_runtime_dir();
|
|
||||||
assert!(dir.starts_with("/run/user/"), "got {dir}");
|
|
||||||
let uid = dir.trim_start_matches("/run/user/");
|
|
||||||
assert!(
|
|
||||||
!uid.is_empty() && uid.chars().all(|c| c.is_ascii_digit()),
|
|
||||||
"fallback uid is not numeric: {uid}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both env-var-dependent cases share one test function: `set_var`/
|
// Both env-var-dependent cases share one test function: `set_var`/
|
||||||
// `remove_var` are process-global, and cargo runs tests in parallel
|
// `remove_var` are process-global, and cargo runs tests in parallel
|
||||||
// threads by default, so two separate #[test] fns racing on the same
|
// threads by default, so two separate #[test] fns racing on the same
|
||||||
|
|
|
||||||
|
|
@ -80,9 +80,6 @@ pub fn try_acquire(app: &str) -> std::io::Result<Acquire> {
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.create(true)
|
.create(true)
|
||||||
// Never truncate on open: an existing lock file may be held by a live
|
|
||||||
// instance. We only clear it (`set_len(0)`) *after* winning the lock.
|
|
||||||
.truncate(false)
|
|
||||||
.open(&path)?;
|
.open(&path)?;
|
||||||
|
|
||||||
match file.try_lock() {
|
match file.try_lock() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue