diff --git a/.forgejo/workflows/dev-bakery.yml b/.forgejo/workflows/dev-bakery.yml index a7c4bde..f63769e 100644 --- a/.forgejo/workflows/dev-bakery.yml +++ b/.forgejo/workflows/dev-bakery.yml @@ -8,7 +8,6 @@ on: branches: ['main'] paths: - 'bakery/**' - - 'bread-utils/**' - 'Cargo.toml' - 'Cargo.lock' - '.forgejo/workflows/dev-bakery.yml' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index ca941f2..6725e22 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -6,9 +6,6 @@ on: jobs: package: - # PKGBUILD pkgver cannot contain `-`; skip RC tags the same way - # release-bakery.yml does. - if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] container: image: archlinux:latest diff --git a/.forgejo/workflows/rc-bakery.yml b/.forgejo/workflows/rc-bakery.yml index d4e7853..dc7f695 100644 --- a/.forgejo/workflows/rc-bakery.yml +++ b/.forgejo/workflows/rc-bakery.yml @@ -7,9 +7,11 @@ name: beta (rc) bakery on: push: tags: ['v*'] - # No paths: filter. Tag pushes compare against an unrelated commit and - # would skip the RC publish if bakery/** wasn't in that diff; the job - # `if: contains -rc.` is the real gate. + paths: + - 'bakery/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.forgejo/workflows/beta-bakery.yml' jobs: build: @@ -33,7 +35,6 @@ jobs: run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" - echo "VERSION=${VERSION}" >> "$GITHUB_ENV" PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" diff --git a/.forgejo/workflows/rc-bread-theme.yml b/.forgejo/workflows/rc-bread-theme.yml index bd53ba1..c364ce6 100644 --- a/.forgejo/workflows/rc-bread-theme.yml +++ b/.forgejo/workflows/rc-bread-theme.yml @@ -7,9 +7,11 @@ name: beta (rc) bread-theme on: push: tags: ['v*'] - # No paths: filter. Tag pushes compare against an unrelated commit and - # would skip the RC publish if bread-theme/** wasn't in that diff; the - # job `if: contains -rc.` is the real gate. + paths: + - 'bread-theme/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.forgejo/workflows/beta-bread-theme.yml' jobs: build: @@ -30,7 +32,6 @@ jobs: run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" - echo "VERSION=${VERSION}" >> "$GITHUB_ENV" PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64" diff --git a/.gitignore b/.gitignore index e96063c..4c046ac 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,3 @@ # scripts/get.sh for how it's consumed via MINISIGN_SEC_KEY). *.minisign-sec minisign.key - -# Local tool caches — not build output, never belongs in the repo. -# (breadbar already excludes graphify-out; this repo did not, and 111k lines -# of it were swept in by a `git add -A`.) -graphify-out/ -.grok/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 349d06b..cb4d0e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,9 +5,7 @@ are `bakery` (the ecosystem package manager) and `bread-theme` (the shared theming crate). Shared crates that sibling apps pin — not bakery packages of their own — are `bread-utils`, `bread-app`, `bread-onnx`, `bread-screenshots`, and `bread-capture`. `bread-polkit` is an in-tree -session agent: it has `bread-polkit/bakery.toml` so it *can* be published, -but it is not in `registry/bread-ecosystem.toml` (unpublished — not on -the bakery index, not on the BOS ISO). Other ecosystem products +session agent, also not a bakery product. 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 diff --git a/Cargo.lock b/Cargo.lock index a9d191d..feaba0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,14 +138,14 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bakery" -version = "0.7.5" +version = "0.7.4" dependencies = [ "anyhow", "bread-utils", "chrono", "clap", "clap_complete", - "dirs 5.0.1", + "dirs", "fs4", "hex", "minisign-verify", @@ -187,14 +187,14 @@ dependencies = [ [[package]] name = "bread-app" -version = "0.7.5" +version = "0.7.4" dependencies = [ "bread-utils", ] [[package]] name = "bread-capture" -version = "0.7.5" +version = "0.7.4" dependencies = [ "anyhow", "bread-utils", @@ -202,18 +202,9 @@ dependencies = [ "image", ] -[[package]] -name = "bread-launcher" -version = "0.7.5" -dependencies = [ - "bread-utils", - "gtk4", - "serde_json", -] - [[package]] name = "bread-onnx" -version = "0.7.5" +version = "0.7.4" dependencies = [ "anyhow", "bread-utils", @@ -228,7 +219,7 @@ dependencies = [ [[package]] name = "bread-polkit" -version = "0.7.5" +version = "0.7.4" dependencies = [ "anyhow", "bread-app", @@ -243,7 +234,7 @@ dependencies = [ [[package]] name = "bread-screenshots" -version = "0.7.5" +version = "0.7.4" dependencies = [ "anyhow", "bread-utils", @@ -252,36 +243,32 @@ dependencies = [ [[package]] name = "bread-shared" -version = "0.8.0" -source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0#cdd5de8f58e437b3fc6d9b9087eb7b3d0fd09704" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" dependencies = [ - "dirs 6.0.0", + "dirs", "serde", "serde_json", "toml 0.8.23", - "uuid", ] [[package]] name = "bread-theme" -version = "0.7.5" +version = "0.7.4" dependencies = [ - "anyhow", - "dirs 5.0.1", + "dirs", "gtk4", "libadwaita", "serde", "serde_json", - "toml 0.8.23", - "tracing", ] [[package]] name = "bread-utils" -version = "0.7.5" +version = "0.7.4" dependencies = [ "bread-shared", - "dirs 5.0.1", + "dirs", "gtk4", "gtk4-layer-shell", "serde", @@ -623,16 +610,7 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys 0.4.1", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys 0.5.0", + "dirs-sys", ] [[package]] @@ -643,22 +621,10 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users 0.4.6", + "redox_users", "windows-sys 0.48.0", ] -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.2", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -2000,17 +1966,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - [[package]] name = "regex" version = "1.13.1" @@ -2748,7 +2703,6 @@ 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", diff --git a/Cargo.toml b/Cargo.toml index 183d182..10d698a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] -members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit", "bread-launcher"] +members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit"] resolver = "2" [workspace.package] -version = "0.7.5" +version = "0.7.4" edition = "2021" license = "MIT" authors = ["Breadway "] diff --git a/README.md b/README.md index 927c629..34ccf27 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ bread-ecosystem/ ├── bread-theme/ # shared pywal + fixed-dark-base theming crate ├── bread-utils/ # shared plumbing (Hyprland IPC, singleton, XDG, BreadClient, …) ├── bread-app/ # GTK bootstrap new tools should use (app id, singleton, overlay, command listen) -├── bread-polkit/ # themed PolicyKit agent (bakery.toml present; unpublished) +├── bread-polkit/ # themed PolicyKit authentication agent (not a bakery product) ├── bread-onnx/ # shared ONNX runtime helpers ├── bread-screenshots/ # grim capture primitive used by app `--screenshot` modes ├── bread-capture/ # orchestrator that drives those `--screenshot` modes @@ -179,10 +179,8 @@ 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. +identity). Not a wrapper around `polkit-gnome`. Not published via bakery +and not on the BOS ISO lockfile. ```sh cargo run -p bread-polkit diff --git a/bakery/src/install.rs b/bakery/src/install.rs index dd31145..2040e35 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -523,17 +523,8 @@ fn fetch_extract_archive( .into_temp_path(); fetch_verify_write(pkg, filename, sha256, &tmp_archive, "data archive")?; - // `fetch_verify_write` treats a download/checksum/missing-sha failure - // as a soft warning and returns `Ok` *without writing* — but the - // `NamedTempFile` above is already created (0 bytes), so gating on - // mere `exists()` would let an empty file through to `tar tvf`, which - // then bails with a confusing "not in gzip format" error that masks - // the real cause and aborts the whole install. Gate on *non-empty*. - if std::fs::metadata(&tmp_archive) - .map(|m| m.len()) - .unwrap_or(0) - == 0 - { + if !tmp_archive.exists() { + // fetch_verify_write already warned (download/checksum failure). return Ok(()); } @@ -1045,34 +1036,6 @@ mod tests { assert!(err.to_string().contains("unsafe path")); } - #[test] - fn fetch_extract_archive_download_failure_is_non_fatal() { - // A checksum mismatch makes `fetch_verify_write` warn-and-return - // Ok without writing, leaving the (already created) NamedTempFile - // empty. The pre-extraction `tar tvf` must be skipped for an empty - // file rather than bailing, so a failed archive download degrades - // gracefully and does NOT abort the package install (regression - // for the empty-temp-file bug). - let base_url = serve_once(b"definitely not a tar.gz"); - let pkg = test_package(&base_url); - - let dest_dir = tempdir().unwrap(); - let res = fetch_extract_archive( - &pkg, - "content.tar.gz", - &Some("0".repeat(64)), // will not match the served bytes - dest_dir.path(), - ); - assert!( - res.is_ok(), - "a data-archive download/checksum failure must be non-fatal" - ); - assert!( - std::fs::read_dir(dest_dir.path()).unwrap().next().is_none(), - "nothing should be extracted into dest_dir" - ); - } - #[test] fn ensure_safe_component_accepts_plain_names() { assert!(ensure_safe_component("breadhelp", "x").is_ok()); diff --git a/bread-capture/src/main.rs b/bread-capture/src/main.rs index d61c87b..e05a756 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -50,17 +50,6 @@ const TARGETS: &[(&str, &[(&str, &str)])] = &[ ("osd-volume", "osd-volume.png"), ("osd-brightness", "osd-brightness.png"), ("wifi-add-dialog", "wifi-add-dialog.png"), - // Theme 04/spotlight's embedded capsule (only rendered under - // `BREAD_SHELL_THEME=spotlight` — every other theme's [bar.slots] - // never places launcher_entry/launcher_results anywhere). - ("capsule-collapsed", "capsule-collapsed.png"), - ("capsule-expanded", "capsule-expanded.png"), - // Phase 6c: query sections (idle "Recent"/"Apps" headers) and - // the `=` calc mode — see breadbar's own `screenshot::KNOWN_VIEWS` - // doc comment for why the search-state width/radius change - // (item E) doesn't need a view of its own. - ("capsule-sections", "capsule-sections.png"), - ("capsule-calc", "capsule-calc.png"), ], ), ("breadbox", &[("launcher", "launcher.png")]), diff --git a/bread-launcher/Cargo.toml b/bread-launcher/Cargo.toml deleted file mode 100644 index c126c18..0000000 --- a/bread-launcher/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "bread-launcher" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "Headless app-launcher core (desktop-entry discovery, fuzzy matching/ranking, launch history, launching) plus an optional GTK4 results-list widget — the shared logic behind breadbox's overlay window and breadbar's embedded capsule" -repository = "https://git.breadway.dev/Breadway/bread-ecosystem" -keywords = ["launcher", "desktop-entry", "gtk4", "wayland"] - -[dependencies] -serde_json = { workspace = true } -# `do_launch`/`emit_launched` publish a `bread..launched` event over -# breadd's IPC socket after a successful spawn, fire-and-forget — this was -# already breadbox's behaviour (`BreadClient::emit` never blocks or errors -# the launching caller), just relocated. Not optional: launching is core, -# headless functionality, unlike the GTK widget below. -bread-utils = { path = "../bread-utils", features = ["bread-client"] } -gtk4 = { version = "0.11", features = ["v4_12"], optional = true } - -[features] -# Enable the GTK4 results-list widget (`gtk` module): row building, fuzzy -# filtering, match/history sorting, and keyboard-style selection movement. -# Optional so a headless consumer of the matching/ranking/launch core (or a -# future non-GTK host) doesn't have to pull in GTK4. -gtk = ["dep:gtk4"] diff --git a/bread-launcher/src/desktop.rs b/bread-launcher/src/desktop.rs deleted file mode 100644 index 1a205c9..0000000 --- a/bread-launcher/src/desktop.rs +++ /dev/null @@ -1,151 +0,0 @@ -use std::{ - fs::{self, File}, - io::{BufRead, BufReader}, - path::{Path, PathBuf}, -}; - -use crate::paths::app_dirs; - -#[derive(Debug, Clone)] -pub struct DesktopEntry { - /// Desktop file id (the `.desktop` filename, e.g. `firefox.desktop`). - /// Empty only if the path had no file name; callers fall back to `exec`. - pub id: String, - pub name: String, - pub exec: String, - pub icon_name: String, - pub icon_path: Option, // resolved by caller from manifest - pub categories: Vec, - pub wm_class: Option, - pub terminal: bool, -} - -pub fn strip_exec_codes(exec: &str) -> String { - let mut out = String::with_capacity(exec.len()); - let mut chars = exec.chars().peekable(); - while let Some(c) = chars.next() { - if c == '%' { - match chars.peek().copied() { - Some('%') => { - chars.next(); - out.push('%'); - } - Some(n) if n.is_ascii_alphabetic() => { - chars.next(); - } - _ => out.push(c), - } - } else { - out.push(c); - } - } - out -} - -/// Returns `None` for entries that should not be shown (hidden, NoDisplay, non-Application type). -pub fn parse_desktop(path: &Path) -> Option { - let file = File::open(path).ok()?; - let mut in_entry = false; - let mut name: Option = None; - let mut exec: Option = None; - let mut icon: Option = None; - let mut categories: Option = None; - let mut wm_class: Option = None; - let mut app_type: Option = None; - let mut no_display = false; - let mut hidden = false; - let mut terminal = false; - - for line in BufReader::new(file).lines() { - let Ok(raw) = line else { continue }; - let s = raw.trim(); - if s.starts_with('#') || s.is_empty() { - continue; - } - if s.starts_with('[') { - in_entry = s == "[Desktop Entry]"; - continue; - } - if !in_entry { - continue; - } - - if let Some(v) = s.strip_prefix("Name=") { - name.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("Exec=") { - exec.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("Icon=") { - icon.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("Categories=") { - categories.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("StartupWMClass=") { - wm_class.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("Type=") { - app_type.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("NoDisplay=") { - no_display = v == "true"; - } else if let Some(v) = s.strip_prefix("Hidden=") { - hidden = v == "true"; - } else if let Some(v) = s.strip_prefix("Terminal=") { - terminal = v == "true" || v == "1"; - } - } - - if no_display || hidden { - return None; - } - if app_type.as_deref() != Some("Application") { - return None; - } - - let name = name?.trim().to_string(); - let exec = strip_exec_codes(exec?.trim()).trim().to_string(); - if name.is_empty() || exec.is_empty() { - return None; - } - - let icon_name = icon.unwrap_or_default().trim().to_string(); - let cats = categories - .unwrap_or_default() - .split(';') - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); - - let id = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .filter(|s| !s.is_empty()) - .unwrap_or_default(); - - Some(DesktopEntry { - id, - name, - exec, - icon_name, - icon_path: None, - categories: cats, - wm_class: wm_class.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), - terminal, - }) -} - -/// Walk all configured application directories and return deduplicated entries. -/// Entries from later directories (user-local) override those from earlier ones. -pub fn load_all_desktop_entries() -> Vec { - let mut seen: std::collections::HashMap = std::collections::HashMap::new(); - for dir in app_dirs() { - let Ok(entries) = fs::read_dir(&dir) else { continue }; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("desktop") { - continue; - } - let key = entry.file_name().to_string_lossy().into_owned(); - if let Some(app) = parse_desktop(&path) { - seen.insert(key, app); - } - } - } - seen.into_values().collect() -} diff --git a/bread-launcher/src/gtk.rs b/bread-launcher/src/gtk.rs deleted file mode 100644 index d365353..0000000 --- a/bread-launcher/src/gtk.rs +++ /dev/null @@ -1,297 +0,0 @@ -//! GTK4 results-list widget: the "row-building half" of what used to be -//! breadbox's `run_ui` (`THEME_SYSTEM_PLAN.md` §3) — desktop-entry rows, -//! fuzzy filtering, match/history sorting, and keyboard-style selection -//! movement, packaged as [`ResultsList`] so any host window can embed it. -//! breadbox wraps it in a full-screen overlay window today; breadbar's -//! embedded capsule (a later phase) puts the same widget in its drawer slot. - -use std::{cell::RefCell, path::Path, rc::Rc}; - -use gtk4::{ - gdk, gio, - pango::EllipsizeMode, - prelude::*, - Align, Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation, PolicyType, - ScrolledWindow, SelectionMode, -}; - -use crate::desktop::DesktopEntry; -use crate::history::LaunchHistory; -use crate::matching::{fuzzy_matches, fuzzy_score, split_sections}; - -fn make_icon(icon_name: &str, icon_path: Option<&Path>, icon_px: i32) -> Image { - // Try loading from resolved cached path via gio::File - if let Some(path) = icon_path { - let gio_file = gio::File::for_path(path); - if let Ok(texture) = gdk::Texture::from_file(&gio_file) { - let img = Image::new(); - img.set_paintable(Some(&texture)); - img.set_pixel_size(icon_px); - return img; - } - } - // Fall back to GTK icon theme lookup by name - let name = if icon_name.is_empty() { - "application-x-executable" - } else { - icon_name - }; - let img = Image::from_icon_name(name); - img.set_pixel_size(icon_px); - img -} - -fn build_row(entry: &DesktopEntry, idx: u32, icon_px: i32) -> ListBoxRow { - let row = ListBoxRow::new(); - let hbox = GBox::new(Orientation::Horizontal, 0); - hbox.set_margin_start(6); - hbox.set_margin_end(6); - hbox.set_valign(Align::Center); - - let icon = make_icon(&entry.icon_name, entry.icon_path.as_deref(), icon_px); - hbox.append(&icon); - - let name_lbl = Label::new(Some(&entry.name)); - name_lbl.add_css_class("app-name"); - name_lbl.set_xalign(0.0); - name_lbl.set_hexpand(true); - name_lbl.set_ellipsize(EllipsizeMode::End); - hbox.append(&name_lbl); - - if let Some(ref wm) = entry.wm_class { - let wm_lbl = Label::new(Some(wm)); - wm_lbl.add_css_class("app-muted"); - wm_lbl.set_xalign(1.0); - hbox.append(&wm_lbl); - } - - row.set_child(Some(&hbox)); - unsafe { row.set_data("entry", entry.clone()) }; - unsafe { row.set_data("initial_order", idx) }; - row -} - -/// A non-selectable, non-activatable "Recent"/"Apps" label row (plan phase -/// 6c, `[launcher].sections`) — deliberately carries no `"entry"` row data, -/// which is exactly what [`row_entry`] (and everything downstream of it: -/// `set_query`'s filter, `select_next`/`select_prev`'s traversal) already -/// uses to tell a header apart from a real app row. -fn build_header_row(label: &str, idx: u32) -> ListBoxRow { - let row = ListBoxRow::new(); - row.set_selectable(false); - row.set_activatable(false); - row.add_css_class("bread-drawer-section-header"); - let lbl = Label::new(Some(label)); - lbl.add_css_class("section-header-label"); - lbl.set_xalign(0.0); - row.set_child(Some(&lbl)); - unsafe { row.set_data("initial_order", idx) }; - row -} - -/// Reads the [`DesktopEntry`] a row was built from — e.g. from a -/// `ListBox::connect_row_activated` handler, which hands back a row -/// reference rather than going through [`ResultsList::selected_entry`]. -pub fn row_entry(row: &ListBoxRow) -> Option { - unsafe { row.data::("entry").map(|p| p.as_ref().clone()) } -} - -/// A scrollable, filterable, rankable list of desktop-entry rows — the -/// widget breadbox's overlay wraps today and breadbar's capsule will embed -/// next (`THEME_SYSTEM_PLAN.md` §7). A host drives it through -/// [`set_query`](Self::set_query) (wire to a search entry's `changed` -/// signal), [`select_next`](Self::select_next)/[`select_prev`](Self::select_prev) -/// (wire to arrow keys), and reads the current pick via -/// [`selected_entry`](Self::selected_entry) — `list`/`scroller` are exposed -/// directly for anything else a host needs (e.g. `connect_row_activated` -/// for click-to-launch, or placing `scroller` in a slot). -#[derive(Clone)] -pub struct ResultsList { - pub scroller: ScrolledWindow, - pub list: ListBox, - query: Rc>, - history: Rc>, -} - -impl ResultsList { - /// Builds one row per entry (in `entries`' given order — that order is - /// also the fallback sort when the query is empty) and wires up sorting - /// against `history`'s launch counts. - /// - /// `sections` (`[launcher].sections`, plan phase 6c): when true, the - /// idle (empty-query) view groups `entries` into "Recent"/"Apps" - /// [`build_header_row`]s via [`split_sections`] instead of one flat - /// list. Sections disappear the moment a query is typed — `set_query` - /// falls back to the same flat fuzzy-ranked list either way — so this - /// only changes the initial build order and the header rows' presence, - /// never the (unchanged) search behaviour. `false` reproduces the - /// exact pre-phase-6c flat list breadbox's own overlay still uses. - pub fn new( - entries: &[DesktopEntry], - icon_px: i32, - history: Rc>, - sections: bool, - ) -> Self { - let list = ListBox::new(); - list.set_selection_mode(SelectionMode::Browse); - - let mut idx = 0u32; - if sections { - let (recent, apps) = split_sections(entries.to_vec(), &history.borrow()); - if !recent.is_empty() { - list.append(&build_header_row("Recent", idx)); - idx += 1; - for entry in &recent { - list.append(&build_row(entry, idx, icon_px)); - idx += 1; - } - } - if !apps.is_empty() { - list.append(&build_header_row("Apps", idx)); - idx += 1; - for entry in &apps { - list.append(&build_row(entry, idx, icon_px)); - idx += 1; - } - } - } else { - for entry in entries { - list.append(&build_row(entry, idx, icon_px)); - idx += 1; - } - } - - let query: Rc> = Rc::new(RefCell::new(String::new())); - { - let query = Rc::clone(&query); - let history = Rc::clone(&history); - list.set_sort_func(move |row_a, row_b| { - let query = query.borrow(); - if query.is_empty() { - let oa = unsafe { - row_a.data::("initial_order").map_or(u32::MAX, |p| *p.as_ref()) - }; - let ob = unsafe { - row_b.data::("initial_order").map_or(u32::MAX, |p| *p.as_ref()) - }; - return oa.cmp(&ob).into(); - } - // A header row carries no "entry" data — sort it after any - // real row rather than treating the comparison as `Equal`, - // though `set_query` also hides every header outright once - // a query is non-empty, so this only matters for the - // underlying (invisible) list order, never what's shown. - match (row_entry(row_a), row_entry(row_b)) { - (Some(ea), Some(eb)) => { - let sa = fuzzy_score(&query, &ea); - let sb = fuzzy_score(&query, &eb); - let history = history.borrow(); - let ca = history.count(&ea.name); - let cb = history.count(&eb.name); - sa.cmp(&sb) - .then(cb.cmp(&ca)) - .then(ea.name.to_lowercase().cmp(&eb.name.to_lowercase())) - .into() - } - (None, Some(_)) => std::cmp::Ordering::Greater.into(), - (Some(_), None) => std::cmp::Ordering::Less.into(), - (None, None) => std::cmp::Ordering::Equal.into(), - } - }); - } - - let first_real = (0i32..) - .map_while(|i| list.row_at_index(i)) - .find(|r| row_entry(r).is_some()); - if let Some(first) = first_real { - list.select_row(Some(&first)); - } - - let scroller = ScrolledWindow::new(); - scroller.set_policy(PolicyType::Never, PolicyType::Automatic); - scroller.set_max_content_height(480); - scroller.set_propagate_natural_height(true); - scroller.set_child(Some(&list)); - - ResultsList { scroller, list, query, history } - } - - /// Re-filters (fuzzy match against name, `wm_class`, and `exec`) and - /// re-sorts by `query`, then selects the first visible row. A header - /// row (see [`build_header_row`]) only ever shows in the idle - /// (empty-query) browse view — it has no name/`wm_class`/`exec` of its - /// own to filter against. - pub fn set_query(&self, query: &str) { - *self.query.borrow_mut() = query.to_string(); - let mut i = 0i32; - while let Some(row) = self.list.row_at_index(i) { - let vis = match row_entry(&row) { - Some(e) => { - fuzzy_matches(query, &e.name) - || e.wm_class.as_deref().is_some_and(|w| fuzzy_matches(query, w)) - || fuzzy_matches(query, &e.exec) - } - None => query.is_empty(), - }; - row.set_visible(vis); - i += 1; - } - self.list.invalidate_sort(); - let first_vis = (0i32..) - .map_while(|j| self.list.row_at_index(j)) - .find(|r| r.is_visible() && row_entry(r).is_some()); - self.list.select_row(first_vis.as_ref()); - } - - pub fn selected_entry(&self) -> Option { - self.list.selected_row().and_then(|r| row_entry(&r)) - } - - /// Moves the selection to the next visible row, if any. Skips header - /// rows even though they may be visible (the idle browse view) — - /// `set_selectable(false)` alone doesn't stop a programmatic - /// `select_row` call from landing on one. - pub fn select_next(&self) { - let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(-1); - let mut i = cur + 1; - loop { - match self.list.row_at_index(i) { - Some(r) if r.is_visible() && row_entry(&r).is_some() => { - self.list.select_row(Some(&r)); - break; - } - Some(_) => i += 1, - None => break, - } - } - } - - /// Moves the selection to the previous visible row, if any. See - /// [`select_next`](Self::select_next) on skipping header rows. - pub fn select_prev(&self) { - let cur = self.list.selected_row().map(|r| r.index()).unwrap_or(0); - let mut i = cur - 1; - loop { - if i < 0 { - break; - } - match self.list.row_at_index(i) { - Some(r) if r.is_visible() && row_entry(&r).is_some() => { - self.list.select_row(Some(&r)); - break; - } - Some(_) => i -= 1, - None => break, - } - } - } - - /// Records `entry` as launched in the shared history and persists it. - /// Call before actually launching (matching breadbox's original - /// increment-then-launch ordering) — history and launching are separate - /// concerns, so this doesn't call [`crate::do_launch`] itself. - pub fn record_launch(&self, entry: &DesktopEntry) { - self.history.borrow_mut().increment(&entry.name); - self.history.borrow().save(); - } -} diff --git a/bread-launcher/src/history.rs b/bread-launcher/src/history.rs deleted file mode 100644 index d642141..0000000 --- a/bread-launcher/src/history.rs +++ /dev/null @@ -1,126 +0,0 @@ -use std::{collections::HashMap, fs, path::PathBuf}; - -pub struct LaunchHistory { - counts: HashMap, - path: PathBuf, -} - -impl LaunchHistory { - /// `app` picks the cache subdirectory (see [`crate::cache_dir`]) the - /// history file lives in. - pub fn load(app: &str) -> Self { - let path = crate::paths::cache_dir(app).join("history.json"); - let counts = fs::read_to_string(&path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); - LaunchHistory { counts, path } - } - - pub fn count(&self, name: &str) -> u32 { - self.counts.get(name).copied().unwrap_or(0) - } - - pub fn increment(&mut self, name: &str) { - *self.counts.entry(name.to_string()).or_insert(0) += 1; - } - - /// Writes `counts` to `path` as JSON. Best-effort — a broken cache dir - /// (missing parent, full disk, permissions) must not stop the caller - /// from launching anything, so this never returns an error — but it now - /// logs one on failure rather than swallowing it silently. Shared by two - /// hosts (breadbox's overlay and breadbar's embedded capsule, both keyed - /// under [`crate::LAUNCHER_APP`]), so a save failure here silently stops - /// ranking history for both. - pub fn save(&self) { - match serde_json::to_string(&self.counts) { - Ok(json) => { - if let Err(err) = fs::write(&self.path, json) { - eprintln!( - "bread-launcher: failed to save launch history to {}: {err}", - self.path.display() - ); - } - } - Err(err) => { - eprintln!("bread-launcher: failed to serialize launch history: {err}"); - } - } - } - - /// In-memory history with no backing file — [`save`](Self::save) fails - /// (an empty `path` is not writable) and now logs that failure to - /// stderr rather than swallowing it, same as any other broken-path - /// case. Lets a test (or a future in-memory host) control counts - /// directly instead of writing through `~/.cache//history.json`. - #[cfg(test)] - pub(crate) fn from_counts(counts: HashMap) -> Self { - LaunchHistory { - counts, - path: PathBuf::new(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn temp_history_path(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "bread-launcher-history-test-{}-{name}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )); - std::fs::create_dir_all(&dir).unwrap(); - dir.join("history.json") - } - - #[test] - fn save_then_load_round_trips_counts() { - let path = temp_history_path("roundtrip"); - let mut history = LaunchHistory { - counts: HashMap::new(), - path: path.clone(), - }; - history.increment("firefox.desktop"); - history.increment("firefox.desktop"); - history.increment("kitty.desktop"); - history.save(); - - let text = std::fs::read_to_string(&path).expect("save should have written the file"); - let counts: HashMap = serde_json::from_str(&text).unwrap(); - assert_eq!(counts.get("firefox.desktop"), Some(&2)); - assert_eq!(counts.get("kitty.desktop"), Some(&1)); - - // load() from the same path should see the same counts. - let reloaded = LaunchHistory { - counts: serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(), - path: path.clone(), - }; - assert_eq!(reloaded.count("firefox.desktop"), 2); - - let _ = std::fs::remove_dir_all(path.parent().unwrap()); - } - - /// `save()` on an unwritable path (e.g. the parent directory doesn't - /// exist, or `path` is empty) must not panic — it's best-effort, called - /// from a launcher's shutdown path where a hard failure would be worse - /// than a lost history entry. This exercises exactly the failure branch - /// the `eprintln!` above was added for; there is no return value to - /// assert on, so "did not panic" is the contract under test. - #[test] - fn save_to_a_broken_path_does_not_panic() { - let history = LaunchHistory::from_counts(HashMap::from([("x".to_string(), 1)])); - history.save(); - - let history = LaunchHistory { - counts: HashMap::new(), - path: PathBuf::from("/nonexistent-dir/definitely-not-there/history.json"), - }; - history.save(); - } -} diff --git a/bread-launcher/src/icon.rs b/bread-launcher/src/icon.rs deleted file mode 100644 index f6ad616..0000000 --- a/bread-launcher/src/icon.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::{fs, path::PathBuf}; - -pub struct IconCache { - pub dir: PathBuf, -} - -impl IconCache { - /// `app` picks the cache subdirectory (see [`crate::cache_dir`]) — pass - /// the same name across a process's calls so `path_for` and - /// `manifest_path` agree on where icons live. - pub fn new(app: &str) -> Self { - IconCache { dir: crate::paths::cache_dir(app).join("icons") } - } - - pub fn path_for(&self, icon_name: &str) -> PathBuf { - self.dir.join(format!("{}.png", icon_name)) - } - - pub fn manifest_path(app: &str) -> PathBuf { - crate::paths::cache_dir(app).join("manifest.json") - } - - pub fn ensure_dir(&self) -> std::io::Result<()> { - fs::create_dir_all(&self.dir) - } -} diff --git a/bread-launcher/src/launch.rs b/bread-launcher/src/launch.rs deleted file mode 100644 index d035a52..0000000 --- a/bread-launcher/src/launch.rs +++ /dev/null @@ -1,141 +0,0 @@ -use std::{ - env, - path::Path, - process::{Command, Stdio}, -}; - -use bread_utils::bread_client::BreadClient; - -use crate::desktop::DesktopEntry; - -fn pick_terminal() -> String { - if let Ok(t) = env::var("TERMINAL") { - if !t.is_empty() { - return t; - } - } - let path_var = env::var("PATH").unwrap_or_default(); - for t in ["foot", "kitty", "alacritty", "wezterm", "ghostty", "xterm"] { - if path_var.split(':').any(|d| Path::new(d).join(t).exists()) { - return t.to_string(); - } - } - "xterm".to_string() -} - -/// Spawns `entry`'s command (through a terminal if `entry.terminal` is set) -/// and, on a successful spawn, publishes `event` via [`emit_launched`]. -/// -/// `app_id` here is the caller's **bread event-namespace id** (e.g. -/// `"box"` for breadbox) — NOT [`crate::LAUNCHER_APP`] (`"breadbox"`). -/// Those are two different identities that happen to look similar: -/// `LAUNCHER_APP` only picks the shared cache/history directory (see its own -/// doc comment), while `app_id` here is threaded straight into -/// `BreadClient::connect(app_id)` and must be the caller's own namespace, or -/// `BreadClient::emit`'s `validate_app_namespace` check -/// (`event.starts_with("bread.{app_id}.")`) rejects `event` and drops it -/// with only an eprintln — passing `LAUNCHER_APP` here by mistake is exactly -/// that bug. breadbox passes its own `"box"` (see breadbox's `APP_ID`) so -/// its events publish as `bread.box.*`, matching [`emit_launched`]'s doc -/// example below. -pub fn do_launch(entry: &DesktopEntry, app_id: &str, event: &str) { - let cmd = entry.exec.trim(); - let spawned = if entry.terminal { - let term = pick_terminal(); - Command::new(&term) - .args(["-e", "bash", "-c", cmd]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - } else { - Command::new("bash") - .args(["-c", cmd]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - }; - if spawned.is_ok() { - emit_launched(entry, app_id, event); - } -} - -/// Publishes `event` under `app_id`'s bread namespace after a successful -/// spawn — e.g. breadbox calls this with `app_id = "box"` and -/// `event = "bread.box.launched"`, its own namespace. Fire-and-forget and -/// non-fatal (`BreadClient::emit` never blocks or errors this caller) — -/// breadd being absent must never affect launching itself. `app_id` must be -/// the caller's *own* namespace id, not [`crate::LAUNCHER_APP`] — see -/// [`do_launch`]'s doc comment for why those are different identities and -/// what happens if they're confused. -pub fn emit_launched(entry: &DesktopEntry, app_id: &str, event: &str) { - let id = if entry.id.is_empty() { - entry.exec.as_str() - } else { - entry.id.as_str() - }; - BreadClient::connect(app_id).emit( - event, - serde_json::json!({ "id": id, "name": entry.name }), - ); -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Mirrors `bread_shared::apps::validate_app_namespace` exactly - /// (`event.starts_with(&format!("bread.{app}."))`) without pulling in - /// that crate here — this is the one check that decides whether - /// [`emit_launched`]'s event actually gets published. - fn passes_namespace_check(app_id: &str, event: &str) -> bool { - event.starts_with(&format!("bread.{app_id}.")) - } - - #[test] - fn documented_app_id_and_event_pair_passes_the_namespace_check() { - // breadbox's real call site (breadbox/breadbox/src/main.rs): - // APP_ID = "box", LAUNCHED_EVENT = "bread.box.launched". - assert!( - passes_namespace_check("box", "bread.box.launched"), - "do_launch/emit_launched's own doc example must actually pass \ - BreadClient::emit's namespace check" - ); - } - - #[test] - fn launcher_app_is_not_a_valid_app_id_for_the_documented_event() { - // The historical bug this doc fix guards against: passing - // `LAUNCHER_APP` ("breadbox", the cache/history identity) as - // `app_id` instead of the caller's own namespace id ("box") would - // silently drop `bread.box.launched` — event.starts_with( - // "bread.breadbox.") is false for "bread.box.launched". - assert!( - !passes_namespace_check(crate::LAUNCHER_APP, "bread.box.launched"), - "LAUNCHER_APP must NOT satisfy the namespace check for the \ - documented event — if this ever passes, do_launch's doc comment \ - warning about confusing the two identities is wrong" - ); - } - - #[test] - fn emit_launched_does_not_panic_when_breadd_is_unreachable() { - // No daemon is running in a test environment — emit_launched (and - // the BreadClient::emit it wraps) must degrade silently rather than - // panicking or blocking, for both a valid and a namespace-violating - // app_id. - let entry = DesktopEntry { - id: "firefox.desktop".to_string(), - name: "Firefox".to_string(), - exec: "firefox".to_string(), - icon_name: String::new(), - icon_path: None, - categories: vec![], - wm_class: None, - terminal: false, - }; - emit_launched(&entry, "box", "bread.box.launched"); - emit_launched(&entry, crate::LAUNCHER_APP, "bread.box.launched"); - } -} diff --git a/bread-launcher/src/lib.rs b/bread-launcher/src/lib.rs deleted file mode 100644 index 4f5952a..0000000 --- a/bread-launcher/src/lib.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Headless app-launcher core — desktop-entry discovery, fuzzy matching and -//! ranking, launch history, and process launching — plus an optional GTK4 -//! results-list widget behind the `gtk` feature. -//! -//! Lives in `bread-ecosystem`, not an app repo, so breadbar (which must not -//! depend on an app repo) can embed the same launcher logic breadbox's -//! overlay window already wraps: one implementation, two hosts -//! (`THEME_SYSTEM_PLAN.md` §3, §7). -//! -//! Every path/cache/history entry point here takes an explicit `app: &str` -//! rather than hardcoding an app name, so more than one host can use this -//! crate without colliding — see [`cache_dir`]/[`config_dir`]. [`LAUNCHER_APP`] -//! is the one identity every *launcher* host (as opposed to some unrelated -//! future consumer of `cache_dir`/`config_dir`) should actually pass — see -//! its own doc comment for why. - -mod desktop; -mod history; -mod icon; -mod launch; -mod matching; -mod paths; -mod query; - -#[cfg(feature = "gtk")] -pub mod gtk; - -pub use desktop::{load_all_desktop_entries, parse_desktop, strip_exec_codes, DesktopEntry}; -pub use history::LaunchHistory; -pub use icon::IconCache; -pub use launch::{do_launch, emit_launched}; -pub use matching::{ - fuzzy_matches, fuzzy_score, load_sorted_entries, matches_term, priority_rank, split_sections, -}; -pub use paths::{app_dirs, cache_dir, config_dir, home_dir}; -pub use query::{builtin_commands, eval_calc, filter_commands, parse_query, Command, ParsedQuery, QueryKind}; - -/// The launcher's one shared identity, passed to [`cache_dir`]/[`config_dir`]/ -/// [`IconCache::new`]/[`LaunchHistory::load`] by every host that embeds this -/// crate — breadbox's overlay window AND breadbar's embedded capsule -/// (theme 04/spotlight) alike. -/// -/// This is deliberate, not a leftover of breadbox being first: theme 04's -/// whole premise is that breadbar's capsule IS the launcher wearing a -/// different shell, not a second launcher with its own history -/// (`THEME_SYSTEM_PLAN.md` §7). If each host passed its own binary name here, -/// the same physical launcher would rank a user's apps differently -/// depending on which theme happened to be active — the icon cache and -/// "most launched" ordering would silently fork in two. Sharing this -/// constant is what keeps them one launcher. -/// -/// Do not pass a bare `"breadbox"` string literal at a call site instead of -/// this constant — that reads exactly like an unfixed bug (breadbar naming -/// another app's identity) and invites a later "fix" that would quietly -/// break the shared history this constant exists to guarantee. -/// -/// **Not** the `app_id` for [`do_launch`]/[`emit_launched`]: those publish -/// bread-bus events, which must be namespaced under the caller's *own* -/// identity (breadbox's is `"box"`, not `"breadbox"`) or -/// `BreadClient::emit`'s namespace check silently drops them. This constant -/// is scoped to the cache/history path family only — see [`do_launch`]'s -/// doc comment for the concrete failure mode if the two get swapped. -pub const LAUNCHER_APP: &str = "breadbox"; diff --git a/bread-launcher/src/matching.rs b/bread-launcher/src/matching.rs deleted file mode 100644 index e732a80..0000000 --- a/bread-launcher/src/matching.rs +++ /dev/null @@ -1,387 +0,0 @@ -use std::{collections::HashMap, path::PathBuf}; - -use crate::desktop::{load_all_desktop_entries, DesktopEntry}; -use crate::history::LaunchHistory; - -// ---- Fuzzy matching (query filter) ------------------------------------------ - -/// Subsequence match used to *filter* rows as the user types: every char of -/// `pattern`, in order, must appear somewhere in `text` (case-insensitive). -/// Looser than [`fuzzy_score`], which ranks the rows that pass this filter. -pub fn fuzzy_matches(pattern: &str, text: &str) -> bool { - if pattern.is_empty() { - return true; - } - let mut chars = text.chars(); - for pc in pattern.chars() { - let pl = pc.to_lowercase().next().unwrap_or(pc); - if !chars - .by_ref() - .any(|tc| tc.to_lowercase().next().unwrap_or(tc) == pl) - { - return false; - } - } - true -} - -/// Ranks how well `query` matches `entry` — lower is better. Exact match (by -/// name or `wm_class`) sorts first, then name-prefix, then name-contains, -/// then `wm_class`-prefix/contains, then everything else that still passed -/// [`fuzzy_matches`] (a subsequence match with no stronger relationship). -pub fn fuzzy_score(query: &str, entry: &DesktopEntry) -> u32 { - let q = query.to_lowercase(); - let name = entry.name.to_lowercase(); - let wm = entry.wm_class.as_deref().unwrap_or("").to_lowercase(); - if name == q || wm == q { - return 0; - } - if name.starts_with(&q) { - return 1; - } - if name.contains(&q) { - return 2; - } - if wm.starts_with(&q) || wm.contains(&q) { - return 3; - } - 4 // subsequence match -} - -// ---- Priority ranking (empty-query ordering) -------------------------------- - -/// Whole-word / exact match of `term` within `field` (both lowercase). Avoids -/// "code" matching "vscodium" while still matching "Code", "code-oss", and -/// "Visual Studio Code". -pub fn matches_term(field: &str, term: &str) -> bool { - if term.is_empty() || field.is_empty() { - return false; - } - if field == term { - return true; - } - let bytes = field.as_bytes(); - let tlen = term.len(); - let mut start = 0; - while let Some(pos) = field[start..].find(term) { - let i = start + pos; - let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric(); - let after = i + tlen; - let after_ok = after >= bytes.len() || !bytes[after].is_ascii_alphanumeric(); - if before_ok && after_ok { - return true; - } - // Advance past the WHOLE match, not one byte past its start. Both `i` - // (a match start) and `i + tlen` (its end) are guaranteed char - // boundaries; `i + 1` is not, so a multi-byte term that failed the - // word-boundary check left `start` inside a character and the next - // `field[start..]` slice panicked outright. `matches_term("café", "é")` - // reproduced it: "byte index 4 is not a char boundary". Reached via - // priority_rank over real .desktop `Name=` values, so any non-ASCII - // app name could crash the launcher's sort. - start = i + tlen; - if start >= field.len() { - break; - } - } - false -} - -/// Position of `entry` in the (already-lowercased) `priority` list, matched -/// against either its name or `wm_class`. `None` if `entry` isn't named -/// there at all. -pub fn priority_rank(entry: &DesktopEntry, priority_lower: &[String]) -> Option { - let name_l = entry.name.to_lowercase(); - let wm_l = entry.wm_class.as_deref().unwrap_or("").to_lowercase(); - priority_lower - .iter() - .position(|p| matches_term(&name_l, p) || matches_term(&wm_l, p)) -} - -/// Loads every known desktop entry, resolves each one's icon path from -/// `manifest`, and sorts them: entries named in `priority` come first (in -/// that order), then everything else by most-launched (via `history`), then -/// alphabetically. -pub fn load_sorted_entries( - manifest: &HashMap, - priority: &[String], - history: &LaunchHistory, -) -> Vec { - let mut entries = load_all_desktop_entries(); - - // Populate icon_path from manifest - for entry in &mut entries { - if let Some(path) = manifest.get(&entry.icon_name) { - if path.exists() { - entry.icon_path = Some(path.clone()); - } - } - } - - let priority_lower: Vec = priority.iter().map(|s| s.to_lowercase()).collect(); - - entries.sort_by(|a, b| { - let ai = priority_rank(a, &priority_lower); - let bi = priority_rank(b, &priority_lower); - match (ai, bi) { - (Some(i), Some(j)) => i.cmp(&j), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => { - // Most-launched first, then alphabetical - history - .count(&b.name) - .cmp(&history.count(&a.name)) - .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) - } - } - }); - - entries -} - -/// The demo's own cap (`BOS.pushRecent`'s `.slice(0, 4)`) on how many -/// entries the "Recent" section shows. -pub const MAX_RECENT: usize = 4; - -/// Splits `entries` (already ordered by [`load_sorted_entries`]) into a -/// "recent" section — the entries `history` has any launch count for, most- -/// launched first, capped at [`MAX_RECENT`] — and an "apps" section: every -/// other entry, in its existing relative order. No new tracking beyond -/// `LaunchHistory`'s existing counts (THEME_SYSTEM_PLAN.md phase 6c task -/// notes: "`LaunchHistory` already tracks counts for a recents list"). -/// -/// Only meaningful when `entries` has no priority-ranked prefix (breadbar's -/// capsule calls [`load_sorted_entries`] with an empty `priority` list) — -/// with a non-empty priority list, priority-ranked entries still sort first -/// and would be treated as "apps" here even if launched often, since this -/// function has no way to tell "sorted first because launched a lot" from -/// "sorted first because priority-ranked" apart from the count itself. -pub fn split_sections( - entries: Vec, - history: &LaunchHistory, -) -> (Vec, Vec) { - let mut recent = Vec::new(); - let mut apps = Vec::new(); - for entry in entries { - if recent.len() < MAX_RECENT && history.count(&entry.name) > 0 { - recent.push(entry); - } else { - apps.push(entry); - } - } - (recent, apps) -} - -#[cfg(test)] -mod tests { - #[test] - fn multibyte_term_that_fails_word_boundary_does_not_panic() { - // "é" occurs in "café" but is preceded by an alphanumeric, so the - // whole-word check fails and the scan must continue — landing mid - // character before the fix. - assert!(!super::matches_term("café", "é")); - assert!(!super::matches_term("naïve café", "ï")); - } - - #[test] - fn multibyte_whole_word_still_matches() { - assert!(super::matches_term("café bar", "café")); - assert!(super::matches_term("día", "día")); - } - - use super::*; - - fn entry(name: &str, wm_class: Option<&str>) -> DesktopEntry { - DesktopEntry { - id: format!("{name}.desktop"), - name: name.to_string(), - exec: "true".to_string(), - icon_name: String::new(), - icon_path: None, - categories: Vec::new(), - wm_class: wm_class.map(|s| s.to_string()), - terminal: false, - } - } - - // ---- fuzzy_matches ------------------------------------------------- - - #[test] - fn fuzzy_matches_empty_pattern_matches_anything() { - assert!(fuzzy_matches("", "Firefox")); - assert!(fuzzy_matches("", "")); - } - - #[test] - fn fuzzy_matches_in_order_subsequence() { - assert!(fuzzy_matches("ffx", "Firefox")); - assert!(fuzzy_matches("frfx", "Firefox")); - } - - #[test] - fn fuzzy_matches_is_case_insensitive() { - assert!(fuzzy_matches("FIREFOX", "firefox")); - assert!(fuzzy_matches("firefox", "FireFox")); - } - - #[test] - fn fuzzy_matches_rejects_out_of_order() { - assert!(!fuzzy_matches("xfr", "Firefox")); - } - - #[test] - fn fuzzy_matches_rejects_missing_chars() { - assert!(!fuzzy_matches("firefoxx", "Firefox")); - } - - // ---- fuzzy_score ----------------------------------------------------- - - #[test] - fn fuzzy_score_exact_name_match_is_best() { - let e = entry("Firefox", None); - assert_eq!(fuzzy_score("firefox", &e), 0); - } - - #[test] - fn fuzzy_score_exact_wm_class_match_is_best() { - let e = entry("Firefox Web Browser", Some("firefox")); - assert_eq!(fuzzy_score("firefox", &e), 0); - } - - #[test] - fn fuzzy_score_name_prefix_beats_name_contains() { - let prefix = entry("Firefox", None); - let contains = entry("GNU IceCat (Firefox fork)", None); - assert_eq!(fuzzy_score("fire", &prefix), 1); - assert_eq!(fuzzy_score("fire", &contains), 2); - assert!(fuzzy_score("fire", &prefix) < fuzzy_score("fire", &contains)); - } - - #[test] - fn fuzzy_score_wm_class_beats_pure_subsequence() { - let wm_hit = entry("Web Browser", Some("firefox")); - let subseq_only = entry("Fine Iris Reflex Editor for XML", None); - assert_eq!(fuzzy_score("fire", &wm_hit), 3); - assert_eq!(fuzzy_score("fire", &subseq_only), 4); - } - - // ---- matches_term ------------------------------------------------------ - - #[test] - fn matches_term_exact_field_matches() { - assert!(matches_term("code", "code")); - } - - #[test] - fn matches_term_whole_word_within_longer_field() { - assert!(matches_term("visual studio code", "code")); - } - - #[test] - fn matches_term_rejects_substring_of_a_larger_word() { - // "code" must not match inside "vscodium" — this is the whole - // reason matches_term exists instead of a plain `contains`. - assert!(!matches_term("vscodium", "code")); - } - - #[test] - fn matches_term_matches_hyphenated_variant() { - assert!(matches_term("code-oss", "code")); - } - - #[test] - fn matches_term_empty_term_or_field_never_matches() { - assert!(!matches_term("code", "")); - assert!(!matches_term("", "code")); - } - - // ---- priority_rank ----------------------------------------------------- - - #[test] - fn priority_rank_matches_by_name() { - let e = entry("Firefox", None); - let priority = vec!["firefox".to_string(), "code".to_string()]; - assert_eq!(priority_rank(&e, &priority), Some(0)); - } - - #[test] - fn priority_rank_matches_by_wm_class() { - let e = entry("Web Browser", Some("firefox")); - let priority = vec!["code".to_string(), "firefox".to_string()]; - assert_eq!(priority_rank(&e, &priority), Some(1)); - } - - #[test] - fn priority_rank_none_when_unlisted() { - let e = entry("Nautilus", None); - let priority = vec!["firefox".to_string()]; - assert_eq!(priority_rank(&e, &priority), None); - } - - #[test] - fn priority_rank_does_not_match_substring_of_a_word() { - let e = entry("VSCodium", None); - let priority = vec!["code".to_string()]; - assert_eq!(priority_rank(&e, &priority), None); - } - - // ---- split_sections -------------------------------------------------- - - #[test] - fn split_sections_no_history_is_all_apps() { - let entries = vec![entry("Firefox", None), entry("GoLand", None)]; - let history = LaunchHistory::from_counts(HashMap::new()); - let (recent, apps) = split_sections(entries, &history); - assert!(recent.is_empty()); - assert_eq!(apps.len(), 2); - } - - #[test] - fn split_sections_launched_entries_go_to_recent() { - let entries = vec![ - entry("Firefox", None), - entry("GoLand", None), - entry("Steam", None), - ]; - let mut counts = HashMap::new(); - counts.insert("Firefox".to_string(), 5); - let history = LaunchHistory::from_counts(counts); - let (recent, apps) = split_sections(entries, &history); - assert_eq!(recent.iter().map(|e| &e.name).collect::>(), vec!["Firefox"]); - assert_eq!( - apps.iter().map(|e| &e.name).collect::>(), - vec!["GoLand", "Steam"] - ); - } - - #[test] - fn split_sections_caps_recent_at_max() { - let entries: Vec = (0..(MAX_RECENT + 2)) - .map(|i| entry(&format!("App{i}"), None)) - .collect(); - let counts = entries - .iter() - .map(|e| (e.name.clone(), 1)) - .collect::>(); - let history = LaunchHistory::from_counts(counts); - let (recent, apps) = split_sections(entries, &history); - assert_eq!(recent.len(), MAX_RECENT); - assert_eq!(apps.len(), 2); - } - - #[test] - fn split_sections_preserves_relative_order_within_each_group() { - let mut counts = HashMap::new(); - counts.insert("A".to_string(), 1); - counts.insert("C".to_string(), 3); - let history = LaunchHistory::from_counts(counts); - // load_sorted_entries would already have ordered these by count - // desc before calling split_sections; split_sections itself just - // partitions in whatever order it's handed, so feed it pre-sorted. - let pre_sorted = vec![entry("C", None), entry("A", None), entry("B", None)]; - let (recent, apps) = split_sections(pre_sorted, &history); - assert_eq!(recent.iter().map(|e| &e.name).collect::>(), vec!["C", "A"]); - assert_eq!(apps.iter().map(|e| &e.name).collect::>(), vec!["B"]); - } -} diff --git a/bread-launcher/src/paths.rs b/bread-launcher/src/paths.rs deleted file mode 100644 index 5a87df0..0000000 --- a/bread-launcher/src/paths.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::{env, path::PathBuf}; - -// ---- XDG path helpers ------------------------------------------------------- - -pub fn home_dir() -> PathBuf { - PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into())) -} - -/// `$XDG_CACHE_HOME/` (or `~/.cache/`). `app` is the caller's own -/// name in this scheme — e.g. breadbox passes `"breadbox"` to keep using the -/// on-disk layout it always has; a future host picks its own. -pub fn cache_dir(app: &str) -> PathBuf { - env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")) - .join(app) -} - -/// `$XDG_CONFIG_HOME/` (or `~/.config/`). See [`cache_dir`]. -pub fn config_dir(app: &str) -> PathBuf { - env::var("XDG_CONFIG_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".config")) - .join(app) -} - -/// The `applications/` directories a `.desktop` file may live in, per the -/// XDG base-directory spec (system-wide first, user-local last so later -/// entries can override earlier ones on lookup by filename). -pub fn app_dirs() -> Vec { - let home = home_dir(); - let mut dirs = vec![PathBuf::from("/usr/share/applications")]; - - let xdg_data_dirs = - env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".into()); - for d in xdg_data_dirs.split(':') { - let p = PathBuf::from(d).join("applications"); - if p != dirs[0] { - dirs.push(p); - } - } - - dirs.push( - env::var("XDG_DATA_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home.join(".local/share")) - .join("applications"), - ); - dirs -} diff --git a/bread-launcher/src/query.rs b/bread-launcher/src/query.rs deleted file mode 100644 index e6a696b..0000000 --- a/bread-launcher/src/query.rs +++ /dev/null @@ -1,391 +0,0 @@ -//! Query modes (`04-spotlight.html`'s `BOS.parseQuery`/`BOS.evalCalc`/ -//! `BOS.COMMANDS`, THEME_SYSTEM_PLAN.md phase 6c): a leading `=`/`>`/`.` -//! switches the launcher from filtering apps to evaluating an arithmetic -//! expression, listing bread commands, or treating the rest of the query as -//! a URL to open. Pure/headless — no GTK here — so both breadbar's embedded -//! capsule and (per the module doc comment on `crate`) breadbox's own -//! overlay window can adopt the same parsing/eval/filter logic. A host is -//! expected to gate which prefixes it actually acts on against its theme's -//! `[launcher].modes` list — this module recognizes all four kinds -//! unconditionally and leaves that gating to the caller. - -use crate::matching::fuzzy_matches; - -/// Which of the four query modes a raw entry-text string names, per its -/// leading character (mirrors `BOS.parseQuery`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum QueryKind { - /// No recognized prefix — the ordinary app-filter query. - Apps, - /// Leading `=` — the rest is an arithmetic expression for [`eval_calc`]. - Calc, - /// Leading `>` — the rest filters [`builtin_commands`]. - Cmd, - /// Leading `.` — the rest is a URL to open. - Url, -} - -/// A parsed query: which mode it names, and the text after the prefix -/// character (empty string for a bare `=`/`>`/`.` with nothing typed yet). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParsedQuery { - pub kind: QueryKind, - pub value: String, -} - -/// Splits `raw` on its leading mode character, if any. Mirrors -/// `BOS.parseQuery` exactly: only the FIRST character is checked, and it is -/// stripped along with any immediately-following whitespace. -pub fn parse_query(raw: &str) -> ParsedQuery { - let (kind, rest) = if let Some(rest) = raw.strip_prefix('=') { - (QueryKind::Calc, rest) - } else if let Some(rest) = raw.strip_prefix('>') { - (QueryKind::Cmd, rest) - } else if let Some(rest) = raw.strip_prefix('.') { - (QueryKind::Url, rest) - } else { - (QueryKind::Apps, raw) - }; - ParsedQuery { - kind, - value: rest.trim().to_string(), - } -} - -// ---- Calc --------------------------------------------------------------- - -/// Evaluates `expr` as a small four-function arithmetic expression -/// (`+ - * / ( )`, decimal literals, unary minus) and formats the result -/// the same way `BOS.evalCalc` does: rounded to 8 decimal places, an -/// integer result prints with no trailing `.0`, a non-finite result prints -/// "∞", an expression containing anything outside `[0-9.\s+\-*/()]` -/// returns "bad expr", and anything else that fails to parse or evaluate -/// (unbalanced parens, division producing NaN, trailing garbage) returns -/// "err". `None` only for an empty/whitespace-only expression — the -/// caller's "nothing typed after `=` yet" case, which has no result to -/// show at all (the demo's own `if (!expr) return null;`). -pub fn eval_calc(expr: &str) -> Option { - let expr = expr.trim(); - if expr.is_empty() { - return None; - } - if !expr.chars().all(|c| c.is_ascii_digit() || " .+-*/()".contains(c)) { - return Some("bad expr".to_string()); - } - let mut p = CalcParser { - bytes: expr.as_bytes(), - pos: 0, - }; - let result = p.parse_expr().filter(|_| p.skip_ws() == p.bytes.len()); - Some(match result { - Some(n) if !n.is_finite() => "∞".to_string(), - Some(n) => format_calc_result(n), - None => "err".to_string(), - }) -} - -/// Rounds to 8 decimal places and formats without a trailing `.0` for whole -/// numbers — `String(Math.round(n * 1e8) / 1e8)` in JS, `{}` on `f64` -/// already behaves the same way in Rust (`format!("{}", 4.0_f64)` == "4"). -fn format_calc_result(n: f64) -> String { - let rounded = (n * 1e8).round() / 1e8; - // Avoid printing "-0" for a result that rounds to negative zero. - let rounded = if rounded == 0.0 { 0.0 } else { rounded }; - format!("{rounded}") -} - -/// Minimal recursive-descent parser: `expr := term (('+'|'-') term)*`, -/// `term := factor (('*'|'/') factor)*`, `factor := '-' factor | number | -/// '(' expr ')'`. Byte-indexed since the character set is already -/// restricted to ASCII by [`eval_calc`]'s pre-check. -struct CalcParser<'a> { - bytes: &'a [u8], - pos: usize, -} - -impl<'a> CalcParser<'a> { - fn skip_ws(&mut self) -> usize { - while self.pos < self.bytes.len() && self.bytes[self.pos] == b' ' { - self.pos += 1; - } - self.pos - } - - fn peek(&mut self) -> Option { - self.skip_ws(); - self.bytes.get(self.pos).copied() - } - - fn parse_expr(&mut self) -> Option { - let mut val = self.parse_term()?; - loop { - match self.peek() { - Some(b'+') => { - self.pos += 1; - val += self.parse_term()?; - } - Some(b'-') => { - self.pos += 1; - val -= self.parse_term()?; - } - _ => break, - } - } - Some(val) - } - - fn parse_term(&mut self) -> Option { - let mut val = self.parse_factor()?; - loop { - match self.peek() { - Some(b'*') => { - self.pos += 1; - val *= self.parse_factor()?; - } - Some(b'/') => { - self.pos += 1; - val /= self.parse_factor()?; - } - _ => break, - } - } - Some(val) - } - - fn parse_factor(&mut self) -> Option { - match self.peek()? { - b'-' => { - self.pos += 1; - Some(-self.parse_factor()?) - } - b'+' => { - self.pos += 1; - self.parse_factor() - } - b'(' => { - self.pos += 1; - let val = self.parse_expr()?; - if self.peek() == Some(b')') { - self.pos += 1; - Some(val) - } else { - None - } - } - c if c.is_ascii_digit() || c == b'.' => self.parse_number(), - _ => None, - } - } - - fn parse_number(&mut self) -> Option { - self.skip_ws(); - let start = self.pos; - let mut seen_dot = false; - let mut seen_digit = false; - while let Some(&c) = self.bytes.get(self.pos) { - if c.is_ascii_digit() { - seen_digit = true; - self.pos += 1; - } else if c == b'.' && !seen_dot { - seen_dot = true; - self.pos += 1; - } else { - break; - } - } - if !seen_digit { - return None; - } - std::str::from_utf8(&self.bytes[start..self.pos]) - .ok() - .and_then(|s| s.parse::().ok()) - } -} - -// ---- Commands ------------------------------------------------------------- - -/// One `>`-mode command palette entry: a display `name` and the shell -/// command it runs (via `bash -c`, same spawn convention [`crate::do_launch`] -/// already uses for a desktop entry's `Exec=` line). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Command { - pub id: &'static str, - pub name: &'static str, - pub exec: &'static str, -} - -/// A small, real bread-ecosystem command palette — deliberately not the -/// demo's placeholder set (`Lock session`/`Open settings`/`Test -/// notification` map to nothing real in this codebase). `loginctl -/// lock-session` and `bread reload` are both already-documented, safe, -/// no-argument commands (see the bread CLI reference / systemd-logind). -pub fn builtin_commands() -> &'static [Command] { - &[ - Command { - id: "lock", - name: "Lock session", - exec: "loginctl lock-session", - }, - Command { - id: "reload-breadd", - name: "Reload breadd", - exec: "bread reload", - }, - ] -} - -/// Fuzzy-filters `commands` by `query` against each command's `name` (same -/// subsequence match [`crate::fuzzy_matches`] uses for app rows) — an empty -/// query matches everything, same as the app list's own empty-query case. -pub fn filter_commands(query: &str, commands: &[Command]) -> Vec { - commands - .iter() - .filter(|c| fuzzy_matches(query, c.name)) - .cloned() - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - // ---- parse_query ------------------------------------------------- - - #[test] - fn parse_query_bare_text_is_apps() { - let p = parse_query("firefox"); - assert_eq!(p.kind, QueryKind::Apps); - assert_eq!(p.value, "firefox"); - } - - #[test] - fn parse_query_empty_is_apps() { - let p = parse_query(""); - assert_eq!(p.kind, QueryKind::Apps); - assert_eq!(p.value, ""); - } - - #[test] - fn parse_query_equals_is_calc() { - let p = parse_query("=2+2"); - assert_eq!(p.kind, QueryKind::Calc); - assert_eq!(p.value, "2+2"); - } - - #[test] - fn parse_query_gt_is_cmd() { - let p = parse_query(">lock"); - assert_eq!(p.kind, QueryKind::Cmd); - assert_eq!(p.value, "lock"); - } - - #[test] - fn parse_query_dot_is_url() { - let p = parse_query(".breadway.dev"); - assert_eq!(p.kind, QueryKind::Url); - assert_eq!(p.value, "breadway.dev"); - } - - #[test] - fn parse_query_strips_leading_whitespace_after_prefix() { - let p = parse_query("= 2 + 2 "); - assert_eq!(p.kind, QueryKind::Calc); - assert_eq!(p.value, "2 + 2"); - } - - #[test] - fn parse_query_bare_prefix_has_empty_value() { - assert_eq!(parse_query("=").value, ""); - assert_eq!(parse_query(">").value, ""); - assert_eq!(parse_query(".").value, ""); - } - - // ---- eval_calc ----------------------------------------------------- - - #[test] - fn eval_calc_empty_expr_is_none() { - assert_eq!(eval_calc(""), None); - assert_eq!(eval_calc(" "), None); - } - - #[test] - fn eval_calc_simple_addition() { - assert_eq!(eval_calc("2+2"), Some("4".to_string())); - } - - #[test] - fn eval_calc_precedence() { - assert_eq!(eval_calc("2+3*4"), Some("14".to_string())); - } - - #[test] - fn eval_calc_parens() { - assert_eq!(eval_calc("(2+3)*4"), Some("20".to_string())); - } - - #[test] - fn eval_calc_unary_minus() { - assert_eq!(eval_calc("-5+2"), Some("-3".to_string())); - } - - #[test] - fn eval_calc_decimals_round_to_8_places() { - assert_eq!(eval_calc("0.1+0.2"), Some("0.3".to_string())); - } - - #[test] - fn eval_calc_division_by_zero_is_infinity_symbol() { - assert_eq!(eval_calc("1/0"), Some("∞".to_string())); - } - - #[test] - fn eval_calc_bad_chars_is_bad_expr() { - assert_eq!(eval_calc("2+alert(1)"), Some("bad expr".to_string())); - assert_eq!(eval_calc("rm -rf /"), Some("bad expr".to_string())); - } - - #[test] - fn eval_calc_unbalanced_parens_is_err() { - assert_eq!(eval_calc("(2+3"), Some("err".to_string())); - } - - #[test] - fn eval_calc_trailing_garbage_is_err() { - assert_eq!(eval_calc("2+3)"), Some("err".to_string())); - } - - #[test] - fn eval_calc_double_operator_is_err() { - assert_eq!(eval_calc("2++"), Some("err".to_string())); - } - - #[test] - fn eval_calc_whitespace_is_tolerated() { - assert_eq!(eval_calc(" 2 + 2 "), Some("4".to_string())); - } - - // ---- commands -------------------------------------------------------- - - #[test] - fn builtin_commands_are_non_empty() { - assert!(!builtin_commands().is_empty()); - } - - #[test] - fn filter_commands_empty_query_matches_all() { - let all = builtin_commands(); - assert_eq!(filter_commands("", all).len(), all.len()); - } - - #[test] - fn filter_commands_filters_by_name_subsequence() { - let matches = filter_commands("lock", builtin_commands()); - assert!(matches.iter().any(|c| c.id == "lock")); - assert!(!matches.iter().any(|c| c.id == "reload-breadd")); - } - - #[test] - fn filter_commands_no_match_is_empty() { - assert!(filter_commands("zzzznotacommand", builtin_commands()).is_empty()); - } -} diff --git a/bread-polkit/bakery.toml b/bread-polkit/bakery.toml deleted file mode 100644 index 4dd8e44..0000000 --- a/bread-polkit/bakery.toml +++ /dev/null @@ -1,11 +0,0 @@ -name = "bread-polkit" -description = "Themed PolicyKit authentication agent for the bread desktop" -binaries = ["bread-polkit"] -system_deps = ["gtk4", "gtk4-layer-shell", "polkit"] -optional_system_deps = ["hyprland"] -bread_deps = [] -license_file = "LICENSE" -desktop_file = "bread-polkit.desktop" - -[install] -post_install = [] diff --git a/bread-polkit/src/agent.rs b/bread-polkit/src/agent.rs index 27cda84..94840eb 100644 --- a/bread-polkit/src/agent.rs +++ b/bread-polkit/src/agent.rs @@ -90,7 +90,6 @@ impl Agent { let username = pick_user(&users, current_uid()) .map(|u| u.name.clone()) .ok_or_else(|| AgentError::Failed("no unix-user identity".into()))?; - let allowed_users: Vec = users.iter().map(|u| u.name.clone()).collect(); let (tx, mut rx) = mpsc::channel(4); *self.pending.lock().await = Some(tx.clone()); @@ -108,7 +107,7 @@ impl Agent { } }); - let result = self.drive_prompt(&cookie, &username, &allowed_users, &mut rx).await; + let result = self.drive_prompt(&cookie, &username, &mut rx).await; *self.pending.lock().await = None; let cookie_close = cookie.clone(); @@ -130,7 +129,6 @@ impl Agent { &self, cookie: &str, default_user: &str, - allowed_users: &[String], rx: &mut mpsc::Receiver, ) -> Result<(), AgentError> { loop { @@ -142,17 +140,12 @@ impl Agent { 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; + let user = if username.is_empty() { + default_user + } else { + username.as_str() }; - match auth::authenticate(&self.transport, &user, cookie, &password).await { + match auth::authenticate(&self.transport, user, cookie, &password).await { Ok(Outcome::Success) => return Ok(()), Ok(Outcome::Failure { message }) => { let text = message @@ -173,30 +166,6 @@ impl Agent { } } -const INVALID_USER_MESSAGE: &str = - "That user is not one of the identities this request offered — use the prefilled user."; - -/// Choose the username to authenticate as from the prompt input. -/// -/// Empty input falls back to `default`. Non-empty input must be one of -/// `allowed` — the `unix-user` identities polkit actually offered in -/// `BeginAuthentication` — otherwise it returns `None`. Without this, a -/// request scoped to specific accounts (say, `root` only) could have its -/// editable username field redirected to an arbitrary local user, kicking -/// off a PAM conversation for an account the request never offered. -/// (`auth::authenticate`'s PAM result still has to clear polkit's own -/// authorization, but this closes the obvious foot-gun at the agent — the -/// one place the identity list is actually known.) -fn resolve_user(default: &str, allowed: &[String], input: &str) -> Option { - if input.is_empty() { - return Some(default.to_string()); - } - if allowed.iter().any(|u| u.as_str() == input) { - return Some(input.to_string()); - } - None -} - fn unix_users(identities: &[Identity]) -> Vec { let mut uids = Vec::new(); for identity in identities { @@ -329,42 +298,3 @@ async fn run() -> Result<()> { Ok(()) } } - -#[cfg(test)] -mod tests { - use super::*; - - fn allowed() -> Vec { - vec!["root".to_string(), "1000".to_string()] - } - - #[test] - fn resolve_user_falls_back_to_default_on_empty_input() { - assert_eq!( - resolve_user("root", &allowed(), ""), - Some("root".to_string()) - ); - } - - #[test] - fn resolve_user_accepts_an_offered_identity() { - assert_eq!( - resolve_user("root", &allowed(), "1000"), - Some("1000".to_string()) - ); - } - - #[test] - fn resolve_user_rejects_a_user_polkit_did_not_offer() { - assert_eq!(resolve_user("root", &allowed(), "alice"), None); - assert_eq!(resolve_user("root", &allowed(), "daemon"), None); - } - - #[test] - fn resolve_user_is_case_exact() { - // Usernames are case-significant; "ROOT" is a different principle - // than the offered "root", so it must be rejected. - assert_eq!(resolve_user("root", &allowed(), "ROOT"), None); - } -} - diff --git a/bread-polkit/src/identity.rs b/bread-polkit/src/identity.rs index 0dd0496..e92e395 100644 --- a/bread-polkit/src/identity.rs +++ b/bread-polkit/src/identity.rs @@ -37,7 +37,7 @@ pub fn users_from_uids(uids: &[u32], passwd: &str) -> Vec { } /// Prefer the process's own uid when it is in `users`, otherwise the first. -pub fn pick_user(users: &[UnixUser], current_uid: Option) -> Option<&UnixUser> { +pub fn pick_user<'a>(users: &'a [UnixUser], current_uid: Option) -> Option<&'a UnixUser> { if let Some(uid) = current_uid { if let Some(user) = users.iter().find(|u| u.uid == uid) { return Some(user); diff --git a/bread-polkit/src/main.rs b/bread-polkit/src/main.rs index 4018b41..ca28048 100644 --- a/bread-polkit/src/main.rs +++ b/bread-polkit/src/main.rs @@ -50,13 +50,8 @@ fn main() { 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); + eprintln!("bread-polkit: singleton lock unavailable ({e}); continuing"); + None } }; diff --git a/bread-theme/Cargo.toml b/bread-theme/Cargo.toml index 7298837..f5e1cda 100644 --- a/bread-theme/Cargo.toml +++ b/bread-theme/Cargo.toml @@ -12,11 +12,6 @@ keywords = ["theming", "pywal", "gtk4", "wayland"] serde = { workspace = true } serde_json = { workspace = true } dirs = { workspace = true } -# bread_theme::shell manifest parsing (theme.toml) — gtk-free, so `bread` -# (daemon) and `breadcrumbs` (CLI) can validate a theme without linking GTK. -toml = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } gtk4 = { version = "0.11", features = ["v4_12"], optional = true } # Rust bindings for libadwaita (GNOME's widget library on top of GTK4) — the # actual source of the modern GNOME look (grouped preference rows, real diff --git a/bread-theme/assets/shell/daylight/daylight.css b/bread-theme/assets/shell/daylight/daylight.css deleted file mode 100644 index eb65fe7..0000000 --- a/bread-theme/assets/shell/daylight/daylight.css +++ /dev/null @@ -1,117 +0,0 @@ -/* CSS template for the daylight builtin (bread-theme/src/shell/builtin.rs). - * Same scope and substitution rules as its three siblings: only the - * window/workspace/clock chrome the manifest's own concepts model, `{name}` - * tokens substituted, `@name` palette references passed through untouched. - * Declared-but-not-yet-consumed in production, same as every sibling - * template — see `ShellTheme::css`'s doc comment for why (breadbar hand- - * rolls its own CSS in `breadbar::theme::load_css` instead of calling this - * method). Exercised by this crate's own tests. - * - * Source: bos-ui-demos/proposed/daylight.html's