From 3f1caa99f5562cd7896197d83a898add226376c1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 23:50:27 +0800 Subject: [PATCH 01/37] bakery: restyle CLI output with headers, columns, and progress Catalog views use aligned columns and two-line entries so long -dev versions no longer smash the old 10-char pad. Install/update/remove get action banners and a verb column; downloads >= 256 KB show a real progress bar; clap help matches the same palette. NO_COLOR and non-TTY still strip color. --- bakery/src/doctor.rs | 59 +++-- bakery/src/download.rs | 5 +- bakery/src/install.rs | 243 +++++++++++++------- bakery/src/main.rs | 496 +++++++++++++++++++++++++++++------------ bakery/src/manifest.rs | 70 +++--- bakery/src/ui.rs | 385 +++++++++++++++++++++++++++++++- 6 files changed, 973 insertions(+), 285 deletions(-) diff --git a/bakery/src/doctor.rs b/bakery/src/doctor.rs index 7f43c19..f4ef71b 100644 --- a/bakery/src/doctor.rs +++ b/bakery/src/doctor.rs @@ -11,8 +11,16 @@ pub struct DepReport { pub fn check_deps(required: &[String], optional: &[String]) -> Result { Ok(DepReport { - missing: required.iter().filter(|d| !dep_present(d)).cloned().collect(), - warnings: optional.iter().filter(|d| !dep_present(d)).cloned().collect(), + missing: required + .iter() + .filter(|d| !dep_present(d)) + .cloned() + .collect(), + warnings: optional + .iter() + .filter(|d| !dep_present(d)) + .cloned() + .collect(), }) } @@ -99,14 +107,24 @@ pub fn install_hint(missing: &[String]) -> String { /// Print a formatted doctor report for a package's system deps. /// Returns true if all *required* deps are satisfied. -pub fn report(package_name: &str, required: &[String], optional: &[String]) -> bool { +pub fn report( + package_name: &str, + required: &[String], + optional: &[String], + name_width: usize, +) -> bool { if required.is_empty() && optional.is_empty() { - println!(" {}", ui::ok(&format!("{package_name}: no system deps required"))); + ui::check_row(true, package_name, name_width, "no system deps required"); return true; } match check_deps(required, optional) { Err(e) => { - eprintln!(" {}", ui::fail(&format!("error running doctor for {package_name}: {e}"))); + ui::check_row( + false, + package_name, + name_width, + &format!("error running doctor: {e}"), + ); false } Ok(rep) => { @@ -123,17 +141,24 @@ pub fn report(package_name: &str, required: &[String], optional: &[String]) -> b ); } if rep.missing.is_empty() { - println!(" {}", ui::ok(&format!("{package_name}: all required system deps satisfied"))); + ui::check_row( + true, + package_name, + name_width, + "all required system deps satisfied", + ); true } else { + ui::check_row( + false, + package_name, + name_width, + &format!("missing: {}", rep.missing.join(", ")), + ); eprintln!( " {}", - ui::fail(&format!( - "{package_name}: missing system deps: {}", - rep.missing.join(", ") - )) + ui::dim(&format!("install with: {}", install_hint(&rep.missing))) ); - eprintln!(" install with: {}", install_hint(&rep.missing)); false } } @@ -187,22 +212,14 @@ mod tests { #[test] fn missing_required_dep_detected() { - let rep = check_deps( - &["this-package-does-not-exist-xyzzy42".to_string()], - &[], - ) - .unwrap(); + let rep = check_deps(&["this-package-does-not-exist-xyzzy42".to_string()], &[]).unwrap(); assert_eq!(rep.missing.len(), 1); assert!(rep.warnings.is_empty()); } #[test] fn missing_optional_dep_becomes_warning_not_error() { - let rep = check_deps( - &[], - &["this-package-does-not-exist-xyzzy42".to_string()], - ) - .unwrap(); + let rep = check_deps(&[], &["this-package-does-not-exist-xyzzy42".to_string()]).unwrap(); assert!(rep.missing.is_empty()); assert_eq!(rep.warnings.len(), 1); } diff --git a/bakery/src/download.rs b/bakery/src/download.rs index 14a19fd..d59bf67 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -3,6 +3,7 @@ use sha2::{Digest, Sha256}; use std::path::Path; use crate::manifest::{fetch_binary, Binary}; +use crate::ui; /// Download a binary, verify its SHA-256, then atomically write it into /// place (fsynced, temp-in-same-dir-with-unique-name then rename — see @@ -12,7 +13,7 @@ use crate::manifest::{fetch_binary, Binary}; /// bytes a second time — `verify_sha256` already confirmed `bytes` matches /// `binary.sha256`, so that's the value to return. pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result { - println!(" downloading {}…", binary.name); + ui::step("downloading", &binary.name); let bytes = fetch_binary(&binary.dl_url, &binary.github_url) .with_context(|| format!("downloading {}", binary.name))?; @@ -21,7 +22,7 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result { bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755)) .with_context(|| format!("placing binary at {}", dest.display()))?; - println!(" installed {}", dest.display()); + ui::step("placed", &dest.display().to_string()); Ok(binary.sha256.clone()) } diff --git a/bakery/src/install.rs b/bakery/src/install.rs index c046a00..19a7c76 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -9,6 +9,7 @@ use crate::download::{fetch_and_place, verify_sha256}; use crate::manifest::{fetch_binary, Package, Service}; use crate::state::{InstalledPackage, State}; use crate::track::Track; +use crate::ui; /// Rejects a filename that isn't a safe single path component — no `/`, /// `\`, empty, `.`, or `..`. `bin.name`/`svc.unit`/`cfg.example`/`pkg.name`/ @@ -52,7 +53,7 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool { return false; } use std::io::Write; - print!("{prompt} [y/N] "); + print!("{prompt} {} ", ui::dim("[y/N]")); std::io::stdout().flush().ok(); let mut buf = String::new(); std::io::stdin().read_line(&mut buf).ok(); @@ -74,7 +75,6 @@ pub fn install_package( assume_yes: bool, ) -> Result<()> { ensure_safe_component(&pkg.name, "package name")?; - println!("installing {}@{}…", pkg.name, pkg.version); // 1. Download and verify all binaries. On an update (not a fresh // install), back up the current binary first — best-effort, feeding @@ -126,10 +126,13 @@ pub fn install_package( // confirmation rather than running unconditionally. if !pkg.post_install.is_empty() { if no_hooks { - println!( - " note: skipped {} post_install hook(s) for {} (--no-hooks)", - pkg.post_install.len(), - pkg.name + eprintln!( + " {}", + ui::note(&format!( + "skipped {} post_install hook(s) for {} (--no-hooks)", + pkg.post_install.len(), + pkg.name + )) ); } else if confirm( &format!( @@ -143,7 +146,13 @@ pub fn install_package( run_hook(cmd, &pkg.name)?; } } else { - println!(" skipped post_install hooks for {} (declined)", pkg.name); + eprintln!( + " {}", + ui::note(&format!( + "skipped post_install hooks for {} (declined)", + pkg.name + )) + ); } } @@ -163,7 +172,7 @@ pub fn install_package( Ok(()) })?; - println!(" {} installed successfully", pkg.name); + println!(" {}", ui::ok(&format!("{} installed", pkg.name))); warn_path_if_needed(bin_dir); Ok(()) } @@ -184,13 +193,21 @@ fn backup_current_binary(backup_dir: &Path, binary_name: &str, current_path: &Pa } if let Err(e) = std::fs::create_dir_all(backup_dir) { eprintln!( - " warning: could not create backup dir {} ({e}) — rollback won't be available for this update", - backup_dir.display() + " {}", + ui::warn(&format!( + "could not create backup dir {} ({e}) — rollback won't be available for this update", + backup_dir.display() + )) ); return; } if let Err(e) = std::fs::copy(current_path, backup_dir.join(binary_name)) { - eprintln!(" warning: could not back up {binary_name} before update ({e}) — rollback won't be available for this update"); + eprintln!( + " {}", + ui::warn(&format!( + "could not back up {binary_name} before update ({e}) — rollback won't be available for this update" + )) + ); } } @@ -199,10 +216,11 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b let installed = match installed { Some(p) => p, None => { - eprintln!("{pkg_name} is not installed"); + eprintln!(" {}", ui::fail(&format!("{pkg_name} is not installed"))); return Ok(()); } }; + ui::action("Removing", pkg_name, Some(&installed.version)); // State is already committed by with_lock above — everything from here // is best-effort file cleanup, and must all run even if part of it fails. @@ -214,7 +232,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b let path = bin_dir.join(bin); if path.exists() { match std::fs::remove_file(&path) { - Ok(()) => println!(" removed {}", path.display()), + Ok(()) => ui::step("removed", &path.display().to_string()), Err(e) => failures.push(format!("{}: {e}", path.display())), } } @@ -235,7 +253,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b let _ = Command::new("systemctl") .args(["--user", "daemon-reload"]) .status(); - println!(" removed unit {unit}"); + ui::step("removed", &format!("unit {unit}")); } } } @@ -247,7 +265,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b // surprise no flag should cause. if let Some(cfg_dir) = guess_config_dir(pkg_name) { if cfg_dir.exists() { - println!(" config preserved at {}", cfg_dir.display()); + ui::step("preserved", &format!("config {}", cfg_dir.display())); } } @@ -258,23 +276,34 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b let license_dir = share_dir.join("licenses").join(pkg_name); remove_purged_path(&license_dir, "license dir", true, assume_yes, &mut failures); - let desktop_file = share_dir.join("applications").join(format!("{pkg_name}.desktop")); - remove_purged_path(&desktop_file, "desktop entry", false, assume_yes, &mut failures); + let desktop_file = share_dir + .join("applications") + .join(format!("{pkg_name}.desktop")); + remove_purged_path( + &desktop_file, + "desktop entry", + false, + assume_yes, + &mut failures, + ); remove_purged_path(&data_dir, "data dir", true, assume_yes, &mut failures); } else if data_dir.exists() { - println!(" data preserved at {}", data_dir.display()); + ui::step("preserved", &format!("data {}", data_dir.display())); } if !failures.is_empty() { - eprintln!(" failed to remove {} item(s):", failures.len()); + eprintln!( + " {}", + ui::fail(&format!("failed to remove {} item(s):", failures.len())) + ); for f in &failures { eprintln!(" {f}"); } bail!("{pkg_name} removed from state, but some files could not be deleted"); } - println!(" {pkg_name} removed"); + println!(" {}", ui::ok(&format!("{pkg_name} removed"))); Ok(()) } @@ -284,12 +313,21 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b /// in place and prints the same "preserved at" wording the non-purge path /// already uses. Shared by `remove_package`'s three `--purge` targets /// (license dir, desktop entry, data dir). -fn remove_purged_path(path: &Path, label: &str, recursive: bool, assume_yes: bool, failures: &mut Vec) { +fn remove_purged_path( + path: &Path, + label: &str, + recursive: bool, + assume_yes: bool, + failures: &mut Vec, +) { if !path.exists() { return; } - if !confirm(&format!(" remove {label} at {}?", path.display()), assume_yes) { - println!(" {label} preserved at {}", path.display()); + if !confirm( + &format!(" remove {label} at {}?", path.display()), + assume_yes, + ) { + ui::step("preserved", &format!("{label} {}", path.display())); return; } let result = if recursive { @@ -298,7 +336,7 @@ fn remove_purged_path(path: &Path, label: &str, recursive: bool, assume_yes: boo std::fs::remove_file(path) }; match result { - Ok(()) => println!(" removed {}", path.display()), + Ok(()) => ui::step("removed", &path.display().to_string()), Err(e) => failures.push(format!("{}: {e}", path.display())), } } @@ -318,36 +356,48 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu Ok(()) => { std::fs::write(&dest, &bytes) .with_context(|| format!("writing {}", dest.display()))?; - println!(" installed example config at {}", dest.display()); + ui::step("config", &dest.display().to_string()); } Err(e) => { eprintln!( - " warning: checksum mismatch for example config {example}: {e} — not installed" + " {}", + ui::warn(&format!( + "checksum mismatch for example config {example}: {e} — not installed" + )) ); - println!(" config dir created at {}", dir.display()); + ui::step("config", &dir.display().to_string()); } }, None => { eprintln!( - " warning: index.json has no sha256 for example config \ - {example} — refusing to install an unverified download" + " {}", + ui::warn(&format!( + "index.json has no sha256 for example config \ + {example} — refusing to install an unverified download" + )) ); - println!(" config dir created at {}", dir.display()); + ui::step("config", &dir.display().to_string()); } }, Err(e) => { - eprintln!(" warning: could not download example config {example}: {e}"); - println!(" config dir created at {}", dir.display()); + eprintln!( + " {}", + ui::warn(&format!("could not download example config {example}: {e}")) + ); + ui::step("config", &dir.display().to_string()); } } } else { - println!(" config dir created at {}", dir.display()); + ui::step("config", &dir.display().to_string()); } } else { - println!(" config at {} already exists, skipping", dest.display()); + ui::step( + "config", + &format!("{} already exists, skipping", dest.display()), + ); } } else { - println!(" config dir created at {}", dir.display()); + ui::step("config", &dir.display().to_string()); } Ok(()) } @@ -366,32 +416,46 @@ fn fetch_verify_write( label: &str, ) -> Result<()> { let Some((primary, fallback)) = pkg.artifact_urls(filename) else { - eprintln!(" warning: no artifact URL to download {label} ({filename})"); + eprintln!( + " {}", + ui::warn(&format!("no artifact URL to download {label} ({filename})")) + ); return Ok(()); }; let bytes = match fetch_binary(&primary, &fallback) { Ok(b) => b, Err(e) => { - eprintln!(" warning: could not download {label} {filename}: {e}"); + eprintln!( + " {}", + ui::warn(&format!("could not download {label} {filename}: {e}")) + ); return Ok(()); } }; let Some(expected) = sha256 else { eprintln!( - " warning: index.json has no sha256 for {label} {filename} — \ - refusing to install an unverified download" + " {}", + ui::warn(&format!( + "index.json has no sha256 for {label} {filename} — \ + refusing to install an unverified download" + )) ); return Ok(()); }; if let Err(e) = verify_sha256(&bytes, expected) { - eprintln!(" warning: checksum mismatch for {label} {filename}: {e} — not installed"); + eprintln!( + " {}", + ui::warn(&format!( + "checksum mismatch for {label} {filename}: {e} — not installed" + )) + ); return Ok(()); } if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(dest, &bytes).with_context(|| format!("writing {}", dest.display()))?; - println!(" installed {label} at {}", dest.display()); + ui::step("installed", &format!("{label} {}", dest.display())); Ok(()) } @@ -411,7 +475,13 @@ fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> { .unwrap_or_else(|| PathBuf::from("~/.local/share")) .join("applications") .join(format!("{}.desktop", pkg.name)); - fetch_verify_write(pkg, filename, &pkg.desktop_file_sha256, &dest, "desktop entry") + fetch_verify_write( + pkg, + filename, + &pkg.desktop_file_sha256, + &dest, + "desktop entry", + ) } fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> { @@ -468,9 +538,15 @@ fn fetch_extract_archive( // `tmp_archive` (a `TempPath` guard) deletes the file when it drops here. if status.success() { - println!(" extracted {filename} to {}", dest_dir.display()); + ui::step( + "extracted", + &format!("{filename} → {}", dest_dir.display()), + ); } else { - eprintln!(" warning: tar exited with {status} extracting {filename}"); + eprintln!( + " {}", + ui::warn(&format!("tar exited with {status} extracting {filename}")) + ); } Ok(()) } @@ -552,18 +628,24 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { Ok(bytes) => { std::fs::write(&unit_path, &bytes) .with_context(|| format!("writing {}", unit_path.display()))?; - println!(" downloaded unit {}", unit_path.display()); + ui::step("unit", &unit_path.display().to_string()); } Err(e) => { if had_existing { eprintln!( - " warning: could not refresh unit {} ({e}) — keeping existing copy", - svc.unit + " {}", + ui::warn(&format!( + "could not refresh unit {} ({e}) — keeping existing copy", + svc.unit + )) ); } else { eprintln!( - " warning: unit file {} not found ({e}) — skipping service setup", - svc.unit + " {}", + ui::warn(&format!( + "unit file {} not found ({e}) — skipping service setup", + svc.unit + )) ); return Ok(()); } @@ -578,7 +660,7 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { .map(|s| s.success()) .unwrap_or(false) { - eprintln!(" warning: systemctl daemon-reload failed"); + eprintln!(" {}", ui::warn("systemctl daemon-reload failed")); } if svc.enable { @@ -595,9 +677,9 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { .map(|s| s.success()) .unwrap_or(false) { - println!(" {} restarted", svc.unit); + ui::step("restarted", &svc.unit); } else { - eprintln!(" warning: failed to restart {}", svc.unit); + eprintln!(" {}", ui::warn(&format!("failed to restart {}", svc.unit))); } } else if Command::new("systemctl") .args(["--user", "enable", "--now", &svc.unit]) @@ -605,9 +687,9 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { .map(|s| s.success()) .unwrap_or(false) { - println!(" {} enabled and started", svc.unit); + ui::step("enabled", &svc.unit); } else { - eprintln!(" warning: failed to enable {}", svc.unit); + eprintln!(" {}", ui::warn(&format!("failed to enable {}", svc.unit))); } } @@ -650,13 +732,13 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> { } fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> { - println!(" running post_install hook: {cmd}"); + ui::step("hook", cmd); let status = Command::new("sh") .args(["-c", cmd]) .status() .with_context(|| format!("running post_install hook for {pkg_name}"))?; if !status.success() { - eprintln!(" warning: hook exited with {status}"); + eprintln!(" {}", ui::warn(&format!("hook exited with {status}"))); } Ok(()) } @@ -699,11 +781,14 @@ fn warn_path_if_needed(bin_dir: &Path) { let path_var = std::env::var("PATH").unwrap_or_default(); let bin_str = bin_dir.to_string_lossy(); if !path_var.split(':').any(|p| p == bin_str) { - println!( - "\n note: {} is not in PATH — add to your shell profile:", - bin_str + eprintln!(); + eprintln!( + " {}", + ui::note(&format!( + "{bin_str} is not in PATH — add to your shell profile:" + )) ); - println!(" export PATH=\"{}:$PATH\"", bin_str); + println!(" export PATH=\"{bin_str}:$PATH\""); } } @@ -728,10 +813,7 @@ mod tests { if let Ok((mut stream, _)) = listener.accept() { let mut buf = [0u8; 1024]; let _ = stream.read(&mut buf); - let response = format!( - "HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", - body.len() - ); + let response = format!("HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()); let _ = stream.write_all(response.as_bytes()); let _ = stream.write_all(body); } @@ -748,10 +830,7 @@ mod tests { if let Ok((mut stream, _)) = listener.accept() { let mut buf = [0u8; 1024]; let _ = stream.read(&mut buf); - let response = format!( - "HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", - body.len() - ); + let response = format!("HTTP/1.0 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()); let _ = stream.write_all(response.as_bytes()); let _ = stream.write_all(&body); } @@ -797,8 +876,14 @@ mod tests { let dir = tempdir().unwrap(); let dest = dir.path().join("LICENSE"); - fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license") - .unwrap(); + fetch_verify_write( + &pkg, + "LICENSE", + &pkg.license_file_sha256.clone(), + &dest, + "license", + ) + .unwrap(); assert_eq!(fs::read(&dest).unwrap(), license_bytes); } @@ -836,8 +921,14 @@ mod tests { let dir = tempdir().unwrap(); let dest = dir.path().join("LICENSE"); - fetch_verify_write(&pkg, "LICENSE", &pkg.license_file_sha256.clone(), &dest, "license") - .unwrap(); + fetch_verify_write( + &pkg, + "LICENSE", + &pkg.license_file_sha256.clone(), + &dest, + "license", + ) + .unwrap(); // Refused, not erred (matches scaffold_config's warn-and-continue // posture) — the file must not have been written. @@ -885,8 +976,7 @@ mod tests { let pkg = test_package(&base_url); let dest_dir = tempdir().unwrap(); - fetch_extract_archive(&pkg, "content.tar.gz", &Some(sha256_hex), dest_dir.path()) - .unwrap(); + fetch_extract_archive(&pkg, "content.tar.gz", &Some(sha256_hex), dest_dir.path()).unwrap(); let extracted = dest_dir.path().join("content/tours/onboarding.toml"); assert_eq!(fs::read(&extracted).unwrap(), b"[[step]]\n"); @@ -1043,7 +1133,10 @@ mod tests { backup_current_binary(&backup_dir, "mypkg", ¤t); - assert_eq!(fs::read(backup_dir.join("mypkg")).unwrap(), b"old version bytes"); + assert_eq!( + fs::read(backup_dir.join("mypkg")).unwrap(), + b"old version bytes" + ); } #[test] diff --git a/bakery/src/main.rs b/bakery/src/main.rs index fc7e7d1..d3d09b4 100644 --- a/bakery/src/main.rs +++ b/bakery/src/main.rs @@ -14,7 +14,12 @@ use std::path::{Path, PathBuf}; use track::Track; #[derive(Parser)] -#[command(name = "bakery", about = "Package manager for the bread ecosystem", version)] +#[command( + name = "bakery", + about = "Package manager for the bread ecosystem", + version, + styles = ui::CLAP_STYLES +)] struct Cli { #[command(subcommand)] command: Cmd, @@ -63,13 +68,9 @@ enum Cmd { installed: bool, }, /// Show details for a package - Info { - package: String, - }, + Info { package: String }, /// Search package names and descriptions - Search { - query: String, - }, + Search { query: String }, /// Check system dependencies for installed or requested packages Doctor { /// Package to check; omit to check all installed packages @@ -82,15 +83,11 @@ enum Cmd { }, /// Roll back a package to its previously installed version, from a /// local pre-update backup (not a re-download) - Rollback { - package: String, - }, + Rollback { package: String }, /// Update bakery itself SelfUpdate, /// Generate a shell completion script - Completions { - shell: clap_complete::Shell, - }, + Completions { shell: clap_complete::Shell }, /// View or switch which build track bakery follows (stable/beta/dev) Track { #[command(subcommand)] @@ -130,9 +127,15 @@ fn main() -> Result<()> { Ok(()) } Cmd::Remove { package, purge } => cmd_remove(&package, &bin_dir, assume_yes, purge), - Cmd::Update { package, all } => { - cmd_update(package.as_deref(), all, &bin_dir, track, no_hooks, assume_yes, dry_run) - } + Cmd::Update { package, all } => cmd_update( + package.as_deref(), + all, + &bin_dir, + track, + no_hooks, + assume_yes, + dry_run, + ), Cmd::List { installed } => cmd_list(installed, track), Cmd::Info { package } => cmd_info(&package, track), Cmd::Search { query } => cmd_search(&query, track), @@ -144,7 +147,15 @@ fn main() -> Result<()> { // bakery's own running binary via a normal update already works // (rename-over-running-binary is safe on Linux) but wasn't a real // first-class command. - Cmd::SelfUpdate => cmd_update(Some("bakery"), false, &bin_dir, track, no_hooks, assume_yes, dry_run), + Cmd::SelfUpdate => cmd_update( + Some("bakery"), + false, + &bin_dir, + track, + no_hooks, + assume_yes, + dry_run, + ), Cmd::Completions { shell } => cmd_completions(shell), Cmd::Track { action } => cmd_track(action), } @@ -159,11 +170,11 @@ fn cmd_track(action: TrackCmd) -> Result<()> { let state = state::State::load()?; match action { TrackCmd::Show => { - println!("current track: {}", ui::style(state.track.as_str(), ui::CYAN)); + ui::heading("Track", &[&ui::style(state.track.as_str(), ui::CYAN)]); } TrackCmd::Set { track } => { if state.track == track { - println!("already on track {track}"); + println!("{}", ui::unchanged(&format!("already on track {track}"))); return Ok(()); } // Fail fast on a bad/unreachable track rather than silently @@ -174,10 +185,10 @@ fn cmd_track(action: TrackCmd) -> Result<()> { state.set_track(track); Ok(()) })?; - println!( - "switched to {} — run 'bakery update --all' to install {} builds", - ui::style(track.as_str(), ui::CYAN), - track + ui::action("Switched", track.as_str(), None); + ui::step( + "next", + &format!("bakery update --all to install {track} builds"), ); } } @@ -195,7 +206,16 @@ fn cmd_install( dry_run: bool, ) -> Result<()> { let mut visited = HashSet::new(); - install_with_deps(index, name, bin_dir, track, no_hooks, assume_yes, dry_run, &mut visited) + install_with_deps( + index, + name, + bin_dir, + track, + no_hooks, + assume_yes, + dry_run, + &mut visited, + ) } /// Recursively installs `name` and any bread_deps, skipping already-installed @@ -223,8 +243,10 @@ fn install_with_deps( let state = state::State::load()?; for dep in pkg.bread_deps.clone() { if !state.is_installed(&dep) { - println!("{} bread dependency: {dep}", if dry_run { "would install" } else { "installing" }); - install_with_deps(index, &dep, bin_dir, track, no_hooks, assume_yes, dry_run, visited)?; + ui::step(if dry_run { "would need" } else { "dependency" }, &dep); + install_with_deps( + index, &dep, bin_dir, track, no_hooks, assume_yes, dry_run, visited, + )?; } } @@ -237,26 +259,59 @@ fn install_with_deps( if let Some(installed) = previous { if !is_newer(&installed.version, &pkg.version) { println!( - "{name} already installed at {} (index has {})", - installed.version, pkg.version + " {}", + ui::unchanged(&format!( + "{name} already at {} (index has {})", + installed.version, pkg.version + )) ); return Ok(()); } } - println!("checking system dependencies for {name}…"); + ui::action( + if dry_run { + if previous.is_some() { + "Would update" + } else { + "Would install" + } + } else if previous.is_some() { + "Updating" + } else { + "Installing" + }, + name, + Some(&pkg.version), + ); + ui::step("checking", "system dependencies"); let rep = doctor::check_deps(&pkg.system_deps, &pkg.optional_system_deps)?; for warn in &rep.warnings { - eprintln!(" note: optional dep not installed: {warn}"); + eprintln!( + " {}", + ui::note(&format!("optional dep not installed: {warn}")) + ); } if !rep.missing.is_empty() { - eprintln!("missing system deps for {name}: {}", rep.missing.join(", ")); - eprintln!("install with: {}", doctor::install_hint(&rep.missing)); + eprintln!( + " {}", + ui::fail(&format!( + "missing system deps for {name}: {}", + rep.missing.join(", ") + )) + ); + eprintln!( + " {}", + ui::dim(&format!( + "install with: {}", + doctor::install_hint(&rep.missing) + )) + ); bail!("system deps not satisfied"); } if dry_run { - print_dry_run_plan(pkg, previous); + print_dry_run_plan(pkg); return Ok(()); } @@ -268,22 +323,23 @@ fn install_with_deps( /// already been made — this only renders that decision, it never /// recomputes it, so dry-run and real runs can't drift apart on "would this /// update happen at all". -fn print_dry_run_plan(pkg: &manifest::Package, previous: Option<&state::InstalledPackage>) { - let verb = if previous.is_some() { "update" } else { "install" }; - println!( - " {} would {verb} {} to {}", - ui::style("dry-run:", ui::DIM), - pkg.name, - ui::style(&pkg.version, ui::BOLD) - ); - println!( - " binaries: {}", - pkg.binaries.iter().map(|b| b.name.as_str()).collect::>().join(", ") +fn print_dry_run_plan(pkg: &manifest::Package) { + ui::kv( + "binaries", + &pkg.binaries + .iter() + .map(|b| b.name.as_str()) + .collect::>() + .join(", "), ); if !pkg.services.is_empty() { - println!( - " services: {}", - pkg.services.iter().map(|s| s.unit.as_str()).collect::>().join(", ") + ui::kv( + "services", + &pkg.services + .iter() + .map(|s| s.unit.as_str()) + .collect::>() + .join(", "), ); } } @@ -311,10 +367,16 @@ fn cmd_update( }; if targets.is_empty() { - println!("no packages installed"); + println!("{}", ui::dim("no packages installed")); return Ok(()); } + let mut targets = targets; + targets.sort(); + if all { + ui::heading("Update", &[&format!("{} packages", targets.len())]); + } + let mut any_failed = false; let mut updated = 0u32; let mut unchanged = 0u32; @@ -322,7 +384,10 @@ fn cmd_update( let installed = match state.packages.get(pkg_name.as_str()) { Some(p) => p, None => { - eprintln!("{pkg_name} is not installed, skipping"); + eprintln!( + " {}", + ui::fail(&format!("{pkg_name} is not installed, skipping")) + ); any_failed = true; continue; } @@ -330,7 +395,10 @@ fn cmd_update( let latest = match index.get(pkg_name) { Some(p) => p, None => { - eprintln!("{pkg_name} not found in index, skipping"); + eprintln!( + " {}", + ui::fail(&format!("{pkg_name} not found in index, skipping")) + ); any_failed = true; continue; } @@ -350,57 +418,86 @@ fn cmd_update( // under a terminal palette that maps ANSI colors unusually. println!( " {}", - ui::unchanged(&format!("{pkg_name} is already at {}", installed.version)) + ui::unchanged(&format!("{pkg_name} already at {}", installed.version)) ); unchanged += 1; continue; } + ui::action( + if dry_run { "Would update" } else { "Updating" }, + pkg_name, + Some(&latest.version), + ); if track_switch { - println!( - "{pkg_name} switching track {} {} {}, installing {}", - ui::style(installed.track.as_str(), ui::DIM), - ui::style("→", ui::CYAN), - ui::style(track.as_str(), ui::BOLD), - ui::style(&latest.version, ui::BOLD) + ui::step( + "track", + &format!( + "{} {} {}", + ui::dim(installed.track.as_str()), + ui::style("→", ui::CYAN), + ui::bold(track.as_str()), + ), ); } else { - println!( - "updating {pkg_name} {} {} {}", - ui::style(&installed.version, ui::DIM), - ui::style("→", ui::CYAN), - ui::style(&latest.version, ui::BOLD) + ui::step( + "version", + &format!( + "{} {} {}", + ui::dim(&installed.version), + ui::style("→", ui::CYAN), + ui::bold(&latest.version) + ), ); } let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) { Ok(r) => r, Err(e) => { - eprintln!(" doctor check failed for {pkg_name}: {e}"); + eprintln!( + " {}", + ui::fail(&format!("doctor check failed for {pkg_name}: {e}")) + ); any_failed = true; continue; } }; for warn in &rep.warnings { - eprintln!(" note: optional dep not installed: {warn}"); + eprintln!( + " {}", + ui::note(&format!("optional dep not installed: {warn}")) + ); } if !rep.missing.is_empty() { eprintln!( - " missing deps for {pkg_name}: {} — skipping update", - rep.missing.join(", ") + " {}", + ui::fail(&format!( + "missing deps for {pkg_name}: {} — skipping update", + rep.missing.join(", ") + )) ); any_failed = true; continue; } if dry_run { - print_dry_run_plan(latest, Some(installed)); + print_dry_run_plan(latest); updated += 1; continue; } - if let Err(e) = install::install_package(latest, bin_dir, track, Some(installed), no_hooks, assume_yes) { - eprintln!(" failed to update {pkg_name}: {e}"); + if let Err(e) = install::install_package( + latest, + bin_dir, + track, + Some(installed), + no_hooks, + assume_yes, + ) { + eprintln!( + " {}", + ui::fail(&format!("failed to update {pkg_name}: {e}")) + ); any_failed = true; } else { updated += 1; @@ -410,12 +507,14 @@ fn cmd_update( // Only for --all: a single named update already makes its own outcome // obvious, and "1 updated, 0 already up to date" isn't a useful takeaway. if all { - let mut parts = Vec::new(); + let updated_s = format!("{updated} updated"); + let unchanged_s = format!("{unchanged} already current"); + let mut parts: Vec<&str> = Vec::new(); if updated > 0 { - parts.push(format!("{updated} updated")); + parts.push(&updated_s); } - parts.push(format!("{unchanged} already up to date")); - println!("{}", ui::style(&parts.join(", "), ui::BOLD)); + parts.push(&unchanged_s); + ui::summary(&parts); } if any_failed { @@ -427,7 +526,12 @@ fn cmd_update( /// Whether `pkg_name` should be updated: always true on a track switch /// (an explicit user action that must take effect regardless of version /// ordering), otherwise a real semver comparison via [`is_newer`]. -fn should_update(installed_version: &str, installed_track: Track, active_track: Track, latest_version: &str) -> bool { +fn should_update( + installed_version: &str, + installed_track: Track, + active_track: Track, + latest_version: &str, +) -> bool { if installed_track != active_track { return true; } @@ -441,7 +545,10 @@ fn should_update(installed_version: &str, installed_track: Track, active_track: /// (with a warning) for any version string that isn't valid semver, rather /// than hard-erroring on packages built before this convention existed. fn is_newer(installed: &str, latest: &str) -> bool { - match (semver::Version::parse(installed), semver::Version::parse(latest)) { + match ( + semver::Version::parse(installed), + semver::Version::parse(latest), + ) { (Ok(i), Ok(l)) => l > i, _ => { if installed != latest { @@ -455,15 +562,14 @@ fn is_newer(installed: &str, latest: &str) -> bool { } } -/// Prints one index entry in the shared `list`/`search` format: name, -/// version, description, and an `[installed ]` tag when applicable. -fn print_index_entry(pkg: &manifest::Package, state: &state::State) { - let tag = if state.is_installed(&pkg.name) { - ui::style(&format!(" [installed {}]", state.packages[&pkg.name].version), ui::GREEN) - } else { - String::new() - }; - println!(" {:<14} {:<10} — {}{}", pkg.name, pkg.version, pkg.description, tag); +fn catalog_row(pkg: &manifest::Package, state: &state::State) -> ui::CatalogRow { + ui::CatalogRow { + name: pkg.name.clone(), + version: pkg.version.clone(), + installed: state.is_installed(&pkg.name), + detail: pkg.description.clone(), + aside: String::new(), + } } fn cmd_list(installed_only: bool, track: Track) -> Result<()> { @@ -471,24 +577,44 @@ fn cmd_list(installed_only: bool, track: Track) -> Result<()> { if installed_only { if state.packages.is_empty() { - println!("no packages installed"); - } - for pkg in state.packages.values() { - println!(" {} {} (installed {})", pkg.name, pkg.version, pkg.installed_at); + println!("{}", ui::dim("no packages installed")); + return Ok(()); } + let mut pkgs: Vec<_> = state.packages.values().collect(); + pkgs.sort_by(|a, b| a.name.cmp(&b.name)); + ui::heading("Installed", &[&pkgs.len().to_string()]); + let rows: Vec = pkgs + .iter() + .map(|pkg| ui::CatalogRow { + name: pkg.name.clone(), + version: pkg.version.clone(), + installed: true, + detail: String::new(), + aside: ui::short_date(&pkg.installed_at), + }) + .collect(); + ui::print_catalog(&rows); return Ok(()); } - if !matches!(track, Track::Stable) { - println!("tracking:{}\n", ui::track_badge(track)); - } - let index = manifest::load(false, track)?; let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect(); names.sort(); - for name in names { - print_index_entry(&index.packages[name], &state); - } + let installed = names.iter().filter(|n| state.is_installed(n)).count(); + let track_part = ui::track_tag(track); + ui::heading( + "Packages", + &[ + &format!("{} in index", names.len()), + &format!("{installed} installed"), + &track_part, + ], + ); + let rows: Vec = names + .iter() + .map(|name| catalog_row(&index.packages[*name], &state)) + .collect(); + ui::print_catalog(&rows); Ok(()) } @@ -513,13 +639,19 @@ fn cmd_search(query: &str, track: Track) -> Result<()> { names.sort(); if names.is_empty() { - println!("no packages matched '{query}'"); + println!("{}", ui::dim(&format!("no packages matched '{query}'"))); return Ok(()); } - for name in names { - print_index_entry(&index.packages[name], &state); - } + ui::heading( + "Search", + &[&format!("'{query}'"), &format!("{} matches", names.len())], + ); + let rows: Vec = names + .iter() + .map(|name| catalog_row(&index.packages[*name], &state)) + .collect(); + ui::print_catalog(&rows); Ok(()) } @@ -531,41 +663,45 @@ fn cmd_info(name: &str, track: Track) -> Result<()> { let state = state::State::load()?; let status = if let Some(inst) = state.packages.get(name) { - ui::style(&format!("installed ({})", inst.version), ui::GREEN) + ui::style(&format!("installed {}", inst.version), ui::GREEN) } else { ui::style("not installed", ui::DIM) }; - println!("{}{} {}", ui::style(&pkg.name, ui::BOLD), ui::track_badge(track), pkg.version); - println!(" {}", pkg.description); - println!(" status: {status}"); - println!( - " binaries: {}", - pkg.binaries + let track_part = ui::track_tag(track); + ui::heading(&pkg.name, &[&ui::dim(&pkg.version), &track_part]); + println!(" {}\n", pkg.description); + ui::kv("status", &status); + ui::kv( + "binaries", + &pkg.binaries .iter() .map(|b| b.name.as_str()) .collect::>() - .join(", ") + .join(", "), ); if !pkg.system_deps.is_empty() { - println!(" system deps: {}", pkg.system_deps.join(", ")); + ui::kv("system deps", &pkg.system_deps.join(", ")); } if !pkg.optional_system_deps.is_empty() { - println!(" optional deps: {}", pkg.optional_system_deps.join(", ")); + ui::kv("optional", &pkg.optional_system_deps.join(", ")); } if !pkg.bread_deps.is_empty() { - println!(" bread deps: {}", pkg.bread_deps.join(", ")); + ui::kv("bread deps", &pkg.bread_deps.join(", ")); } if !pkg.services.is_empty() { - println!( - " services: {}", - pkg.services + ui::kv( + "services", + &pkg.services .iter() .map(|s| s.unit.as_str()) .collect::>() - .join(", ") + .join(", "), ); } + if let Some(inst) = state.packages.get(name) { + ui::kv("installed", &ui::short_date(&inst.installed_at)); + } Ok(()) } @@ -584,18 +720,33 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re }; if targets.is_empty() { - println!("no packages installed — nothing to check"); + println!("{}", ui::dim("no packages installed — nothing to check")); return Ok(()); } + let mut targets = targets; + targets.sort(); + ui::heading("Doctor", &[&format!("{} packages", targets.len())]); + let name_w = ui::name_width(&targets); + let mut all_ok = true; for pkg_name in &targets { if let Some(pkg) = index.get(pkg_name) { - if !doctor::report(pkg_name, &pkg.system_deps, &pkg.optional_system_deps) { + if !doctor::report( + pkg_name, + &pkg.system_deps, + &pkg.optional_system_deps, + name_w, + ) { all_ok = false; } } else { - eprintln!(" {pkg_name}: not found in index (removed from registry?)"); + ui::check_row( + false, + pkg_name, + name_w, + "not found in index (removed from registry?)", + ); all_ok = false; } @@ -621,7 +772,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re } if all_ok { - println!("{}", ui::ok("all checks passed")); + ui::summary(&[&ui::ok("all checks passed")]); } Ok(()) } @@ -674,38 +825,47 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> { }; if targets.is_empty() { - println!("no packages installed — nothing to verify"); + println!("{}", ui::dim("no packages installed — nothing to verify")); return Ok(()); } + let mut targets = targets; + targets.sort(); + ui::heading("Verify", &[&format!("{} packages", targets.len())]); + let name_w = ui::name_width(&targets); + let mut any_bad = false; for pkg_name in &targets { let installed = &state.packages[pkg_name]; if installed.binary_sha256.is_empty() { - println!( - " {} {pkg_name}: no recorded checksums (installed before 'bakery verify' support)", - ui::style("?", ui::DIM) + ui::unknown_row( + pkg_name, + name_w, + "no recorded checksums (installed before verify support)", ); continue; } for bin in &installed.binaries { match verify_binary(bin_dir, bin, installed.binary_sha256.get(bin)) { - VerifyStatus::Ok => println!(" {}", ui::ok(&format!("{pkg_name}: {bin}"))), + VerifyStatus::Ok => ui::check_row(true, pkg_name, name_w, bin), VerifyStatus::Missing => { - eprintln!(" {}", ui::fail(&format!("{pkg_name}: {bin} — MISSING"))); + ui::check_row(false, pkg_name, name_w, &format!("{bin} missing")); any_bad = true; } VerifyStatus::Tampered => { - eprintln!( - " {}", - ui::fail(&format!("{pkg_name}: {bin} — TAMPERED (checksum mismatch)")) + ui::check_row( + false, + pkg_name, + name_w, + &format!("{bin} tampered (checksum mismatch)"), ); any_bad = true; } VerifyStatus::Unknown => { - println!( - " {} {pkg_name}: {bin} — UNKNOWN (no recorded checksum for this binary)", - ui::style("?", ui::DIM) + ui::unknown_row( + pkg_name, + name_w, + &format!("{bin} no recorded checksum for this binary"), ); } } @@ -715,7 +875,7 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> { if any_bad { bail!("verification failed for one or more binaries"); } - println!("{}", ui::ok("all recorded checksums match")); + ui::summary(&[&ui::ok("all recorded checksums match")]); Ok(()) } @@ -728,12 +888,19 @@ fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> { /// why rollback is backup-based rather than a network re-pin in the first /// place. Pure with respect to global state (caller supplies both dirs), so /// this is the piece of `bakery rollback` that's directly unit-testable. -fn restore_binaries(backup_dir: &Path, binaries: &[String], bin_dir: &Path) -> Result> { +fn restore_binaries( + backup_dir: &Path, + binaries: &[String], + bin_dir: &Path, +) -> Result> { let mut sha256 = HashMap::new(); for bin in binaries { let backup_path = backup_dir.join(bin); if !backup_path.exists() { - bail!("backup for binary '{bin}' is missing at {}", backup_path.display()); + bail!( + "backup for binary '{bin}' is missing at {}", + backup_path.display() + ); } let bytes = std::fs::read(&backup_path) .with_context(|| format!("reading backup {}", backup_path.display()))?; @@ -767,6 +934,8 @@ fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> { anyhow::anyhow!("no previous version recorded for {pkg_name} — nothing to roll back to") })?; + ui::action("Rolling back", pkg_name, Some(&target_version)); + let backup_dir = state::backup_dir(pkg_name, &target_version); if !backup_dir.exists() { bail!( @@ -794,7 +963,10 @@ fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> { println!( " {}", - ui::ok(&format!("rolled back {pkg_name} {from_version} → {target_version}")) + ui::ok(&format!( + "rolled back {pkg_name} {} → {target_version}", + ui::dim(&from_version) + )) ); Ok(()) } @@ -838,18 +1010,33 @@ mod tests { fn should_update_true_on_track_switch_even_if_not_newer_by_semver() { // "bakery track set stable && bakery update --all" from beta must // always take effect, even though 0.3.0 < 0.4.0-beta by strict semver. - assert!(should_update("0.4.0-beta", Track::Beta, Track::Stable, "0.3.0")); + assert!(should_update( + "0.4.0-beta", + Track::Beta, + Track::Stable, + "0.3.0" + )); } #[test] fn should_update_false_when_same_track_and_not_newer() { - assert!(!should_update("0.3.1", Track::Stable, Track::Stable, "0.3.1")); + assert!(!should_update( + "0.3.1", + Track::Stable, + Track::Stable, + "0.3.1" + )); assert!(!should_update("0.3.2", Track::Dev, Track::Dev, "0.3.1")); } #[test] fn should_update_true_when_same_track_and_newer() { - assert!(should_update("0.3.1", Track::Stable, Track::Stable, "0.3.2")); + assert!(should_update( + "0.3.1", + Track::Stable, + Track::Stable, + "0.3.2" + )); } #[test] @@ -902,7 +1089,10 @@ mod tests { let pkg = empty_binary_package(name, "9.9.9", "http://127.0.0.1:1/unreachable"); let mut packages = std::collections::HashMap::new(); packages.insert(name.to_string(), pkg); - let index = manifest::Index { version: "1".to_string(), packages }; + let index = manifest::Index { + version: "1".to_string(), + packages, + }; let bin_dir = tempdir().unwrap(); let mut visited = HashSet::new(); @@ -926,7 +1116,10 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join("mypkg"), b"good bytes").unwrap(); let hash = hex::encode(Sha256::digest(b"good bytes")); - assert_eq!(verify_binary(dir.path(), "mypkg", Some(&hash)), VerifyStatus::Ok); + assert_eq!( + verify_binary(dir.path(), "mypkg", Some(&hash)), + VerifyStatus::Ok + ); } #[test] @@ -934,21 +1127,30 @@ mod tests { let dir = tempdir().unwrap(); fs::write(dir.path().join("mypkg"), b"tampered bytes").unwrap(); let wrong_hash = "0".repeat(64); - assert_eq!(verify_binary(dir.path(), "mypkg", Some(&wrong_hash)), VerifyStatus::Tampered); + assert_eq!( + verify_binary(dir.path(), "mypkg", Some(&wrong_hash)), + VerifyStatus::Tampered + ); } #[test] fn verify_binary_missing_when_file_absent() { let dir = tempdir().unwrap(); let hash = "0".repeat(64); - assert_eq!(verify_binary(dir.path(), "nope", Some(&hash)), VerifyStatus::Missing); + assert_eq!( + verify_binary(dir.path(), "nope", Some(&hash)), + VerifyStatus::Missing + ); } #[test] fn verify_binary_unknown_when_no_recorded_hash() { let dir = tempdir().unwrap(); fs::write(dir.path().join("mypkg"), b"bytes").unwrap(); - assert_eq!(verify_binary(dir.path(), "mypkg", None), VerifyStatus::Unknown); + assert_eq!( + verify_binary(dir.path(), "mypkg", None), + VerifyStatus::Unknown + ); } #[test] @@ -962,8 +1164,14 @@ mod tests { let hashes = restore_binaries(&backup_dir, &["mypkg".to_string()], &bin_dir).unwrap(); - assert_eq!(fs::read(bin_dir.join("mypkg")).unwrap(), b"old version bytes"); - assert_eq!(hashes["mypkg"], hex::encode(Sha256::digest(b"old version bytes"))); + assert_eq!( + fs::read(bin_dir.join("mypkg")).unwrap(), + b"old version bytes" + ); + assert_eq!( + hashes["mypkg"], + hex::encode(Sha256::digest(b"old version bytes")) + ); } #[test] diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 5b5f5aa..5cf4e9c 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -52,8 +52,7 @@ fn verify_index_signature(bytes: &[u8], sig_text: &str) -> Result<()> { /// exercise the verification logic with a throwaway keypair instead of the /// real production key. fn verify_against_key(bytes: &[u8], sig_text: &str, pubkey_b64: &str) -> Result<()> { - let public_key = - PublicKey::from_base64(pubkey_b64).context("public key is malformed")?; + let public_key = PublicKey::from_base64(pubkey_b64).context("public key is malformed")?; let signature = Signature::decode(sig_text).context("index.json.minisig is malformed or unreadable")?; public_key @@ -183,9 +182,7 @@ pub fn load(force_refresh: bool, track: Track) -> Result { match read_and_verify_cache(&cache_path, &sig_cache_path, track) { Ok(index) => return Ok(index), Err(err) => { - eprintln!( - " warning: cached index.json failed verification ({err}), re-fetching…" - ); + eprintln!(" warning: cached index.json failed verification ({err}), re-fetching…"); } } } @@ -211,17 +208,12 @@ pub fn load(force_refresh: bool, track: Track) -> Result { } } -fn read_and_verify_cache( - cache_path: &Path, - sig_cache_path: &Path, - track: Track, -) -> Result { +fn read_and_verify_cache(cache_path: &Path, sig_cache_path: &Path, track: Track) -> Result { let bytes = std::fs::read(cache_path).context("reading cached index")?; let sig_text = std::fs::read_to_string(sig_cache_path) .context("reading cached index.json.minisig (cache predates signing support)")?; - verify_index_signature(&bytes, &sig_text).with_context(|| { - format!("cached {track} index failed signature verification") - })?; + verify_index_signature(&bytes, &sig_text) + .with_context(|| format!("cached {track} index failed signature verification"))?; serde_json::from_slice(&bytes).context("parsing cached index") } @@ -285,8 +277,10 @@ pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result> { Ok(bytes) => Ok(bytes), Err(primary_err) => { eprintln!( - " primary URL failed ({}), trying GitHub fallback…", - primary_err + " {}", + crate::ui::note(&format!( + "primary URL failed ({primary_err}), trying GitHub fallback…" + )) ); fetch_bytes(fallback_url).context("both primary and GitHub fallback failed") } @@ -305,9 +299,7 @@ const CHUNK_SIZE: usize = 64 * 1024; fn fetch_bytes(url: &str) -> Result> { use std::io::{IsTerminal, Read}; - let resp = ureq::get(url) - .call() - .map_err(|e| anyhow::anyhow!("{e}"))?; + let resp = ureq::get(url).call().map_err(|e| anyhow::anyhow!("{e}"))?; let status = resp.status(); if status != 200 { bail!("HTTP {status} from {url}"); @@ -320,7 +312,11 @@ fn fetch_bytes(url: &str) -> Result> { // is what makes printing partway through the download possible, without // pulling in a progress-bar crate for what's meant to just be reassurance. let content_length: Option = resp.header("Content-Length").and_then(|v| v.parse().ok()); - let show_progress = content_length.is_some() && std::io::stderr().is_terminal(); + // Progress is reassurance for multi-MB binaries. A 4 KB index fetch + // drawing a 100% / 0.0 MB bar is noise, not feedback. + const MIN_PROGRESS_BYTES: u64 = 256 * 1024; + let show_progress = + content_length.is_some_and(|n| n >= MIN_PROGRESS_BYTES) && std::io::stderr().is_terminal(); let mut buf = Vec::new(); let mut reader = resp.into_reader(); @@ -336,27 +332,17 @@ fn fetch_bytes(url: &str) -> Result> { bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit"); } if show_progress && last_print.elapsed() >= PROGRESS_THROTTLE { - print_progress(buf.len() as u64, content_length.unwrap()); + crate::ui::print_progress(buf.len() as u64, content_length.unwrap()); last_print = std::time::Instant::now(); } } if show_progress { - print_progress(buf.len() as u64, content_length.unwrap()); - eprintln!(); + crate::ui::print_progress(buf.len() as u64, content_length.unwrap()); + crate::ui::finish_progress(); } Ok(buf) } -fn print_progress(downloaded: u64, total: u64) { - use std::io::Write; - eprint!( - "\r ⇣ {:.1}/{:.1} MB", - downloaded as f64 / 1_048_576.0, - total as f64 / 1_048_576.0 - ); - let _ = std::io::stderr().flush(); -} - #[cfg(test)] mod tests { use super::*; @@ -408,10 +394,7 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+ fn stable_cache_path_matches_pre_track_filename() { // Must stay exactly "index.json" so an existing warm cache from a // pre-track bakery binary is still used after an upgrade. - assert_eq!( - cache_path(Track::Stable).file_name().unwrap(), - "index.json" - ); + assert_eq!(cache_path(Track::Stable).file_name().unwrap(), "index.json"); } #[test] @@ -428,13 +411,22 @@ znmVfINB4jFDR2a4wuY8rOKlUBeSDOFjMkHYDXV3vxvAjK+r4V12ae9ZRQkfVtQ1YIEmFXbnJfbxywg+ #[test] fn stable_url_has_no_track_prefix() { - assert_eq!(primary_url(Track::Stable), format!("{}/index.json", base_url())); + assert_eq!( + primary_url(Track::Stable), + format!("{}/index.json", base_url()) + ); } #[test] fn beta_and_dev_urls_are_track_prefixed() { - assert_eq!(primary_url(Track::Beta), format!("{}/beta/index.json", base_url())); - assert_eq!(primary_url(Track::Dev), format!("{}/dev/index.json", base_url())); + assert_eq!( + primary_url(Track::Beta), + format!("{}/beta/index.json", base_url()) + ); + assert_eq!( + primary_url(Track::Dev), + format!("{}/dev/index.json", base_url()) + ); } fn minimal_package_json() -> &'static str { diff --git a/bakery/src/ui.rs b/bakery/src/ui.rs index c1408f3..0a90390 100644 --- a/bakery/src/ui.rs +++ b/bakery/src/ui.rs @@ -1,5 +1,6 @@ use crate::track::Track; -use std::io::IsTerminal; +use clap::builder::styling::{AnsiColor, Effects, Styles}; +use std::io::{IsTerminal, Write}; pub const RESET: &str = "\x1b[0m"; pub const BOLD: &str = "\x1b[1m"; @@ -9,6 +10,19 @@ pub const GREEN: &str = "\x1b[32m"; pub const YELLOW: &str = "\x1b[33m"; pub const CYAN: &str = "\x1b[36m"; pub const MAGENTA: &str = "\x1b[35m"; +pub const BOLD_CYAN: &str = "\x1b[1;36m"; + +/// Clap help styling — same cyan headers / green literals / dim placeholders +/// as the rest of bakery, so `bakery --help` doesn't look like a different +/// program from `bakery list`. +pub const CLAP_STYLES: Styles = Styles::styled() + .header(AnsiColor::Cyan.on_default().effects(Effects::BOLD)) + .usage(AnsiColor::Cyan.on_default().effects(Effects::BOLD)) + .literal(AnsiColor::Green.on_default().effects(Effects::BOLD)) + .placeholder(AnsiColor::BrightBlack.on_default()) + .error(AnsiColor::Red.on_default().effects(Effects::BOLD)) + .valid(AnsiColor::Green.on_default().effects(Effects::BOLD)) + .invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD)); /// Colors are on only when stdout is a real terminal and `NO_COLOR` isn't /// set — the ecosystem's existing CLI (breadcrumbs) hardcodes ANSI @@ -18,21 +32,52 @@ pub fn colors_enabled() -> bool { std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() } +pub fn colors_enabled_err() -> bool { + std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal() +} + pub fn style(s: &str, code: &str) -> String { - if colors_enabled() { + paint(s, code, colors_enabled()) +} + +fn style_err(s: &str, code: &str) -> String { + paint(s, code, colors_enabled_err()) +} + +fn paint(s: &str, code: &str, on: bool) -> String { + if on { format!("{code}{s}{RESET}") } else { s.to_string() } } +pub fn bold(s: &str) -> String { + style(s, BOLD) +} + +pub fn dim(s: &str) -> String { + style(s, DIM) +} + /// `" [beta]"` / `" [dev]"`, colored — empty string for `Stable` so the /// common-case output is unchanged. +#[allow(dead_code)] pub fn track_badge(track: Track) -> String { + let tag = track_tag(track); + if tag.is_empty() { + tag + } else { + format!(" {tag}") + } +} + +/// `[beta]` / `[dev]` with no leading space; empty for `Stable`. +pub fn track_tag(track: Track) -> String { match track { Track::Stable => String::new(), - Track::Beta => format!(" {}", style("[beta]", YELLOW)), - Track::Dev => format!(" {}", style("[dev]", MAGENTA)), + Track::Beta => style("[beta]", YELLOW), + Track::Dev => style("[dev]", MAGENTA), } } @@ -54,6 +99,278 @@ pub fn unchanged(s: &str) -> String { style(&format!("· {s}"), DIM) } +pub fn warn(s: &str) -> String { + style(&format!("warning: {s}"), YELLOW) +} + +pub fn note(s: &str) -> String { + style(&format!("note: {s}"), DIM) +} + +/// Cyan verb + bold name + dim version — the install/update/remove banner. +pub fn action(verb: &str, name: &str, version: Option<&str>) { + let mut line = format!("{} {}", style(verb, BOLD_CYAN), style(name, BOLD)); + if let Some(v) = version { + line.push_str(" "); + line.push_str(&style(v, DIM)); + } + println!("{line}"); +} + +/// Section title plus dim meta (`Packages 16 · 15 installed`). +pub fn heading(title: &str, parts: &[&str]) { + let mut line = style(title, BOLD_CYAN); + let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect(); + for (i, part) in visible.iter().enumerate() { + line.push_str(" "); + if i > 0 { + line.push_str(&style("·", DIM)); + line.push_str(" "); + } + line.push_str(part); + } + println!("{line}"); + println!(); +} + +pub fn summary(parts: &[&str]) { + let visible: Vec<&str> = parts.iter().copied().filter(|p| !p.is_empty()).collect(); + if visible.is_empty() { + return; + } + println!(); + println!("{}", style(&visible.join(" · "), BOLD)); +} + +/// Left-aligned verb column so install chatter (`downloading` / `placed` / +/// `unit`) lines up instead of drifting with the verb length. +pub fn step(verb: &str, detail: &str) { + println!(" {:<12} {}", dim(verb), detail); +} + +pub fn kv(key: &str, value: &str) { + println!(" {:<12} {}", dim(key), value); +} + +pub fn check_row(ok_flag: bool, name: &str, name_width: usize, message: &str) { + let glyph = if ok_flag { + style("✓", GREEN) + } else { + style("✗", RED) + }; + println!(" {glyph} {: Vec { + if rows.is_empty() { + return Vec::new(); + } + let name_w = rows.iter().map(|r| r.name.len()).max().unwrap_or(0); + let indent = 5; // " ✓ " / " " + let detail_width = width.saturating_sub(indent).max(24); + + let mut lines = Vec::new(); + for row in rows { + let glyph = if row.installed { + style("✓", GREEN) + } else { + " ".to_string() + }; + let name = style(&format!("{: Vec { + if width == 0 { + return vec![text.to_string()]; + } + let mut lines = Vec::new(); + let mut cur = String::new(); + for word in text.split_whitespace() { + if cur.is_empty() { + cur = word.to_string(); + } else if cur.len() + 1 + word.len() <= width { + cur.push(' '); + cur.push_str(word); + } else { + lines.push(std::mem::take(&mut cur)); + cur = word.to_string(); + } + } + if !cur.is_empty() { + lines.push(cur); + } + lines +} + +pub fn short_date(rfc3339: &str) -> String { + chrono::DateTime::parse_from_rfc3339(rfc3339) + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|_| rfc3339.to_string()) +} + +pub fn name_width>(names: impl IntoIterator) -> usize { + names + .into_iter() + .map(|s| s.as_ref().len()) + .max() + .unwrap_or(0) +} + +/// `\r`-overwritten download bar on stderr. Pads to a stable width so a +/// shorter later frame doesn't leave leftover characters from a longer one. +pub fn print_progress(downloaded: u64, total: u64) { + let width = term_width().clamp(40, 72); + let line = progress_line(downloaded, total, 20); + let padded = fit_width(&line, width); + eprint!("\r{padded}"); + let _ = std::io::stderr().flush(); +} + +pub fn finish_progress() { + eprintln!(); +} + +pub fn progress_line(downloaded: u64, total: u64, bar_width: usize) -> String { + let dl = downloaded as f64 / 1_048_576.0; + let tot = total as f64 / 1_048_576.0; + let frac = if total == 0 { + 0.0 + } else { + (downloaded as f64 / total as f64).clamp(0.0, 1.0) + }; + let filled = ((bar_width as f64) * frac).round() as usize; + let filled = filled.min(bar_width); + let bar = format!("{}{}", "█".repeat(filled), "░".repeat(bar_width - filled)); + let pct = (frac * 100.0).round() as u32; + format!( + " ⇣ {} {:>3}% {:.1}/{:.1} MB", + style_err(&bar, CYAN), + pct, + dl, + tot + ) +} + +fn fit_width(s: &str, width: usize) -> String { + let visible = visible_len(s); + if visible >= width { + return s.to_string(); + } + format!("{s}{}", " ".repeat(width - visible)) +} + +fn visible_len(s: &str) -> usize { + let mut n = 0; + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' { + if chars.peek() == Some(&'[') { + chars.next(); + for next in chars.by_ref() { + if next.is_ascii_alphabetic() { + break; + } + } + } + continue; + } + n += 1; + } + n +} + +pub fn term_width() -> usize { + if let Ok(w) = std::env::var("COLUMNS") { + if let Ok(n) = w.parse::() { + if n >= 40 { + return n; + } + } + } + ioctl_width().filter(|&n| n >= 40).unwrap_or(80) +} + +#[cfg(unix)] +fn ioctl_width() -> Option { + use std::os::fd::AsRawFd; + + #[repr(C)] + struct WinSize { + row: u16, + col: u16, + x: u16, + y: u16, + } + + unsafe extern "C" { + fn ioctl(fd: i32, request: u64, argp: *mut WinSize) -> i32; + } + + let mut ws = WinSize { + row: 0, + col: 0, + x: 0, + y: 0, + }; + // TIOCGWINSZ on Linux. + let fd = std::io::stdout().as_raw_fd(); + let ret = unsafe { ioctl(fd, 0x5413, &mut ws) }; + if ret == 0 && ws.col > 0 { + Some(ws.col as usize) + } else { + None + } +} + +#[cfg(not(unix))] +fn ioctl_width() -> Option { + None +} + #[cfg(test)] mod tests { use super::*; @@ -77,4 +394,64 @@ mod tests { assert!(!ok("foo").contains('·')); assert!(!fail("foo").contains('·')); } + + #[test] + fn catalog_aligns_names_and_versions() { + let lines = format_catalog( + &[ + CatalogRow { + name: "bakery".into(), + version: "0.7.2-dev.20260815142350+30517f1".into(), + installed: true, + detail: "Package manager".into(), + aside: String::new(), + }, + CatalogRow { + name: "breadarr".into(), + version: "0.1.2".into(), + installed: false, + detail: "Homelab arr stack".into(), + aside: String::new(), + }, + ], + 80, + ); + assert_eq!(lines.len(), 4); + assert!(lines[0].contains("bakery")); + assert!(lines[0].contains("0.7.2-dev.20260815142350+30517f1")); + assert!(lines[1].contains("Package manager")); + // Shorter version is padded so the columns stay a block, not a + // ragged list — the long bakery version used to overflow `{: <10}`. + // Compare display columns, not byte offsets: the installed glyph + // is a 3-byte checkmark sitting in a 1-column slot. + let bakery_col = visible_len(&lines[0][..lines[0].find("0.7.2-dev").unwrap()]); + let breadarr_col = visible_len(&lines[2][..lines[2].find("0.1.2").unwrap()]); + assert_eq!(bakery_col, breadarr_col); + } + + #[test] + fn wrap_words_breaks_on_width() { + let lines = wrap_words("one two three four", 9); + assert_eq!(lines, vec!["one two", "three", "four"]); + } + + #[test] + fn progress_line_has_bar_and_percent() { + let line = progress_line(1_048_576, 2_097_152, 10); + assert!(line.contains('█')); + assert!(line.contains('░')); + assert!(line.contains("50%")); + assert!(line.contains("1.0/2.0 MB")); + } + + #[test] + fn visible_len_ignores_ansi() { + assert_eq!(visible_len("hello"), 5); + assert_eq!(visible_len(&format!("{CYAN}hello{RESET}")), 5); + } + + #[test] + fn short_date_from_rfc3339() { + assert_eq!(short_date("2026-08-15T14:23:50+00:00"), "2026-08-15"); + } } From c296d26408d90f956ef04841db4c16bfbd616062 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:00:19 +0800 Subject: [PATCH 02/37] bakery: add system prefix installs for BOS Default remains ~/.local. Setting prefix=/usr/local (via /etc/bakery/config.toml or BAKERY_PREFIX) installs bins and share under that prefix and systemd user units under /usr/lib/systemd/user. Writes that need root use sudo -n, then pkexec. State stays per-user. --- AGENTS.md | 3 + Cargo.lock | 1 + README.md | 20 ++ bakery/Cargo.toml | 1 + bakery/README.md | 30 +++ bakery/src/download.rs | 2 +- bakery/src/install.rs | 142 ++++++----- bakery/src/main.rs | 77 +++--- bakery/src/manifest.rs | 10 +- bakery/src/prefix.rs | 546 +++++++++++++++++++++++++++++++++++++++++ bakery/src/state.rs | 24 +- 11 files changed, 738 insertions(+), 118 deletions(-) create mode 100644 bakery/README.md create mode 100644 bakery/src/prefix.rs diff --git a/AGENTS.md b/AGENTS.md index d338d07..c8819c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,3 +19,6 @@ Follow [`CONTRIBUTING.md`](CONTRIBUTING.md) for any git, branch, or release work ## Don't - Don't embed credentials in remote URLs — SSH or a credential helper only. +- Don't flip bakery's default install prefix. System prefix (`/usr/local` via + `/etc/bakery/config.toml` or `BAKERY_PREFIX`) is for BOS; hermes and + `get.sh` stay on `~/.local`. See [`bakery/README.md`](bakery/README.md). diff --git a/Cargo.lock b/Cargo.lock index 66eb7fc..235b235 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,6 +120,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", + "toml 0.8.23", "ureq", ] diff --git a/README.md b/README.md index d3ed536..b769262 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,26 @@ bakery remove # remove a package (data files are never deleted) `bakery install` runs `doctor` first and bails with a clear message if any system dependency is missing. Binaries land in `~/.local/bin` (override with `BAKERY_BIN_DIR`). +## System prefix (BOS) + +Default install root is `~/.local`. BOS sets a system prefix so bakery-managed +desktop apps live on the `@` root subvolume and ride along with +snapper/grub-btrfs snapshots: + +```toml +# /etc/bakery/config.toml +prefix = "/usr/local" +``` + +`BAKERY_PREFIX` overrides the config file. A non-home prefix installs bins to +`$prefix/bin`, share/data/desktop/licenses to `$prefix/share/...`, and systemd +user units to `/usr/lib/systemd/user`. Per-user state (`installed.json`, +update backups) stays in `~/.local/state/bakery`. Writes that need root use +`sudo -n`, then `pkexec`. `bakery doctor` prints the active prefix. + +Hermes and `get.sh` are unchanged — they keep the user-local default. See +[`bakery/README.md`](bakery/README.md). + ## System dependencies by product `bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block. diff --git a/bakery/Cargo.toml b/bakery/Cargo.toml index 1ccd0fe..06ca297 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -11,6 +11,7 @@ repository = "https://git.breadway.dev/Breadway/bread-ecosystem" anyhow = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +toml = { workspace = true } dirs = { workspace = true } ureq = { workspace = true } sha2 = { workspace = true } diff --git a/bakery/README.md b/bakery/README.md new file mode 100644 index 0000000..21cb2cd --- /dev/null +++ b/bakery/README.md @@ -0,0 +1,30 @@ +# bakery + +Package manager for the bread ecosystem. Usage lives in the +[repo README](../README.md). + +## Install prefix + +Default root is `~/.local` (bins in `~/.local/bin`, data in +`~/.local/share`). That is the hermes / `get.sh` path and must stay the +default. + +BOS sets a system prefix so bakery-managed desktop apps live on the `@` +root subvolume and are included in snapper/grub-btrfs snapshots: + +```toml +# /etc/bakery/config.toml +prefix = "/usr/local" +``` + +`BAKERY_PREFIX` overrides the config file. A non-home prefix installs: + +| Thing | Path | +|-------|------| +| bins | `$prefix/bin` | +| share / desktop / licenses / data | `$prefix/share/...` | +| systemd user units | `/usr/lib/systemd/user` | + +Per-user state (`installed.json` and pre-update backups) stays in +`~/.local/state/bakery`. Writes that need root use `sudo -n`, then +`pkexec`. `bakery doctor` prints the active prefix. diff --git a/bakery/src/download.rs b/bakery/src/download.rs index d59bf67..4d1c1c7 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -20,7 +20,7 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result { verify_sha256(&bytes, &binary.sha256) .with_context(|| format!("checksum mismatch for {}", binary.name))?; - bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755)) + crate::prefix::write_bytes(dest, &bytes, 0o755) .with_context(|| format!("placing binary at {}", dest.display()))?; ui::step("placed", &dest.display().to_string()); Ok(binary.sha256.clone()) diff --git a/bakery/src/install.rs b/bakery/src/install.rs index 19a7c76..2040e35 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -7,6 +7,7 @@ use std::process::Command; use crate::download::{fetch_and_place, verify_sha256}; use crate::manifest::{fetch_binary, Package, Service}; +use crate::prefix::{self, Layout}; use crate::state::{InstalledPackage, State}; use crate::track::Track; use crate::ui; @@ -68,7 +69,7 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool { /// install" for the pre-overwrite backup below. pub fn install_package( pkg: &Package, - bin_dir: &Path, + layout: &Layout, track: Track, previous: Option<&InstalledPackage>, no_hooks: bool, @@ -78,14 +79,17 @@ pub fn install_package( // 1. Download and verify all binaries. On an update (not a fresh // install), back up the current binary first — best-effort, feeding - // `bakery rollback` — before it's overwritten below. + // `bakery rollback` — before it's overwritten below. Backups stay + // per-user under ~/.local/state even when the live binary is in + // /usr/local/bin, so a snapper snapshot of `@` plus this local copy + // is enough to roll back; no second snapshot system. let backup_dir = previous.map(|prev| crate::state::backup_dir(&pkg.name, &prev.version)); let mut binary_names = Vec::new(); let mut binary_sha256 = HashMap::new(); for bin in &pkg.binaries { ensure_safe_component(&bin.name, "binary name")?; let install_name = strip_arch_suffix(&bin.name); - let dest = bin_dir.join(install_name); + let dest = layout.bin_dir.join(install_name); if let Some(dir) = &backup_dir { backup_current_binary(dir, install_name, &dest); } @@ -94,30 +98,32 @@ pub fn install_package( binary_sha256.insert(install_name.to_string(), sha256); } - // 2. Scaffold config dir + download example file. + // 2. Scaffold config dir + download example file. Config stays + // per-user (~/.config) regardless of prefix — it's authored content, + // not bakery-placed bits. if let Some(cfg) = &pkg.config { scaffold_config(cfg, pkg)?; } // 3. Install license file, if declared. if let Some(license) = &pkg.license_file { - install_license(pkg, license)?; + install_license(pkg, license, layout)?; } // 4. Install desktop entry, if declared. if let Some(desktop) = &pkg.desktop_file { - install_desktop_file(pkg, desktop)?; + install_desktop_file(pkg, desktop, layout)?; } // 5. Download + extract data archive, if declared. if let Some(archive) = &pkg.data_archive { - install_data_archive(pkg, archive)?; + install_data_archive(pkg, archive, layout)?; } // 6. Install systemd user units. let mut service_names = Vec::new(); for svc in &pkg.services { - install_service(svc, bin_dir, pkg)?; + install_service(svc, layout, pkg)?; service_names.push(svc.unit.clone()); } @@ -173,7 +179,7 @@ pub fn install_package( })?; println!(" {}", ui::ok(&format!("{} installed", pkg.name))); - warn_path_if_needed(bin_dir); + warn_path_if_needed(&layout.bin_dir); Ok(()) } @@ -211,7 +217,12 @@ fn backup_current_binary(backup_dir: &Path, binary_name: &str, current_path: &Pa } } -pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: bool) -> Result<()> { +pub fn remove_package( + pkg_name: &str, + layout: &Layout, + assume_yes: bool, + purge: bool, +) -> Result<()> { let installed = State::with_lock(|state| Ok(state.remove(pkg_name)))?; let installed = match installed { Some(p) => p, @@ -229,9 +240,9 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b // the config/data-preserved messages below. let mut failures = Vec::new(); for bin in &installed.binaries { - let path = bin_dir.join(bin); + let path = layout.bin_dir.join(bin); if path.exists() { - match std::fs::remove_file(&path) { + match prefix::remove_file(&path) { Ok(()) => ui::step("removed", &path.display().to_string()), Err(e) => failures.push(format!("{}: {e}", path.display())), } @@ -240,7 +251,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b // Prompt for unit removal. if !installed.services.is_empty() { - let service_dir = systemd_user_dir(); + let service_dir = &layout.systemd_user_dir; for unit in &installed.services { let unit_path = service_dir.join(unit); if confirm_remove_unit(unit, assume_yes) { @@ -248,7 +259,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b .args(["--user", "disable", "--now", unit]) .status(); if unit_path.exists() { - std::fs::remove_file(&unit_path).ok(); + let _ = prefix::remove_file(&unit_path); } let _ = Command::new("systemctl") .args(["--user", "daemon-reload"]) @@ -269,7 +280,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b } } - let share_dir = dirs::data_dir().unwrap_or_else(|| PathBuf::from("~/.local/share")); + let share_dir = &layout.share_dir; let data_dir = share_dir.join(pkg_name); if purge { @@ -331,9 +342,9 @@ fn remove_purged_path( return; } let result = if recursive { - std::fs::remove_dir_all(path) + prefix::remove_dir_all(path) } else { - std::fs::remove_file(path) + prefix::remove_file(path) }; match result { Ok(()) => ui::step("removed", &path.display().to_string()), @@ -451,28 +462,26 @@ fn fetch_verify_write( ); return Ok(()); } - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(dest, &bytes).with_context(|| format!("writing {}", dest.display()))?; + prefix::write_bytes(dest, &bytes, 0o644) + .with_context(|| format!("writing {}", dest.display()))?; ui::step("installed", &format!("{label} {}", dest.display())); Ok(()) } -fn install_license(pkg: &Package, filename: &str) -> Result<()> { +fn install_license(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> { ensure_safe_component(filename, "license_file")?; - let dest = dirs::data_dir() - .unwrap_or_else(|| PathBuf::from("~/.local/share")) + let dest = layout + .share_dir .join("licenses") .join(&pkg.name) .join("LICENSE"); fetch_verify_write(pkg, filename, &pkg.license_file_sha256, &dest, "license") } -fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> { +fn install_desktop_file(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> { ensure_safe_component(filename, "desktop_file")?; - let dest = dirs::data_dir() - .unwrap_or_else(|| PathBuf::from("~/.local/share")) + let dest = layout + .share_dir .join("applications") .join(format!("{}.desktop", pkg.name)); fetch_verify_write( @@ -484,11 +493,9 @@ fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> { ) } -fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> { +fn install_data_archive(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> { ensure_safe_component(filename, "data_archive")?; - let data_dir = dirs::data_dir() - .unwrap_or_else(|| PathBuf::from("~/.local/share")) - .join(&pkg.name); + let data_dir = layout.share_dir.join(&pkg.name); fetch_extract_archive(pkg, filename, &pkg.data_archive_sha256, &data_dir) } @@ -497,7 +504,7 @@ fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> { /// crate dependency — `tar` is universally present on Linux and this file /// already shells out to `systemctl` for the same "trust the base system /// has this" reason. Split from `install_data_archive` (which just supplies -/// the real `~/.local/share/` destination) so tests can extract into +/// the real `$prefix/share/` destination) so tests can extract into /// a tempdir instead. fn fetch_extract_archive( pkg: &Package, @@ -523,31 +530,19 @@ fn fetch_extract_archive( verify_archive_paths(&tmp_archive)?; - std::fs::create_dir_all(dest_dir)?; - let status = Command::new("tar") - .args([ - "xzf", - &tmp_archive.to_string_lossy(), - "--no-same-owner", - "--no-same-permissions", - "-C", - ]) - .arg(dest_dir) - .status() - .with_context(|| format!("running tar to extract {filename}"))?; - // `tmp_archive` (a `TempPath` guard) deletes the file when it drops here. - - if status.success() { - ui::step( + match prefix::extract_tar_gz(&tmp_archive, dest_dir) { + Ok(()) => ui::step( "extracted", &format!("{filename} → {}", dest_dir.display()), - ); - } else { - eprintln!( - " {}", - ui::warn(&format!("tar exited with {status} extracting {filename}")) - ); + ), + Err(e) => { + eprintln!( + " {}", + ui::warn(&format!("could not extract {filename}: {e}")) + ); + } } + // `tmp_archive` (a `TempPath` guard) deletes the file when it drops here. Ok(()) } @@ -609,11 +604,11 @@ fn fetch_and_verify_unit(pkg: &Package, svc: &Service) -> Result> { Ok(bytes) } -fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { +fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()> { ensure_safe_component(&svc.unit, "service unit")?; - let service_dir = systemd_user_dir(); - std::fs::create_dir_all(&service_dir)?; + let service_dir = &layout.systemd_user_dir; + prefix::create_dir_all(service_dir)?; let unit_path = service_dir.join(&svc.unit); let had_existing = unit_path.exists(); @@ -623,10 +618,14 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { // applied after the first install, unlike binaries (which always // re-fetch via `fetch_and_place` on every install/update). If the fetch // or checksum fails, fall back to whatever's already on disk rather than - // regressing offline/flaky-network reliability. + // regressing offline/flaky-network reliability. Patch ExecStart in + // memory before the write so a system-prefix install only needs one + // privileged write, not write-then-rewrite. match fetch_and_verify_unit(pkg, svc) { Ok(bytes) => { - std::fs::write(&unit_path, &bytes) + let text = String::from_utf8_lossy(&bytes); + let patched = patch_exec_start_text(&text, &layout.bin_dir); + prefix::write_bytes(&unit_path, patched.as_bytes(), 0o644) .with_context(|| format!("writing {}", unit_path.display()))?; ui::step("unit", &unit_path.display().to_string()); } @@ -639,6 +638,7 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { svc.unit )) ); + patch_exec_start(&unit_path, &layout.bin_dir)?; } else { eprintln!( " {}", @@ -652,8 +652,6 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { } } - patch_exec_start(&unit_path, bin_dir)?; - if !Command::new("systemctl") .args(["--user", "daemon-reload"]) .status() @@ -698,6 +696,12 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> { let text = std::fs::read_to_string(unit_path)?; + let output = patch_exec_start_text(&text, bin_dir); + prefix::write_bytes(unit_path, output.as_bytes(), 0o644)?; + Ok(()) +} + +fn patch_exec_start_text(text: &str, bin_dir: &Path) -> String { let patched: String = text .lines() .map(|line| { @@ -721,14 +725,11 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> { }) .collect::>() .join("\n"); - // Preserve trailing newline if the original had one. - let output = if text.ends_with('\n') { + if text.ends_with('\n') { format!("{patched}\n") } else { patched - }; - std::fs::write(unit_path, output)?; - Ok(()) + } } fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> { @@ -747,12 +748,6 @@ fn confirm_remove_unit(unit: &str, assume_yes: bool) -> bool { confirm(&format!(" remove systemd unit {unit}?"), assume_yes) } -fn systemd_user_dir() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from("~/.config")) - .join("systemd/user") -} - fn guess_config_dir(pkg_name: &str) -> Option { Some(dirs::config_dir()?.join(pkg_name)) } @@ -1063,7 +1058,8 @@ mod tests { let mut pkg = test_package(&base_url); pkg.name = "../evil".to_string(); let dir = tempdir().unwrap(); - let err = install_package(&pkg, dir.path(), Track::Stable, None, true, true).unwrap_err(); + let layout = Layout::from_prefix(dir.path(), None); + let err = install_package(&pkg, &layout, Track::Stable, None, true, true).unwrap_err(); assert!(err.to_string().contains("not a safe filename")); } diff --git a/bakery/src/main.rs b/bakery/src/main.rs index d3d09b4..11659a4 100644 --- a/bakery/src/main.rs +++ b/bakery/src/main.rs @@ -2,6 +2,7 @@ mod doctor; mod download; mod install; mod manifest; +mod prefix; mod state; mod track; mod ui; @@ -48,7 +49,7 @@ enum Cmd { Remove { package: String, /// Also remove the license file, desktop entry, and data dir - /// (~/.local/share//) — config is still preserved + /// ($prefix/share//) — config is still preserved #[arg(long)] purge: bool, }, @@ -104,15 +105,9 @@ enum TrackCmd { Set { track: Track }, } -fn default_bin_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from("~")) - .join(".local/bin") -} - fn main() -> Result<()> { let cli = Cli::parse(); - let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir); + let layout = prefix::resolve(cli.bin_dir); let no_hooks = cli.no_hooks; let assume_yes = cli.yes; let dry_run = cli.dry_run; @@ -122,15 +117,15 @@ fn main() -> Result<()> { Cmd::Install { packages } => { let index = manifest::load(true, track)?; for pkg in &packages { - cmd_install(&index, pkg, &bin_dir, track, no_hooks, assume_yes, dry_run)?; + cmd_install(&index, pkg, &layout, track, no_hooks, assume_yes, dry_run)?; } Ok(()) } - Cmd::Remove { package, purge } => cmd_remove(&package, &bin_dir, assume_yes, purge), + Cmd::Remove { package, purge } => cmd_remove(&package, &layout, assume_yes, purge), Cmd::Update { package, all } => cmd_update( package.as_deref(), all, - &bin_dir, + &layout, track, no_hooks, assume_yes, @@ -139,9 +134,9 @@ fn main() -> Result<()> { Cmd::List { installed } => cmd_list(installed, track), Cmd::Info { package } => cmd_info(&package, track), Cmd::Search { query } => cmd_search(&query, track), - Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &bin_dir), - Cmd::Verify { package } => cmd_verify(package.as_deref(), &bin_dir), - Cmd::Rollback { package } => cmd_rollback(&package, &bin_dir), + Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &layout), + Cmd::Verify { package } => cmd_verify(package.as_deref(), &layout.bin_dir), + Cmd::Rollback { package } => cmd_rollback(&package, &layout.bin_dir), // Same update logic as `bakery update bakery` — this is just a // documented, discoverable entry point for it, since overwriting // bakery's own running binary via a normal update already works @@ -150,7 +145,7 @@ fn main() -> Result<()> { Cmd::SelfUpdate => cmd_update( Some("bakery"), false, - &bin_dir, + &layout, track, no_hooks, assume_yes, @@ -199,7 +194,7 @@ fn cmd_track(action: TrackCmd) -> Result<()> { fn cmd_install( index: &manifest::Index, name: &str, - bin_dir: &std::path::Path, + layout: &prefix::Layout, track: Track, no_hooks: bool, assume_yes: bool, @@ -209,7 +204,7 @@ fn cmd_install( install_with_deps( index, name, - bin_dir, + layout, track, no_hooks, assume_yes, @@ -224,7 +219,7 @@ fn cmd_install( fn install_with_deps( index: &manifest::Index, name: &str, - bin_dir: &std::path::Path, + layout: &prefix::Layout, track: Track, no_hooks: bool, assume_yes: bool, @@ -245,7 +240,7 @@ fn install_with_deps( if !state.is_installed(&dep) { ui::step(if dry_run { "would need" } else { "dependency" }, &dep); install_with_deps( - index, &dep, bin_dir, track, no_hooks, assume_yes, dry_run, visited, + index, &dep, layout, track, no_hooks, assume_yes, dry_run, visited, )?; } } @@ -315,7 +310,7 @@ fn install_with_deps( return Ok(()); } - install::install_package(pkg, bin_dir, track, previous, no_hooks, assume_yes) + install::install_package(pkg, layout, track, previous, no_hooks, assume_yes) } /// Prints what `install_with_deps`/`cmd_update` would do for `pkg` under @@ -344,15 +339,15 @@ fn print_dry_run_plan(pkg: &manifest::Package) { } } -fn cmd_remove(name: &str, bin_dir: &std::path::Path, assume_yes: bool, purge: bool) -> Result<()> { - install::remove_package(name, bin_dir, assume_yes, purge) +fn cmd_remove(name: &str, layout: &prefix::Layout, assume_yes: bool, purge: bool) -> Result<()> { + install::remove_package(name, layout, assume_yes, purge) } #[allow(clippy::too_many_arguments)] fn cmd_update( name: Option<&str>, all: bool, - bin_dir: &std::path::Path, + layout: &prefix::Layout, track: Track, no_hooks: bool, assume_yes: bool, @@ -486,14 +481,9 @@ fn cmd_update( continue; } - if let Err(e) = install::install_package( - latest, - bin_dir, - track, - Some(installed), - no_hooks, - assume_yes, - ) { + if let Err(e) = + install::install_package(latest, layout, track, Some(installed), no_hooks, assume_yes) + { eprintln!( " {}", ui::fail(&format!("failed to update {pkg_name}: {e}")) @@ -705,7 +695,18 @@ fn cmd_info(name: &str, track: Track) -> Result<()> { Ok(()) } -fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Result<()> { +fn report_layout(layout: &prefix::Layout) { + ui::kv( + "prefix", + &format!("{} ({})", layout.prefix.display(), layout.kind_label()), + ); + ui::kv("bins", &layout.bin_dir.display().to_string()); + ui::kv("share", &layout.share_dir.display().to_string()); + ui::kv("units", &layout.systemd_user_dir.display().to_string()); + ui::kv("state", &state::bakery_state_dir().display().to_string()); +} + +fn cmd_doctor(name: Option<&str>, track: Track, layout: &prefix::Layout) -> Result<()> { let index = manifest::load(false, track)?; let state = state::State::load()?; @@ -720,6 +721,9 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re }; if targets.is_empty() { + ui::heading("Doctor", &["no packages"]); + report_layout(layout); + println!(); println!("{}", ui::dim("no packages installed — nothing to check")); return Ok(()); } @@ -727,6 +731,8 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re let mut targets = targets; targets.sort(); ui::heading("Doctor", &[&format!("{} packages", targets.len())]); + report_layout(layout); + println!(); let name_w = ui::name_width(&targets); let mut all_ok = true; @@ -756,7 +762,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re // only, not a checksum re-verification — see `bakery verify` for that. if let Some(installed) = state.packages.get(pkg_name) { for bin in &installed.binaries { - let path = bin_dir.join(bin); + let path = layout.bin_dir.join(bin); if !path.exists() { eprintln!( " {}", @@ -906,7 +912,7 @@ fn restore_binaries( .with_context(|| format!("reading backup {}", backup_path.display()))?; let hash = hex::encode(Sha256::digest(&bytes)); let dest = bin_dir.join(bin); - bread_utils::atomic::write_atomic_bytes(&dest, &bytes, Some(0o755)) + prefix::write_bytes(&dest, &bytes, 0o755) .with_context(|| format!("restoring {}", dest.display()))?; sha256.insert(bin.clone(), hash); } @@ -1095,11 +1101,12 @@ mod tests { }; let bin_dir = tempdir().unwrap(); + let layout = prefix::Layout::from_prefix(bin_dir.path(), None); let mut visited = HashSet::new(); install_with_deps( &index, name, - bin_dir.path(), + &layout, Track::Stable, true, true, diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 5cf4e9c..0ce04d7 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -107,21 +107,21 @@ pub struct Package { #[serde(default)] pub post_install: Vec, /// License artifact filename (e.g. "LICENSE"), installed to - /// `~/.local/share/licenses//LICENSE` — the bakery equivalent of - /// what a PKGBUILD's `package()` does with `/usr/share/licenses`. + /// `$prefix/share/licenses//LICENSE` (`~/.local/share/...` by + /// default) — the bakery equivalent of a PKGBUILD's `package()` step. #[serde(default)] pub license_file: Option, #[serde(default)] pub license_file_sha256: Option, /// Desktop entry artifact filename (e.g. "breadhelp.desktop"), - /// installed to `~/.local/share/applications/.desktop` so the - /// app shows up in any XDG-compliant launcher without root. + /// installed to `$prefix/share/applications/.desktop` so the + /// app shows up in any XDG-compliant launcher. #[serde(default)] pub desktop_file: Option, #[serde(default)] pub desktop_file_sha256: Option, /// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz` - /// in the release dir, extracted to `~/.local/share//` on + /// in the release dir, extracted to `$prefix/share//` on /// install. For arbitrary data a package needs at runtime beyond a /// config example (e.g. breadhelp's guide content), where a single /// downloadable file + `tar` extraction is simpler than teaching diff --git a/bakery/src/prefix.rs b/bakery/src/prefix.rs new file mode 100644 index 0000000..dd5b8bd --- /dev/null +++ b/bakery/src/prefix.rs @@ -0,0 +1,546 @@ +//! Install prefix: default `~/.local`, or a system root for BOS. +//! +//! Hermes and `get.sh` keep the user-local default. BOS sets +//! `prefix = "/usr/local"` in `/etc/bakery/config.toml` (or `BAKERY_PREFIX`) +//! so bakery-managed desktop apps live on the `@` root subvolume and ride +//! along with snapper/grub-btrfs snapshots. Per-user state stays under +//! `~/.local/state/bakery` either way — bakery still records what *this* +//! user asked for; the prefix only changes where bits land on disk. +//! +//! Writes that hit `EACCES` use `sudo -n` first, then `pkexec` if a +//! graphical session is available. Interactive `sudo` (password on stdin) +//! is never used — a GUI hook must not block on a TTY prompt. + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::ffi::OsStr; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// Default user-local prefix when no config/env override is set. +const DEFAULT_USER_PREFIX: &str = ".local"; + +/// System-wide user units, used when the prefix is not under `$HOME`. +const SYSTEM_USER_UNIT_DIR: &str = "/usr/lib/systemd/user"; + +const SYSTEM_CONFIG_PATH: &str = "/etc/bakery/config.toml"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Layout { + pub prefix: PathBuf, + pub bin_dir: PathBuf, + pub share_dir: PathBuf, + pub systemd_user_dir: PathBuf, + /// True when `prefix` is not under the user's home directory. + pub is_system: bool, +} + +impl Layout { + pub fn kind_label(&self) -> &'static str { + if self.is_system { + "system" + } else { + "user" + } + } + + /// Map a (possibly custom) prefix onto bin/share/unit paths. + /// `bin_override` is `--bin-dir` / `BAKERY_BIN_DIR` and wins for bins only. + pub fn from_prefix(prefix: &Path, bin_override: Option) -> Self { + let prefix = normalize_prefix_path(prefix); + let is_system = is_system_prefix(&prefix); + let bin_dir = bin_override.unwrap_or_else(|| prefix.join("bin")); + let share_dir = prefix.join("share"); + let systemd_user_dir = if is_system { + PathBuf::from(SYSTEM_USER_UNIT_DIR) + } else { + user_systemd_dir() + }; + Self { + prefix, + bin_dir, + share_dir, + systemd_user_dir, + is_system, + } + } + + /// Historical default: `~/.local` bins, XDG data dir for share, + /// `~/.config/systemd/user` for units. Used when neither `BAKERY_PREFIX` + /// nor `/etc/bakery/config.toml` sets a prefix — hermes / get.sh. + pub fn user_default(bin_override: Option) -> Self { + let prefix = default_user_prefix(); + let bin_dir = bin_override.unwrap_or_else(|| prefix.join("bin")); + let share_dir = dirs::data_dir().unwrap_or_else(|| prefix.join("share")); + Self { + prefix, + bin_dir, + share_dir, + systemd_user_dir: user_systemd_dir(), + is_system: false, + } + } +} + +/// Resolve the active layout. `BAKERY_PREFIX` wins over `/etc/bakery/config.toml`; +/// neither set keeps the `~/.local` default. `bin_override` is the existing +/// `--bin-dir` / `BAKERY_BIN_DIR` knob. +pub fn resolve(bin_override: Option) -> Layout { + let env = std::env::var("BAKERY_PREFIX").ok(); + resolve_from(env.as_deref(), Path::new(SYSTEM_CONFIG_PATH), bin_override) +} + +/// Same as [`resolve`] with the env value and config path injected, so +/// tests don't have to mutate process-global env or touch `/etc`. +pub fn resolve_from( + env_prefix: Option<&str>, + config_path: &Path, + bin_override: Option, +) -> Layout { + match configured_prefix_from(env_prefix, config_path) { + Some(prefix) => Layout::from_prefix(&prefix, bin_override), + None => Layout::user_default(bin_override), + } +} + +pub fn configured_prefix_from(env_prefix: Option<&str>, config_path: &Path) -> Option { + if let Some(raw) = env_prefix { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + return Some(normalize_prefix(trimmed)); + } + } + load_config_prefix(config_path) +} + +#[derive(Debug, Default, Deserialize)] +struct BakeryConfig { + prefix: Option, +} + +/// Reads `prefix = "..."` from a bakery config file. Missing file or empty +/// key → `None` (caller falls back to the user-local default). A file that +/// exists but fails to parse is warned about, not treated as fatal — a typo +/// in `/etc/bakery/config.toml` must not take down `bakery list`. +pub fn load_config_prefix(path: &Path) -> Option { + if !path.exists() { + return None; + } + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) => { + eprintln!( + " {}", + crate::ui::warn(&format!("could not read {}: {e}", path.display())) + ); + return None; + } + }; + match toml::from_str::(&text) { + Ok(cfg) => cfg + .prefix + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(normalize_prefix), + Err(e) => { + eprintln!( + " {}", + crate::ui::warn(&format!("could not parse {}: {e}", path.display())) + ); + None + } + } +} + +fn default_user_prefix() -> PathBuf { + home_dir().join(DEFAULT_USER_PREFIX) +} + +fn home_dir() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")) +} + +fn user_systemd_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| home_dir().join(".config")) + .join("systemd/user") +} + +fn is_system_prefix(prefix: &Path) -> bool { + match dirs::home_dir() { + Some(home) => !prefix.starts_with(&home), + None => true, + } +} + +fn normalize_prefix(raw: &str) -> PathBuf { + normalize_prefix_path(&expand_tilde(raw)) +} + +fn normalize_prefix_path(path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + } +} + +fn expand_tilde(path: &str) -> PathBuf { + if path == "~" { + home_dir() + } else if let Some(rest) = path.strip_prefix("~/") { + home_dir().join(rest) + } else { + PathBuf::from(path) + } +} + +fn is_permission_denied(err: &io::Error) -> bool { + err.kind() == io::ErrorKind::PermissionDenied +} + +pub fn privilege_denied_msg(dest: &Path) -> String { + format!( + "permission denied writing {} — need root for this prefix. \ + bakery tried `sudo -n` then `pkexec`; neither succeeded. \ + Run from a root shell, grant passwordless sudo -n for install/rm/tar, \ + or install a polkit rule. bakery will not prompt for a sudo password.", + dest.display() + ) +} + +fn has_graphical_session() -> bool { + std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some() +} + +/// Write `bytes` to `dest`, creating parent dirs. Escalates on `EACCES`. +pub fn write_bytes(dest: &Path, bytes: &[u8], mode: u32) -> Result<()> { + match bread_utils::atomic::write_atomic_bytes(dest, bytes, Some(mode)) { + Ok(()) => Ok(()), + Err(e) if is_permission_denied(&e) => write_bytes_privileged(dest, bytes, mode), + Err(e) => Err(e).with_context(|| format!("writing {}", dest.display())), + } +} + +fn write_bytes_privileged(dest: &Path, bytes: &[u8], mode: u32) -> Result<()> { + let mut tmp = + tempfile::NamedTempFile::new().context("creating temp file for privileged write")?; + tmp.write_all(bytes) + .and_then(|_| tmp.flush()) + .and_then(|_| tmp.as_file().sync_all()) + .context("writing temp file for privileged write")?; + let mode_str = format!("{mode:o}"); + run_privileged( + Path::new("/usr/bin/install"), + &[ + OsStr::new("-D"), + OsStr::new("-m"), + OsStr::new(&mode_str), + tmp.path().as_os_str(), + dest.as_os_str(), + ], + dest, + ) +} + +pub fn create_dir_all(path: &Path) -> Result<()> { + match std::fs::create_dir_all(path) { + Ok(()) => Ok(()), + Err(e) if is_permission_denied(&e) => run_privileged( + Path::new("/usr/bin/install"), + &[ + OsStr::new("-d"), + OsStr::new("-m"), + OsStr::new("755"), + path.as_os_str(), + ], + path, + ), + Err(e) => Err(e).with_context(|| format!("creating directory {}", path.display())), + } +} + +pub fn remove_file(path: &Path) -> Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) if is_permission_denied(&e) => run_privileged( + Path::new("/usr/bin/rm"), + &[OsStr::new("-f"), path.as_os_str()], + path, + ), + Err(e) => Err(e).with_context(|| format!("removing {}", path.display())), + } +} + +pub fn remove_dir_all(path: &Path) -> Result<()> { + match std::fs::remove_dir_all(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) if is_permission_denied(&e) => run_privileged( + Path::new("/usr/bin/rm"), + &[OsStr::new("-rf"), path.as_os_str()], + path, + ), + Err(e) => Err(e).with_context(|| format!("removing {}", path.display())), + } +} + +/// Extract `archive` (a `.tar.gz`) into `dest_dir`. Escalates the `tar` +/// invocation when `dest_dir` is not writable by this user — typical for +/// `$prefix/share/` under `/usr/local`. +pub fn extract_tar_gz(archive: &Path, dest_dir: &Path) -> Result<()> { + create_dir_all(dest_dir)?; + if dir_writable_by_self(dest_dir) { + let status = Command::new("tar") + .args([ + "xzf", + &archive.to_string_lossy(), + "--no-same-owner", + "--no-same-permissions", + "-C", + ]) + .arg(dest_dir) + .status() + .with_context(|| format!("running tar to extract {}", archive.display()))?; + if !status.success() { + bail!("tar exited with {status} extracting {}", archive.display()); + } + return Ok(()); + } + run_privileged( + Path::new("/usr/bin/tar"), + &[ + OsStr::new("xzf"), + archive.as_os_str(), + OsStr::new("--no-same-owner"), + OsStr::new("--no-same-permissions"), + OsStr::new("-C"), + dest_dir.as_os_str(), + ], + dest_dir, + ) +} + +fn dir_writable_by_self(dir: &Path) -> bool { + tempfile::Builder::new() + .prefix(".bakery-wprobe-") + .tempfile_in(dir) + .is_ok() +} + +fn run_privileged(program: &Path, args: &[&OsStr], dest: &Path) -> Result<()> { + // `sudo -n` never prompts; stdin is null so a misconfigured sudoers + // can't fall through to a password read on a GUI hook's non-tty stdin. + let sudo = Command::new("sudo") + .arg("-n") + .arg(program) + .args(args) + .stdin(Stdio::null()) + .status(); + if matches!(sudo, Ok(status) if status.success()) { + return Ok(()); + } + + // pkexec pops a polkit dialog — only useful with a display, and the + // one acceptable password prompt (GUI, not a stolen sudo TTY). + if has_graphical_session() { + let pk = Command::new("pkexec").arg(program).args(args).status(); + if matches!(pk, Ok(status) if status.success()) { + return Ok(()); + } + } + + bail!("{}", privilege_denied_msg(dest)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn user_default_is_not_system_and_uses_local_bin() { + let layout = Layout::user_default(None); + assert!(!layout.is_system); + assert_eq!(layout.prefix, default_user_prefix()); + assert_eq!(layout.bin_dir, default_user_prefix().join("bin")); + assert_eq!(layout.kind_label(), "user"); + assert!(layout.systemd_user_dir.ends_with(Path::new("systemd/user"))); + assert_ne!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR)); + } + + #[test] + fn user_default_honors_bin_override() { + let layout = Layout::user_default(Some(PathBuf::from("/tmp/custom-bins"))); + assert_eq!(layout.bin_dir, PathBuf::from("/tmp/custom-bins")); + assert!(!layout.is_system); + assert_eq!(layout.prefix, default_user_prefix()); + } + + #[test] + fn usr_local_is_system_layout() { + let layout = Layout::from_prefix(Path::new("/usr/local"), None); + assert!(layout.is_system); + assert_eq!(layout.prefix, PathBuf::from("/usr/local")); + assert_eq!(layout.bin_dir, PathBuf::from("/usr/local/bin")); + assert_eq!(layout.share_dir, PathBuf::from("/usr/local/share")); + assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR)); + assert_eq!(layout.kind_label(), "system"); + } + + #[test] + fn custom_home_prefix_is_not_system() { + let home = dirs::home_dir().expect("home dir"); + let prefix = home.join("apps"); + let layout = Layout::from_prefix(&prefix, None); + assert!(!layout.is_system); + assert_eq!(layout.bin_dir, prefix.join("bin")); + assert_eq!(layout.share_dir, prefix.join("share")); + assert_ne!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR)); + } + + #[test] + fn temp_prefix_maps_bin_and_share_under_prefix() { + let dir = tempdir().unwrap(); + let layout = Layout::from_prefix(dir.path(), None); + assert_eq!(layout.bin_dir, dir.path().join("bin")); + assert_eq!(layout.share_dir, dir.path().join("share")); + // /tmp is not under $HOME, so this is a system-shaped prefix — + // units would go to /usr/lib/systemd/user. Writes still try + // unprivileged first, so tests can use a temp prefix without sudo. + assert!(layout.is_system); + assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR)); + } + + #[test] + fn bin_override_does_not_move_share_or_units() { + let layout = Layout::from_prefix( + Path::new("/usr/local"), + Some(PathBuf::from("/opt/override/bin")), + ); + assert_eq!(layout.bin_dir, PathBuf::from("/opt/override/bin")); + assert_eq!(layout.share_dir, PathBuf::from("/usr/local/share")); + assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR)); + } + + #[test] + fn load_config_prefix_reads_value() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "prefix = \"/usr/local\"\n").unwrap(); + assert_eq!(load_config_prefix(&path), Some(PathBuf::from("/usr/local"))); + } + + #[test] + fn load_config_prefix_expands_tilde() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "prefix = \"~/.local\"\n").unwrap(); + assert_eq!(load_config_prefix(&path), Some(default_user_prefix())); + } + + #[test] + fn load_config_prefix_missing_file_is_none() { + assert_eq!( + load_config_prefix(Path::new("/no/such/bakery-config.toml")), + None + ); + } + + #[test] + fn load_config_prefix_ignores_empty_value() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "prefix = \"\"\n").unwrap(); + assert_eq!(load_config_prefix(&path), None); + } + + #[test] + fn load_config_prefix_malformed_is_none() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "prefix = [\n").unwrap(); + assert_eq!(load_config_prefix(&path), None); + } + + #[test] + fn env_prefix_wins_over_config() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "prefix = \"/usr/local\"\n").unwrap(); + let layout = resolve_from(Some("/opt/bread"), &path, None); + assert_eq!(layout.prefix, PathBuf::from("/opt/bread")); + assert_eq!(layout.bin_dir, PathBuf::from("/opt/bread/bin")); + assert!(layout.is_system); + } + + #[test] + fn empty_env_falls_through_to_config() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "prefix = \"/usr/local\"\n").unwrap(); + let layout = resolve_from(Some(" "), &path, None); + assert_eq!(layout.prefix, PathBuf::from("/usr/local")); + } + + #[test] + fn no_env_no_config_is_user_default() { + let dir = tempdir().unwrap(); + let path = dir.path().join("missing.toml"); + let layout = resolve_from(None, &path, None); + assert_eq!(layout, Layout::user_default(None)); + } + + #[test] + fn write_bytes_to_writable_temp_prefix_needs_no_root() { + let dir = tempdir().unwrap(); + let dest = dir.path().join("bin").join("foo"); + write_bytes(&dest, b"hello", 0o755).unwrap(); + assert_eq!(fs::read(&dest).unwrap(), b"hello"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&dest).unwrap().permissions().mode() & 0o777, + 0o755 + ); + } + } + + #[test] + fn create_and_remove_under_temp_prefix() { + let dir = tempdir().unwrap(); + let nested = dir.path().join("share/licenses/pkg"); + create_dir_all(&nested).unwrap(); + assert!(nested.is_dir()); + let file = nested.join("LICENSE"); + write_bytes(&file, b"MIT\n", 0o644).unwrap(); + remove_file(&file).unwrap(); + assert!(!file.exists()); + remove_dir_all(&dir.path().join("share")).unwrap(); + assert!(!dir.path().join("share").exists()); + } + + #[test] + fn privilege_denied_msg_names_the_dest() { + let msg = privilege_denied_msg(Path::new("/usr/local/bin/breadd")); + assert!(msg.contains("/usr/local/bin/breadd")); + assert!(msg.contains("sudo -n")); + assert!(msg.contains("pkexec")); + assert!(msg.contains("will not prompt")); + } + + #[test] + fn is_system_prefix_classifies_home_and_usr() { + let home = dirs::home_dir().expect("home dir"); + assert!(!is_system_prefix(&home.join(".local"))); + assert!(is_system_prefix(Path::new("/usr/local"))); + assert!(is_system_prefix(Path::new("/opt/bread"))); + } +} diff --git a/bakery/src/state.rs b/bakery/src/state.rs index 4e29055..bee3f9c 100644 --- a/bakery/src/state.rs +++ b/bakery/src/state.rs @@ -56,8 +56,7 @@ impl State { pub fn save(&self) -> Result<()> { let path = state_path(); let text = serde_json::to_string_pretty(self)?; - bread_utils::atomic::write_atomic(&path, &text, None) - .context("writing installed.json") + bread_utils::atomic::write_atomic(&path, &text, None).context("writing installed.json") } /// Runs `f` against a freshly-loaded `State` while holding an exclusive @@ -114,7 +113,13 @@ fn state_base_dir() -> PathBuf { } fn state_path() -> PathBuf { - state_base_dir().join("bakery/installed.json") + bakery_state_dir().join("installed.json") +} + +/// Per-user bakery state dir (`~/.local/state/bakery`). Independent of the +/// install prefix — system-prefix installs still record what this user asked for. +pub fn bakery_state_dir() -> PathBuf { + state_base_dir().join("bakery") } /// Local backup dir for `pkg_name`'s `version` binaries, populated by @@ -205,7 +210,10 @@ mod tests { assert_eq!(restored.packages["bar"].version, "2.0.0"); assert_eq!(restored.packages["bar"].services, ["bar.service"]); assert_eq!(restored.packages["bar"].track, Track::Beta); - assert_eq!(restored.packages["bar"].previous_version.as_deref(), Some("1.0.0")); + assert_eq!( + restored.packages["bar"].previous_version.as_deref(), + Some("1.0.0") + ); assert_eq!(restored.packages["bar"].binary_sha256["bar"], "abc123"); } @@ -228,6 +236,14 @@ mod tests { assert!(installed.binary_sha256.is_empty()); } + #[test] + fn bakery_state_dir_is_under_state_home_and_independent_of_prefix() { + let dir = bakery_state_dir(); + assert!(dir.ends_with("bakery")); + // Must not follow BAKERY_PREFIX — state is always per-user. + assert!(!dir.starts_with("/usr/local")); + } + #[test] fn backup_dir_is_distinct_per_package_and_version() { let a = backup_dir("bakery", "0.3.1"); From 11c0e844e54ca0dc42cef4c741bfb1f1ba701a1e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:34:12 +0800 Subject: [PATCH 03/37] workspace: add bread-app crate and first-cut bread-polkit agent bread-app is the GTK bootstrap new tools should use instead of another copied main.rs: com.breadway.* app id, singleton lock, optional gtk_popup re-export, optional bread.command..** listen loop. Tests cover app-id helpers and command-verb parse. Existing apps are not migrated. bread-polkit is an own PolicyKit1 session authentication agent with a bread-theme GTK4 password prompt (not a polkit-gnome wrapper). Autostart via contrib/bread-polkit.desktop or exec-once. Not a bakery product; not added to the BOS ISO lockfile. --- CONTRIBUTING.md | 21 +- Cargo.lock | 439 ++++++++++++++++++++-- Cargo.toml | 2 +- README.md | 37 +- bread-app/Cargo.toml | 20 + bread-app/src/command.rs | 121 ++++++ bread-app/src/id.rs | 145 +++++++ bread-app/src/lib.rs | 67 ++++ bread-polkit/Cargo.toml | 27 ++ bread-polkit/contrib/bread-polkit.desktop | 12 + bread-polkit/contrib/hyprland.conf | 10 + bread-polkit/src/agent.rs | 300 +++++++++++++++ bread-polkit/src/auth.rs | 109 ++++++ bread-polkit/src/helper.rs | 205 ++++++++++ bread-polkit/src/identity.rs | 128 +++++++ bread-polkit/src/lib.rs | 10 + bread-polkit/src/main.rs | 94 +++++ bread-polkit/src/session.rs | 49 +++ bread-polkit/src/ui.rs | 285 ++++++++++++++ 19 files changed, 2049 insertions(+), 32 deletions(-) create mode 100644 bread-app/Cargo.toml create mode 100644 bread-app/src/command.rs create mode 100644 bread-app/src/id.rs create mode 100644 bread-app/src/lib.rs create mode 100644 bread-polkit/Cargo.toml create mode 100644 bread-polkit/contrib/bread-polkit.desktop create mode 100644 bread-polkit/contrib/hyprland.conf create mode 100644 bread-polkit/src/agent.rs create mode 100644 bread-polkit/src/auth.rs create mode 100644 bread-polkit/src/helper.rs create mode 100644 bread-polkit/src/identity.rs create mode 100644 bread-polkit/src/lib.rs create mode 100644 bread-polkit/src/main.rs create mode 100644 bread-polkit/src/session.rs create mode 100644 bread-polkit/src/ui.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d816a4..cb4d0e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,10 +3,13 @@ This repo is a Cargo workspace. Bakery-channel products shipped from here are `bakery` (the ecosystem package manager) and `bread-theme` (the shared theming crate). Shared crates that sibling apps pin — not bakery packages -of their own — are `bread-utils`, `bread-onnx`, `bread-screenshots`, and -`bread-capture`. Other ecosystem products (`bread`, `breadbar`, `breadbox`, -…) live in their own repos under `Breadway/` but follow the same workflow -described here. The product list is `registry/bread-ecosystem.toml`. +of their own — are `bread-utils`, `bread-app`, `bread-onnx`, +`bread-screenshots`, and `bread-capture`. `bread-polkit` is an in-tree +session agent, 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 +`bread-app` instead of copying another app's bootstrap. ## Branches @@ -88,10 +91,12 @@ cargo build --release -p bakery cargo test --release -p bakery ``` -`bakery`, `bread-theme`, `bread-utils`, `bread-onnx`, `bread-screenshots`, -and `bread-capture` are all workspace members. Run the same commands with -`-p bread-theme --bin bread-theme` for that crate, or `-p bread-utils ---features bread-client` for the IPC client. +`bakery`, `bread-theme`, `bread-utils`, `bread-app`, `bread-polkit`, +`bread-onnx`, `bread-screenshots`, and `bread-capture` are all workspace +members. Run the same commands with `-p bread-theme --bin bread-theme` +for that crate, `-p bread-utils --features bread-client` for the IPC +client, or `-p bread-app --features bread-client` for the GTK bootstrap +helpers. ## CI diff --git a/Cargo.lock b/Cargo.lock index 235b235..070c24d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,6 +96,40 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -151,6 +185,13 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bread-app" +version = "0.7.2" +dependencies = [ + "bread-utils", +] + [[package]] name = "bread-capture" version = "0.7.2" @@ -176,6 +217,21 @@ dependencies = [ "ureq", ] +[[package]] +name = "bread-polkit" +version = "0.7.2" +dependencies = [ + "anyhow", + "bread-app", + "bread-theme", + "gtk4", + "serde", + "tokio", + "tracing", + "tracing-subscriber", + "zbus", +] + [[package]] name = "bread-screenshots" version = "0.7.2" @@ -239,6 +295,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cairo-rs" version = "0.22.0" @@ -350,7 +412,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -478,7 +540,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.119", ] [[package]] @@ -489,7 +551,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -519,7 +581,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -529,7 +591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.119", ] [[package]] @@ -571,7 +633,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -586,6 +648,33 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -611,6 +700,26 @@ dependencies = [ "cc", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -709,6 +818,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.33" @@ -717,7 +839,7 @@ checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -921,7 +1043,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1055,7 +1177,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1302,6 +1424,12 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libadwaita" version = "0.9.2" @@ -1397,6 +1525,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matrixmultiply" version = "0.3.11" @@ -1444,6 +1581,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "monostate" version = "0.1.18" @@ -1463,7 +1611,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1568,6 +1716,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "ort" version = "2.0.0-rc.12" @@ -1610,6 +1768,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "paste" version = "1.0.15" @@ -1960,7 +2124,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1976,6 +2140,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2005,12 +2180,31 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2029,6 +2223,16 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spm_precompiled" version = "0.1.4" @@ -2076,6 +2280,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -2084,7 +2299,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2145,7 +2360,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2156,7 +2371,16 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", ] [[package]] @@ -2203,6 +2427,34 @@ dependencies = [ "unicode_categories", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "toml" version = "0.8.23" @@ -2314,7 +2566,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2326,12 +2578,38 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "once_cell", + "regex-automata", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2419,6 +2697,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -2478,7 +2767,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -2540,7 +2829,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2551,7 +2840,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2781,10 +3070,75 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.54" @@ -2802,7 +3156,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2822,7 +3176,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -2862,7 +3216,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2870,3 +3224,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index 555bc60..edccbba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture"] +members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screenshots", "bread-capture", "bread-app", "bread-polkit"] resolver = "2" [workspace.package] diff --git a/README.md b/README.md index b769262..34ccf27 100644 --- a/README.md +++ b/README.md @@ -145,13 +145,15 @@ Install all required deps with `sudo pacman -S `. Use `pacman -Q This repo is a Cargo workspace. Bakery-channel products shipped from here are `bakery` and `bread-theme`; the other members are shared crates sibling -apps pin, not bakery packages of their own. +apps pin, or in-tree tools that are not bakery packages of their own. ``` bread-ecosystem/ ├── bakery/ # package manager binary ├── bread-theme/ # shared pywal + fixed-dark-base theming crate ├── bread-utils/ # shared plumbing (Hyprland IPC, singleton, XDG, BreadClient, …) +├── bread-app/ # GTK bootstrap new tools should use (app id, singleton, overlay, command listen) +├── bread-polkit/ # themed PolicyKit 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 @@ -162,6 +164,39 @@ bread-ecosystem/ └── gen-readme-products.sh # rewrites the Products table from the registry ``` +### New GTK tools + +Do not copy another app's `main.rs`. Depend on `bread-app`: + +- `bread_app::application_id` / `try_acquire` / `toggle_or_kill` for the + `com.breadway.*` application id and single-instance lock +- feature `gtk` re-exports `bread_utils::gtk_popup` (layer-shell overlay) +- feature `bread-client` for `listen_commands` on `bread.command..**` + +See the `bread-app` crate docs. Existing apps are not migrated in this +tree; `bread-polkit` is the first in-tree consumer. + +### bread-polkit + +A session PolicyKit authentication agent (password prompt, cancel, +identity). Not a wrapper around `polkit-gnome`. Not published via bakery +and not on the BOS ISO lockfile. + +```sh +cargo run -p bread-polkit +``` + +Autostart — pick one: + +```sh +cp bread-polkit/contrib/bread-polkit.desktop ~/.config/autostart/ +``` + +``` +# hyprland.conf +exec-once = bread-polkit +``` + ## Release pipeline Each product repo (`Breadway/bread`, `Breadway/breadbar`, …) has diff --git a/bread-app/Cargo.toml b/bread-app/Cargo.toml new file mode 100644 index 0000000..a212db3 --- /dev/null +++ b/bread-app/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "bread-app" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "GTK application bootstrap for bread desktop tools: app id, singleton, optional overlay popup, and command listen loop" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" +keywords = ["gtk4", "wayland", "hyprland"] + +[dependencies] +bread-utils = { path = "../bread-utils" } + +[features] +# Layer-shell overlay helper (`gtk_popup`). Matches `bread-utils/gtk` so a +# consumer that only wants app-id / singleton helpers does not pull GTK4. +gtk = ["bread-utils/gtk"] +# `BreadClient` listen loop on `bread.command..**`. Matches +# `bread-utils/bread-client`. +bread-client = ["bread-utils/bread-client"] diff --git a/bread-app/src/command.rs b/bread-app/src/command.rs new file mode 100644 index 0000000..41ad12a --- /dev/null +++ b/bread-app/src/command.rs @@ -0,0 +1,121 @@ +//! Command-bus helpers for `bread.command..**`. +//! +//! The `command_id` here is the breadd sibling-app id (`clip`, `box`, +//! `shot`) — often shorter than the GTK / singleton name (`breadclip`). + +use crate::id::{parse_app_name, InvalidAppId}; +use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription}; + +/// Same charset as [`parse_app_name`]: a single command-bus segment. +pub fn parse_command_id(command_id: &str) -> Result<&str, InvalidAppId> { + parse_app_name(command_id) +} + +/// Subscribe glob: `bread.command..**`. +pub fn command_pattern(command_id: &str) -> Result { + let id = parse_command_id(command_id)?; + Ok(format!("bread.command.{id}.**")) +} + +/// The verb segment of `bread.command..` (and extra trailing +/// segments, if any). `None` when the event is not addressed to +/// `command_id` or the verb is missing. +/// +/// Extra dotted remainder (`bread.command.clip.stack.clear`) yields the +/// first remaining segment (`stack`) — a verb is one segment, matching +/// [`BreadClient::command`]. +pub fn command_verb<'a>(event: &'a str, command_id: &str) -> Option<&'a str> { + if command_id.is_empty() { + return None; + } + let prefix = format!("bread.command.{command_id}."); + let rest = event.strip_prefix(&prefix)?; + let verb = rest.split('.').next()?; + if verb.is_empty() { + None + } else { + Some(verb) + } +} + +/// Subscribe to `bread.command..**` and invoke `on_verb` with +/// the parsed verb plus the raw event. +/// +/// Fail-silent: constructing the client and holding the subscription never +/// requires breadd to be running. Drop the returned [`Subscription`] (or +/// call [`Subscription::stop`]) to end the loop. +pub fn listen_commands(command_id: &str, on_verb: F) -> Result +where + F: Fn(&str, BreadEvent) + Send + 'static, +{ + let id = parse_command_id(command_id)?.to_string(); + let client = BreadClient::connect(id.clone()); + let pattern = format!("bread.command.{id}.**"); + Ok(client.subscribe(pattern, move |event| { + let Some(verb) = command_verb(&event.event, &id).map(str::to_owned) else { + return; + }; + on_verb(&verb, event); + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_pattern_uses_double_star() { + assert_eq!(command_pattern("clip").unwrap(), "bread.command.clip.**"); + assert_eq!(command_pattern("shot").unwrap(), "bread.command.shot.**"); + } + + #[test] + fn command_pattern_rejects_invalid_id() { + assert!(command_pattern("").is_err()); + assert!(command_pattern("clip.clear").is_err()); + } + + #[test] + fn command_verb_strips_app_prefix() { + assert_eq!( + command_verb("bread.command.clip.clear", "clip"), + Some("clear") + ); + assert_eq!( + command_verb("bread.command.shot.region", "shot"), + Some("region") + ); + assert_eq!( + command_verb("bread.command.shot.annotate", "shot"), + Some("annotate") + ); + } + + #[test] + fn command_verb_takes_first_segment_only() { + assert_eq!( + command_verb("bread.command.clip.stack.clear", "clip"), + Some("stack") + ); + } + + #[test] + fn command_verb_rejects_other_apps_and_missing_verb() { + assert_eq!(command_verb("bread.command.clip.clear", "shot"), None); + assert_eq!(command_verb("bread.command.clip", "clip"), None); + assert_eq!(command_verb("bread.command.clip.", "clip"), None); + assert_eq!(command_verb("bread.clip.copied", "clip"), None); + assert_eq!(command_verb("bread.command.clip.clear", ""), None); + } + + #[test] + fn listen_commands_rejects_invalid_id() { + assert!(listen_commands("", |_, _| {}).is_err()); + } + + #[test] + fn listen_commands_stop_joins_without_a_daemon() { + let sub = listen_commands("clip", |_, _| {}).unwrap(); + sub.stop(); + } +} diff --git a/bread-app/src/id.rs b/bread-app/src/id.rs new file mode 100644 index 0000000..8fdaaab --- /dev/null +++ b/bread-app/src/id.rs @@ -0,0 +1,145 @@ +//! App-id helpers shared by GTK tools and the singleton lock. +//! +//! The process / pid-file name (`breadbox`, `bread-polkit`) is also the +//! last segment of the GApplication id (`com.breadway.breadbox`). That is +//! *not* always the breadd command-bus id (`box`, `clip`) — see +//! [`crate::command_verb`] under feature `bread-client`. + +use std::io; + +use crate::singleton::{self, Acquire, Toggle}; + +/// Why [`parse_app_name`] / [`application_id`] rejected a string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvalidAppId { + /// The rejected input, owned so the error is `'static`. + pub name: String, + /// Short reason suitable for an `io::Error` / clap message. + pub reason: &'static str, +} + +impl std::fmt::Display for InvalidAppId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "invalid app id '{}': {}", self.name, self.reason) + } +} + +impl std::error::Error for InvalidAppId {} + +/// Accept a process / GTK application name (`breadbox`, `bread-polkit`). +/// +/// Rules match a GApplication id *element*: non-empty, ASCII letter first, +/// then ASCII alphanumeric / `-` / `_`. Dots are rejected so the name can +/// sit in `com.breadway.` without creating extra segments. +pub fn parse_app_name(name: &str) -> Result<&str, InvalidAppId> { + if name.is_empty() { + return Err(InvalidAppId { + name: name.to_string(), + reason: "must not be empty", + }); + } + let mut chars = name.chars(); + let first = chars.next().expect("non-empty"); + if !first.is_ascii_alphabetic() { + return Err(InvalidAppId { + name: name.to_string(), + reason: "must start with an ASCII letter", + }); + } + if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return Err(InvalidAppId { + name: name.to_string(), + reason: "only ASCII letters, digits, '-' and '_' are allowed", + }); + } + Ok(name) +} + +/// Reverse-DNS GApplication id: `com.breadway.`. +pub fn application_id(app_name: &str) -> Result { + let name = parse_app_name(app_name)?; + Ok(format!("com.breadway.{name}")) +} + +/// [`singleton::try_acquire`] after [`parse_app_name`]. +/// +/// Invalid names become [`io::ErrorKind::InvalidInput`] and never touch +/// the pid file. +pub fn try_acquire(app_name: &str) -> io::Result { + let name = + parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + singleton::try_acquire(name) +} + +/// [`singleton::toggle_or_kill`] after [`parse_app_name`]. +pub fn toggle_or_kill(app_name: &str) -> io::Result { + let name = + parse_app_name(app_name).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + singleton::toggle_or_kill(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_app_name_accepts_existing_tool_names() { + for name in ["breadbox", "breadclip", "bread-polkit", "breadcast"] { + assert_eq!(parse_app_name(name), Ok(name)); + } + } + + #[test] + fn parse_app_name_rejects_empty_dot_and_leading_digit() { + assert!(parse_app_name("").is_err()); + assert!(parse_app_name("bread.box").is_err()); + assert!(parse_app_name("1box").is_err()); + assert!(parse_app_name("-box").is_err()); + assert!(parse_app_name("bread box").is_err()); + } + + #[test] + fn application_id_uses_com_breadway_prefix() { + assert_eq!(application_id("breadbox").unwrap(), "com.breadway.breadbox"); + assert_eq!( + application_id("bread-polkit").unwrap(), + "com.breadway.bread-polkit" + ); + } + + #[test] + fn application_id_rejects_invalid_name() { + assert!(application_id("").is_err()); + assert!(application_id("bread.box").is_err()); + } + + #[test] + fn try_acquire_rejects_invalid_name_before_lock() { + match try_acquire("") { + Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput), + Ok(_) => panic!("empty name must not acquire a lock"), + } + match try_acquire("bread.box") { + Err(err) => assert_eq!(err.kind(), io::ErrorKind::InvalidInput), + Ok(_) => panic!("dotted name must not acquire a lock"), + } + } + + #[test] + fn try_acquire_accepts_valid_name() { + let name = format!("bread-app-id-test-{}", std::process::id()); + match try_acquire(&name).unwrap() { + Acquire::Acquired(_guard) => {} + Acquire::HeldByOther(_) => panic!("expected first acquire to succeed"), + } + } + + #[test] + fn toggle_or_kill_starts_when_nothing_else_is_running() { + let name = format!("bread-app-toggle-test-{}", std::process::id()); + match toggle_or_kill(&name).unwrap() { + Toggle::Started(_guard) => {} + Toggle::KilledExisting => panic!("expected to start as the first instance"), + } + } +} diff --git a/bread-app/src/lib.rs b/bread-app/src/lib.rs new file mode 100644 index 0000000..f848509 --- /dev/null +++ b/bread-app/src/lib.rs @@ -0,0 +1,67 @@ +//! GTK application bootstrap for bread desktop tools. +//! +//! New GTK tools should depend on this crate instead of copying a sixth +//! `main.rs` that wires a `com.breadway.*` application id, a +//! [`bread_utils::singleton`] lock, a layer-shell overlay, and a +//! `bread.command..**` listen loop. +//! +//! # What this is +//! +//! The pieces every bread GTK binary already copies: +//! +//! - [`application_id`] / [`parse_app_name`] — reverse-DNS id +//! (`com.breadway.breadbox`) and the same name used for the singleton +//! pid file. +//! - [`try_acquire`] / [`toggle_or_kill`] — [`bread_utils::singleton`] +//! wrappers that reject an invalid name before touching the lock. +//! - feature `gtk` — re-exports [`gtk_popup`] (`bread_utils::gtk_popup`) +//! for the full-screen overlay breadbox / breadclip / breadcast start +//! from. +//! - feature `bread-client` — [`listen_commands`] plus [`command_verb`] / +//! [`command_pattern`] so a tool can honor `bread.command..**` +//! without re-deriving the prefix strip. +//! +//! This crate does **not** migrate existing apps. Callers still own their +//! widgets, CSS, and clap. Screenshot / `--screenshot` helpers stay in +//! [`bread_utils::screenshot_cli`]. +//! +//! # Example +//! +//! ```ignore +//! let _guard = match bread_app::try_acquire("breadbox")? { +//! bread_app::singleton::Acquire::Acquired(g) => g, +//! bread_app::singleton::Acquire::HeldByOther(_) => return Ok(()), +//! }; +//! let app = gtk4::Application::builder() +//! .application_id(&bread_app::application_id("breadbox")?) +//! .build(); +//! +//! #[cfg(feature = "gtk")] +//! app.connect_activate(|app| { +//! let window = bread_app::gtk_popup::new_overlay_window(app, "breadbox"); +//! window.present(); +//! }); +//! +//! #[cfg(feature = "bread-client")] +//! let _commands = bread_app::listen_commands("box", |verb, event| { +//! // verb is the single segment after `bread.command.box.` +//! let _ = (verb, event); +//! })?; +//! ``` + +pub use bread_utils::singleton; + +#[cfg(feature = "gtk")] +pub use bread_utils::gtk_popup; + +mod id; + +pub use id::{application_id, parse_app_name, toggle_or_kill, try_acquire, InvalidAppId}; + +#[cfg(feature = "bread-client")] +mod command; + +#[cfg(feature = "bread-client")] +pub use bread_utils::bread_client::{BreadClient, BreadEvent, Subscription}; +#[cfg(feature = "bread-client")] +pub use command::{command_pattern, command_verb, listen_commands, parse_command_id}; diff --git a/bread-polkit/Cargo.toml b/bread-polkit/Cargo.toml new file mode 100644 index 0000000..b0c7603 --- /dev/null +++ b/bread-polkit/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "bread-polkit" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "Themed PolicyKit authentication agent for the bread desktop" +repository = "https://git.breadway.dev/Breadway/bread-ecosystem" +keywords = ["polkit", "gtk4", "wayland"] + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "bread-polkit" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +bread-app = { path = "../bread-app", features = ["gtk"] } +bread-theme = { path = "../bread-theme", features = ["gtk"] } +gtk4 = { version = "0.11", features = ["v4_12"] } +serde = { workspace = true } +tokio = { version = "1", features = ["rt", "net", "sync", "time", "macros", "io-util", "process"] } +tracing = { workspace = true } +tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "std"] } +zbus = { version = "5", default-features = false, features = ["tokio"] } diff --git a/bread-polkit/contrib/bread-polkit.desktop b/bread-polkit/contrib/bread-polkit.desktop new file mode 100644 index 0000000..b30a167 --- /dev/null +++ b/bread-polkit/contrib/bread-polkit.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Bread PolicyKit Agent +Comment=Themed PolicyKit authentication agent for the bread desktop +Exec=bread-polkit +Icon=dialog-password +Terminal=false +Categories=System;Security; +StartupNotify=false +X-GNOME-Autostart-Phase=Initialization +X-GNOME-AutoRestart=true +X-GNOME-Autostart-Notify=false diff --git a/bread-polkit/contrib/hyprland.conf b/bread-polkit/contrib/hyprland.conf new file mode 100644 index 0000000..50ce845 --- /dev/null +++ b/bread-polkit/contrib/hyprland.conf @@ -0,0 +1,10 @@ +# bread-polkit — add to hyprland.conf +# +# Session authentication agent. Copy contrib/bread-polkit.desktop to +# ~/.config/autostart/ instead if you prefer XDG autostart. + +exec-once = bread-polkit + +# Optional: blur the overlay panel (namespace is bread-polkit). +layerrule = blur, bread-polkit +layerrule = ignorezero, bread-polkit diff --git a/bread-polkit/src/agent.rs b/bread-polkit/src/agent.rs new file mode 100644 index 0000000..94840eb --- /dev/null +++ b/bread-polkit/src/agent.rs @@ -0,0 +1,300 @@ +//! Session-bus registration and the PolicyKit1 AuthenticationAgent. + +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; + +use anyhow::{Context, Result}; +use gtk4::glib; +use gtk4::prelude::*; +use serde::{Deserialize, Serialize}; +use tokio::sync::{mpsc, Mutex}; +use zbus::zvariant::{OwnedValue, Type, Value}; +use zbus::{connection, interface, proxy, DBusError}; + +use bread_polkit::helper::{discover_transport, Transport}; +use bread_polkit::identity::{current_uid, pick_user, read_passwd, users_from_uids, UnixUser}; +use bread_polkit::session::session_id; + +use crate::auth::{self, Outcome}; +use crate::ui::{self, Prompt}; + +pub const OBJECT_PATH: &str = "/com/breadway/PolicyKit1/AuthenticationAgent"; + +/// Reply from the GTK prompt. +#[derive(Debug)] +pub enum UserAction { + Submit { username: String, password: String }, + Cancel, +} + +#[derive(Debug, DBusError)] +#[zbus(prefix = "org.freedesktop.PolicyKit1.Error")] +enum AgentError { + #[zbus(error)] + ZBus(zbus::Error), + Failed(String), + Cancelled(String), +} + +#[derive(Debug, Deserialize, Serialize, Type)] +struct Identity { + kind: String, + details: HashMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Type)] +struct Subject { + kind: String, + details: HashMap, +} + +#[proxy( + interface = "org.freedesktop.PolicyKit1.Authority", + default_service = "org.freedesktop.PolicyKit1", + default_path = "/org/freedesktop/PolicyKit1/Authority" +)] +trait Authority { + fn register_authentication_agent( + &self, + subject: &Subject, + locale: &str, + object_path: &str, + ) -> zbus::Result<()>; + + fn unregister_authentication_agent( + &self, + subject: &Subject, + object_path: &str, + ) -> zbus::Result<()>; +} + +struct Agent { + transport: Transport, + pending: Arc>>>, +} + +#[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")] +impl Agent { + async fn begin_authentication( + &mut self, + action_id: String, + message: String, + _icon_name: String, + _details: HashMap, + cookie: String, + identities: Vec, + ) -> Result<(), AgentError> { + tracing::info!(%action_id, %cookie, "BeginAuthentication"); + + let users = unix_users(&identities); + let username = pick_user(&users, current_uid()) + .map(|u| u.name.clone()) + .ok_or_else(|| AgentError::Failed("no unix-user identity".into()))?; + + let (tx, mut rx) = mpsc::channel(4); + *self.pending.lock().await = Some(tx.clone()); + + let prompt = Prompt { + cookie: cookie.clone(), + message: message.clone(), + action_id: action_id.clone(), + username: username.clone(), + reply: tx, + }; + invoke_ui(move || { + if let Some(app) = running_app() { + ui::show_prompt(&app, prompt); + } + }); + + let result = self.drive_prompt(&cookie, &username, &mut rx).await; + + *self.pending.lock().await = None; + let cookie_close = cookie.clone(); + invoke_ui(move || ui::close_prompt(&cookie_close)); + result + } + + async fn cancel_authentication(&self, cookie: String) { + tracing::info!(%cookie, "CancelAuthentication"); + if let Some(tx) = self.pending.lock().await.as_ref() { + let _ = tx.try_send(UserAction::Cancel); + } + invoke_ui(move || ui::close_prompt(&cookie)); + } +} + +impl Agent { + async fn drive_prompt( + &self, + cookie: &str, + default_user: &str, + rx: &mut mpsc::Receiver, + ) -> Result<(), AgentError> { + loop { + match rx.recv().await { + None => { + return Err(AgentError::Cancelled("authentication prompt closed".into())); + } + Some(UserAction::Cancel) => { + return Err(AgentError::Cancelled("user cancelled".into())); + } + Some(UserAction::Submit { username, password }) => { + let user = if username.is_empty() { + default_user + } else { + username.as_str() + }; + match auth::authenticate(&self.transport, user, cookie, &password).await { + Ok(Outcome::Success) => return Ok(()), + Ok(Outcome::Failure { message }) => { + let text = message + .unwrap_or_else(|| auth::default_failure_message().to_string()); + let cookie = cookie.to_string(); + invoke_ui(move || ui::show_retry(&cookie, &text)); + } + Err(e) => { + tracing::warn!("helper: {e:#}"); + let text = e.to_string(); + let cookie = cookie.to_string(); + invoke_ui(move || ui::show_retry(&cookie, &text)); + } + } + } + } + } + } +} + +fn unix_users(identities: &[Identity]) -> Vec { + let mut uids = Vec::new(); + for identity in identities { + if identity.kind != "unix-user" { + continue; + } + if let Some(uid) = uid_from_details(&identity.details) { + uids.push(uid); + } + } + users_from_uids(&uids, &read_passwd()) +} + +fn uid_from_details(details: &HashMap) -> Option { + let value = details.get("uid")?; + u32::try_from(value).ok().or_else(|| { + i32::try_from(value) + .ok() + .and_then(|n| u32::try_from(n).ok()) + }) +} + +fn running_app() -> Option { + gtk4::gio::Application::default().and_then(|app| app.downcast::().ok()) +} + +/// GTK thread-default context, captured in [`spawn`] so the dbus thread +/// can `invoke` onto the UI thread instead of its own empty context. +static GTK_CTX: OnceLock = OnceLock::new(); + +fn invoke_ui(f: impl FnOnce() + Send + 'static) { + let ctx = GTK_CTX + .get() + .cloned() + .unwrap_or_else(glib::MainContext::default); + ctx.invoke(f); +} + +fn unix_session_subject(id: &str) -> Result { + let value = Value::from(id.to_string()); + let owned = OwnedValue::try_from(value).context("session-id variant")?; + let mut details = HashMap::new(); + details.insert("session-id".into(), owned); + Ok(Subject { + kind: "unix-session".into(), + details, + }) +} + +/// Spawn the system-bus agent on a background thread. Returns once the +/// thread has been started; registration errors quit the GTK app. +/// +/// Must be called from the GTK thread so the main context we capture is +/// the one driving the password prompt. +pub fn spawn() -> Result<()> { + let _ = GTK_CTX.set(glib::MainContext::default()); + std::thread::Builder::new() + .name("bread-polkit-dbus".into()) + .spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + invoke_ui(move || { + eprintln!("bread-polkit: tokio runtime failed: {e}"); + if let Some(app) = running_app() { + app.quit(); + } + }); + return; + } + }; + rt.block_on(async move { + if let Err(e) = run().await { + eprintln!("bread-polkit: {e:#}"); + invoke_ui(|| { + if let Some(app) = running_app() { + app.quit(); + } + }); + } + }); + }) + .context("spawn dbus thread")?; + Ok(()) +} + +async fn run() -> Result<()> { + let transport = discover_transport().context( + "no polkit helper: expected /run/polkit/agent-helper.socket \ + or /usr/lib/polkit-1/polkit-agent-helper-1", + )?; + tracing::info!(?transport, "using polkit helper"); + + let session = session_id().context( + "no session id (XDG_SESSION_ID / /proc/self/sessionid); \ + cannot register a session authentication agent", + )?; + let subject = unix_session_subject(&session)?; + let locale = std::env::var("LANG").unwrap_or_else(|_| "C".into()); + + let agent = Agent { + transport, + pending: Arc::new(Mutex::new(None)), + }; + + let connection = connection::Builder::system()? + .serve_at(OBJECT_PATH, agent)? + .build() + .await + .context("system bus")?; + + let authority = AuthorityProxy::new(&connection) + .await + .context("PolicyKit1 authority proxy")?; + authority + .register_authentication_agent(&subject, &locale, OBJECT_PATH) + .await + .context("RegisterAuthenticationAgent")?; + tracing::info!(%session, "registered as PolicyKit authentication agent"); + + std::future::pending::<()>().await; + #[allow(unreachable_code)] + { + let _ = authority + .unregister_authentication_agent(&subject, OBJECT_PATH) + .await; + Ok(()) + } +} diff --git a/bread-polkit/src/auth.rs b/bread-polkit/src/auth.rs new file mode 100644 index 0000000..b67bf20 --- /dev/null +++ b/bread-polkit/src/auth.rs @@ -0,0 +1,109 @@ +//! PAM conversation with the polkit agent helper. + +use std::process::Stdio; + +use anyhow::{Context, Result}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::process::Command; + +use bread_polkit::helper::{parse_helper_line, HelperLine, Transport}; + +/// Outcome of one helper conversation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + Success, + Failure { message: Option }, +} + +/// Handshake + PAM loop for one password attempt. +pub async fn authenticate( + transport: &Transport, + username: &str, + cookie: &str, + password: &str, +) -> Result { + match transport { + Transport::Socket(path) => { + let mut stream = UnixStream::connect(path) + .await + .with_context(|| format!("connect {}", path.display()))?; + stream.write_all(username.as_bytes()).await?; + stream.write_all(b"\n").await?; + stream.write_all(cookie.as_bytes()).await?; + stream.write_all(b"\n").await?; + let (reader, writer) = stream.into_split(); + converse(BufReader::new(reader), writer, password).await + } + Transport::Exec(path) => { + let mut child = Command::new(path) + .arg(username) + .env("LC_ALL", "C") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("spawn {}", path.display()))?; + let mut stdin = child.stdin.take().context("polkit helper has no stdin")?; + let stdout = child.stdout.take().context("polkit helper has no stdout")?; + stdin.write_all(cookie.as_bytes()).await?; + stdin.write_all(b"\n").await?; + let outcome = converse(BufReader::new(stdout), stdin, password).await; + let _ = child.wait().await; + outcome + } + } +} + +async fn converse(mut reader: BufReader, mut writer: W, password: &str) -> Result +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let mut last_info: Option = None; + let mut line = String::new(); + loop { + line.clear(); + let n = reader.read_line(&mut line).await?; + if n == 0 { + return Ok(Outcome::Failure { + message: last_info.take(), + }); + } + match parse_helper_line(&line) { + HelperLine::PromptEchoOff(_) => { + writer.write_all(password.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + } + HelperLine::PromptEchoOn(_) => { + // Visible prompt (username, etc.) — we already sent the + // identity in the handshake. An empty line is safer than + // echoing the password. + writer.write_all(b"\n").await?; + writer.flush().await?; + } + HelperLine::ErrorMsg(msg) | HelperLine::TextInfo(msg) => { + if !msg.is_empty() { + last_info = Some(msg); + } + } + HelperLine::Success => return Ok(Outcome::Success), + HelperLine::Failure => { + return Ok(Outcome::Failure { + message: last_info.take(), + }); + } + HelperLine::Other(other) => { + if !other.is_empty() { + tracing::debug!("helper: {other}"); + } + } + } + } +} + +/// Shared default when the helper gives no `PAM_*` text on failure. +pub fn default_failure_message() -> &'static str { + "Authentication failed. Try again." +} diff --git a/bread-polkit/src/helper.rs b/bread-polkit/src/helper.rs new file mode 100644 index 0000000..c35a520 --- /dev/null +++ b/bread-polkit/src/helper.rs @@ -0,0 +1,205 @@ +//! `polkit-agent-helper-1` transport and PAM line parser. +//! +//! Arch polkit 127+ talks over `/run/polkit/agent-helper.socket`. Older +//! builds still spawn the setuid helper at +//! `/usr/lib/polkit-1/polkit-agent-helper-1`. Prefer the socket when it +//! exists. + +use std::path::{Path, PathBuf}; + +/// How this agent will talk to polkit's helper. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Transport { + /// systemd socket-activated helper (polkit 127+). + Socket(PathBuf), + /// Legacy setuid helper binary. + Exec(PathBuf), +} + +const SOCKET_CANDIDATES: &[&str] = &["/run/polkit/agent-helper.socket"]; +const HELPER_CANDIDATES: &[&str] = &[ + "/usr/lib/polkit-1/polkit-agent-helper-1", + "/usr/libexec/polkit-1/polkit-agent-helper-1", +]; + +/// One stdout line from the helper after the cookie handshake. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HelperLine { + PromptEchoOff(String), + PromptEchoOn(String), + ErrorMsg(String), + TextInfo(String), + Success, + Failure, + Other(String), +} + +/// Pick a live transport: `BREAD_POLKIT_SOCKET` / `BREAD_POLKIT_HELPER` +/// if set and present, otherwise the first existing well-known path. +pub fn discover_transport() -> Option { + discover_transport_from( + std::env::var_os("BREAD_POLKIT_SOCKET") + .map(PathBuf::from) + .as_deref(), + std::env::var_os("BREAD_POLKIT_HELPER") + .map(PathBuf::from) + .as_deref(), + SOCKET_CANDIDATES, + HELPER_CANDIDATES, + |p| p.exists(), + ) +} + +/// Testable discovery: `exists` is injected so unit tests do not need a +/// real `/run/polkit` socket. +pub fn discover_transport_from( + socket_override: Option<&Path>, + helper_override: Option<&Path>, + sockets: &[&str], + helpers: &[&str], + exists: impl Fn(&Path) -> bool, +) -> Option { + if let Some(path) = socket_override { + if exists(path) { + return Some(Transport::Socket(path.to_path_buf())); + } + } + for candidate in sockets { + let path = Path::new(candidate); + if exists(path) { + return Some(Transport::Socket(path.to_path_buf())); + } + } + if let Some(path) = helper_override { + if exists(path) { + return Some(Transport::Exec(path.to_path_buf())); + } + } + for candidate in helpers { + let path = Path::new(candidate); + if exists(path) { + return Some(Transport::Exec(path.to_path_buf())); + } + } + None +} + +/// Parse one helper protocol line. Prefix match is case-sensitive and +/// matches polkit's own `PAM_*` / `SUCCESS` / `FAILURE` tokens. +pub fn parse_helper_line(line: &str) -> HelperLine { + let line = line.trim_end_matches(['\r', '\n']); + if line == "SUCCESS" || line.starts_with("SUCCESS") { + return HelperLine::Success; + } + if line == "FAILURE" || line.starts_with("FAILURE") { + return HelperLine::Failure; + } + if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_OFF") { + return HelperLine::PromptEchoOff(rest.trim().to_string()); + } + if let Some(rest) = line.strip_prefix("PAM_PROMPT_ECHO_ON") { + return HelperLine::PromptEchoOn(rest.trim().to_string()); + } + if let Some(rest) = line.strip_prefix("PAM_ERROR_MSG") { + return HelperLine::ErrorMsg(rest.trim().to_string()); + } + if let Some(rest) = line.strip_prefix("PAM_TEXT_INFO") { + return HelperLine::TextInfo(rest.trim().to_string()); + } + HelperLine::Other(line.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use std::path::PathBuf; + + #[test] + fn parse_helper_line_known_tokens() { + assert_eq!(parse_helper_line("SUCCESS"), HelperLine::Success); + assert_eq!(parse_helper_line("SUCCESS\n"), HelperLine::Success); + assert_eq!(parse_helper_line("FAILURE"), HelperLine::Failure); + assert_eq!( + parse_helper_line("PAM_PROMPT_ECHO_OFF Password:"), + HelperLine::PromptEchoOff("Password:".into()) + ); + assert_eq!( + parse_helper_line("PAM_PROMPT_ECHO_OFF"), + HelperLine::PromptEchoOff(String::new()) + ); + assert_eq!( + parse_helper_line("PAM_PROMPT_ECHO_ON login:"), + HelperLine::PromptEchoOn("login:".into()) + ); + assert_eq!( + parse_helper_line("PAM_ERROR_MSG Authentication failure"), + HelperLine::ErrorMsg("Authentication failure".into()) + ); + assert_eq!( + parse_helper_line("PAM_TEXT_INFO Account locked"), + HelperLine::TextInfo("Account locked".into()) + ); + assert_eq!( + parse_helper_line("garbage"), + HelperLine::Other("garbage".into()) + ); + } + + #[test] + fn discover_prefers_socket_over_exec() { + let present: HashSet = [ + "/run/polkit/agent-helper.socket", + "/usr/lib/polkit-1/polkit-agent-helper-1", + ] + .into_iter() + .map(PathBuf::from) + .collect(); + let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| { + present.contains(p) + }); + assert_eq!( + got, + Some(Transport::Socket(PathBuf::from( + "/run/polkit/agent-helper.socket" + ))) + ); + } + + #[test] + fn discover_falls_back_to_helper_binary() { + let present: HashSet = ["/usr/lib/polkit-1/polkit-agent-helper-1"] + .into_iter() + .map(PathBuf::from) + .collect(); + let got = discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |p| { + present.contains(p) + }); + assert_eq!( + got, + Some(Transport::Exec(PathBuf::from( + "/usr/lib/polkit-1/polkit-agent-helper-1" + ))) + ); + } + + #[test] + fn discover_override_socket_wins_when_present() { + let override_path = Path::new("/tmp/bread-polkit-test.sock"); + let got = discover_transport_from( + Some(override_path), + None, + SOCKET_CANDIDATES, + HELPER_CANDIDATES, + |p| p == override_path, + ); + assert_eq!(got, Some(Transport::Socket(override_path.to_path_buf()))); + } + + #[test] + fn discover_none_when_nothing_exists() { + let got = + discover_transport_from(None, None, SOCKET_CANDIDATES, HELPER_CANDIDATES, |_| false); + assert_eq!(got, None); + } +} diff --git a/bread-polkit/src/identity.rs b/bread-polkit/src/identity.rs new file mode 100644 index 0000000..e92e395 --- /dev/null +++ b/bread-polkit/src/identity.rs @@ -0,0 +1,128 @@ +//! Unix-user identities from a PolicyKit `BeginAuthentication` call. + +/// A `unix-user` identity the agent can authenticate as. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnixUser { + pub uid: u32, + pub name: String, +} + +/// Look up `uid` in a passwd-file dump (`name:x:uid:...` lines). +pub fn name_for_uid(uid: u32, passwd: &str) -> Option { + for line in passwd.lines() { + if line.starts_with('#') { + continue; + } + let mut parts = line.split(':'); + let name = parts.next()?; + let _pw = parts.next()?; + let id = parts.next()?.parse::().ok()?; + if id == uid && !name.is_empty() { + return Some(name.to_string()); + } + } + None +} + +/// Resolve each uid to a [`UnixUser`], falling back to `uid N` when +/// `/etc/passwd` has no name. +pub fn users_from_uids(uids: &[u32], passwd: &str) -> Vec { + uids.iter() + .copied() + .map(|uid| UnixUser { + uid, + name: name_for_uid(uid, passwd).unwrap_or_else(|| format!("uid {uid}")), + }) + .collect() +} + +/// Prefer the process's own uid when it is in `users`, otherwise the first. +pub fn pick_user<'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); + } + } + users.first() +} + +/// Real uid from a `/proc/self/status` dump (`Uid:\t ...`). +pub fn uid_from_status(status: &str) -> Option { + for line in status.lines() { + let Some(rest) = line.strip_prefix("Uid:") else { + continue; + }; + return rest.split_whitespace().next()?.parse().ok(); + } + None +} + +/// Current real uid, or `None` if `/proc/self/status` is unreadable. +pub fn current_uid() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + uid_from_status(&status) +} + +/// Contents of `/etc/passwd`, or empty if unreadable. +pub fn read_passwd() -> String { + std::fs::read_to_string("/etc/passwd").unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + const PASSWD: &str = "\ +# comment +root:x:0:0:root:/root:/bin/sh +alice:x:1000:1000:Alice:/home/alice:/bin/zsh +bob:x:1001:1001:Bob:/home/bob:/bin/bash +"; + + #[test] + fn name_for_uid_reads_passwd_lines() { + assert_eq!(name_for_uid(0, PASSWD).as_deref(), Some("root")); + assert_eq!(name_for_uid(1000, PASSWD).as_deref(), Some("alice")); + assert_eq!(name_for_uid(99, PASSWD), None); + } + + #[test] + fn users_from_uids_falls_back_to_uid_label() { + let users = users_from_uids(&[1000, 42], PASSWD); + assert_eq!( + users, + vec![ + UnixUser { + uid: 1000, + name: "alice".into() + }, + UnixUser { + uid: 42, + name: "uid 42".into() + }, + ] + ); + } + + #[test] + fn pick_user_prefers_current_uid() { + let users = users_from_uids(&[0, 1000], PASSWD); + let picked = pick_user(&users, Some(1000)).unwrap(); + assert_eq!(picked.name, "alice"); + } + + #[test] + fn pick_user_falls_back_to_first() { + let users = users_from_uids(&[0, 1000], PASSWD); + let picked = pick_user(&users, Some(7)).unwrap(); + assert_eq!(picked.name, "root"); + assert!(pick_user(&[], Some(1000)).is_none()); + } + + #[test] + fn uid_from_status_reads_real_uid() { + let status = "Name:\tbread-polkit\nUid:\t1000\t1000\t1000\t1000\n"; + assert_eq!(uid_from_status(status), Some(1000)); + assert_eq!(uid_from_status("Name:\tfoo\n"), None); + } +} diff --git a/bread-polkit/src/lib.rs b/bread-polkit/src/lib.rs new file mode 100644 index 0000000..2f9e574 --- /dev/null +++ b/bread-polkit/src/lib.rs @@ -0,0 +1,10 @@ +//! Non-GTK PolicyKit helper logic for `bread-polkit`. +//! +//! The binary (`bread-polkit`) registers as a session authentication +//! agent and shows a themed password prompt. This library is the +//! transport / identity / session parsing that can be unit-tested +//! without a display. + +pub mod helper; +pub mod identity; +pub mod session; diff --git a/bread-polkit/src/main.rs b/bread-polkit/src/main.rs new file mode 100644 index 0000000..ca28048 --- /dev/null +++ b/bread-polkit/src/main.rs @@ -0,0 +1,94 @@ +//! bread-polkit — themed PolicyKit authentication agent. +//! +//! Registers on the `org.freedesktop.PolicyKit1.AuthenticationAgent` +//! interface and shows a bread-theme GTK4 password prompt. This is an +//! agent, not a wrapper that execs `polkit-gnome`. +//! +//! Autostart: copy `contrib/bread-polkit.desktop` to +//! `~/.config/autostart/`, or add `exec-once = bread-polkit` to Hyprland. + +mod agent; +mod auth; +mod ui; + +use bread_app::singleton::Acquire; +use gtk4::prelude::*; + +const APP_NAME: &str = "bread-polkit"; + +fn main() { + let arg = std::env::args().nth(1); + match arg.as_deref() { + Some("-h") | Some("--help") => { + print_help(); + return; + } + Some("-V") | Some("--version") => { + println!("bread-polkit {}", env!("CARGO_PKG_VERSION")); + return; + } + Some(other) => { + eprintln!("bread-polkit: unknown argument '{other}'"); + print_help(); + std::process::exit(2); + } + None => {} + } + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_target(false) + .init(); + + let _guard = match bread_app::try_acquire(APP_NAME) { + Ok(Acquire::Acquired(g)) => Some(g), + Ok(Acquire::HeldByOther(pid)) => { + eprintln!("bread-polkit: already running (pid {pid:?})"); + std::process::exit(0); + } + Err(e) => { + eprintln!("bread-polkit: singleton lock unavailable ({e}); continuing"); + None + } + }; + + let app_id = bread_app::application_id(APP_NAME).expect("static app name"); + let app = gtk4::Application::builder().application_id(&app_id).build(); + + app.connect_activate(|app| { + bread_theme::gtk::apply_shared(); + bread_theme::gtk::apply_app_css(ui::app_css); + // No window until polkit asks; hold so GApplication stays alive. + std::mem::forget(app.hold()); + if let Err(e) = agent::spawn() { + eprintln!("bread-polkit: {e:#}"); + app.quit(); + } + }); + + app.run(); +} + +fn print_help() { + print!( + "\ +bread-polkit — themed PolicyKit authentication agent + +Usage: + bread-polkit + bread-polkit --help + bread-polkit --version + +Autostart (pick one): + cp contrib/bread-polkit.desktop ~/.config/autostart/ + exec-once = bread-polkit # Hyprland + +The agent talks to the polkit1 AuthenticationAgent API and prompts for +a password. It does not exec polkit-gnome. Not a bakery product; not +on the BOS ISO lockfile. +" + ); +} diff --git a/bread-polkit/src/session.rs b/bread-polkit/src/session.rs new file mode 100644 index 0000000..c23cbe1 --- /dev/null +++ b/bread-polkit/src/session.rs @@ -0,0 +1,49 @@ +//! Session subject for `RegisterAuthenticationAgent`. + +/// Logind session id from `XDG_SESSION_ID`, falling back to +/// `/proc/self/sessionid` when the kernel has one. +pub fn session_id() -> Option { + let xdg = std::env::var("XDG_SESSION_ID").ok(); + let proc = std::fs::read_to_string("/proc/self/sessionid").ok(); + session_id_from(xdg.as_deref(), proc.as_deref()) +} + +/// `None` when both sources are empty or the kernel reports the +/// unsigned `-1` sentinel (`4294967295`) meaning "no session". +pub fn session_id_from(xdg: Option<&str>, proc_sessionid: Option<&str>) -> Option { + if let Some(id) = xdg.map(str::trim).filter(|s| !s.is_empty()) { + return Some(id.to_string()); + } + let raw = proc_sessionid?.trim(); + if raw.is_empty() || raw == "4294967295" { + return None; + } + Some(raw.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefers_xdg_session_id() { + assert_eq!(session_id_from(Some("3"), Some("7")).as_deref(), Some("3")); + assert_eq!( + session_id_from(Some(" 3 "), Some("7")).as_deref(), + Some("3") + ); + } + + #[test] + fn falls_back_to_proc_sessionid() { + assert_eq!(session_id_from(Some(""), Some("7")).as_deref(), Some("7")); + assert_eq!(session_id_from(None, Some("7\n")).as_deref(), Some("7")); + } + + #[test] + fn rejects_unset_kernel_session() { + assert_eq!(session_id_from(None, Some("4294967295")), None); + assert_eq!(session_id_from(Some(""), Some("")), None); + assert_eq!(session_id_from(None, None), None); + } +} diff --git a/bread-polkit/src/ui.rs b/bread-polkit/src/ui.rs new file mode 100644 index 0000000..50275bd --- /dev/null +++ b/bread-polkit/src/ui.rs @@ -0,0 +1,285 @@ +//! GTK4 password prompt, themed with bread-theme. + +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4::gdk::Key; +use gtk4::glib::{self, Propagation}; +use gtk4::prelude::*; +use gtk4::{ + Align, Application, ApplicationWindow, Box as GBox, Button, Entry, EventControllerKey, Label, + Orientation, +}; + +use bread_theme::tokens; + +use crate::agent::UserAction; + +const PANEL_WIDTH: i32 = 400; + +struct Active { + cookie: String, + window: ApplicationWindow, + password: Entry, + error: Label, + reply: tokio::sync::mpsc::Sender, + username: String, +} + +thread_local! { + static ACTIVE: RefCell> = const { RefCell::new(None) }; +} + +/// App-specific rules layered on the shared bread-theme stylesheet. +pub fn app_css() -> String { + format!( + ".polkit-panel {{\ + background-color: @surface; color: @on-surface;\ + border-radius: {r}px; padding: {pad}px;\ + min-width: {w}px;\ + }}\n\ + .polkit-title {{ font-size: 1.4em; font-weight: bold; }}\n\ + .polkit-message {{ opacity: 0.85; }}\n\ + .polkit-identity {{ opacity: 0.7; font-size: {sec}px; }}\n\ + .polkit-error {{ color: @on-red; }}\n\ + .polkit-buttons {{ padding-top: {sm}px; }}\n", + r = tokens::RADIUS_PRIMARY, + pad = tokens::SPACE_XL, + w = PANEL_WIDTH, + sec = tokens::FONT_SIZE_SECONDARY, + sm = tokens::SPACE_SM, + ) +} + +pub struct Prompt { + pub cookie: String, + pub message: String, + pub action_id: String, + pub username: String, + pub reply: tokio::sync::mpsc::Sender, +} + +/// Show (or replace) the password overlay for this cookie. +pub fn show_prompt(app: &Application, prompt: Prompt) { + close_if_other_cookie(&prompt.cookie); + + if ACTIVE.with(|a| { + a.borrow() + .as_ref() + .is_some_and(|active| active.cookie == prompt.cookie) + }) { + present_existing(&prompt); + return; + } + + let window = bread_app::gtk_popup::new_overlay_window(app, "bread-polkit"); + + let panel = GBox::new(Orientation::Vertical, tokens::SPACE_MD as i32); + panel.add_css_class("polkit-panel"); + panel.add_css_class("card"); + panel.set_halign(Align::Center); + panel.set_valign(Align::Center); + panel.set_size_request(PANEL_WIDTH, -1); + + let title = Label::new(Some("Authentication required")); + title.add_css_class("polkit-title"); + title.add_css_class("page-title"); + title.set_halign(Align::Start); + title.set_wrap(true); + panel.append(&title); + + let message = if prompt.message.trim().is_empty() { + prompt.action_id.clone() + } else { + prompt.message.clone() + }; + let msg = Label::new(Some(&message)); + msg.add_css_class("polkit-message"); + msg.set_halign(Align::Start); + msg.set_wrap(true); + msg.set_xalign(0.0); + panel.append(&msg); + + if !prompt.username.is_empty() { + let identity = Label::new(Some(&format!("Authenticating as {}", prompt.username))); + identity.add_css_class("polkit-identity"); + identity.add_css_class("dim-label"); + identity.set_halign(Align::Start); + panel.append(&identity); + } + + let error = Label::new(None); + error.add_css_class("polkit-error"); + error.set_halign(Align::Start); + error.set_wrap(true); + error.set_visible(false); + panel.append(&error); + + let password = Entry::builder() + .visibility(false) + .input_purpose(gtk4::InputPurpose::Password) + .placeholder_text("Password") + .hexpand(true) + .build(); + panel.append(&password); + + let buttons = GBox::new(Orientation::Horizontal, tokens::SPACE_SM as i32); + buttons.add_css_class("polkit-buttons"); + buttons.set_halign(Align::End); + let cancel = Button::with_label("Cancel"); + cancel.add_css_class("flat"); + let confirm = Button::with_label("Authenticate"); + confirm.add_css_class("suggested-action"); + buttons.append(&cancel); + buttons.append(&confirm); + panel.append(&buttons); + + window.set_child(Some(&panel)); + + let reply = prompt.reply.clone(); + let cookie = prompt.cookie.clone(); + let username = prompt.username.clone(); + + let submit = { + let password = password.clone(); + let reply = reply.clone(); + let username = username.clone(); + Rc::new(move || { + let secret = password.text().to_string(); + password.set_text(""); + let _ = reply.try_send(UserAction::Submit { + username: username.clone(), + password: secret, + }); + }) + }; + let cancel_fn = { + let reply = reply.clone(); + let window = window.clone(); + Rc::new(move || { + let _ = reply.try_send(UserAction::Cancel); + window.close(); + ACTIVE.with(|a| a.replace(None)); + }) + }; + + confirm.connect_clicked({ + let submit = submit.clone(); + move |_| submit() + }); + password.connect_activate({ + let submit = submit.clone(); + move |_| submit() + }); + cancel.connect_clicked({ + let cancel_fn = cancel_fn.clone(); + move |_| cancel_fn() + }); + + let keys = EventControllerKey::new(); + keys.connect_key_pressed({ + let cancel_fn = cancel_fn.clone(); + move |_, key, _, _| { + if key == Key::Escape { + cancel_fn(); + Propagation::Stop + } else { + Propagation::Proceed + } + } + }); + window.add_controller(keys); + + bread_app::gtk_popup::close_on_outside_click(&window, &panel, { + let cancel_fn = cancel_fn.clone(); + move || cancel_fn() + }); + + window.connect_close_request({ + let reply = reply.clone(); + move |_| { + let closing_ours = ACTIVE.with(|a| { + a.borrow() + .as_ref() + .is_some_and(|active| active.cookie == cookie) + }); + if closing_ours { + let _ = reply.try_send(UserAction::Cancel); + ACTIVE.with(|a| a.replace(None)); + } + glib::Propagation::Proceed + } + }); + + ACTIVE.with(|a| { + *a.borrow_mut() = Some(Active { + cookie: prompt.cookie, + window: window.clone(), + password: password.clone(), + error, + reply, + username, + }); + }); + + window.present(); + password.grab_focus(); +} + +fn present_existing(prompt: &Prompt) { + ACTIVE.with(|a| { + if let Some(active) = a.borrow_mut().as_mut() { + active.reply = prompt.reply.clone(); + active.username = prompt.username.clone(); + active.error.set_visible(false); + active.password.set_text(""); + active.window.present(); + active.password.grab_focus(); + } + }); +} + +/// Show a retry message on the open dialog for `cookie`. +pub fn show_retry(cookie: &str, message: &str) { + ACTIVE.with(|a| { + let mut guard = a.borrow_mut(); + let Some(active) = guard.as_mut() else { + return; + }; + if active.cookie != cookie { + return; + } + active.error.set_label(message); + active.error.set_visible(true); + active.password.set_text(""); + active.window.present(); + active.password.grab_focus(); + }); +} + +/// Close the dialog if it is still showing `cookie`. +pub fn close_prompt(cookie: &str) { + ACTIVE.with(|a| { + let Some(active) = a.borrow_mut().take() else { + return; + }; + if active.cookie == cookie { + active.window.close(); + } else { + *a.borrow_mut() = Some(active); + } + }); +} + +fn close_if_other_cookie(cookie: &str) { + ACTIVE.with(|a| { + let Some(active) = a.borrow_mut().take() else { + return; + }; + if active.cookie == cookie { + *a.borrow_mut() = Some(active); + } else { + active.window.close(); + } + }); +} From fcba3760387e2523edb71350f8efea3bc851b21e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:20:00 +0800 Subject: [PATCH 04/37] Add per-output palettes and window-scoped theme binding Each Hyprland/GDK connector can have its own palette and stylesheet under $XDG_RUNTIME_DIR/bread/{palettes,themes}/. GTK apps bind a widget-level provider so two windows in one process can follow different wallpapers. Bump workspace version to 0.7.4 for the tag. --- Cargo.toml | 2 +- bread-polkit/src/ui.rs | 1 + bread-theme/CHANGELOG.md | 45 +++- bread-theme/src/bin/bread-theme.rs | 160 +++++++++++++- bread-theme/src/gtk.rs | 331 +++++++++++++++++++++++++++- bread-theme/src/lib.rs | 184 +++++++++++++--- bread-theme/src/output.rs | 338 +++++++++++++++++++++++++++++ bread-theme/src/palette.rs | 13 +- 8 files changed, 1016 insertions(+), 58 deletions(-) create mode 100644 bread-theme/src/output.rs diff --git a/Cargo.toml b/Cargo.toml index edccbba..10d698a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["bakery", "bread-theme", "bread-utils", "bread-onnx", "bread-screensh resolver = "2" [workspace.package] -version = "0.7.2" +version = "0.7.4" edition = "2021" license = "MIT" authors = ["Breadway "] diff --git a/bread-polkit/src/ui.rs b/bread-polkit/src/ui.rs index 50275bd..033f171 100644 --- a/bread-polkit/src/ui.rs +++ b/bread-polkit/src/ui.rs @@ -135,6 +135,7 @@ pub fn show_prompt(app: &Application, prompt: Prompt) { panel.append(&buttons); window.set_child(Some(&panel)); + bread_theme::gtk::bind_window_auto_with_app_css(&window, |_| app_css()); let reply = prompt.reply.clone(); let cookie = prompt.cookie.clone(); diff --git a/bread-theme/CHANGELOG.md b/bread-theme/CHANGELOG.md index 2675e55..9b27493 100644 --- a/bread-theme/CHANGELOG.md +++ b/bread-theme/CHANGELOG.md @@ -1,11 +1,48 @@ # bread-theme changelog +## 0.7.4 + +Per-output (per-monitor) theming. Session-global `theme.css` remains the +fallback / focused-monitor sheet; each Hyprland/GDK connector can now have +its own palette and stylesheet. BOS still keeps bg/surface/overlay/fg +fixed — only color1–6 come from the wallpaper. + +On disk under `$XDG_RUNTIME_DIR/bread/` (same fallback as `shared_css_path`): + +- `palettes/.json` — accents only (round-trips through + `from_wal_json` / a color1–6 object; never persists pywal's light bg) +- `themes/.css` — `stylesheet()` for that palette + +New lib API: + +- `themes_dir`, `palettes_dir`, `output_css_path`, `output_palette_path`, + `sanitize_output` +- `load_palette_for`, `write_output_palette`, `write_output_css`, + `write_shared_css_from` +- `palette_from_image` (isolated `wal -i`, does not touch `~/.cache/wal`), + `generate_output`, `palette_from_json` +- `stylesheet_resolved` — inlines `@accent` / `@on-bg` / … to hex so GTK's + display-global `@define-color` cannot leak the wrong monitor's accent + +GTK (`gtk` feature): `bind_window`, `bind_window_with_app_css`, +`output_for_widget`, `bind_window_auto`, `bind_window_auto_with_app_css`. +Widget-scoped providers at `USER - 10` so they beat `apply_shared` but +lose to user CSS. Existing `apply_shared` / `apply_app_css` / +`apply_css` / `apply_user_css` are unchanged. + +CLI: `bread-theme generate-output --image | --from-json + [--shared]`. Does not write `theme.css` unless `--shared`. + ## Coordinated bump policy -`bread-theme` is consumed by `breadbar`, `breadbox`, and `breadpad` as a pinned -git dependency. A breaking change to `Palette`, `css_vars`, or the `gtk` feature -API requires all three dependents to bump their `Cargo.toml` git tag and cut a -release together. Note the impact in this file before tagging. +`bread-theme` is consumed by `breadbar`, `breadbox`, `breadpad`, and the other +GTK bread apps as a pinned git dependency. A breaking change to `Palette`, +`css_vars`, or the `gtk` feature API requires dependents to bump their +`Cargo.toml` git tag and cut a release together. Note the impact in this file +before tagging. + +**0.7.4** adds per-output bind APIs (`bind_window*`, `load_palette_for`, +`generate_output`). Apps that call those must pin `tag = "v0.7.4"`. --- diff --git a/bread-theme/src/bin/bread-theme.rs b/bread-theme/src/bin/bread-theme.rs index 266ea9c..3d7a862 100644 --- a/bread-theme/src/bin/bread-theme.rs +++ b/bread-theme/src/bin/bread-theme.rs @@ -9,6 +9,8 @@ //! # signal every running bread GUI to recolour //! bread-theme path # print the stylesheet path //! bread-theme print # render to stdout (no write) +//! bread-theme generate-output --image [--shared] +//! bread-theme generate-output --from-json [--shared] use std::process::ExitCode; @@ -25,6 +27,149 @@ fn write_and_report(verb: &str) -> ExitCode { } } +fn print_help() { + eprintln!( + "bread-theme — shared stylesheet generator\n\n\ + USAGE:\n\ + \x20 bread-theme [generate|reload|path|print]\n\ + \x20 bread-theme generate-output --image [--shared]\n\ + \x20 bread-theme generate-output --from-json [--shared]\n\n\ + generate render the pywal palette to the shared stylesheet (default)\n\ + reload re-render and signal running bread GUIs to recolour live\n\ + path print the stylesheet path ({})\n\ + print render to stdout without writing\n\ + generate-output write palettes/.json and themes/.css\n\ + \x20 --image isolated `wal -i` (does not touch ~/.cache/wal)\n\ + \x20 --from-json wal colors.json or a color1-6 object\n\ + \x20 --shared also write the session-global theme.css", + bread_theme::shared_css_path().display() + ); +} + +fn generate_output_cmd() -> ExitCode { + let args: Vec = std::env::args().skip(2).collect(); + if args.is_empty() + || args + .iter() + .any(|a| matches!(a.as_str(), "-h" | "--help" | "help")) + { + print_help(); + return if args.is_empty() { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + }; + } + + let output = args[0].as_str(); + if output.starts_with('-') { + eprintln!("bread-theme: generate-output requires an OUTPUT name (got '{output}')"); + return ExitCode::FAILURE; + } + + let mut image: Option<&str> = None; + let mut from_json: Option<&str> = None; + let mut shared = false; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--shared" => shared = true, + "--image" => { + i += 1; + match args.get(i) { + Some(p) => image = Some(p.as_str()), + None => { + eprintln!("bread-theme: --image requires a path"); + return ExitCode::FAILURE; + } + } + } + "--from-json" => { + i += 1; + match args.get(i) { + Some(p) => from_json = Some(p.as_str()), + None => { + eprintln!("bread-theme: --from-json requires a path"); + return ExitCode::FAILURE; + } + } + } + other => { + eprintln!("bread-theme: unknown generate-output flag '{other}'"); + return ExitCode::FAILURE; + } + } + i += 1; + } + + match (image, from_json) { + (Some(_), Some(_)) => { + eprintln!("bread-theme: pass only one of --image or --from-json"); + ExitCode::FAILURE + } + (None, None) => { + eprintln!("bread-theme: generate-output needs --image or --from-json "); + ExitCode::FAILURE + } + (Some(path), None) => { + match bread_theme::generate_output(output, std::path::Path::new(path)) { + Ok(css) => finish_generate_output(output, css, shared), + Err(e) => { + eprintln!("bread-theme: generate-output failed: {e}"); + ExitCode::FAILURE + } + } + } + (None, Some(path)) => match write_output_from_json(output, path, shared) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("bread-theme: generate-output failed: {e}"); + ExitCode::FAILURE + } + }, + } +} + +fn write_output_from_json(output: &str, json_path: &str, shared: bool) -> std::io::Result<()> { + let json = std::fs::read_to_string(json_path)?; + let palette = bread_theme::palette_from_json(&json).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("could not parse palette JSON: {json_path}"), + ) + })?; + let pal_path = bread_theme::write_output_palette(output, &palette)?; + let css_path = bread_theme::write_output_css(output, &palette)?; + eprintln!( + "bread-theme: wrote {} and {}", + pal_path.display(), + css_path.display() + ); + if shared { + let shared_path = bread_theme::write_shared_css_from(&palette)?; + eprintln!("bread-theme: wrote shared {}", shared_path.display()); + } + Ok(()) +} + +fn finish_generate_output(output: &str, css: std::path::PathBuf, shared: bool) -> ExitCode { + eprintln!("bread-theme: wrote {}", css.display()); + if shared { + match bread_theme::write_shared_css_from(&bread_theme::load_palette_for(output)) { + Ok(path) => { + eprintln!("bread-theme: wrote shared {}", path.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("bread-theme: failed to write shared stylesheet: {e}"); + ExitCode::FAILURE + } + } + } else { + ExitCode::SUCCESS + } +} + fn main() -> ExitCode { let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into()); match cmd.as_str() { @@ -42,20 +187,15 @@ fn main() -> ExitCode { // the file monitor in every running bread GUI, so they all re-read the // palette and recolour live — shared widgets *and* each app's own rules. "reload" => write_and_report("reloaded"), + "generate-output" => generate_output_cmd(), "-h" | "--help" | "help" => { - eprintln!( - "bread-theme — shared stylesheet generator\n\n\ - USAGE:\n bread-theme [generate|reload|path|print]\n\n\ - generate render the pywal palette to the shared stylesheet (default)\n\ - reload re-render and signal running bread GUIs to recolour live\n\ - path print the stylesheet path ({})\n\ - print render to stdout without writing", - bread_theme::shared_css_path().display() - ); + print_help(); ExitCode::SUCCESS } other => { - eprintln!("bread-theme: unknown command '{other}' (try generate|reload|path|print)"); + eprintln!( + "bread-theme: unknown command '{other}' (try generate|reload|path|print|generate-output)" + ); ExitCode::FAILURE } } diff --git a/bread-theme/src/gtk.rs b/bread-theme/src/gtk.rs index fb759c7..43e450d 100644 --- a/bread-theme/src/gtk.rs +++ b/bread-theme/src/gtk.rs @@ -1,8 +1,18 @@ +use gtk4::gdk::prelude::*; use gtk4::gio; +use gtk4::glib::object::ObjectType; use gtk4::prelude::*; use gtk4::CssProvider; use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; use std::path::Path; +use std::rc::Rc; + +use crate::Palette; + +/// Above APPLICATION (600) so we beat [`apply_shared`], below USER (800) +/// so `apply_user_css` still wins. +const BIND_PRIORITY: u32 = gtk4::STYLE_PROVIDER_PRIORITY_USER - 10; thread_local! { static SHARED_PROVIDER: RefCell> = const { RefCell::new(None) }; @@ -14,8 +24,7 @@ thread_local! { } fn reload_shared() { - let css = std::fs::read_to_string(crate::shared_css_path()) - .unwrap_or_else(|_| crate::render()); + let css = std::fs::read_to_string(crate::shared_css_path()).unwrap_or_else(|_| crate::render()); SHARED_PROVIDER.with(|cell| apply_css(&css, cell)); } @@ -121,7 +130,10 @@ pub fn apply_css(css: &str, provider: &RefCell>) { /// breadpad, and breadman (both cream), none of which agreed with each /// other or with the shared token. pub fn chip(label: &str) -> gtk4::Button { - gtk4::Button::builder().label(label).css_classes(["chip"]).build() + gtk4::Button::builder() + .label(label) + .css_classes(["chip"]) + .build() } /// Toggles a chip's (or any widget's) `active` CSS class — the `.chip.active` @@ -137,6 +149,319 @@ pub fn set_chip_active(chip: &impl IsA, active: bool) { } } +/// Gdk connector for the monitor currently showing this widget, if any. +pub fn output_for_widget(widget: &impl IsA) -> Option { + let widget = widget.as_ref(); + let native = widget.native()?; + let surface = NativeExt::surface(&native)?; + let monitor = widget.display().monitor_at_surface(&surface)?; + monitor.connector().map(|c| c.to_string()) +} + +struct WidgetBind { + output: String, + theme: CssProvider, + app: Option, + app_build: Option String>>, + /// Keep the directory monitor + child model alive for this widget. + _watch: Option, +} + +thread_local! { + static BINDS: RefCell> = RefCell::new(HashMap::new()); + static THEMES_MONITOR: RefCell> = const { RefCell::new(None) }; + static DESTROY_HOOKED: RefCell> = RefCell::new(HashSet::new()); + static AUTO_HOOKED: RefCell> = RefCell::new(HashSet::new()); + static ENTER_HOOKED: RefCell> = RefCell::new(HashSet::new()); +} + +fn widget_key(widget: >k4::Widget) -> usize { + widget.as_ptr() as usize +} + +#[allow(deprecated)] +fn add_widget_provider(widget: >k4::Widget, provider: &CssProvider, prio: u32) { + widget.style_context().add_provider(provider, prio); +} + +/// Same `CssProvider` on the widget and its current descendants so component +/// rules actually reach buttons/labels (a style-context provider is not +/// inherited by children). +fn attach_tree(widget: >k4::Widget, theme: &CssProvider, app: Option<&CssProvider>) { + add_widget_provider(widget, theme, BIND_PRIORITY); + if let Some(app) = app { + add_widget_provider(widget, app, BIND_PRIORITY + 1); + } + let mut child = widget.first_child(); + while let Some(c) = child { + attach_tree(&c, theme, app); + child = c.next_sibling(); + } +} + +fn ensure_destroy_cleanup(widget: >k4::Widget) { + let key = widget_key(widget); + let inserted = DESTROY_HOOKED.with(|s| s.borrow_mut().insert(key)); + if !inserted { + return; + } + widget.connect_destroy(move |w| { + let key = widget_key(w); + BINDS.with(|b| { + b.borrow_mut().remove(&key); + }); + DESTROY_HOOKED.with(|s| { + s.borrow_mut().remove(&key); + }); + AUTO_HOOKED.with(|s| { + s.borrow_mut().remove(&key); + }); + }); +} + +fn ensure_themes_watch() { + THEMES_MONITOR.with(|cell| { + if cell.borrow().is_some() { + return; + } + let dir = crate::themes_dir(); + let _ = std::fs::create_dir_all(&dir); + let monitor = gio::File::for_path(&dir) + .monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE) + .ok(); + if let Some(ref m) = monitor { + m.connect_changed(move |_, file, other, _event| { + let path = file.path().or_else(|| other.and_then(|f| f.path())); + let Some(path) = path else { + return; + }; + if path.extension().and_then(|e| e.to_str()) != Some("css") { + return; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + return; + }; + reload_binds_for_sanitized(stem); + }); + } + *cell.borrow_mut() = monitor; + }); +} + +fn reload_binds_for_sanitized(sanitized: &str) { + BINDS.with(|binds| { + for bind in binds.borrow_mut().values_mut() { + if crate::sanitize_output(&bind.output) != sanitized { + continue; + } + let palette = crate::load_palette_for(&bind.output); + bind.theme + .load_from_string(&crate::stylesheet_resolved(&palette)); + if let (Some(build), Some(provider)) = (&bind.app_build, &bind.app) { + provider.load_from_string(&crate::resolve_color_names(&build(&palette), &palette)); + } + } + }); +} + +fn watch_root_children(widget: >k4::Widget) -> gio::ListModel { + let model = widget.observe_children(); + let root = widget.downgrade(); + model.connect_items_changed(move |_, _, _, _| { + let Some(root) = root.upgrade() else { + return; + }; + let key = widget_key(&root); + BINDS.with(|binds| { + if let Some(bind) = binds.borrow().get(&key) { + attach_tree(&root, &bind.theme, bind.app.as_ref()); + } + }); + }); + model +} + +fn bind_window_inner( + widget: >k4::Widget, + output: &str, + app_build: Option String>>, +) { + let key = widget_key(widget); + let palette = crate::load_palette_for(output); + let theme_css = crate::stylesheet_resolved(&palette); + let app_css = app_build + .as_ref() + .map(|build| crate::resolve_color_names(&build(&palette), &palette)); + + BINDS.with(|binds| { + let mut map = binds.borrow_mut(); + if let Some(existing) = map.get_mut(&key) { + existing.output = output.to_string(); + existing.theme.load_from_string(&theme_css); + existing.app_build = app_build.clone(); + match (&app_css, existing.app.as_ref()) { + (Some(css), Some(p)) => p.load_from_string(css), + (Some(css), None) => { + let p = CssProvider::new(); + p.load_from_string(css); + add_widget_provider(widget, &p, BIND_PRIORITY + 1); + existing.app = Some(p); + } + (None, Some(p)) => p.load_from_string(""), + (None, None) => {} + } + attach_tree(widget, &existing.theme, existing.app.as_ref()); + return; + } + + let theme = CssProvider::new(); + theme.load_from_string(&theme_css); + add_widget_provider(widget, &theme, BIND_PRIORITY); + + let app = app_css.map(|css| { + let p = CssProvider::new(); + p.load_from_string(&css); + add_widget_provider(widget, &p, BIND_PRIORITY + 1); + p + }); + + attach_tree(widget, &theme, app.as_ref()); + + let child_model = watch_root_children(widget); + map.insert( + key, + WidgetBind { + output: output.to_string(), + theme, + app, + app_build, + _watch: Some(child_model), + }, + ); + }); + + ensure_destroy_cleanup(widget); + ensure_themes_watch(); + ensure_map_reattach(widget); +} + +fn ensure_map_reattach(widget: >k4::Widget) { + // `connect_map` once per widget — re-bind already lives in BINDS. + thread_local! { + static MAP_HOOKED: RefCell> = RefCell::new(HashSet::new()); + } + let key = widget_key(widget); + let inserted = MAP_HOOKED.with(|s| s.borrow_mut().insert(key)); + if !inserted { + return; + } + widget.connect_map(|w| { + BINDS.with(|binds| { + if let Some(bind) = binds.borrow().get(&widget_key(w)) { + attach_tree(w, &bind.theme, bind.app.as_ref()); + } + }); + }); + widget.connect_destroy(move |_| { + MAP_HOOKED.with(|s| { + s.borrow_mut().remove(&key); + }); + }); +} + +/// Attach a widget-level `CssProvider` with +/// `stylesheet_resolved(load_palette_for(output))` above APPLICATION so it +/// beats [`apply_shared`] for this widget tree. User CSS still wins. +/// Calling again on the same widget replaces the provider; it does not stack. +pub fn bind_window(widget: &impl IsA, output: &str) { + bind_window_inner(widget.as_ref(), output, None); +} + +/// [`bind_window`], then also apply `build(&palette)` on the same widget. +/// App CSS may still use `@accent` etc.; those names are inlined against +/// the same palette before loading. +pub fn bind_window_with_app_css(widget: &impl IsA, output: &str, build: F) +where + F: Fn(&Palette) -> String + 'static, +{ + bind_window_inner(widget.as_ref(), output, Some(Rc::new(build))); +} + +fn attach_enter_monitor(widget: >k4::Widget, build: Option String>>) { + let Some(native) = widget.native() else { + return; + }; + let Some(surface) = NativeExt::surface(&native) else { + return; + }; + let surf_key = surface.as_ptr() as usize; + let already = ENTER_HOOKED.with(|s| !s.borrow_mut().insert(surf_key)); + if already { + return; + } + let widget = widget.clone(); + surface.connect_enter_monitor(move |_, monitor| { + let Some(conn) = monitor.connector() else { + return; + }; + bind_window_inner(&widget, conn.as_str(), build.clone()); + }); +} + +fn bind_auto(native: >k4::Native, build: Option String>>) { + let widget = native.upcast_ref::().clone(); + + let apply = { + let widget = widget.clone(); + let build = build.clone(); + Rc::new(move || { + if let Some(output) = output_for_widget(&widget) { + bind_window_inner(&widget, &output, build.clone()); + } + }) + }; + + apply(); + + let key = widget_key(&widget); + let inserted = AUTO_HOOKED.with(|s| s.borrow_mut().insert(key)); + if inserted { + widget.connect_realize({ + let apply = apply.clone(); + let widget = widget.clone(); + let build = build.clone(); + move |_| { + apply(); + attach_enter_monitor(&widget, build.clone()); + } + }); + widget.connect_map({ + let apply = apply.clone(); + move |_| apply() + }); + ensure_destroy_cleanup(&widget); + } + + if widget.is_realized() { + attach_enter_monitor(&widget, build); + } +} + +/// Realize + `GdkSurface::enter-monitor`: rebind when the window moves +/// outputs. If the connector is unknown, leave unbound (display fallback) +/// rather than guessing the wrong monitor. +pub fn bind_window_auto(window: &impl IsA) { + bind_auto(window.as_ref(), None); +} + +/// [`bind_window_auto`] plus per-output app CSS, resolved to hex. +pub fn bind_window_auto_with_app_css(window: &impl IsA, build: F) +where + F: Fn(&Palette) -> String + 'static, +{ + bind_auto(window.as_ref(), Some(Rc::new(build))); +} + /// Apply a user CSS override file at USER priority. Clears the provider if the /// file is absent so stale overrides don't persist across SIGHUP reloads. pub fn apply_user_css(path: &Path, provider: &RefCell>) { diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index 15058fb..f280027 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -1,9 +1,15 @@ -pub mod palette; -#[cfg(feature = "gtk")] -pub mod gtk; #[cfg(feature = "adw")] pub mod adw; +#[cfg(feature = "gtk")] +pub mod gtk; +mod output; +pub mod palette; +pub use output::{ + generate_output, load_palette_for, output_css_path, output_palette_path, palette_from_image, + palette_from_json, palettes_dir, sanitize_output, themes_dir, write_output_css, + write_output_palette, write_shared_css_from, +}; pub use palette::{load_palette, Palette}; /// Design tokens from BREAD_DESIGN_SYSTEM.md. @@ -53,7 +59,11 @@ pub fn luminance(hex: &str) -> f32 { let h = hex.trim_start_matches('#'); let lin = |i: usize| -> f32 { let c = u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f32 / 255.0; - if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } }; 0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4) } @@ -64,7 +74,11 @@ pub fn luminance(hex: &str) -> f32 { /// text readable no matter how light or dark pywal makes a given palette slot, /// without altering the palette colours themselves. pub fn ink_on(hex: &str) -> &'static str { - if luminance(hex) > 0.179 { "#11111b" } else { "#f5f5f5" } + if luminance(hex) > 0.179 { + "#11111b" + } else { + "#f5f5f5" + } } /// Canonical (name, value) list: the single naming all bread apps share. @@ -143,9 +157,18 @@ pub fn css_tokens() -> String { \x20\x20--radius-tertiary: {r3}px;\n\ \x20\x20--radius-pill: {pill}px;\n\ }}\n", - font = FONT_FAMILY, base = FONT_SIZE_BASE, sec = FONT_SIZE_SECONDARY, - xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, xl = SPACE_XL, - r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, r3 = RADIUS_TERTIARY, pill = RADIUS_PILL, + font = FONT_FAMILY, + base = FONT_SIZE_BASE, + sec = FONT_SIZE_SECONDARY, + xs = SPACE_XS, + sm = SPACE_SM, + md = SPACE_MD, + lg = SPACE_LG, + xl = SPACE_XL, + r1 = RADIUS_PRIMARY, + r2 = RADIUS_SECONDARY, + r3 = RADIUS_TERTIARY, + pill = RADIUS_PILL, ) } @@ -261,28 +284,33 @@ pub fn render() -> String { /// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`, /// falling back to the cache dir. pub fn shared_css_path() -> std::path::PathBuf { - if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") { - if !rt.is_empty() { - return std::path::PathBuf::from(rt).join("bread").join("theme.css"); - } - } - dirs::cache_dir() - .unwrap_or_else(|| std::path::PathBuf::from("/tmp")) - .join("bread") - .join("theme.css") + output::runtime_bread_dir().join("theme.css") } /// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns /// the path written. Used by the `bread-theme` CLI. pub fn write_shared_css() -> std::io::Result { - let path = shared_css_path(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; + write_shared_css_from(&load_palette()) +} + +/// `stylesheet()` with `@name` references in rule bodies replaced by hex. +/// Longer names first (`on-surface` before `surface`, `on-bg` before `bg`) +/// so a prefix match cannot half-replace `@on-bg`. +pub fn stylesheet_resolved(p: &Palette) -> String { + resolve_color_names(&stylesheet(p), p) +} + +/// Replace `@define-color` names (`@accent`, `@on-bg`, …) with hex values. +/// Used by [`stylesheet_resolved`] and by GTK `bind_window` so display-global +/// named colors cannot leak the wrong monitor's accent. +pub(crate) fn resolve_color_names(css: &str, p: &Palette) -> String { + let mut pairs: Vec<(&str, String)> = color_pairs(p).into_iter().collect(); + pairs.sort_by(|a, b| b.0.len().cmp(&a.0.len())); + let mut out = css.to_string(); + for (name, value) in pairs { + out = out.replace(&format!("@{name}"), &value); } - let tmp = path.with_extension("css.tmp"); - std::fs::write(&tmp, render())?; - std::fs::rename(&tmp, &path)?; - Ok(path) + out } /// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`. @@ -301,8 +329,13 @@ mod tests { #[test] fn css_vars_contains_all_define_color_names() { let css = css_vars(&Palette::default()); - for name in &["bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay"] { - assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); + for name in &[ + "bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay", + ] { + assert!( + css.contains(&format!("@define-color {name} ")), + "missing @define-color {name}" + ); } } @@ -322,8 +355,18 @@ mod tests { // color name — the illegible-text bug. css_vars() must now emit // exactly the same color set as the full stylesheet. let css = css_vars(&Palette::default()); - for name in &["accent", "on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] { - assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); + for name in &[ + "accent", + "on-bg", + "on-surface", + "on-accent", + "on-red", + "on-overlay", + ] { + assert!( + css.contains(&format!("@define-color {name} ")), + "missing @define-color {name}" + ); } } @@ -334,7 +377,16 @@ mod tests { let p = Palette::default(); let vars = css_vars(&p); let sheet = stylesheet(&p); - for name in &["bg", "fg", "surface", "overlay", "accent", "on-bg", "on-surface", "on-accent"] { + for name in &[ + "bg", + "fg", + "surface", + "overlay", + "accent", + "on-bg", + "on-surface", + "on-accent", + ] { let needle = format!("@define-color {name} "); assert!(vars.contains(&needle) && sheet.contains(&needle)); } @@ -344,10 +396,21 @@ mod tests { fn stylesheet_defines_canonical_colors_and_components() { let css = stylesheet(&Palette::default()); for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] { - assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); + assert!( + css.contains(&format!("@define-color {name} ")), + "missing @define-color {name}" + ); } // a representative spread of the shared component selectors - for sel in &["button", "entry", "switch:checked", ".card", ".sidebar", "scrollbar slider", ".page-title"] { + for sel in &[ + "button", + "entry", + "switch:checked", + ".card", + ".sidebar", + "scrollbar slider", + ".page-title", + ] { assert!(css.contains(sel), "stylesheet missing selector: {sel}"); } assert!(css.contains("Varela Round")); @@ -362,7 +425,10 @@ mod tests { let gtk = define_colors(&p); let web = css_custom_properties(&p); for (name, _) in color_pairs(&p) { - assert!(gtk.contains(&format!("@define-color {name} ")), "gtk missing {name}"); + assert!( + gtk.contains(&format!("@define-color {name} ")), + "gtk missing {name}" + ); assert!(web.contains(&format!("--{name}: ")), "web missing {name}"); } } @@ -409,7 +475,10 @@ mod tests { fn stylesheet_defines_on_colors() { let css = stylesheet(&Palette::default()); for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] { - assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); + assert!( + css.contains(&format!("@define-color {name} ")), + "missing @define-color {name}" + ); } } @@ -418,13 +487,58 @@ mod tests { // A bare `label { color: ... }` would override container colours on child // labels — the bug that made coloured-background text illegible. let css = stylesheet(&Palette::default()); - assert!(!css.contains("label { color:"), "blanket label colour rule reintroduced"); + assert!( + !css.contains("label { color:"), + "blanket label colour rule reintroduced" + ); } #[test] fn shared_css_path_uses_runtime_dir() { + let _lock = crate::output::XDG_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234"); - assert_eq!(shared_css_path(), std::path::PathBuf::from("/run/user/1234/bread/theme.css")); + assert_eq!( + shared_css_path(), + std::path::PathBuf::from("/run/user/1234/bread/theme.css") + ); + } + + #[test] + fn stylesheet_resolved_inlines_color4_and_drops_named_refs_in_rules() { + let mut p = Palette::default(); + p.color4 = "#7aa2f7".into(); + let css = stylesheet_resolved(&p); + assert!(css.contains("#7aa2f7"), "color4 must appear as hex: {css}"); + // Rule bodies must not keep named colors — GTK display-global + // @define-color would otherwise leak the wrong monitor's accent. + let rules = css + .lines() + .filter(|l| !l.trim_start().starts_with("@define-color")) + .collect::>() + .join("\n"); + assert!( + !rules.contains("@accent"), + "leftover @accent in rules:\n{rules}" + ); + assert!( + !rules.contains("@on-bg"), + "leftover @on-bg in rules:\n{rules}" + ); + assert!( + !rules.contains("@on-surface"), + "leftover @on-surface in rules:\n{rules}" + ); + assert!( + !rules.contains("@on-accent"), + "leftover @on-accent in rules:\n{rules}" + ); + // Longer names first: @on-bg must not become @on-#... + assert!( + !rules.contains("@on-#"), + "half-replaced on-* name:\n{rules}" + ); } #[test] diff --git a/bread-theme/src/output.rs b/bread-theme/src/output.rs new file mode 100644 index 0000000..4f2f069 --- /dev/null +++ b/bread-theme/src/output.rs @@ -0,0 +1,338 @@ +//! Per-output (per-monitor) palette and stylesheet paths under +//! `$XDG_RUNTIME_DIR/bread/{palettes,themes}/`. + +use serde::Serialize; +use std::path::{Path, PathBuf}; + +use crate::palette::{from_wal_json, Palette}; +use crate::{load_palette, stylesheet}; + +/// Session-scoped `$XDG_RUNTIME_DIR/bread`, same fallback as [`crate::shared_css_path`]. +pub(crate) fn runtime_bread_dir() -> PathBuf { + if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") { + if !rt.is_empty() { + return PathBuf::from(rt).join("bread"); + } + } + dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("bread") +} + +/// Keep `[A-Za-z0-9._-]`; replace everything else with `_`. +pub fn sanitize_output(output: &str) -> String { + let s: String = output + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '_' + } + }) + .collect(); + if s.is_empty() { + "_".into() + } else { + s + } +} + +pub fn themes_dir() -> PathBuf { + runtime_bread_dir().join("themes") +} + +pub fn palettes_dir() -> PathBuf { + runtime_bread_dir().join("palettes") +} + +pub fn output_css_path(output: &str) -> PathBuf { + themes_dir().join(format!("{}.css", sanitize_output(output))) +} + +pub fn output_palette_path(output: &str) -> PathBuf { + palettes_dir().join(format!("{}.json", sanitize_output(output))) +} + +fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = match path.file_name().and_then(|n| n.to_str()) { + Some(name) => path.with_file_name(format!("{name}.tmp")), + None => path.with_extension("tmp"), + }; + std::fs::write(&tmp, contents)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +/// Accents only — never persist pywal's light background/surface/overlay/fg. +#[derive(Serialize)] +struct StoredColors { + color1: String, + color2: String, + color3: String, + color4: String, + color5: String, + color6: String, +} + +#[derive(Serialize)] +struct StoredPalette { + colors: StoredColors, +} + +/// Parse on-disk JSON: wal `colors.json` shape, or a flat `{color1..color6}` object. +/// Always forces FIXED background/foreground/color0/color7 via [`from_wal_json`]. +pub fn palette_from_json(json: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(json).ok()?; + if value + .get("colors") + .and_then(|c| c.as_object()) + .is_some_and(|o| !o.is_empty()) + { + return from_wal_json(json); + } + if value.get("color1").is_some() + || value.get("color2").is_some() + || value.get("color3").is_some() + || value.get("color4").is_some() + || value.get("color5").is_some() + || value.get("color6").is_some() + { + let wrapped = serde_json::json!({ "colors": value }); + return from_wal_json(&wrapped.to_string()); + } + from_wal_json(json) +} + +/// Load `palettes/.json`; fall back to [`load_palette`]. +pub fn load_palette_for(output: &str) -> Palette { + std::fs::read_to_string(output_palette_path(output)) + .ok() + .and_then(|s| palette_from_json(&s)) + .unwrap_or_else(load_palette) +} + +pub fn write_output_palette(output: &str, palette: &Palette) -> std::io::Result { + let path = output_palette_path(output); + let stored = StoredPalette { + colors: StoredColors { + color1: palette.color1.clone(), + color2: palette.color2.clone(), + color3: palette.color3.clone(), + color4: palette.color4.clone(), + color5: palette.color5.clone(), + color6: palette.color6.clone(), + }, + }; + let json = serde_json::to_string_pretty(&stored) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + atomic_write(&path, &json)?; + Ok(path) +} + +pub fn write_output_css(output: &str, palette: &Palette) -> std::io::Result { + let path = output_css_path(output); + atomic_write(&path, &stylesheet(palette))?; + Ok(path) +} + +/// Like [`crate::write_shared_css`] but from an explicit palette. +pub fn write_shared_css_from(palette: &Palette) -> std::io::Result { + let path = crate::shared_css_path(); + atomic_write(&path, &stylesheet(palette))?; + Ok(path) +} + +/// Isolated `wal -i -n -q` with `XDG_CACHE_HOME` set to a unique temp +/// dir so the user's `~/.cache/wal` is not clobbered. +pub fn palette_from_image(path: &Path) -> std::io::Result { + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let tmp = std::env::temp_dir().join(format!("bread-theme-wal-{pid}-{nanos}")); + std::fs::create_dir_all(&tmp)?; + struct Rm(PathBuf); + impl Drop for Rm { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _guard = Rm(tmp.clone()); + + // Classic pywal ignores XDG_CACHE_HOME and writes $HOME/.cache/wal. + // Point HOME at the temp dir so a per-output extract cannot clobber + // the session cache (or the other monitor's last `wal -i`). + let status = match std::process::Command::new("wal") + .arg("-i") + .arg(path) + .args(["-n", "-q"]) + .env("HOME", &tmp) + .env("XDG_CACHE_HOME", tmp.join(".cache")) + .status() + { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "wal is not installed", + )); + } + Err(e) => return Err(e), + Ok(s) => s, + }; + if !status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("wal failed with {status}"), + )); + } + + let json_path = [ + tmp.join(".cache").join("wal").join("colors.json"), + tmp.join("wal").join("colors.json"), + ] + .into_iter() + .find(|p| p.is_file()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "wal did not write colors.json under the isolated cache", + ) + })?; + let json = std::fs::read_to_string(&json_path)?; + from_wal_json(&json).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "wal produced unparseable colors.json", + ) + }) +} + +/// [`palette_from_image`] + [`write_output_palette`] + [`write_output_css`]. +pub fn generate_output(output: &str, image: &Path) -> std::io::Result { + let palette = palette_from_image(image)?; + write_output_palette(output, &palette)?; + write_output_css(output, &palette) +} + +#[cfg(test)] +pub(crate) static XDG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +mod tests { + use super::*; + use crate::palette::{FIXED_BACKGROUND, FIXED_FOREGROUND, FIXED_OVERLAY, FIXED_SURFACE}; + + fn lock_xdg() -> std::sync::MutexGuard<'static, ()> { + XDG_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn with_runtime_dir(f: impl FnOnce(&Path) -> T) -> T { + let _lock = lock_xdg(); + let dir = std::env::temp_dir().join(format!( + "bread-theme-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let old = std::env::var("XDG_RUNTIME_DIR").ok(); + std::env::set_var("XDG_RUNTIME_DIR", &dir); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&dir))); + match old { + Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v), + None => std::env::remove_var("XDG_RUNTIME_DIR"), + } + let _ = std::fs::remove_dir_all(&dir); + match result { + Ok(v) => v, + Err(e) => std::panic::resume_unwind(e), + } + } + + #[test] + fn sanitize_output_keeps_hyprland_connectors() { + assert_eq!(sanitize_output("HDMI-A-1"), "HDMI-A-1"); + assert_eq!(sanitize_output("eDP-1"), "eDP-1"); + assert_eq!(sanitize_output("DP-2"), "DP-2"); + } + + #[test] + fn sanitize_output_replaces_unsafe_chars() { + assert_eq!(sanitize_output("HDMI A:1"), "HDMI_A_1"); + assert_eq!(sanitize_output("foo/bar"), "foo_bar"); + assert_eq!(sanitize_output(""), "_"); + assert_eq!(sanitize_output("..ok_name-1"), "..ok_name-1"); + } + + #[test] + fn output_paths_use_sanitize_and_sit_under_dirs() { + let _lock = lock_xdg(); + std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234"); + let css = output_css_path("HDMI A:1"); + let pal = output_palette_path("HDMI A:1"); + assert_eq!(css, themes_dir().join("HDMI_A_1.css")); + assert_eq!(pal, palettes_dir().join("HDMI_A_1.json")); + assert!(css.starts_with(themes_dir())); + assert!(pal.starts_with(palettes_dir())); + assert_eq!( + output_css_path("eDP-1"), + PathBuf::from("/run/user/1234/bread/themes/eDP-1.css") + ); + } + + #[test] + fn load_palette_for_missing_file_has_fixed_bg() { + with_runtime_dir(|_| { + let p = load_palette_for("no-such-output"); + assert_eq!(p.background, FIXED_BACKGROUND); + assert!(p.color4.starts_with('#')); + }); + } + + #[test] + fn write_output_palette_roundtrips_color4() { + with_runtime_dir(|_| { + let mut p = Palette::default(); + p.color4 = "#7aa2f7".into(); + p.background = "#ffffff".into(); + write_output_palette("HDMI-A-1", &p).unwrap(); + let loaded = load_palette_for("HDMI-A-1"); + assert_eq!(loaded.color4, "#7aa2f7"); + assert_eq!(loaded.background, FIXED_BACKGROUND); + assert_eq!(loaded.foreground, FIXED_FOREGROUND); + assert_eq!(loaded.color0, FIXED_SURFACE); + assert_eq!(loaded.color7, FIXED_OVERLAY); + }); + } + + #[test] + fn write_shared_css_from_writes_shared_css_path() { + with_runtime_dir(|rt| { + let path = write_shared_css_from(&Palette::default()).unwrap(); + assert_eq!(path, crate::shared_css_path()); + assert_eq!(path, rt.join("bread").join("theme.css")); + let css = std::fs::read_to_string(&path).unwrap(); + assert!(css.contains("@define-color accent ")); + }); + } + + #[test] + fn load_palette_for_accepts_flat_color_object() { + with_runtime_dir(|_| { + let path = output_palette_path("DP-1"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, r##"{"color4":"#112233","color1":"#abcdef"}"##).unwrap(); + let p = load_palette_for("DP-1"); + assert_eq!(p.color4, "#112233"); + assert_eq!(p.color1, "#abcdef"); + assert_eq!(p.background, FIXED_BACKGROUND); + }); + } +} diff --git a/bread-theme/src/palette.rs b/bread-theme/src/palette.rs index 85a8aa7..e51f6b0 100644 --- a/bread-theme/src/palette.rs +++ b/bread-theme/src/palette.rs @@ -9,10 +9,10 @@ use std::path::PathBuf; /// off-hue background, and every bread GUI's panels inherit it — the app /// stops looking like a dark BOS tool and starts looking like whatever colour /// the wallpaper happened to be. -const FIXED_BACKGROUND: &str = "#0c0c0c"; -const FIXED_FOREGROUND: &str = "#e8e8e8"; -const FIXED_SURFACE: &str = "#1a1a1a"; -const FIXED_OVERLAY: &str = "#d8d8d8"; +pub(crate) const FIXED_BACKGROUND: &str = "#0c0c0c"; +pub(crate) const FIXED_FOREGROUND: &str = "#e8e8e8"; +pub(crate) const FIXED_SURFACE: &str = "#1a1a1a"; +pub(crate) const FIXED_OVERLAY: &str = "#d8d8d8"; /// Accent fallback when no pywal palette exists yet (fresh install, before /// any wallpaper has been set for real) — BOS's own bread-toned accents, @@ -84,7 +84,10 @@ pub fn load_palette() -> Palette { pub(crate) fn from_wal_json(json: &str) -> Option { let wal: WalColors = serde_json::from_str(json).ok()?; let c = |k: &str, fallback: &str| -> String { - wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into()) + wal.colors + .get(k) + .cloned() + .unwrap_or_else(|| fallback.into()) }; Some(Palette { background: FIXED_BACKGROUND.into(), From a9754d90ed32efcc26765abd01c9f441bfb01b1e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:28:18 +0800 Subject: [PATCH 05/37] Lock workspace packages at 0.7.4 so --locked release builds match the tag --- Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 070c24d..feaba0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,7 +138,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bakery" -version = "0.7.2" +version = "0.7.4" dependencies = [ "anyhow", "bread-utils", @@ -187,14 +187,14 @@ dependencies = [ [[package]] name = "bread-app" -version = "0.7.2" +version = "0.7.4" dependencies = [ "bread-utils", ] [[package]] name = "bread-capture" -version = "0.7.2" +version = "0.7.4" dependencies = [ "anyhow", "bread-utils", @@ -204,7 +204,7 @@ dependencies = [ [[package]] name = "bread-onnx" -version = "0.7.2" +version = "0.7.4" dependencies = [ "anyhow", "bread-utils", @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "bread-polkit" -version = "0.7.2" +version = "0.7.4" dependencies = [ "anyhow", "bread-app", @@ -234,7 +234,7 @@ dependencies = [ [[package]] name = "bread-screenshots" -version = "0.7.2" +version = "0.7.4" dependencies = [ "anyhow", "bread-utils", @@ -254,7 +254,7 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.2" +version = "0.7.4" dependencies = [ "dirs", "gtk4", @@ -265,7 +265,7 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.7.2" +version = "0.7.4" dependencies = [ "bread-shared", "dirs", From e2c6452e4f9166be6b057fbedce9796d0029d486 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:37:08 +0800 Subject: [PATCH 06/37] ci: export VERSION for rc sign steps and drop unsafe tag path filters RC sign steps read ${VERSION} from the environment, but prepare only set it locally. Export it via GITHUB_ENV like dev-bakery.yml. Drop paths: filters on tag-triggered rc workflows (they pointed at the old beta-*.yml names and can skip an RC publish when the tag diff misses those paths); the job if: contains -rc. is the real gate. Skip -rc. tags in package.yml because PKGBUILD pkgver cannot contain a hyphen. Rebuild bakery on bread-utils changes (path dependency). --- .forgejo/workflows/dev-bakery.yml | 1 + .forgejo/workflows/package.yml | 3 +++ .forgejo/workflows/rc-bakery.yml | 9 ++++----- .forgejo/workflows/rc-bread-theme.yml | 9 ++++----- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/dev-bakery.yml b/.forgejo/workflows/dev-bakery.yml index f63769e..a7c4bde 100644 --- a/.forgejo/workflows/dev-bakery.yml +++ b/.forgejo/workflows/dev-bakery.yml @@ -8,6 +8,7 @@ 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 6725e22..ca941f2 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -6,6 +6,9 @@ on: jobs: package: + # PKGBUILD pkgver cannot contain `-`; skip RC tags the same way + # release-bakery.yml does. + if: ${{ !contains(github.ref_name, '-rc.') }} runs-on: [self-hosted, hestia] container: image: archlinux:latest diff --git a/.forgejo/workflows/rc-bakery.yml b/.forgejo/workflows/rc-bakery.yml index dc7f695..d4e7853 100644 --- a/.forgejo/workflows/rc-bakery.yml +++ b/.forgejo/workflows/rc-bakery.yml @@ -7,11 +7,9 @@ name: beta (rc) bakery on: push: tags: ['v*'] - paths: - - 'bakery/**' - - 'Cargo.toml' - - 'Cargo.lock' - - '.forgejo/workflows/beta-bakery.yml' + # No paths: filter. Tag pushes compare against an unrelated commit and + # would skip the RC publish if bakery/** wasn't in that diff; the job + # `if: contains -rc.` is the real gate. jobs: build: @@ -35,6 +33,7 @@ jobs: run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" PKG_DIR="/srv/breadway-dl/beta/bakery/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/bakery" "${PKG_DIR}/bakery-x86_64" diff --git a/.forgejo/workflows/rc-bread-theme.yml b/.forgejo/workflows/rc-bread-theme.yml index c364ce6..bd53ba1 100644 --- a/.forgejo/workflows/rc-bread-theme.yml +++ b/.forgejo/workflows/rc-bread-theme.yml @@ -7,11 +7,9 @@ name: beta (rc) bread-theme on: push: tags: ['v*'] - paths: - - 'bread-theme/**' - - 'Cargo.toml' - - 'Cargo.lock' - - '.forgejo/workflows/beta-bread-theme.yml' + # No paths: filter. Tag pushes compare against an unrelated commit and + # would skip the RC publish if bread-theme/** wasn't in that diff; the + # job `if: contains -rc.` is the real gate. jobs: build: @@ -32,6 +30,7 @@ jobs: run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" PKG_DIR="/srv/breadway-dl/beta/bread-theme/${VERSION}" mkdir -p "${PKG_DIR}" cp "src/target/release/bread-theme" "${PKG_DIR}/bread-theme-x86_64" From 4f8859527d0aab43912799528fef2af60574f334 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:38:04 +0800 Subject: [PATCH 07/37] bread-utils: pin bread-shared to v0.8.0 The bread-client feature still resolved bread-shared from tag v0.7.0. Point the git dependency at v0.8.0 and refresh Cargo.lock. --- Cargo.lock | 50 +++++++++++++++++++++++++++++++++++------- bread-utils/Cargo.toml | 2 +- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index feaba0f..0598260 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,7 +145,7 @@ dependencies = [ "chrono", "clap", "clap_complete", - "dirs", + "dirs 5.0.1", "fs4", "hex", "minisign-verify", @@ -243,20 +243,21 @@ dependencies = [ [[package]] name = "bread-shared" -version = "0.7.0" -source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +version = "0.8.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.8.0#cdd5de8f58e437b3fc6d9b9087eb7b3d0fd09704" dependencies = [ - "dirs", + "dirs 6.0.0", "serde", "serde_json", "toml 0.8.23", + "uuid", ] [[package]] name = "bread-theme" version = "0.7.4" dependencies = [ - "dirs", + "dirs 5.0.1", "gtk4", "libadwaita", "serde", @@ -268,7 +269,7 @@ name = "bread-utils" version = "0.7.4" dependencies = [ "bread-shared", - "dirs", + "dirs 5.0.1", "gtk4", "gtk4-layer-shell", "serde", @@ -610,7 +611,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -621,10 +631,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -1966,6 +1988,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.13.1" @@ -2703,6 +2736,7 @@ 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/bread-utils/Cargo.toml b/bread-utils/Cargo.toml index 69e2172..a0ab6c2 100644 --- a/bread-utils/Cargo.toml +++ b/bread-utils/Cargo.toml @@ -15,7 +15,7 @@ dirs = { workspace = true } gtk4 = { version = "0.11", features = ["v4_12"], optional = true } gtk4-layer-shell = { version = "0.8", optional = true } toml_edit = { version = "0.22", optional = true } -bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.7.0", optional = true } +bread-shared = { git = "https://git.breadway.dev/Breadway/bread", tag = "v0.8.0", optional = true } [features] # Enable the layer-shell popup scaffold (breadbox, breadclip). Kept optional From f5a47490f70a66d201b38afa14814a2c45f127a7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:38:04 +0800 Subject: [PATCH 08/37] bread-theme: quote only the named font so sans-serif stays a fallback FONT_FAMILY is "Varela Round, sans-serif" but emission wrapped the whole string in quotes, so CSS looked up one family named that string. Emit 'Varela Round', sans-serif instead and lock that in the tests. --- bread-theme/src/lib.rs | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index f280027..2111c17 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -32,6 +32,13 @@ pub mod tokens { pub const RADIUS_PILL: u16 = 999; } +/// CSS `font-family` list: quote the named face, leave the generic fallback +/// unquoted. Wrapping [`tokens::FONT_FAMILY`] in one pair of quotes would +/// make a single family named "Varela Round, sans-serif" and drop sans-serif. +fn css_font_family() -> &'static str { + "'Varela Round', sans-serif" +} + /// Emit the `@define-color` block that all bread apps use, plus the shared /// font rule. /// @@ -47,9 +54,9 @@ pub mod tokens { /// one color-block implementation and it cannot drift again. pub fn css_vars(p: &Palette) -> String { format!( - "{vars}* {{ font-family: '{font}'; font-size: {size}px; }}\n", + "{vars}* {{ font-family: {font}; font-size: {size}px; }}\n", vars = define_colors(p), - font = tokens::FONT_FAMILY, + font = css_font_family(), size = tokens::FONT_SIZE_BASE, ) } @@ -144,7 +151,7 @@ pub fn css_tokens() -> String { use tokens::*; format!( ":root {{\n\ - \x20\x20--font-family: '{font}';\n\ + \x20\x20--font-family: {font};\n\ \x20\x20--font-size-base: {base}px;\n\ \x20\x20--font-size-secondary: {sec}px;\n\ \x20\x20--space-xs: {xs}px;\n\ @@ -157,7 +164,7 @@ pub fn css_tokens() -> String { \x20\x20--radius-tertiary: {r3}px;\n\ \x20\x20--radius-pill: {pill}px;\n\ }}\n", - font = FONT_FAMILY, + font = css_font_family(), base = FONT_SIZE_BASE, sec = FONT_SIZE_SECONDARY, xs = SPACE_XS, @@ -182,7 +189,7 @@ pub fn stylesheet(p: &Palette) -> String { use tokens::*; format!( "{vars}\ - * {{ font-family: '{font}'; font-size: {base}px; }}\n\ + * {{ font-family: {font}; font-size: {base}px; }}\n\ /* Colour is set on containers; labels inherit it, so text on any panel,\ button, or accent is always the legible ink for that background. Bare\ `label {{ color }}` is deliberately avoided — as a type selector it\ @@ -265,7 +272,7 @@ pub fn stylesheet(p: &Palette) -> String { textview, .mono {{ font-family: monospace; }}\n\ textview text {{ background-color: @surface; color: @on-surface; }}\n", vars = define_colors(p), - font = FONT_FAMILY, + font = css_font_family(), base = FONT_SIZE_BASE, sec = FONT_SIZE_SECONDARY, xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG, @@ -342,7 +349,11 @@ mod tests { #[test] fn css_vars_contains_font_rule() { let css = css_vars(&Palette::default()); - assert!(css.contains("Varela Round")); + assert!(css.contains("font-family: 'Varela Round', sans-serif;")); + assert!( + !css.contains("font-family: 'Varela Round, sans-serif'"), + "named face and generic fallback must not be one quoted family" + ); assert!(css.contains("14px")); } @@ -413,7 +424,11 @@ mod tests { ] { assert!(css.contains(sel), "stylesheet missing selector: {sel}"); } - assert!(css.contains("Varela Round")); + assert!(css.contains("font-family: 'Varela Round', sans-serif;")); + assert!( + !css.contains("font-family: 'Varela Round, sans-serif'"), + "named face and generic fallback must not be one quoted family" + ); } #[test] @@ -445,7 +460,11 @@ mod tests { #[test] fn css_tokens_contains_font_and_spacing_vars() { let css = css_tokens(); - assert!(css.contains("--font-family: 'Varela Round, sans-serif';")); + assert!(css.contains("--font-family: 'Varela Round', sans-serif;")); + assert!( + !css.contains("--font-family: 'Varela Round, sans-serif'"), + "named face and generic fallback must not be one quoted family" + ); assert!(css.contains("--font-size-base: 14px;")); assert!(css.contains("--space-md: 12px;")); assert!(css.contains("--radius-pill: 999px;")); From 347f356b1ddac442fe4b5dd7f23349a591351e74 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:38:04 +0800 Subject: [PATCH 09/37] bread-polkit: add bakery.toml so the agent can be published later Not added to registry/bread-ecosystem.toml: that would put it on the bakery index (and risk the BOS ISO) without a lockfile update. bakery.toml declares the binary and contrib desktop file; README/CONTRIBUTING note that it stays unpublished. --- CONTRIBUTING.md | 4 +++- README.md | 8 +++++--- bread-polkit/bakery.toml | 11 +++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 bread-polkit/bakery.toml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb4d0e1..349d06b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,9 @@ 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, also not a bakery product. Other ecosystem products +session agent: it has `bread-polkit/bakery.toml` so it *can* be published, +but it is not in `registry/bread-ecosystem.toml` (unpublished — not on +the bakery index, not on the BOS ISO). Other ecosystem products (`bread`, `breadbar`, `breadbox`, …) live in their own repos under `Breadway/` but follow the same workflow described here. The product list is `registry/bread-ecosystem.toml`. New GTK tools should depend on diff --git a/README.md b/README.md index 34ccf27..927c629 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 authentication agent (not a bakery product) +├── bread-polkit/ # themed PolicyKit agent (bakery.toml present; unpublished) ├── bread-onnx/ # shared ONNX runtime helpers ├── bread-screenshots/ # grim capture primitive used by app `--screenshot` modes ├── bread-capture/ # orchestrator that drives those `--screenshot` modes @@ -179,8 +179,10 @@ 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`. Not published via bakery -and not on the BOS ISO lockfile. +identity). Not a wrapper around `polkit-gnome`. `bread-polkit/bakery.toml` +exists so it can be published via bakery; it is not in +`registry/bread-ecosystem.toml` and is therefore unpublished — not on the +bakery index and not on the BOS ISO lockfile. ```sh cargo run -p bread-polkit diff --git a/bread-polkit/bakery.toml b/bread-polkit/bakery.toml new file mode 100644 index 0000000..4dd8e44 --- /dev/null +++ b/bread-polkit/bakery.toml @@ -0,0 +1,11 @@ +name = "bread-polkit" +description = "Themed PolicyKit authentication agent for the bread desktop" +binaries = ["bread-polkit"] +system_deps = ["gtk4", "gtk4-layer-shell", "polkit"] +optional_system_deps = ["hyprland"] +bread_deps = [] +license_file = "LICENSE" +desktop_file = "bread-polkit.desktop" + +[install] +post_install = [] From 96aa6a513b0fed5ff73ab9252907572032a7c482 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 15:21:08 +0800 Subject: [PATCH 10/37] Add bread_theme::shell manifest system (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the shell theme manifest layer from THEME_SYSTEM_PLAN.md §4-5: ShellTheme/WindowSpec/Slots/Tokens/LayerRule types, TOML discovery (user -> system -> compiled-in builtin), one level of `extends` deep-merge, deny_unknown_fields validation naming the offending key, slot module-name validation, and css() token substitution with an extra.css overlay. load() never fails, falling back to the compiled-in builtin and logging once. Ships exactly one builtin manifest, liquid-motion, describing breadbar/breadbox as they exist today (not the design-doc demo, which disagrees with the code on bar side margin, launcher geometry, and the easing curves). Compositor rules and surface specs are keyed by layer-shell namespace and cover all five breadbar namespaces plus breadbox/breadbar-panel/breadbar-dismiss. watch() is gated behind the existing `gtk` feature (gio::FileMonitor is a gtk4 dependency); the rest of the module is gtk-free so bread and breadcrumbs can validate a theme without linking GTK. No consumer changes — breadbar/breadbox still use their own hardcoded values. --- Cargo.lock | 3 + bread-theme/Cargo.toml | 5 + .../shell/liquid-motion/liquid-motion.css | 104 ++ .../assets/shell/liquid-motion/theme.toml | 154 +++ bread-theme/src/lib.rs | 1 + bread-theme/src/shell/builtin.rs | 23 + bread-theme/src/shell/hotreload.rs | 32 + bread-theme/src/shell/manifest.rs | 518 ++++++++++ bread-theme/src/shell/mod.rs | 899 ++++++++++++++++++ bread-theme/src/shell/types.rs | 358 +++++++ 10 files changed, 2097 insertions(+) create mode 100644 bread-theme/assets/shell/liquid-motion/liquid-motion.css create mode 100644 bread-theme/assets/shell/liquid-motion/theme.toml create mode 100644 bread-theme/src/shell/builtin.rs create mode 100644 bread-theme/src/shell/hotreload.rs create mode 100644 bread-theme/src/shell/manifest.rs create mode 100644 bread-theme/src/shell/mod.rs create mode 100644 bread-theme/src/shell/types.rs diff --git a/Cargo.lock b/Cargo.lock index 0598260..5af4c11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -257,11 +257,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/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/liquid-motion/liquid-motion.css b/bread-theme/assets/shell/liquid-motion/liquid-motion.css new file mode 100644 index 0000000..4d45d0e --- /dev/null +++ b/bread-theme/assets/shell/liquid-motion/liquid-motion.css @@ -0,0 +1,104 @@ +/* CSS template for the liquid-motion builtin (bread-theme/src/shell/builtin.rs). + * + * Curly-brace placeholders like {radius_bar} or {spring} are substituted + * from [tokens] by ShellTheme::css / Tokens::substitute; @-prefixed palette + * references (@accent, @on-bg, ...) pass through untouched, exactly like + * `bread_theme::stylesheet()` — GTK's own @define-color mechanism resolves + * them when the CSS provider is attached to a display/output. + * + * Scope: this is the window/workspace/clock chrome the manifest's own + * concepts (bar.window, modules.workspaces, modules.clock) actually model — + * not a byte-for-byte copy of breadbar/src/theme.rs::load_css's full ~250 + * lines (notification cards, wifi popover, control panel, media widget, ...). + * Those stay hand-written in breadbar for now; migrating them behind this + * same token-substitution mechanism is Phase 2/3 work (plan §6), once + * breadbar actually consumes ShellTheme and can verify pixel parity itself + * via --screenshot. Phase 1's job is the mechanism, exercised end-to-end + * here with a representative slice, not the full port. + */ + +@keyframes row-in { + from { opacity: 0; margin-top: 8px; } + to { opacity: 1; margin-top: 0; } +} +@keyframes digit-flip { + from { opacity: 0; margin-top: 7px; } + to { opacity: 1; margin-top: 0; } +} + +window.breadbar { + background-color: alpha(@bg, {bg_alpha}); + color: @on-bg; + border-radius: {radius_bar}px; + border: 1px solid alpha(@on-bg, 0.08); +} +window.breadbar > centerbox { padding: 0 8px 0 6px; } +window.breadbar button { min-height: 0; min-width: 0; } + +.workspace-trail { + background-image: linear-gradient(90deg, @{accent_from}, @{accent_to}); + background-color: @{accent_from}; + border-radius: {radius_card}px; +} +.workspace-btn { + background: transparent; + opacity: 0.36; + color: @on-bg; + border-radius: {radius_card}px; + border: none; + outline: none; + box-shadow: none; + min-width: 28px; + min-height: {chip_height}px; + margin: 0; + padding: 0 7px; + font-size: 22px; + font-weight: bold; + transition: opacity 0.22s {spring_settle}, background-color 0.22s {spring_settle}; +} +.workspace-btn:hover { opacity: 0.85; background: alpha(@on-bg, 0.08); } +.workspace-btn.occupied { opacity: 0.78; } +.workspace-btn.active { background: transparent; color: @on-accent; opacity: 1; } +.workspace-btn.active:hover { background: transparent; } +.workspace-btn.ws-in { animation: row-in 0.32s {spring_settle} both; } + +.clock-box { padding: 0 4px; } +.clock-label { + font-size: 24px; + font-weight: bold; + letter-spacing: 0.04em; + min-height: 0; + padding: 0; + margin-top: 3px; +} +.clock-digit { + font-size: 24px; + font-weight: bold; + letter-spacing: 0.04em; + min-width: 15px; + min-height: 0; + padding: 0; + margin: 0; +} +.clock-colon { min-width: 10px; opacity: 0.7; } +.clock-digit.flip { animation: digit-flip 0.45s {spring} both; } + +window.breadbar-osd { + background-color: alpha(@bg, 0.70); + color: @on-bg; + border-radius: {radius_pill}px; + border: 1px solid alpha(@on-bg, 0.10); +} + +window.breadbar-panel { + background-color: alpha(@bg, {bg_alpha}); + color: @on-bg; + border-radius: 14px; + border: 1px solid alpha(@on-bg, 0.12); +} + +window.breadbar-dismiss { + background-color: alpha(#000000, 0.02); +} + +.bread-widget-slot { margin-right: {pad}px; } diff --git a/bread-theme/assets/shell/liquid-motion/theme.toml b/bread-theme/assets/shell/liquid-motion/theme.toml new file mode 100644 index 0000000..72a8f4d --- /dev/null +++ b/bread-theme/assets/shell/liquid-motion/theme.toml @@ -0,0 +1,154 @@ +# The compiled-in builtin theme (bread-theme/src/shell/builtin.rs). Describes +# breadbar/breadbox AS THEY EXIST TODAY, not the 01-liquid-motion.html demo — +# where the two disagree (bar side margin, launcher width/top, the two easing +# curves), this file follows the current Rust source, since Phase 2's +# acceptance test is pixel-identical rendering against today's bar. +# +# Sources: breadbar/src/main.rs:16-22 (BAR_* / CHIP_HEIGHT / ICON_PX consts), +# breadbar/src/theme.rs (load_css's radius/pad/spring locals and the actual +# CSS selectors), breadbar/src/{panel,osd}.rs and +# breadbar/src/notifications/{popup,history}.rs (satellite window anchors, +# margins, namespaces), breadbox/breadbox/src/main.rs:341-344 (launcher +# geometry), ~/.config/hypr/scripts/ui/rules.lua (compositor rules). + +name = "Liquid Motion" +id = "liquid-motion" + +[tokens] +font_family = "Varela Round, sans-serif" +font_fallback = "sans-serif" +font_size_base = 14 +radius_bar = 16 +radius_card = 12 +radius_sm = 9 +# Not in the plan's §4 schema list, but a named local in theme.rs::load_css +# alongside radius_bar/radius_card/radius_sm (`radius_pill = "999px"`) — the +# OSD pill's corner radius. +radius_pill = 999 +pad = 12 +bg_alpha = 0.72 +# Two curves theme.rs actually uses, not one: `spring` is the overshoot/bounce +# curve (clock flips, pop-ins, the workspace caret draw); `spring_settle` is +# the flatter curve used for hovers and workspace/stat-pair transitions. The +# plan's §4 example names only `spring`. +spring = "cubic-bezier(0.22, 1.35, 0.36, 1)" +spring_settle = "cubic-bezier(0.22, 1.2, 0.36, 1)" +# "accent" flows through as @accent (the workspace-trail gradient's start); +# these are palette token NAMES, not hex - pywal still drives colour. +accent_from = "accent" +accent_to = "teal" +# Not in the plan's §4 schema list, but breadbar::CHIP_HEIGHT / ::ICON_PX +# today. +chip_height = 32 +icon_px = 24 + +[bar.window] +anchors = ["top", "left", "right"] +width = "fill" +height = 44 +margin = { top = 12, left = 16, right = 16 } +exclusive = "auto" +# breadbar never calls gtk4-layer-shell's set_keyboard_mode today, which +# defaults to KeyboardMode::None — spelled out explicitly here rather than +# left to omit-means-default, since "the shell must never fail to start +# because a theme file is malformed" cuts both ways: an explicit builtin +# value can't silently drift if the crate's own Default ever changes. +keyboard = "none" +layer = "top" + +[bar.slots] +left = ["workspaces"] +centre = ["media", "clock"] +right = ["volume", "wifi", "battery", "control"] +drawer = [] + +[modules.workspaces] +style = "trail" +show_empty = true + +[modules.clock] +style = "flip" +format = "%H:%M" +show_date = false + +[launcher] +# breadbox/breadbox/src/main.rs:341-344 sets vbox.set_margin_top(120) and +# vbox.set_size_request(600, -1) — the demo's "540px overlay, 16% top" is a +# design-doc approximation, not what the code does today. +mode = "overlay" +width = 600 +top = "120px" +radius = 20 +icon_px = 36 +row_anim = "flip" +rule = "gradient" +footer = "count_apps" +sections = false +modes = ["apps"] + +# Keyed by layer-shell namespace, matching [compositor.*] below, so the two +# tables share one keyspace and can be validated against each other. +# breadbar-notif's popup toast uses set_default_width(320) (its history +# sibling on the same namespace uses 360 — 320 is the live-toast surface, the +# one this positions). breadbar-panel sets no window width at all: its +# popovers size from their own CSS (.control-panel-inner / .wifi-popover-inner +# min-width), which is why its width is "auto" rather than a number. +[surfaces."breadbar-notif"] +anchor = "top_right" +offset = [16, 64] +width = 320 +layer = "overlay" + +[surfaces."breadbar-osd"] +anchor = "bottom_centre" +offset = 80 +width = 180 +layer = "overlay" + +[surfaces."breadbar-panel"] +anchor = "top_right" +offset = [16, 64] +width = "auto" +layer = "overlay" + +[surfaces."breadbar-dismiss"] +# Anchored to all four edges (a fullscreen click-away scrim) with only a top +# margin, so it starts below the bar rather than covering it. +anchor = "fill" +offset = 56 +width = "fill" +layer = "overlay" + +# Faithful to scripts/ui/rules.lua's five breadbar namespaces + breadbox. +[compositor."breadbar"] +blur = true +ignore_alpha = 0.2 +blur_popups = true +animation = "slide top" + +[compositor."breadbar-osd"] +blur = true +ignore_alpha = 0.2 +animation = "slide bottom" + +[compositor."breadbar-notif"] +blur = true +ignore_alpha = 0.2 +animation = "slide right" + +[compositor."breadbar-panel"] +blur = true +ignore_alpha = 0.2 +animation = "slide right" + +[compositor."breadbar-dismiss"] +no_anim = true + +[compositor."breadbox"] +blur = true +ignore_alpha = 0.2 + +# No `css = "..."` overlay: the builtin is compiled in via `include_str!` +# and has no on-disk directory to resolve a relative overlay path against. +# `extra.css` is for user/system themes, which do have one — see +# `resolve_theme` in mod.rs. diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index 2111c17..d20c955 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -4,6 +4,7 @@ pub mod adw; pub mod gtk; mod output; pub mod palette; +pub mod shell; pub use output::{ generate_output, load_palette_for, output_css_path, output_palette_path, palette_from_image, diff --git a/bread-theme/src/shell/builtin.rs b/bread-theme/src/shell/builtin.rs new file mode 100644 index 0000000..bd25560 --- /dev/null +++ b/bread-theme/src/shell/builtin.rs @@ -0,0 +1,23 @@ +//! The one compiled-in theme (plan §11 phase 1: "**One** built-in manifest +//! (`liquid-motion`) describing the bar as it exists today"). Both files are +//! plain data, not Rust — `theme.toml` is the manifest text a user override +//! would otherwise supply, and `liquid-motion.css` is the CSS template +//! `ShellTheme::css` substitutes tokens into (see that method's doc comment +//! for why this template is a representative subset of `breadbar::theme:: +//! load_css`'s full stylesheet rather than a byte-for-byte copy of it). +//! +//! Both are read with `include_str!` so a broken build can't ship without +//! them, and so [`super::builtin`] never touches the filesystem — it must +//! work identically whether or not `$XDG_CONFIG_HOME` exists at all. + +pub const LIQUID_MOTION_ID: &str = "liquid-motion"; + +pub const LIQUID_MOTION_TOML: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/liquid-motion/theme.toml" +)); + +pub const LIQUID_MOTION_CSS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/shell/liquid-motion/liquid-motion.css" +)); diff --git a/bread-theme/src/shell/hotreload.rs b/bread-theme/src/shell/hotreload.rs new file mode 100644 index 0000000..15e68a2 --- /dev/null +++ b/bread-theme/src/shell/hotreload.rs @@ -0,0 +1,32 @@ +//! `watch()` — only compiled under the `gtk` feature, since +//! `gio::FileMonitor` is a gtk4 dependency and the rest of `shell` is +//! deliberately gtk-free (`bread`/`breadcrumbs` link this crate without the +//! `gtk` feature at all). + +use gtk4::gio; +use gtk4::prelude::*; + +/// Fires `f` with a freshly-[`super::load`]ed [`super::ShellTheme`] whenever +/// the active theme's own directory changes on disk. Keep the returned +/// monitor alive — dropping it disarms the watch. +/// +/// Watches the *directory*, not the `theme.toml` file itself, for the same +/// reason `bread_theme::gtk::watch_theme_file` does (see that function's doc +/// comment): an editor or `bread-theme` doing an atomic write-tmp-then- +/// rename replaces the inode, and a monitor on the file itself dies after +/// the first replace (inotify reports `DELETE_SELF` and never re-arms). +pub fn watch(f: F) -> gio::FileMonitor { + let id = super::active_theme_id(); + let dir = super::user_theme_path(&id) + .parent() + .expect("user_theme_path always has a parent") + .to_path_buf(); + let _ = std::fs::create_dir_all(&dir); + let monitor = gio::File::for_path(&dir) + .monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE) + .expect("failed to create a file monitor for the shell theme directory"); + monitor.connect_changed(move |_, _file, _other, _event| { + f(super::load()); + }); + monitor +} diff --git a/bread-theme/src/shell/manifest.rs b/bread-theme/src/shell/manifest.rs new file mode 100644 index 0000000..08be709 --- /dev/null +++ b/bread-theme/src/shell/manifest.rs @@ -0,0 +1,518 @@ +//! `theme.toml` deserialization, validation, and resolution into +//! [`crate::shell::ShellTheme`]. +//! +//! Two layers on purpose: `Raw*` types mirror the TOML shape exactly (every +//! field optional, `deny_unknown_fields` everywhere so a typo'd key is a +//! hard error naming that key rather than a silent no-op) and know nothing +//! about defaults; [`RawManifest::resolve`] is the one place defaults get +//! filled and string enums get validated, producing the fully-resolved +//! types in `types.rs`. + +use anyhow::{anyhow, bail, Context}; +use std::collections::{BTreeMap, HashMap}; + +use super::types::*; + +/// Module names a slot entry may reference without recompiling anything — +/// plan §2 tier 1/2 (declarative slots) plus the `widget:*` escape hatch +/// (tier 3, validated separately since its suffix is open-ended). This is +/// intentionally the set the *current* theme and the plan's own schema +/// example use; Phase 3 (breadbar's module registry) is the place a new +/// built-in module name gets added for real. +const KNOWN_MODULES: &[&str] = &[ + "workspaces", + "media", + "clock", + "volume", + "wifi", + "battery", + "control", + "launcher_entry", + "launcher_results", +]; + +pub(super) fn validate_module_name(theme_id: &str, slot: &str, module: &str) -> anyhow::Result<()> { + if module.starts_with("widget:") || KNOWN_MODULES.contains(&module) { + return Ok(()); + } + bail!( + "theme '{theme_id}': slot \"{slot}\" references unknown module \"{module}\" \ + (known modules: {}, or widget:)", + KNOWN_MODULES.join(", ") + ); +} + +pub(super) fn validate_slots(raw: &RawManifest, theme_id: &str) -> anyhow::Result<()> { + let Some(bar) = &raw.bar else { return Ok(()) }; + let Some(slots) = &bar.slots else { + return Ok(()); + }; + for (slot_name, list) in [ + ("left", &slots.left), + ("centre", &slots.centre), + ("right", &slots.right), + ("drawer", &slots.drawer), + ] { + for module in list { + validate_module_name(theme_id, slot_name, module)?; + } + } + Ok(()) +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawManifest { + pub(super) name: Option, + pub(super) id: Option, + /// Present only so `extends` deserializes as a *known* field (otherwise + /// `deny_unknown_fields` would reject every theme that sets it). The + /// value itself is read straight off the raw `toml::Value` in + /// `mod.rs::resolve_theme` — before this struct exists — since the + /// merge has to happen ahead of (and separately from) deserialization. + #[allow(dead_code)] + pub(super) extends: Option, + #[serde(default)] + pub(super) tokens: HashMap, + pub(super) bar: Option, + pub(super) modules: Option, + pub(super) launcher: Option, + pub(super) surfaces: Option>, + pub(super) compositor: Option>, + /// Overlay CSS path, resolved relative to the theme file's own + /// directory, appended last by `ShellTheme::css`. (Schema note: plan §4 + /// shows `css = "extra.css"` textually after the `[compositor]` table + /// with no table header of its own between them, which in real TOML + /// would nest it *inside* `[compositor]`. Treated here as a top-level + /// field per §5's `css()` doc — see this crate's implementation notes.) + pub(super) css: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawBar { + pub(super) window: Option, + pub(super) slots: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawWindow { + pub(super) anchors: Option>, + pub(super) width: Option, + pub(super) height: Option, + pub(super) margin: Option, + pub(super) exclusive: Option, + pub(super) keyboard: Option, + pub(super) layer: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +pub(super) enum RawSize { + Named(String), + Px(i64), +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +pub(super) enum RawExclusive { + Named(String), + Px(i64), +} + +#[derive(Debug, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, default)] +pub(super) struct RawMargin { + pub(super) top: i64, + pub(super) left: i64, + pub(super) right: i64, + pub(super) bottom: i64, +} + +#[derive(Debug, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, default)] +pub(super) struct RawSlots { + pub(super) left: Vec, + pub(super) centre: Vec, + pub(super) right: Vec, + pub(super) drawer: Vec, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawModules { + pub(super) workspaces: Option, + pub(super) clock: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawWorkspacesModule { + pub(super) style: Option, + pub(super) show_empty: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawClockModule { + pub(super) style: Option, + pub(super) format: Option, + pub(super) show_date: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawLauncher { + pub(super) mode: Option, + pub(super) width: Option, + pub(super) top: Option, + pub(super) radius: Option, + pub(super) icon_px: Option, + pub(super) row_anim: Option, + pub(super) rule: Option, + pub(super) footer: Option, + pub(super) sections: Option, + pub(super) modes: Option>, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawSurface { + pub(super) anchor: Option, + pub(super) offset: Option, + pub(super) width: Option, + pub(super) layer: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +pub(super) enum RawOffset { + Single(f64), + Pair([f64; 2]), +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +pub(super) enum RawSurfaceWidth { + Named(String), + Px(i64), +} + +#[derive(Debug, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, default)] +pub(super) struct RawLayerRule { + pub(super) blur: Option, + pub(super) ignore_alpha: Option, + pub(super) blur_popups: Option, + pub(super) animation: Option, + pub(super) no_anim: Option, +} + +fn token_value(v: &toml::Value) -> anyhow::Result { + match v { + toml::Value::String(s) => Ok(TokenValue::Str(s.clone())), + toml::Value::Integer(i) => Ok(TokenValue::Int(*i)), + toml::Value::Float(f) => Ok(TokenValue::Float(*f)), + toml::Value::Boolean(b) => Ok(TokenValue::Bool(*b)), + other => Err(anyhow!("must be a string, number, or bool, got {other:?}")), + } +} + +fn resolve_window(theme_id: &str, w: &RawWindow) -> anyhow::Result { + let default = WindowSpec::default(); + + let anchors = match &w.anchors { + Some(list) => { + for a in list { + if !matches!(a.as_str(), "top" | "bottom" | "left" | "right") { + bail!( + "theme '{theme_id}': bar.window.anchors contains unknown anchor \"{a}\" \ + (expected top|bottom|left|right)" + ); + } + } + list.clone() + } + None => default.anchors, + }; + + let width = match &w.width { + Some(RawSize::Named(s)) if s == "fill" => Width::Fill, + Some(RawSize::Named(other)) => bail!( + "theme '{theme_id}': bar.window.width = \"{other}\" is not \"fill\" \ + (use a bare number for a fixed width)" + ), + Some(RawSize::Px(n)) => Width::Px(*n as i32), + None => default.width, + }; + + let height = w.height.map(|h| h as i32).unwrap_or(default.height); + + let margin = w + .margin + .as_ref() + .map(|m| Margin { + top: m.top as i32, + left: m.left as i32, + right: m.right as i32, + bottom: m.bottom as i32, + }) + .unwrap_or(default.margin); + + let exclusive = match &w.exclusive { + Some(RawExclusive::Named(s)) if s == "auto" => Exclusive::Auto, + Some(RawExclusive::Named(s)) if s == "none" => Exclusive::None, + Some(RawExclusive::Named(other)) => bail!( + "theme '{theme_id}': bar.window.exclusive = \"{other}\" is not \"auto\" or \"none\" \ + (use a bare number for a fixed exclusive zone)" + ), + Some(RawExclusive::Px(n)) => Exclusive::Px(*n as i32), + None => default.exclusive, + }; + + let keyboard = match w.keyboard.as_deref() { + None => default.keyboard, + Some("none") => Keyboard::None, + Some("on_demand") => Keyboard::OnDemand, + Some("exclusive") => Keyboard::Exclusive, + Some(other) => bail!( + "theme '{theme_id}': bar.window.keyboard = \"{other}\" is not none|on_demand|exclusive" + ), + }; + + let layer = match w.layer.as_deref() { + None => default.layer, + Some("top") => "top".to_string(), + Some("overlay") => "overlay".to_string(), + Some(other) => { + bail!("theme '{theme_id}': bar.window.layer = \"{other}\" is not top|overlay") + } + }; + + Ok(WindowSpec { + anchors, + width, + height, + margin, + exclusive, + keyboard, + layer, + }) +} + +fn resolve_modules(theme_id: &str, m: Option<&RawModules>) -> anyhow::Result { + let ws = m.and_then(|m| m.workspaces.as_ref()); + let style = match ws.and_then(|w| w.style.as_deref()) { + None => WorkspaceStyle::Trail, + Some("trail") => WorkspaceStyle::Trail, + Some("pill") => WorkspaceStyle::Pill, + Some("dots") => WorkspaceStyle::Dots, + Some(other) => bail!( + "theme '{theme_id}': modules.workspaces.style = \"{other}\" is not trail|pill|dots" + ), + }; + let show_empty = ws.and_then(|w| w.show_empty).unwrap_or(true); + + let ck = m.and_then(|m| m.clock.as_ref()); + let cstyle = match ck.and_then(|c| c.style.as_deref()) { + None => ClockStyle::Flip, + Some("flip") => ClockStyle::Flip, + Some("plain") => ClockStyle::Plain, + Some("none") => ClockStyle::None, + Some(other) => { + bail!("theme '{theme_id}': modules.clock.style = \"{other}\" is not flip|plain|none") + } + }; + let format = ck + .and_then(|c| c.format.clone()) + .unwrap_or_else(|| "%H:%M".to_string()); + let show_date = ck.and_then(|c| c.show_date).unwrap_or(false); + + Ok(Modules { + workspaces: WorkspacesModule { style, show_empty }, + clock: ClockModule { + style: cstyle, + format, + show_date, + }, + }) +} + +fn resolve_launcher(theme_id: &str, l: Option<&RawLauncher>) -> anyhow::Result { + let mode = match l.and_then(|l| l.mode.as_deref()) { + None => LauncherMode::Overlay, + Some("overlay") => LauncherMode::Overlay, + Some("embedded") => LauncherMode::Embedded, + Some(other) => { + bail!("theme '{theme_id}': launcher.mode = \"{other}\" is not overlay|embedded") + } + }; + Ok(Launcher { + mode, + width: l.and_then(|l| l.width).unwrap_or(540) as i32, + top: l + .and_then(|l| l.top.clone()) + .unwrap_or_else(|| "16%".to_string()), + radius: l.and_then(|l| l.radius).unwrap_or(20) as i32, + icon_px: l.and_then(|l| l.icon_px).unwrap_or(36) as i32, + row_anim: l + .and_then(|l| l.row_anim.clone()) + .unwrap_or_else(|| "flip".to_string()), + rule: l + .and_then(|l| l.rule.clone()) + .unwrap_or_else(|| "gradient".to_string()), + footer: l + .and_then(|l| l.footer.clone()) + .unwrap_or_else(|| "count_apps".to_string()), + sections: l.and_then(|l| l.sections).unwrap_or(false), + modes: l + .and_then(|l| l.modes.clone()) + .unwrap_or_else(|| vec!["apps".to_string()]), + }) +} + +fn resolve_surfaces( + theme_id: &str, + raw: Option<&HashMap>, +) -> anyhow::Result> { + let mut out = BTreeMap::new(); + let Some(raw) = raw else { return Ok(out) }; + for (namespace, s) in raw { + let offset = match &s.offset { + None => vec![], + Some(RawOffset::Single(v)) => vec![*v], + Some(RawOffset::Pair(v)) => v.to_vec(), + }; + let width = match &s.width { + None => SurfaceWidth::Auto, + Some(RawSurfaceWidth::Named(n)) if n == "fill" => SurfaceWidth::Fill, + Some(RawSurfaceWidth::Named(n)) if n == "auto" => SurfaceWidth::Auto, + Some(RawSurfaceWidth::Named(other)) => bail!( + "theme '{theme_id}': surfaces.{namespace}.width = \"{other}\" is not \"fill\" or \"auto\" \ + (use a bare number for a fixed width)" + ), + Some(RawSurfaceWidth::Px(n)) => SurfaceWidth::Px(*n as i32), + }; + let layer = match s.layer.as_deref() { + None | Some("overlay") => "overlay".to_string(), + Some("top") => "top".to_string(), + Some(other) => bail!( + "theme '{theme_id}': surfaces.{namespace}.layer = \"{other}\" is not top|overlay" + ), + }; + out.insert( + namespace.clone(), + Surface { + anchor: s.anchor.clone().unwrap_or_default(), + offset, + width, + layer, + }, + ); + } + Ok(out) +} + +fn resolve_compositor(raw: Option<&HashMap>) -> BTreeMap { + let mut out = BTreeMap::new(); + let Some(raw) = raw else { return out }; + for (namespace, r) in raw { + out.insert( + namespace.clone(), + LayerRule { + blur: r.blur.unwrap_or(false), + ignore_alpha: r.ignore_alpha, + blur_popups: r.blur_popups.unwrap_or(false), + animation: r.animation.clone(), + no_anim: r.no_anim.unwrap_or(false), + }, + ); + } + out +} + +impl RawManifest { + /// Fill every default and validate every enum-ish string, producing a + /// fully-resolved [`super::ShellTheme`]. `requested_id` is the id this + /// manifest was looked up under (used as the id/name fallback when the + /// TOML omits `id`/`name`); `css_template` and `extra_css` are threaded + /// in by the discovery/extends logic in `mod.rs` since neither is a + /// plain TOML field (extra_css is *read from* a TOML field, `css`, but + /// resolving that path against the theme's own directory happens in the + /// caller, which is the only place that still has the directory handy). + pub(super) fn resolve( + &self, + requested_id: &str, + css_template: String, + extra_css: Option, + ) -> anyhow::Result { + let id = self.id.clone().unwrap_or_else(|| requested_id.to_string()); + let name = self.name.clone().unwrap_or_else(|| id.clone()); + + let mut tokens_map = BTreeMap::new(); + for (k, v) in &self.tokens { + let tv = token_value(v).with_context(|| format!("theme '{id}': tokens.{k}"))?; + tokens_map.insert(k.clone(), tv); + } + let tokens = Tokens::from_map(tokens_map); + + let window = match self.bar.as_ref().and_then(|b| b.window.as_ref()) { + Some(w) => resolve_window(&id, w)?, + None => WindowSpec::default(), + }; + + let slots = self + .bar + .as_ref() + .and_then(|b| b.slots.as_ref()) + .map(|s| Slots { + left: s.left.clone(), + centre: s.centre.clone(), + right: s.right.clone(), + drawer: s.drawer.clone(), + }) + .unwrap_or_default(); + + let modules = resolve_modules(&id, self.modules.as_ref())?; + let launcher = resolve_launcher(&id, self.launcher.as_ref())?; + let surfaces = resolve_surfaces(&id, self.surfaces.as_ref())?; + let compositor = resolve_compositor(self.compositor.as_ref()); + + Ok(super::ShellTheme { + name, + id, + tokens, + window, + slots, + modules, + launcher, + surfaces, + compositor, + css_template, + extra_css, + }) + } +} + +/// Deep-merge `over` onto `base`: tables merge key-by-key recursively; +/// anything else (scalars, arrays — including slot lists) is a full +/// replacement. This is `extends`'s one-level merge (plan §4/§11): +/// `mod.rs` calls this exactly once per `load_named`, with the base's own +/// `extends` key already stripped by the caller so a chain can't go deeper +/// than one level. +pub(super) fn merge_values(base: toml::Value, over: toml::Value) -> toml::Value { + match (base, over) { + (toml::Value::Table(mut base_t), toml::Value::Table(over_t)) => { + for (k, v) in over_t { + let merged = match base_t.remove(&k) { + Some(existing) => merge_values(existing, v), + None => v, + }; + base_t.insert(k, merged); + } + toml::Value::Table(base_t) + } + (_, over) => over, + } +} diff --git a/bread-theme/src/shell/mod.rs b/bread-theme/src/shell/mod.rs new file mode 100644 index 0000000..526b87c --- /dev/null +++ b/bread-theme/src/shell/mod.rs @@ -0,0 +1,899 @@ +//! `bread_theme::shell` — the shell theme manifest system (Phase 1 of the +//! `THEME_SYSTEM_PLAN.md` design: manifest types, discovery, `extends` +//! merge, validation, `css()`, `watch()`, and the one compiled-in +//! `liquid-motion` builtin describing breadbar/breadbox as they exist +//! today). +//! +//! This module is intentionally gtk-free except for [`watch`] (only +//! compiled under the `gtk` feature, since `gio::FileMonitor` is a gtk4 +//! dependency) — `bread` (the daemon) and `breadcrumbs` (the CLI) can read +//! and validate a theme without linking GTK at all. +//! +//! ## Discovery (plan §4) +//! +//! A theme id resolves through, first hit wins: +//! 1. `$XDG_CONFIG_HOME/bread/themes//theme.toml` (user) +//! 2. `/usr/share/bread/themes//theme.toml` (system, BOS package) +//! 3. the compiled-in builtin (currently only `liquid-motion`) +//! +//! The *active* id comes from `~/.config/bread/shell.toml`'s `active = "..."` +//! key, overridden by `$BREAD_SHELL_THEME` (the `--theme` CLI flag mentioned +//! in the plan is a consumer-side concern — breadbar/breadbox would set +//! `$BREAD_SHELL_THEME` themselves before calling [`load`], rather than this +//! crate parsing argv). +//! +//! ## `extends` (plan §4/§11) +//! +//! One level, deep-merged: a theme's raw TOML is merged over its `extends` +//! target's raw TOML (child wins key-by-key, recursing into tables; arrays — +//! including slot lists — are replaced wholesale, not concatenated). If the +//! base itself declares `extends`, that second-level `extends` is dropped +//! before merging — chains longer than one level are not supported, by +//! design (plan explicitly scopes this to "one level"). +//! +//! ## Never fails (plan §4/§5) +//! +//! [`load`] cannot fail: a missing or malformed *active* theme falls back to +//! the compiled-in builtin, logging once via `tracing::warn!` +//! ([`load_named`] is the fallible primitive underneath, for callers that +//! want to know *why* — e.g. a "broken theme" banner in bos-settings). + +mod builtin; +mod manifest; +mod types; + +#[cfg(feature = "gtk")] +mod hotreload; +#[cfg(feature = "gtk")] +pub use hotreload::watch; + +pub use types::*; + +use anyhow::{anyhow, Context}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +use manifest::RawManifest; + +/// A theme, fully resolved: every default filled, every enum validated. +/// Built once by [`load`]/[`load_named`] and handed to consumers as an +/// immutable snapshot — a theme *change* (edit, `extends` retarget, hot +/// reload) produces a new `ShellTheme` rather than mutating this one. +#[derive(Debug, Clone, PartialEq)] +pub struct ShellTheme { + name: String, + id: String, + tokens: Tokens, + window: WindowSpec, + slots: Slots, + modules: Modules, + launcher: Launcher, + surfaces: BTreeMap, + compositor: BTreeMap, + css_template: String, + extra_css: Option, +} + +impl ShellTheme { + pub fn name(&self) -> &str { + &self.name + } + pub fn id(&self) -> &str { + &self.id + } + pub fn tokens(&self) -> &Tokens { + &self.tokens + } + pub fn window(&self) -> &WindowSpec { + &self.window + } + pub fn slots(&self) -> &Slots { + &self.slots + } + pub fn modules(&self) -> &Modules { + &self.modules + } + pub fn launcher(&self) -> &Launcher { + &self.launcher + } + pub fn surfaces(&self) -> &BTreeMap { + &self.surfaces + } + pub fn compositor_rules(&self) -> &BTreeMap { + &self.compositor + } + + /// Token substitution into the theme's CSS template, plus the optional + /// `extra.css` overlay appended last — plan §5. + /// + /// `palette` is accepted to match the plan §5 signature and for parity + /// with [`crate::stylesheet_resolved`]-style consumers in the future, + /// but is deliberately unused today: per this task's brief, `@accent` / + /// `@on-bg` *pass through untouched* here, the same way + /// [`crate::stylesheet`] leaves them for GTK's own `@define-color` + /// mechanism to resolve once the CSS provider is attached + /// (`bgtk::bind_window_with_app_css` / `apply_app_css`). A caller that + /// wants hex-resolved CSS combines this with + /// [`crate::resolve_color_names`] itself, exactly as + /// `breadbar::theme::load_css_for` does today. + #[allow(unused_variables)] + pub fn css(&self, palette: &crate::Palette) -> String { + let mut out = self.tokens.substitute(&self.css_template); + if let Some(extra) = &self.extra_css { + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + out.push_str(&self.tokens.substitute(extra)); + } + out + } +} + +/// Where a discovered theme's manifest text came from — also [`ThemeSummary`]'s +/// `source` field for a picker UI (bos-settings, plan §5 `list()`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThemeSource { + User, + System, + Builtin, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThemeSummary { + pub id: String, + pub name: String, + pub source: ThemeSource, +} + +fn config_home() -> PathBuf { + if let Ok(v) = std::env::var("XDG_CONFIG_HOME") { + if !v.is_empty() { + return PathBuf::from(v); + } + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") +} + +fn user_themes_dir() -> PathBuf { + config_home().join("bread/themes") +} + +fn system_themes_dir() -> PathBuf { + PathBuf::from("/usr/share/bread/themes") +} + +fn user_theme_path(id: &str) -> PathBuf { + user_themes_dir().join(id).join("theme.toml") +} + +fn system_theme_path(id: &str) -> PathBuf { + system_themes_dir().join(id).join("theme.toml") +} + +enum Source { + User(PathBuf), + System(PathBuf), + Builtin, +} + +fn find_source(id: &str) -> Option { + let user = user_theme_path(id); + if user.is_file() { + return Some(Source::User(user)); + } + let system = system_theme_path(id); + if system.is_file() { + return Some(Source::System(system)); + } + if id == builtin::LIQUID_MOTION_ID { + return Some(Source::Builtin); + } + None +} + +/// Manifest text plus, for on-disk sources, the directory it lives in (used +/// to resolve a relative `css = "extra.css"` overlay path). +fn read_source(src: &Source) -> anyhow::Result<(String, Option)> { + match src { + Source::User(p) | Source::System(p) => { + let text = + std::fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))?; + Ok((text, p.parent().map(|d| d.to_path_buf()))) + } + Source::Builtin => Ok((builtin::LIQUID_MOTION_TOML.to_string(), None)), + } +} + +fn css_template_for(id: &str) -> String { + if id == builtin::LIQUID_MOTION_ID { + builtin::LIQUID_MOTION_CSS.to_string() + } else { + String::new() + } +} + +/// The fallible primitive: look up `id` through discovery, apply one level +/// of `extends`, validate, and resolve. Returns `Err` (naming the offending +/// key/path) rather than falling back — [`load`] is the caller that turns a +/// failure into the builtin. +pub fn load_named(id: &str) -> anyhow::Result { + resolve_theme(id, 0) +} + +fn resolve_theme(id: &str, extends_depth: u8) -> anyhow::Result { + let src = find_source(id).ok_or_else(|| { + anyhow!("no theme named '{id}' (checked user config, system dir, and builtins)") + })?; + let (text, dir) = read_source(&src)?; + let mut value: toml::Value = + toml::from_str(&text).with_context(|| format!("parsing theme '{id}'"))?; + + let extends = value + .get("extends") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let mut css_template = css_template_for(id); + + if let (Some(base_id), 0) = (&extends, extends_depth) { + let base_src = find_source(base_id) + .ok_or_else(|| anyhow!("theme '{id}' extends unknown theme '{base_id}'"))?; + let (base_text, _base_dir) = read_source(&base_src)?; + let mut base_value: toml::Value = toml::from_str(&base_text) + .with_context(|| format!("parsing base theme '{base_id}' (extended by '{id}')"))?; + // Cap at one level: drop the base's own `extends` so a chain can't + // go deeper (plan §4: "one level, deep-merged"). + if let toml::Value::Table(t) = &mut base_value { + t.remove("extends"); + } + css_template = css_template_for(base_id); + value = manifest::merge_values(base_value, value); + } + + let raw: RawManifest = value + .try_into() + .with_context(|| format!("theme '{id}' has an invalid or unrecognized field"))?; + + manifest::validate_slots(&raw, id)?; + + let extra_css = match &raw.css { + Some(rel) => { + let dir = dir.ok_or_else(|| { + anyhow!( + "theme '{id}' sets css = \"{rel}\" but has no on-disk directory to \ + resolve it against" + ) + })?; + let path = dir.join(rel); + Some( + std::fs::read_to_string(&path) + .with_context(|| format!("reading css overlay {}", path.display()))?, + ) + } + None => None, + }; + + raw.resolve(id, css_template, extra_css) +} + +/// Bypasses discovery entirely and resolves straight from the compiled-in +/// `LIQUID_MOTION_TOML`/`LIQUID_MOTION_CSS` constants — used as [`load`]'s +/// fallback specifically *because* it cannot be affected by a broken user +/// override file at the same id (unlike calling `load_named("liquid-motion")` +/// again, which would hit that same broken file first via discovery and +/// fail identically). +fn resolve_builtin() -> ShellTheme { + let value: toml::Value = toml::from_str(builtin::LIQUID_MOTION_TOML) + .expect("compiled-in builtin theme.toml must parse"); + let raw: RawManifest = value + .try_into() + .expect("compiled-in builtin theme.toml must satisfy the manifest schema"); + manifest::validate_slots(&raw, builtin::LIQUID_MOTION_ID) + .expect("compiled-in builtin theme.toml must use only known module names"); + raw.resolve( + builtin::LIQUID_MOTION_ID, + builtin::LIQUID_MOTION_CSS.to_string(), + None, + ) + .expect("compiled-in builtin theme.toml must resolve") +} + +static FALLBACK_LOGGED: std::sync::Once = std::sync::Once::new(); + +/// The active theme. Never fails: a missing or malformed active theme logs +/// once (`tracing::warn!`) and falls back to the compiled-in builtin — "the +/// shell must never fail to start because a theme file is malformed" (plan +/// §4). +pub fn load() -> ShellTheme { + let id = active_theme_id(); + match load_named(&id) { + Ok(theme) => theme, + Err(err) => { + FALLBACK_LOGGED.call_once(|| { + tracing::warn!( + "bread-theme: shell theme '{id}' failed to load ({err:#}); \ + falling back to builtin '{}'", + builtin::LIQUID_MOTION_ID + ); + }); + resolve_builtin() + } + } +} + +fn active_theme_id() -> String { + if let Ok(v) = std::env::var("BREAD_SHELL_THEME") { + if !v.trim().is_empty() { + return v; + } + } + let path = config_home().join("bread/shell.toml"); + if let Ok(text) = std::fs::read_to_string(&path) { + if let Ok(value) = toml::from_str::(&text) { + if let Some(active) = value.get("active").and_then(|v| v.as_str()) { + if !active.trim().is_empty() { + return active.to_string(); + } + } + } + } + builtin::LIQUID_MOTION_ID.to_string() +} + +fn scan_theme_dir( + dir: &std::path::Path, + source: ThemeSource, + out: &mut Vec, + seen: &mut std::collections::HashSet, +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let toml_path = entry.path().join("theme.toml"); + let Ok(text) = std::fs::read_to_string(&toml_path) else { + continue; + }; + let Ok(value) = toml::from_str::(&text) else { + continue; + }; + let id = value + .get("id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| entry.file_name().to_str().map(|s| s.to_string())); + let Some(id) = id else { continue }; + if !seen.insert(id.clone()) { + continue; + } + let name = value + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(&id) + .to_string(); + out.push(ThemeSummary { id, name, source }); + } +} + +/// Every theme discoverable across user config, system dir, and builtins — +/// for a picker UI (plan §5: "bos-settings picker"). User shadows system +/// shadows builtin for the same id, matching [`load_named`]'s discovery +/// order. +pub fn list() -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + scan_theme_dir(&user_themes_dir(), ThemeSource::User, &mut out, &mut seen); + scan_theme_dir( + &system_themes_dir(), + ThemeSource::System, + &mut out, + &mut seen, + ); + if seen.insert(builtin::LIQUID_MOTION_ID.to_string()) { + out.push(ThemeSummary { + id: builtin::LIQUID_MOTION_ID.to_string(), + name: "Liquid Motion".to_string(), + source: ThemeSource::Builtin, + }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Guards mutation of XDG_CONFIG_HOME / BREAD_SHELL_THEME, which are + // process-global — mirrors bread_theme::output's XDG_ENV_LOCK pattern + // (a different env var, same reason: cargo test runs a module's tests + // in parallel by default). + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + _lock: std::sync::MutexGuard<'static, ()>, + dir: PathBuf, + old_xdg: Option, + old_theme_var: Option, + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.old_xdg { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + match &self.old_theme_var { + Some(v) => std::env::set_var("BREAD_SHELL_THEME", v), + None => std::env::remove_var("BREAD_SHELL_THEME"), + } + let _ = std::fs::remove_dir_all(&self.dir); + } + } + + /// Isolated `$XDG_CONFIG_HOME` pointing at a fresh temp dir, with + /// `BREAD_SHELL_THEME` cleared so `active_theme_id()` can't pick up + /// whatever's set in the outer test-runner environment. Held for the + /// guard's lifetime. + fn isolated_xdg() -> EnvGuard { + let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!( + "bread-theme-shell-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let old_xdg = std::env::var("XDG_CONFIG_HOME").ok(); + let old_theme_var = std::env::var("BREAD_SHELL_THEME").ok(); + std::env::set_var("XDG_CONFIG_HOME", &dir); + std::env::remove_var("BREAD_SHELL_THEME"); + EnvGuard { + _lock: lock, + dir, + old_xdg, + old_theme_var, + } + } + + fn write_theme(xdg: &EnvGuard, id: &str, toml_body: &str) { + let dir = xdg.dir.join("bread/themes").join(id); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("theme.toml"), toml_body).unwrap(); + } + + // ---- builtin fidelity ------------------------------------------------- + + #[test] + fn builtin_window_spec_matches_current_breadbar_constants() { + // breadbar/src/main.rs:16-22: BAR_HEIGHT=44, BAR_MARGIN_TOP=12, + // BAR_MARGIN_SIDES=16, CHIP_HEIGHT=32, ICON_PX=24. The demo's + // 14px side margin is deliberately NOT what this asserts. + let theme = resolve_builtin(); + let w = theme.window(); + assert_eq!(w.height, 44); + assert_eq!(w.margin.top, 12); + assert_eq!(w.margin.left, 16); + assert_eq!(w.margin.right, 16); + assert_eq!(w.anchors, vec!["top", "left", "right"]); + assert!(matches!(w.width, Width::Fill)); + assert!(matches!(w.exclusive, Exclusive::Auto)); + assert!(matches!(w.keyboard, Keyboard::None)); + assert_eq!(w.layer, "top"); + + assert_eq!(theme.tokens().chip_height(), 32); + assert_eq!(theme.tokens().icon_px(), 24); + } + + #[test] + fn builtin_tokens_match_theme_rs_load_css_locals() { + // theme.rs::load_css: radius="12px", radius_bar="16px", + // radius_sm="9px", radius_pill="999px", pad="12px". + let theme = resolve_builtin(); + let t = theme.tokens(); + assert_eq!(t.radius_card(), 12); + assert_eq!(t.radius_bar(), 16); + assert_eq!(t.radius_sm(), 9); + assert_eq!(t.radius_pill(), 999); + assert_eq!(t.pad(), 12); + assert_eq!(t.spring(), "cubic-bezier(0.22, 1.35, 0.36, 1)"); + assert_eq!(t.spring_settle(), "cubic-bezier(0.22, 1.2, 0.36, 1)"); + } + + #[test] + fn builtin_covers_all_five_breadbar_namespaces_plus_breadbox() { + let theme = resolve_builtin(); + let rules = theme.compositor_rules(); + for ns in [ + "breadbar", + "breadbar-osd", + "breadbar-notif", + "breadbar-panel", + "breadbar-dismiss", + "breadbox", + ] { + assert!(rules.contains_key(ns), "missing compositor rule for {ns}"); + } + assert!(rules["breadbar"].blur); + assert!(rules["breadbar"].blur_popups); + assert_eq!(rules["breadbar"].animation.as_deref(), Some("slide top")); + assert!(rules["breadbar-dismiss"].no_anim); + assert!(!rules["breadbar-dismiss"].blur); + } + + #[test] + fn builtin_surfaces_are_keyed_by_namespace_and_cover_all_four() { + let theme = resolve_builtin(); + let surfaces = theme.surfaces(); + for ns in [ + "breadbar-notif", + "breadbar-osd", + "breadbar-panel", + "breadbar-dismiss", + ] { + assert!(surfaces.contains_key(ns), "missing surface spec for {ns}"); + } + assert_eq!(surfaces["breadbar-osd"].anchor, "bottom_centre"); + assert!(matches!( + surfaces["breadbar-panel"].width, + SurfaceWidth::Auto + )); + assert!(matches!( + surfaces["breadbar-dismiss"].width, + SurfaceWidth::Fill + )); + } + + #[test] + fn builtin_slots_and_modules_are_trail_and_flip() { + let theme = resolve_builtin(); + assert_eq!(theme.slots().left, vec!["workspaces"]); + assert_eq!(theme.slots().centre, vec!["media", "clock"]); + assert!(matches!( + theme.modules().workspaces.style, + WorkspaceStyle::Trail + )); + assert!(matches!(theme.modules().clock.style, ClockStyle::Flip)); + } + + #[test] + fn builtin_css_substitutes_tokens_and_leaves_palette_names_untouched() { + let theme = resolve_builtin(); + let css = theme.css(&crate::Palette::default()); + assert!( + css.contains("border-radius: 16px"), + "radius_bar not substituted:\n{css}" + ); + assert!( + css.contains("cubic-bezier(0.22, 1.35, 0.36, 1)"), + "spring not substituted:\n{css}" + ); + assert!( + css.contains("@accent, @teal"), + "accent_from/accent_to gradient not substituted:\n{css}" + ); + assert!( + css.contains("@on-bg"), + "palette name resolved when it should pass through:\n{css}" + ); + for placeholder in [ + "{radius_bar}", + "{radius_card}", + "{radius_sm}", + "{radius_pill}", + "{pad}", + "{spring}", + "{spring_settle}", + "{bg_alpha}", + "{accent_from}", + "{accent_to}", + "{chip_height}", + ] { + assert!( + !css.contains(placeholder), + "unsubstituted token placeholder {placeholder}:\n{css}" + ); + } + } + + // ---- extends merge ------------------------------------------------ + + #[test] + fn extends_deep_merges_one_level() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "base", + r#" + name = "Base" + id = "base" + [tokens] + radius_bar = 10 + pad = 8 + [bar.window] + height = 40 + [bar.slots] + left = ["workspaces"] + right = ["battery"] + "#, + ); + write_theme( + &xdg, + "child", + r#" + name = "Child" + id = "child" + extends = "base" + [tokens] + radius_bar = 99 + [bar.slots] + right = ["wifi", "battery"] + "#, + ); + + let theme = load_named("child").expect("child theme should resolve"); + // Overridden by the child. + assert_eq!(theme.tokens().radius_bar(), 99); + // Inherited from the base, untouched by the child. + assert_eq!(theme.tokens().pad(), 8); + assert_eq!(theme.window().height, 40); + // Arrays replace wholesale, not concatenate. + assert_eq!(theme.slots().right, vec!["wifi", "battery"]); + // A key the child never mentions at all stays inherited. + assert_eq!(theme.slots().left, vec!["workspaces"]); + } + + #[test] + fn extends_chain_is_capped_at_one_level() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "grandparent", + r#" + id = "grandparent" + [tokens] + pad = 1 + "#, + ); + write_theme( + &xdg, + "parent", + r#" + id = "parent" + extends = "grandparent" + [tokens] + pad = 2 + "#, + ); + write_theme( + &xdg, + "child", + r#" + id = "child" + extends = "parent" + "#, + ); + + let theme = load_named("child").expect("child theme should resolve"); + // Only one level is honored: child merges with parent (pad=2), and + // parent's own `extends = "grandparent"` is dropped rather than + // chased — the grandparent's pad=1 never applies. + assert_eq!(theme.tokens().pad(), 2); + } + + // ---- validation ------------------------------------------------------ + + #[test] + fn unknown_key_error_names_the_offending_key() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "typo", + r#" + id = "typo" + [bar.window] + heihgt = 44 + "#, + ); + let err = load_named("typo").expect_err("typo'd key must be a hard error"); + let msg = format!("{err:#}"); + assert!( + msg.contains("heihgt"), + "error should name the bad key, got: {msg}" + ); + } + + #[test] + fn unknown_top_level_key_is_an_error() { + let xdg = isolated_xdg(); + write_theme(&xdg, "typo2", "id = \"typo2\"\nfont = \"nope\"\n"); + let err = load_named("typo2").expect_err("unknown top-level key must be a hard error"); + assert!(format!("{err:#}").contains("font")); + } + + #[test] + fn unknown_slot_module_names_the_module() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "badmodule", + r#" + id = "badmodule" + [bar.slots] + left = ["teleporter"] + "#, + ); + let err = load_named("badmodule").expect_err("unknown module must be a hard error"); + let msg = format!("{err:#}"); + assert!( + msg.contains("teleporter"), + "error should name the module, got: {msg}" + ); + } + + #[test] + fn widget_prefixed_slot_entries_are_always_valid() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "widgetslot", + r#" + id = "widgetslot" + [bar.slots] + left = ["widget:my-lua-module"] + "#, + ); + let theme = load_named("widgetslot").expect("widget: entries should validate"); + assert_eq!(theme.slots().left, vec!["widget:my-lua-module"]); + } + + #[test] + fn invalid_enum_value_is_an_error() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "badstyle", + r#" + id = "badstyle" + [modules.workspaces] + style = "hexagon" + "#, + ); + let err = load_named("badstyle").expect_err("invalid style must be an error"); + assert!(format!("{err:#}").contains("hexagon")); + } + + // ---- fallback on broken theme ----------------------------------------- + + #[test] + fn malformed_active_theme_falls_back_to_builtin_without_panicking() { + let xdg = isolated_xdg(); + // A broken override sitting at the *active* id's own path — this is + // the case resolve_builtin() exists to survive without re-touching + // the same broken file. + write_theme(&xdg, "liquid-motion", "this is not [valid toml"); + + let theme = load(); // must not panic + assert_eq!(theme.window().height, 44); + assert_eq!(theme.id(), "liquid-motion"); + } + + #[test] + fn missing_active_theme_falls_back_to_builtin() { + let xdg = isolated_xdg(); + std::env::set_var("BREAD_SHELL_THEME", "does-not-exist"); + let theme = load(); + assert_eq!(theme.id(), "liquid-motion"); + drop(xdg); + } + + #[test] + fn load_named_on_broken_theme_returns_err_not_panic() { + let xdg = isolated_xdg(); + write_theme(&xdg, "broken", "not = [valid"); + assert!(load_named("broken").is_err()); + } + + // ---- css() / extra.css ------------------------------------------------- + + #[test] + fn extra_css_overlay_is_appended_and_token_substituted() { + let xdg = isolated_xdg(); + let dir = xdg.dir.join("bread/themes/overlaid"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("theme.toml"), + r#" + id = "overlaid" + extends = "liquid-motion" + css = "extra.css" + [tokens] + pad = 21 + "#, + ) + .unwrap(); + std::fs::write(dir.join("extra.css"), ".custom { margin: {pad}px; }\n").unwrap(); + + let theme = load_named("overlaid").expect("theme with extra.css should resolve"); + let css = theme.css(&crate::Palette::default()); + assert!( + css.contains(".custom { margin: 21px; }"), + "overlay not appended/substituted:\n{css}" + ); + // The inherited liquid-motion template should still be present too. + assert!(css.contains("window.breadbar")); + } + + // ---- active theme resolution ------------------------------------------- + + #[test] + fn env_var_overrides_shell_toml_active() { + let xdg = isolated_xdg(); + std::fs::create_dir_all(xdg.dir.join("bread")).unwrap(); + std::fs::write( + xdg.dir.join("bread/shell.toml"), + "active = \"liquid-motion\"\n", + ) + .unwrap(); + write_theme(&xdg, "envwins", "id = \"envwins\"\n"); + std::env::set_var("BREAD_SHELL_THEME", "envwins"); + assert_eq!(active_theme_id(), "envwins"); + } + + #[test] + fn shell_toml_active_used_when_no_env_var() { + let xdg = isolated_xdg(); + std::fs::create_dir_all(xdg.dir.join("bread")).unwrap(); + std::fs::write(xdg.dir.join("bread/shell.toml"), "active = \"from-file\"\n").unwrap(); + assert_eq!(active_theme_id(), "from-file"); + } + + #[test] + fn defaults_to_liquid_motion_with_nothing_configured() { + let _xdg = isolated_xdg(); + assert_eq!(active_theme_id(), "liquid-motion"); + } + + // ---- list() ------------------------------------------------------------ + + #[test] + fn list_includes_user_themes_and_the_builtin() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "custom-one", + "id = \"custom-one\"\nname = \"Custom One\"\n", + ); + let summaries = list(); + assert!(summaries + .iter() + .any(|s| s.id == "custom-one" && s.source == ThemeSource::User)); + assert!(summaries + .iter() + .any(|s| s.id == "liquid-motion" && s.source == ThemeSource::Builtin)); + } + + #[test] + fn list_user_theme_shadows_builtin_of_the_same_id() { + let xdg = isolated_xdg(); + write_theme( + &xdg, + "liquid-motion", + "id = \"liquid-motion\"\nname = \"User Override\"\n", + ); + let summaries = list(); + let matches: Vec<_> = summaries + .iter() + .filter(|s| s.id == "liquid-motion") + .collect(); + assert_eq!( + matches.len(), + 1, + "same id must not appear twice: {summaries:?}" + ); + assert_eq!(matches[0].source, ThemeSource::User); + } +} diff --git a/bread-theme/src/shell/types.rs b/bread-theme/src/shell/types.rs new file mode 100644 index 0000000..06982a2 --- /dev/null +++ b/bread-theme/src/shell/types.rs @@ -0,0 +1,358 @@ +//! Fully-resolved shell theme types — see `bread-theme/src/shell/mod.rs` for +//! the module overview and `manifest.rs` for how these are built from TOML. +//! +//! Every type here has all defaults filled in; there is no further "is this +//! set" branching once a `ShellTheme` exists. That resolution work happens +//! once, in `manifest.rs`, so consumers (breadbar, breadbox, bos-settings) +//! never have to know the manifest format at all. + +use std::collections::BTreeMap; + +/// Workspace strip rendering. Phase 1 ships only `Trail` (what breadbar draws +/// today); `Pill`/`Dots` exist now so 02/04 (plan §11 phases 5-6) are additive. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceStyle { + Trail, + Pill, + Dots, +} + +/// Clock rendering. Phase 1 ships only `Flip` (today's per-digit flip clock). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClockStyle { + Flip, + Plain, + None, +} + +/// How the launcher attaches to the shell. Phase 1 ships only `Overlay` +/// (breadbox's own window); `Embedded` is theme 04's bar-drawer launcher. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LauncherMode { + Overlay, + Embedded, +} + +/// `gtk4_layer_shell::KeyboardMode` mirror, kept independent of the `gtk` +/// feature so the manifest types stay usable without GTK linked in (bread, +/// breadcrumbs). Values map 1:1 onto `KeyboardMode::{None,Exclusive,OnDemand}` +/// (verified against gtk4-layer-shell 0.8.1's `src/auto/enums.rs`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Keyboard { + None, + OnDemand, + Exclusive, +} + +/// `bar.window.width` / a surface's `width`: `"fill"` spans the anchored +/// edges, a bare number is a fixed/centred/hug width. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Width { + Fill, + Px(i32), +} + +/// `bar.window.exclusive`: `"auto"` reserves `height + margin.top`, `"none"` +/// reserves nothing (theme 04's capsule), or a literal pixel override. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Exclusive { + Auto, + None, + Px(i32), +} + +/// A satellite surface's width: unlike the bar window, a satellite can also +/// be `Auto` — sized by its own content/CSS with no `set_default_width` call +/// at all. `breadbar-panel` is exactly this today (popover content decides +/// its width via `.control-panel-inner`/`.wifi-popover-inner` min-width, not +/// the layer-shell window). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SurfaceWidth { + Fill, + Auto, + Px(i32), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Margin { + pub top: i32, + pub left: i32, + pub right: i32, + pub bottom: i32, +} + +/// `bar.window` — plan §2: window shape is data, not a closed layout enum. +/// Island/Edge/Capsule are three *values* of this struct, not three code +/// paths. +#[derive(Debug, Clone, PartialEq)] +pub struct WindowSpec { + pub anchors: Vec, + pub width: Width, + pub height: i32, + pub margin: Margin, + pub exclusive: Exclusive, + pub keyboard: Keyboard, + pub layer: String, +} + +impl Default for WindowSpec { + /// Generic baseline for a theme that omits `[bar.window]` entirely — + /// deliberately the plan §4 schema example's numbers, not necessarily + /// any particular shipped theme's. `liquid-motion` sets every field + /// explicitly, so it never falls through to this. + fn default() -> Self { + WindowSpec { + anchors: vec!["top".into(), "left".into(), "right".into()], + width: Width::Fill, + height: 44, + margin: Margin { + top: 12, + left: 14, + right: 14, + bottom: 0, + }, + exclusive: Exclusive::Auto, + keyboard: Keyboard::None, + layer: "top".into(), + } + } +} + +/// `bar.slots` — plan §2: structure is slots, not layout code. `drawer` is +/// the only thing Capsule/theme-04 adds over Island, and it's just an +/// (empty, for now) slot list, not a code path. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Slots { + pub left: Vec, + pub centre: Vec, + pub right: Vec, + pub drawer: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspacesModule { + pub style: WorkspaceStyle, + pub show_empty: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClockModule { + pub style: ClockStyle, + pub format: String, + pub show_date: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Modules { + pub workspaces: WorkspacesModule, + pub clock: ClockModule, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Launcher { + pub mode: LauncherMode, + pub width: i32, + pub top: String, + pub radius: i32, + pub icon_px: i32, + pub row_anim: String, + pub rule: String, + pub footer: String, + pub sections: bool, + pub modes: Vec, +} + +/// A satellite surface, keyed by layer-shell namespace in `[surfaces.*]` — +/// deliberately the same keyspace as `[compositor.*]` (see module docs) +/// rather than a role name, so the two tables can be validated against each +/// other and a namespace's positioning and compositor treatment live under +/// one lookup. +#[derive(Debug, Clone, PartialEq)] +pub struct Surface { + pub anchor: String, + pub offset: Vec, + pub width: SurfaceWidth, + pub layer: String, +} + +/// One `[compositor.*]` entry — plan §9: the per-namespace layer-shell rule +/// an app ships as its default and a theme may override. Mirrors the field +/// set `hl.layer_rule` actually accepts in `scripts/ui/rules.lua` (blur, +/// ignore_alpha, blur_popups, animation, no_anim) — that Lua API isn't in +/// hyprland-api.lua's type annotations, so this field set is evidenced by +/// working usage, not documentation (plan §12 risk 3). +#[derive(Debug, Clone, PartialEq, Default)] +pub struct LayerRule { + pub blur: bool, + pub ignore_alpha: Option, + pub blur_popups: bool, + /// Passed through verbatim to `hl.layer_rule`'s `animation` field + /// (`"slide top"`, `"slide bottom"`, …) — kept as a plain string rather + /// than a closed enum since `hl.layer_rule`'s own field set is only + /// evidenced by working usage in `rules.lua`, not documented (plan §12 + /// risk 3); a closed Rust enum here would need updating in lockstep + /// with Hyprland additions this crate has no way to know about. + pub animation: Option, + pub no_anim: bool, +} + +/// A raw TOML scalar carried through to [`Tokens`] for `{name}` substitution +/// in [`crate::shell::ShellTheme::css`]. Kept untyped (rather than forcing +/// every token into a `String`) so `css()` can format a number without a +/// theme author having to quote it, while `bg_alpha = 0.72` etc. still round +/// -trips as a real float for any future non-string consumer. +#[derive(Debug, Clone, PartialEq)] +pub enum TokenValue { + Str(String), + Int(i64), + Float(f64), + Bool(bool), +} + +impl TokenValue { + /// Textual form used both for `{name}` substitution in CSS and for the + /// typed accessors' fallback formatting. + pub fn as_css(&self) -> String { + match self { + TokenValue::Str(s) => s.clone(), + TokenValue::Int(i) => i.to_string(), + TokenValue::Float(f) => { + if f.fract() == 0.0 { + format!("{f:.0}") + } else { + f.to_string() + } + } + TokenValue::Bool(b) => b.to_string(), + } + } +} + +/// `[tokens]`, resolved. Deliberately an open bag (`BTreeMap`), not a fixed +/// struct: the schema (plan §4) names eleven fields, but a theme may define +/// arbitrary extra keys purely for `{name}` substitution in `extra.css` +/// (`radius_pill`, `chip_height`, `icon_px`, `spring_settle` below are all +/// exactly this — real values `theme.rs::load_css` uses today that the plan +/// text's `[tokens]` example didn't list). The named accessors below give +/// the documented fields typed access with sensible defaults; [`Tokens::get`] +/// and [`Tokens::substitute`] cover everything else. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Tokens { + pub(crate) map: BTreeMap, +} + +impl Tokens { + pub fn from_map(map: BTreeMap) -> Self { + Tokens { map } + } + + pub fn get(&self, key: &str) -> Option<&TokenValue> { + self.map.get(key) + } + + pub fn keys(&self) -> impl Iterator { + self.map.keys().map(|s| s.as_str()) + } + + fn str_or(&self, key: &str, default: &str) -> String { + match self.map.get(key) { + Some(v) => v.as_css(), + None => default.to_string(), + } + } + + fn int_or(&self, key: &str, default: i64) -> i64 { + match self.map.get(key) { + Some(TokenValue::Int(i)) => *i, + Some(TokenValue::Float(f)) => *f as i64, + Some(TokenValue::Str(s)) => s.parse().unwrap_or(default), + _ => default, + } + } + + fn float_or(&self, key: &str, default: f64) -> f64 { + match self.map.get(key) { + Some(TokenValue::Float(f)) => *f, + Some(TokenValue::Int(i)) => *i as f64, + Some(TokenValue::Str(s)) => s.parse().unwrap_or(default), + _ => default, + } + } + + pub fn font_family(&self) -> String { + self.str_or("font_family", crate::tokens::FONT_FAMILY) + } + pub fn font_fallback(&self) -> String { + self.str_or("font_fallback", "sans-serif") + } + pub fn font_size_base(&self) -> i64 { + self.int_or("font_size_base", crate::tokens::FONT_SIZE_BASE as i64) + } + pub fn radius_bar(&self) -> i64 { + self.int_or("radius_bar", crate::tokens::RADIUS_PRIMARY as i64) + } + pub fn radius_card(&self) -> i64 { + self.int_or("radius_card", crate::tokens::RADIUS_PRIMARY as i64) + } + pub fn radius_sm(&self) -> i64 { + self.int_or("radius_sm", crate::tokens::RADIUS_SECONDARY as i64) + } + /// Not in the plan §4 schema list, but a named local in + /// `theme.rs::load_css` (`radius_pill = "999px"`) alongside the three + /// siblings that are. See the [`Tokens`] doc comment. + pub fn radius_pill(&self) -> i64 { + self.int_or("radius_pill", crate::tokens::RADIUS_PILL as i64) + } + pub fn pad(&self) -> i64 { + self.int_or("pad", crate::tokens::SPACE_MD as i64) + } + pub fn bg_alpha(&self) -> f64 { + self.float_or("bg_alpha", 0.72) + } + /// The overshoot/bounce curve (`0.22, 1.35, 0.36, 1`) — clock flips, + /// pop-ins, the workspace caret draw. + pub fn spring(&self) -> String { + self.str_or("spring", "cubic-bezier(0.22, 1.35, 0.36, 1)") + } + /// The settle curve (`0.22, 1.2, 0.36, 1`) — hovers, workspace-btn + /// opacity/background transitions, OSD/notification slide-ins. Not in + /// the plan §4 schema (which names only `spring`), but `theme.rs` uses + /// it just as pervasively as the overshoot curve. See [`Tokens`] doc. + pub fn spring_settle(&self) -> String { + self.str_or("spring_settle", "cubic-bezier(0.22, 1.2, 0.36, 1)") + } + pub fn accent_from(&self) -> String { + self.str_or("accent_from", "accent") + } + pub fn accent_to(&self) -> String { + let from = self.accent_from(); + self.str_or("accent_to", &from) + } + /// Workspace-pill / chip height. Not in the plan §4 schema, but + /// `breadbar::CHIP_HEIGHT` (32) today. See [`Tokens`] doc. + pub fn chip_height(&self) -> i64 { + self.int_or("chip_height", 32) + } + /// Not in the plan §4 schema, but `breadbar::ICON_PX` (24) today. See + /// [`Tokens`] doc. + pub fn icon_px(&self) -> i64 { + self.int_or("icon_px", 24) + } + + /// Replace every `{name}` occurrence in `template` with that token's + /// [`TokenValue::as_css`] form. Longest names are substituted first + /// (mirrors [`crate::resolve_color_names`]) so `{radius}` cannot + /// half-consume `{radius_bar}` if a theme happens to define both. + /// `@name` palette references are untouched — this only ever looks at + /// `{...}` tokens. + pub fn substitute(&self, template: &str) -> String { + let mut keys: Vec<&String> = self.map.keys().collect(); + keys.sort_by_key(|k| std::cmp::Reverse(k.len())); + let mut out = template.to_string(); + for k in keys { + let value = self.map[k].as_css(); + out = out.replace(&format!("{{{k}}}"), &value); + } + out + } +} From 92d3362a6bf114518866dec1549ff625ba245043 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 18:23:31 +0800 Subject: [PATCH 11/37] shell: add widget: entries to the builtin's [bar.slots] (Phase 3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduces breadbar's now-removed fixed Lua-widget interleave (right-of-workspaces, left/right-of-clock, left-of-stats) as explicit widget: slot entries, so the builtin theme still renders pixel-identical to today's bar under breadbar's new theme-driven widget placement. `tray` deliberately has no slot entry anywhere — it stays in the control-panel popover regardless of [bar.slots]. Updates the one test asserting the builtin's slot contents. --- bread-theme/assets/shell/liquid-motion/theme.toml | 13 ++++++++++--- bread-theme/src/shell/mod.rs | 10 ++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/bread-theme/assets/shell/liquid-motion/theme.toml b/bread-theme/assets/shell/liquid-motion/theme.toml index 72a8f4d..d8bf1ec 100644 --- a/bread-theme/assets/shell/liquid-motion/theme.toml +++ b/bread-theme/assets/shell/liquid-motion/theme.toml @@ -57,9 +57,16 @@ keyboard = "none" layer = "top" [bar.slots] -left = ["workspaces"] -centre = ["media", "clock"] -right = ["volume", "wifi", "battery", "control"] +# The `widget:*` entries reproduce breadbar's pre-Phase-3b fixed Lua-widget +# interleave exactly (main.rs's now-removed widget_right_of_workspaces / +# widget_left_of_clock / widget_right_of_clock / widget_left_of_stats), so +# this manifest still renders pixel-identically to today's bar. `tray` +# deliberately has NO slot entry anywhere — it lives in the control-panel +# popover, not the bar, and is keyed directly by breadbar regardless of +# `[bar.slots]`. +left = ["workspaces", "widget:right_of_workspaces"] +centre = ["media", "widget:left_of_clock", "clock", "widget:right_of_clock"] +right = ["widget:left_of_stats", "volume", "wifi", "battery", "control"] drawer = [] [modules.workspaces] diff --git a/bread-theme/src/shell/mod.rs b/bread-theme/src/shell/mod.rs index 526b87c..a241ca9 100644 --- a/bread-theme/src/shell/mod.rs +++ b/bread-theme/src/shell/mod.rs @@ -550,8 +550,14 @@ mod tests { #[test] fn builtin_slots_and_modules_are_trail_and_flip() { let theme = resolve_builtin(); - assert_eq!(theme.slots().left, vec!["workspaces"]); - assert_eq!(theme.slots().centre, vec!["media", "clock"]); + assert_eq!( + theme.slots().left, + vec!["workspaces", "widget:right_of_workspaces"] + ); + assert_eq!( + theme.slots().centre, + vec!["media", "widget:left_of_clock", "clock", "widget:right_of_clock"] + ); assert!(matches!( theme.modules().workspaces.style, WorkspaceStyle::Trail From ea9758a5d5182653414d44cbe54ca47e3da0a7b6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 18:57:35 +0800 Subject: [PATCH 12/37] bread-theme: generate Hyprland layer rules from the shell theme (Phase 4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `bread-theme layerrules`, which writes the active shell theme's [compositor] table to ~/.config/hypr/layerrules.json (atomic write). This lets ~/.config/hypr/scripts/ui/rules.lua read theme-driven blur/transparency/ animation for the breadbar/breadbox layer-shell namespaces instead of having them hardcoded, following THEME_SYSTEM_PLAN.md §9. rules.lua keeps its previous hardcoded rules as a pcall-guarded fallback for when the JSON is missing or malformed (lives outside this repo, so not part of this commit). Also fixes a pre-existing test race: shell::tests and the new layerrules::tests both mutate XDG_CONFIG_HOME in parallel `cargo test` threads but previously used separate, unrelated locks (or none), so they could observe each other's env var changes mid-test. Both now share bread_theme::test_support::XDG_CONFIG_HOME_LOCK. --- bread-theme/src/bin/bread-theme.rs | 30 ++++- bread-theme/src/layerrules.rs | 182 +++++++++++++++++++++++++++++ bread-theme/src/lib.rs | 15 +++ bread-theme/src/output.rs | 2 +- bread-theme/src/shell/mod.rs | 14 +-- bread-theme/src/shell/types.rs | 12 +- 6 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 bread-theme/src/layerrules.rs diff --git a/bread-theme/src/bin/bread-theme.rs b/bread-theme/src/bin/bread-theme.rs index 3d7a862..a6d5b49 100644 --- a/bread-theme/src/bin/bread-theme.rs +++ b/bread-theme/src/bin/bread-theme.rs @@ -11,6 +11,8 @@ //! bread-theme print # render to stdout (no write) //! bread-theme generate-output --image [--shared] //! bread-theme generate-output --from-json [--shared] +//! bread-theme layerrules # write the active theme's [compositor] table +//! # to ~/.config/hypr/layerrules.json (plan §9) use std::process::ExitCode; @@ -31,7 +33,7 @@ fn print_help() { eprintln!( "bread-theme — shared stylesheet generator\n\n\ USAGE:\n\ - \x20 bread-theme [generate|reload|path|print]\n\ + \x20 bread-theme [generate|reload|path|print|layerrules]\n\ \x20 bread-theme generate-output --image [--shared]\n\ \x20 bread-theme generate-output --from-json [--shared]\n\n\ generate render the pywal palette to the shared stylesheet (default)\n\ @@ -41,8 +43,14 @@ fn print_help() { generate-output write palettes/.json and themes/.css\n\ \x20 --image isolated `wal -i` (does not touch ~/.cache/wal)\n\ \x20 --from-json wal colors.json or a color1-6 object\n\ - \x20 --shared also write the session-global theme.css", - bread_theme::shared_css_path().display() + \x20 --shared also write the session-global theme.css\n\ + layerrules write the active shell theme's [compositor] table to\n\ + \x20 {} —\n\ + \x20 scripts/ui/rules.lua reads it for per-namespace blur/\n\ + \x20 animation, falling back to its hardcoded rules if this\n\ + \x20 is missing or malformed", + bread_theme::shared_css_path().display(), + bread_theme::layerrules_path().display() ); } @@ -170,6 +178,19 @@ fn finish_generate_output(output: &str, css: std::path::PathBuf, shared: bool) - } } +fn layerrules_cmd() -> ExitCode { + match bread_theme::write_layerrules_active() { + Ok(path) => { + eprintln!("bread-theme: wrote {}", path.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("bread-theme: failed to write layer rules: {e}"); + ExitCode::FAILURE + } + } +} + fn main() -> ExitCode { let cmd = std::env::args().nth(1).unwrap_or_else(|| "generate".into()); match cmd.as_str() { @@ -188,13 +209,14 @@ fn main() -> ExitCode { // palette and recolour live — shared widgets *and* each app's own rules. "reload" => write_and_report("reloaded"), "generate-output" => generate_output_cmd(), + "layerrules" => layerrules_cmd(), "-h" | "--help" | "help" => { print_help(); ExitCode::SUCCESS } other => { eprintln!( - "bread-theme: unknown command '{other}' (try generate|reload|path|print|generate-output)" + "bread-theme: unknown command '{other}' (try generate|reload|path|print|generate-output|layerrules)" ); ExitCode::FAILURE } diff --git a/bread-theme/src/layerrules.rs b/bread-theme/src/layerrules.rs new file mode 100644 index 0000000..76bcca8 --- /dev/null +++ b/bread-theme/src/layerrules.rs @@ -0,0 +1,182 @@ +//! Generates `~/.config/hypr/layerrules.json` from the active shell theme's +//! `[compositor]` table (`THEME_SYSTEM_PLAN.md` §9). `scripts/ui/rules.lua` +//! reads this file and emits `hl.layer_rule` calls from it, keeping its own +//! hardcoded rules as a pcall-guarded fallback for when this file is missing +//! or malformed — so generation here never has to be perfect, only present. +//! +//! Scope (plan §9's appearance/placement boundary): a theme's `[compositor]` +//! table owns per-namespace *appearance* only — blur, ignore_alpha, +//! blur_popups, animation, no_anim (the [`crate::shell::LayerRule`] field +//! set). It never owns placement, workspace-assignment, or focus rules — +//! those stay Lua-side user policy (`rules.lua`'s `hl.window_rule` calls) +//! that this generator does not touch. + +use std::path::PathBuf; + +use crate::shell::ShellTheme; + +fn config_home() -> PathBuf { + if let Ok(v) = std::env::var("XDG_CONFIG_HOME") { + if !v.is_empty() { + return PathBuf::from(v); + } + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") +} + +/// `~/.config/hypr/layerrules.json` (or under `$XDG_CONFIG_HOME` if set) — +/// alongside `binds.json`, `settings.json`, `monitors.json`, and +/// `autostart.json`, the established flat-JSON-under-`hypr/` convention +/// those files already use (see `~/.config/hypr/scripts/input/binds.lua` for +/// the read side of that pattern, which `scripts/ui/rules.lua` now mirrors). +pub fn layerrules_path() -> PathBuf { + config_home().join("hypr").join("layerrules.json") +} + +/// Render `theme`'s `[compositor]` table as the JSON object +/// `scripts/ui/rules.lua` expects: keyed by layer-shell namespace (e.g. +/// `"breadbar"`, `"breadbox"`), each value the namespace's +/// [`crate::shell::LayerRule`] fields. `compositor_rules()` returns a +/// `BTreeMap`, so namespace order is stable (alphabetical) across runs and a +/// rewritten file diffs cleanly. +pub fn layerrules_json(theme: &ShellTheme) -> String { + serde_json::to_string_pretty(theme.compositor_rules()) + .expect("LayerRule serialization is infallible (no maps/floats that can fail)") +} + +/// [`layerrules_json`] + atomic write (tmp + rename), the same durability +/// [`crate::write_shared_css_from`] uses so a reload can never observe a +/// half-written file. +pub fn write_layerrules(theme: &ShellTheme) -> std::io::Result { + let path = layerrules_path(); + let json = layerrules_json(theme); + crate::output::atomic_write(&path, &json)?; + Ok(path) +} + +/// [`write_layerrules`] from the active theme ([`crate::shell::load`], which +/// never fails — a broken active theme falls back to the builtin). Used by +/// the `bread-theme layerrules` CLI subcommand. +pub fn write_layerrules_active() -> std::io::Result { + write_layerrules(&crate::shell::load()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn lock_xdg() -> std::sync::MutexGuard<'static, ()> { + // Shared with `shell::tests::isolated_xdg`, which also mutates + // XDG_CONFIG_HOME — must be the *same* lock, not a look-alike one, + // or the two modules' parallel tests race each other's env var + // reads (see `crate::test_support`'s doc comment). + crate::test_support::XDG_CONFIG_HOME_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()) + } + + fn with_config_home(f: impl FnOnce(&Path) -> T) -> T { + let _lock = lock_xdg(); + let dir = std::env::temp_dir().join(format!( + "bread-theme-layerrules-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let old = std::env::var("XDG_CONFIG_HOME").ok(); + std::env::set_var("XDG_CONFIG_HOME", &dir); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&dir))); + match old { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + let _ = std::fs::remove_dir_all(&dir); + match result { + Ok(v) => v, + Err(e) => std::panic::resume_unwind(e), + } + } + + #[test] + fn layerrules_path_sits_under_config_hypr() { + with_config_home(|dir| { + assert_eq!(layerrules_path(), dir.join("hypr").join("layerrules.json")); + }); + } + + /// `load_named("liquid-motion")` resolves through discovery (user dir, + /// then system dir, then the compiled-in builtin) — isolate + /// `XDG_CONFIG_HOME` to an empty dir so this can't pick up a real + /// `~/.config/bread/themes/liquid-motion/theme.toml` override and land + /// on a different `[compositor]` table than the builtin's. + fn builtin_theme() -> ShellTheme { + with_config_home(|_| crate::shell::load_named("liquid-motion").unwrap()) + } + + #[test] + fn layerrules_json_covers_all_six_builtin_namespaces() { + let theme = builtin_theme(); + let json = layerrules_json(&theme); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + let obj = value.as_object().expect("top-level object"); + for ns in [ + "breadbar", + "breadbar-osd", + "breadbar-notif", + "breadbar-panel", + "breadbar-dismiss", + "breadbox", + ] { + assert!(obj.contains_key(ns), "missing namespace {ns} in JSON"); + } + } + + #[test] + fn layerrules_json_shape_matches_breadbar_rule() { + let theme = builtin_theme(); + let json = layerrules_json(&theme); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + let bar = &value["breadbar"]; + assert_eq!(bar["blur"], true); + assert_eq!(bar["ignore_alpha"], 0.2); + assert_eq!(bar["blur_popups"], true); + assert_eq!(bar["animation"], "slide top"); + // no_anim is a plain bool (not Option), so it's always present, even + // when false — unlike ignore_alpha/animation which are omitted. + assert_eq!(bar["no_anim"], false); + + let dismiss = &value["breadbar-dismiss"]; + assert_eq!(dismiss["no_anim"], true); + // ignore_alpha/animation are unset for breadbar-dismiss, so the + // skip_serializing_if omits them entirely rather than writing null. + assert!(dismiss.get("ignore_alpha").is_none()); + assert!(dismiss.get("animation").is_none()); + } + + #[test] + fn write_layerrules_active_writes_atomically_and_is_reloadable() { + with_config_home(|dir| { + let path = write_layerrules_active().unwrap(); + assert_eq!(path, dir.join("hypr").join("layerrules.json")); + assert!(path.is_file()); + // No leftover .tmp file after the atomic rename. + assert!(!path.with_file_name("layerrules.json.tmp").exists()); + + let contents = std::fs::read_to_string(&path).unwrap(); + let value: serde_json::Value = serde_json::from_str(&contents).unwrap(); + assert!(value.as_object().unwrap().contains_key("breadbox")); + + // Rewriting (theme switch, pywal hook, etc.) must not fail or + // leave a stale temp file behind. + let path2 = write_layerrules_active().unwrap(); + assert_eq!(path, path2); + assert!(!path.with_file_name("layerrules.json.tmp").exists()); + }); + } +} diff --git a/bread-theme/src/lib.rs b/bread-theme/src/lib.rs index d20c955..12fa498 100644 --- a/bread-theme/src/lib.rs +++ b/bread-theme/src/lib.rs @@ -2,10 +2,12 @@ pub mod adw; #[cfg(feature = "gtk")] pub mod gtk; +mod layerrules; mod output; pub mod palette; pub mod shell; +pub use layerrules::{layerrules_json, layerrules_path, write_layerrules, write_layerrules_active}; pub use output::{ generate_output, load_palette_for, output_css_path, output_palette_path, palette_from_image, palette_from_json, palettes_dir, sanitize_output, themes_dir, write_output_css, @@ -13,6 +15,19 @@ pub use output::{ }; pub use palette::{load_palette, Palette}; +/// Env-var locks shared by any test module that mutates process-global +/// state (`std::env::set_var`) — `cargo test` runs a crate's tests in +/// parallel by default, so every module touching the *same* env var must +/// serialize through the *same* lock or their mutations race each other's +/// reads. `bread_theme::output`'s own `XDG_ENV_LOCK` guards `XDG_RUNTIME_DIR` +/// specifically and stays where it is; `XDG_CONFIG_HOME_LOCK` here is the +/// one shared by `shell::tests` and `layerrules::tests`, which both point +/// `XDG_CONFIG_HOME` at an isolated temp dir. +#[cfg(test)] +pub(crate) mod test_support { + pub(crate) static XDG_CONFIG_HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +} + /// Design tokens from BREAD_DESIGN_SYSTEM.md. pub mod tokens { pub const FONT_FAMILY: &str = "Varela Round, sans-serif"; diff --git a/bread-theme/src/output.rs b/bread-theme/src/output.rs index 4f2f069..5389277 100644 --- a/bread-theme/src/output.rs +++ b/bread-theme/src/output.rs @@ -54,7 +54,7 @@ pub fn output_palette_path(output: &str) -> PathBuf { palettes_dir().join(format!("{}.json", sanitize_output(output))) } -fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> { +pub(crate) fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } diff --git a/bread-theme/src/shell/mod.rs b/bread-theme/src/shell/mod.rs index a241ca9..dc48683 100644 --- a/bread-theme/src/shell/mod.rs +++ b/bread-theme/src/shell/mod.rs @@ -403,13 +403,6 @@ pub fn list() -> Vec { #[cfg(test)] mod tests { use super::*; - use std::sync::Mutex; - - // Guards mutation of XDG_CONFIG_HOME / BREAD_SHELL_THEME, which are - // process-global — mirrors bread_theme::output's XDG_ENV_LOCK pattern - // (a different env var, same reason: cargo test runs a module's tests - // in parallel by default). - static ENV_LOCK: Mutex<()> = Mutex::new(()); struct EnvGuard { _lock: std::sync::MutexGuard<'static, ()>, @@ -437,7 +430,12 @@ mod tests { /// whatever's set in the outer test-runner environment. Held for the /// guard's lifetime. fn isolated_xdg() -> EnvGuard { - let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Shared with `layerrules::tests`, which also isolates + // XDG_CONFIG_HOME — see `crate::test_support` for why this must be + // the *same* lock rather than a module-private one. + let lock = crate::test_support::XDG_CONFIG_HOME_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); let dir = std::env::temp_dir().join(format!( "bread-theme-shell-test-{}-{}", std::process::id(), diff --git a/bread-theme/src/shell/types.rs b/bread-theme/src/shell/types.rs index 06982a2..872ab40 100644 --- a/bread-theme/src/shell/types.rs +++ b/bread-theme/src/shell/types.rs @@ -181,9 +181,18 @@ pub struct Surface { /// ignore_alpha, blur_popups, animation, no_anim) — that Lua API isn't in /// hyprland-api.lua's type annotations, so this field set is evidenced by /// working usage, not documentation (plan §12 risk 3). -#[derive(Debug, Clone, PartialEq, Default)] +/// +/// Also `Serialize`: this is the per-namespace shape written to +/// `~/.config/hypr/layerrules.json` by `bread_theme::layerrules` (plan §9 +/// step 3-4), which `scripts/ui/rules.lua` parses back into `hl.layer_rule` +/// calls. `Option::None` fields are omitted rather than emitted as `null` — +/// the Lua JSON reader treats a missing key and a `null` value identically +/// (assigning `nil` into a table key is a no-op), so either encoding is +/// correct, but omitting keeps the file legible for hand inspection. +#[derive(Debug, Clone, PartialEq, Default, serde::Serialize)] pub struct LayerRule { pub blur: bool, + #[serde(skip_serializing_if = "Option::is_none")] pub ignore_alpha: Option, pub blur_popups: bool, /// Passed through verbatim to `hl.layer_rule`'s `animation` field @@ -192,6 +201,7 @@ pub struct LayerRule { /// evidenced by working usage in `rules.lua`, not documented (plan §12 /// risk 3); a closed Rust enum here would need updating in lockstep /// with Hyprland additions this crate has no way to know about. + #[serde(skip_serializing_if = "Option::is_none")] pub animation: Option, pub no_anim: bool, } From 53a6c59f2d8b89c6bd73e94e4d00fb94c632fd8a Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 19:13:36 +0800 Subject: [PATCH 13/37] bread-theme: correct [launcher] manifest to match breadbox's actual behaviour radius (20 -> 8) and icon_px (36 -> 32) were demo-derived aspirations, not what breadbox implements today (.launcher-bg's actual border-radius and make_icon's actual set_pixel_size). row_anim/rule/footer/sections/modes are kept but marked declared-but-not-yet-consumed: breadbox implements none of row animation, a rule/divider, a footer, sections, or query modes today, so the manifest should say so rather than imply they're live. Adds a builtin launcher test mirroring the existing window/tokens fidelity tests. --- .../assets/shell/liquid-motion/theme.toml | 26 ++++++++++++++++--- bread-theme/src/shell/mod.rs | 15 +++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/bread-theme/assets/shell/liquid-motion/theme.toml b/bread-theme/assets/shell/liquid-motion/theme.toml index d8bf1ec..5b3c8f5 100644 --- a/bread-theme/assets/shell/liquid-motion/theme.toml +++ b/bread-theme/assets/shell/liquid-motion/theme.toml @@ -8,8 +8,10 @@ # breadbar/src/theme.rs (load_css's radius/pad/spring locals and the actual # CSS selectors), breadbar/src/{panel,osd}.rs and # breadbar/src/notifications/{popup,history}.rs (satellite window anchors, -# margins, namespaces), breadbox/breadbox/src/main.rs:341-344 (launcher -# geometry), ~/.config/hypr/scripts/ui/rules.lua (compositor rules). +# margins, namespaces), breadbox/breadbox/src/main.rs:341-346 (launcher +# margin/size), :151 (build_css's .launcher-bg radius), :174-193 (make_icon's +# set_pixel_size calls), ~/.config/hypr/scripts/ui/rules.lua (compositor +# rules). name = "Liquid Motion" id = "liquid-motion" @@ -85,8 +87,24 @@ show_date = false mode = "overlay" width = 600 top = "120px" -radius = 20 -icon_px = 36 +# .launcher-bg { border-radius: 8px } (build_css, main.rs:151) — corrected +# from an earlier demo-derived 20px; breadbox does not implement the demo's +# 20px rounding. +radius = 8 +# make_icon() (main.rs:174-193) calls img.set_pixel_size(32) on both the +# cached-texture (gio::File/gdk::Texture) path and the GTK icon-theme +# fallback path — corrected from a demo-derived 36px. +icon_px = 32 +# Declared-but-not-yet-consumed: breadbox's Phase 4b-i wiring reads only +# mode/width/top/radius/icon_px above; it does not read any of the five keys +# below. row_anim/rule/footer are demo-derived aspirations breadbox +# implements not at all today (no row entrance/exit animation, no +# rule/divider element between the search entry and the list, no footer +# widget summarizing app count). sections/modes happen to already describe +# today's actual (trivial) behaviour — one flat unsectioned list, plain app +# search with no calc/command/url modes — but are likewise unread by any +# code path; a real sections/modes implementation is Phase 5/6, same as the +# other three. row_anim = "flip" rule = "gradient" footer = "count_apps" diff --git a/bread-theme/src/shell/mod.rs b/bread-theme/src/shell/mod.rs index dc48683..c847896 100644 --- a/bread-theme/src/shell/mod.rs +++ b/bread-theme/src/shell/mod.rs @@ -563,6 +563,21 @@ mod tests { assert!(matches!(theme.modules().clock.style, ClockStyle::Flip)); } + #[test] + fn builtin_launcher_matches_current_breadbox_geometry() { + // breadbox/breadbox/src/main.rs:341-346 (margin/size), :151 + // (build_css's .launcher-bg radius), :174-193 (make_icon's + // set_pixel_size calls). radius=8 and icon_px=32 are current CODE + // values, not the demo's 20/36 — see Phase 4b-i's manifest audit. + let theme = resolve_builtin(); + let l = theme.launcher(); + assert!(matches!(l.mode, LauncherMode::Overlay)); + assert_eq!(l.width, 600); + assert_eq!(l.top, "120px"); + assert_eq!(l.radius, 8); + assert_eq!(l.icon_px, 32); + } + #[test] fn builtin_css_substitutes_tokens_and_leaves_palette_names_untouched() { let theme = resolve_builtin(); From 96fa79c1d4e26c1dadc76fb89dde19fb7040d32e Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 24 Aug 2026 23:13:22 +0800 Subject: [PATCH 14/37] shell theme: add glass-workbench as a second compiled-in theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of the shell theme system (THEME_SYSTEM_PLAN.md §11): a second builtin, demo 02's flush edge-to-edge bar with pill workspaces, a plain date+time clock, and cpu/ram chips instead of the media widget. - bread-theme/assets/shell/glass-workbench/: theme.toml + CSS template, faithful to bos-ui-demos/02-glass-workbench.html. Accent maps to the `green` palette token (flat, not a gradient) rather than a hex literal, so pywal theming still works. - builtin.rs: generalized from a single hardcoded liquid-motion constant pair to a small BuiltinTheme registry (builtin::ALL / builtin::find), so mod.rs's discovery/list()/resolve_builtin no longer special-case one id. liquid-motion stays the pinned fallback in resolve_builtin(). - manifest.rs: KNOWN_MODULES gains "cpu"/"ram". - types.rs: new Tokens::bar_border() ("full" default vs "bottom") so a flush bar can ask for a single hairline instead of an island's full border. - Tests: builtin loads, appears in list() alongside liquid-motion, and its window spec is the flush/edge shape (36px, zero margin, radius 0). cargo test -p bread-theme --lib: 63 passing (59 prior + 4 new). --- .../shell/glass-workbench/glass-workbench.css | 86 +++++++++ .../assets/shell/glass-workbench/theme.toml | 169 ++++++++++++++++++ bread-theme/src/shell/builtin.rs | 78 ++++++-- bread-theme/src/shell/manifest.rs | 5 + bread-theme/src/shell/mod.rs | 159 +++++++++++++--- bread-theme/src/shell/types.rs | 10 ++ 6 files changed, 467 insertions(+), 40 deletions(-) create mode 100644 bread-theme/assets/shell/glass-workbench/glass-workbench.css create mode 100644 bread-theme/assets/shell/glass-workbench/theme.toml diff --git a/bread-theme/assets/shell/glass-workbench/glass-workbench.css b/bread-theme/assets/shell/glass-workbench/glass-workbench.css new file mode 100644 index 0000000..f34f33a --- /dev/null +++ b/bread-theme/assets/shell/glass-workbench/glass-workbench.css @@ -0,0 +1,86 @@ +/* CSS template for the glass-workbench builtin (bread-theme/src/shell/ + * builtin.rs). Same scope and substitution rules as liquid-motion.css: only + * the window/workspace/clock chrome the manifest's own concepts model, `{name}` + * tokens substituted, `@name` palette references passed through untouched. + * + * Source: bos-ui-demos/02-glass-workbench.html's