diff --git a/.gitignore b/.gitignore index 4c046ac..e96063c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,9 @@ # 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/Cargo.lock b/Cargo.lock index 0598260..434d3a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,6 +202,15 @@ dependencies = [ "image", ] +[[package]] +name = "bread-launcher" +version = "0.7.4" +dependencies = [ + "bread-utils", + "gtk4", + "serde_json", +] + [[package]] name = "bread-onnx" version = "0.7.4" @@ -257,11 +266,14 @@ dependencies = [ name = "bread-theme" version = "0.7.4" dependencies = [ + "anyhow", "dirs 5.0.1", "gtk4", "libadwaita", "serde", "serde_json", + "toml 0.8.23", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 10d698a..ef5d936 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit"] +members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit", "bread-launcher"] resolver = "2" [workspace.package] diff --git a/bakery/src/install.rs b/bakery/src/install.rs index 2040e35..dd31145 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -523,8 +523,17 @@ fn fetch_extract_archive( .into_temp_path(); fetch_verify_write(pkg, filename, sha256, &tmp_archive, "data archive")?; - if !tmp_archive.exists() { - // fetch_verify_write already warned (download/checksum failure). + // `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 + { return Ok(()); } @@ -1036,6 +1045,34 @@ 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 e05a756..d61c87b 100644 --- a/bread-capture/src/main.rs +++ b/bread-capture/src/main.rs @@ -50,6 +50,17 @@ 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 new file mode 100644 index 0000000..c126c18 --- /dev/null +++ b/bread-launcher/Cargo.toml @@ -0,0 +1,26 @@ +[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 new file mode 100644 index 0000000..1a205c9 --- /dev/null +++ b/bread-launcher/src/desktop.rs @@ -0,0 +1,151 @@ +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 new file mode 100644 index 0000000..d365353 --- /dev/null +++ b/bread-launcher/src/gtk.rs @@ -0,0 +1,297 @@ +//! 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 new file mode 100644 index 0000000..d642141 --- /dev/null +++ b/bread-launcher/src/history.rs @@ -0,0 +1,126 @@ +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 new file mode 100644 index 0000000..f6ad616 --- /dev/null +++ b/bread-launcher/src/icon.rs @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..d035a52 --- /dev/null +++ b/bread-launcher/src/launch.rs @@ -0,0 +1,141 @@ +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 new file mode 100644 index 0000000..4f5952a --- /dev/null +++ b/bread-launcher/src/lib.rs @@ -0,0 +1,63 @@ +//! 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 new file mode 100644 index 0000000..e732a80 --- /dev/null +++ b/bread-launcher/src/matching.rs @@ -0,0 +1,387 @@ +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 new file mode 100644 index 0000000..5a87df0 --- /dev/null +++ b/bread-launcher/src/paths.rs @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..e6a696b --- /dev/null +++ b/bread-launcher/src/query.rs @@ -0,0 +1,391 @@ +//! 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/src/agent.rs b/bread-polkit/src/agent.rs index 94840eb..27cda84 100644 --- a/bread-polkit/src/agent.rs +++ b/bread-polkit/src/agent.rs @@ -90,6 +90,7 @@ 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()); @@ -107,7 +108,7 @@ impl Agent { } }); - let result = self.drive_prompt(&cookie, &username, &mut rx).await; + let result = self.drive_prompt(&cookie, &username, &allowed_users, &mut rx).await; *self.pending.lock().await = None; let cookie_close = cookie.clone(); @@ -129,6 +130,7 @@ impl Agent { &self, cookie: &str, default_user: &str, + allowed_users: &[String], rx: &mut mpsc::Receiver, ) -> Result<(), AgentError> { loop { @@ -140,12 +142,17 @@ impl Agent { return Err(AgentError::Cancelled("user cancelled".into())); } Some(UserAction::Submit { username, password }) => { - let user = if username.is_empty() { - default_user - } else { - username.as_str() + // 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 { + match auth::authenticate(&self.transport, &user, cookie, &password).await { Ok(Outcome::Success) => return Ok(()), Ok(Outcome::Failure { message }) => { let text = message @@ -166,6 +173,30 @@ 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 { @@ -298,3 +329,42 @@ 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 e92e395..0dd0496 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<'a>(users: &'a [UnixUser], current_uid: Option) -> Option<&'a UnixUser> { +pub fn pick_user(users: &[UnixUser], current_uid: Option) -> Option<&UnixUser> { if let Some(uid) = current_uid { if let Some(user) = users.iter().find(|u| u.uid == uid) { return Some(user); diff --git a/bread-polkit/src/main.rs b/bread-polkit/src/main.rs index ca28048..4018b41 100644 --- a/bread-polkit/src/main.rs +++ b/bread-polkit/src/main.rs @@ -50,8 +50,13 @@ fn main() { std::process::exit(0); } Err(e) => { - eprintln!("bread-polkit: singleton lock unavailable ({e}); continuing"); - None + // 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); } }; diff --git a/bread-theme/Cargo.toml b/bread-theme/Cargo.toml index f5e1cda..7298837 100644 --- a/bread-theme/Cargo.toml +++ b/bread-theme/Cargo.toml @@ -12,6 +12,11 @@ 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 new file mode 100644 index 0000000..eb65fe7 --- /dev/null +++ b/bread-theme/assets/shell/daylight/daylight.css @@ -0,0 +1,117 @@ +/* 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