From 81f2f3545a5c17e5e695ec37873c1287f2914449 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 13:31:05 +0800 Subject: [PATCH 01/12] bos-settings 0.4.1: fix broken panels, add breadsearch view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - breadbox panel edited [[contexts]] but breadbox reads [[context]] (serde rename mismatch) — panel was always empty and Save appended a dead array. - Packages panel parsed installed.json as a flat map instead of unwrapping {"packages": {...}} — always showed one bogus row. - load_doc silently produced an empty document on any parse error, so the next Save could clobber a config file with a syntax typo; now backs it up first. - Hyprland panel opened hyprland.conf/keybinds.conf (BOS ships hyprland.lua) via $EDITOR with no terminal (dead click for any TUI editor) and defaulted to foot (BOS ships kitty). Now opens hyprland.lua and a keybinds cheat sheet via kitty -e $EDITOR. - New breadsearch view: power (enabled/run-on-battery), compute backend (cpu/npu/rocm), index/search settings. --- Cargo.lock | 2 +- bos-settings/Cargo.toml | 2 +- bos-settings/src/config/mod.rs | 30 +++++-- bos-settings/src/ui/sidebar.rs | 1 + bos-settings/src/ui/views/breadbox.rs | 12 +-- bos-settings/src/ui/views/breadsearch.rs | 105 +++++++++++++++++++++++ bos-settings/src/ui/views/hyprland.rs | 40 +++++---- bos-settings/src/ui/views/mod.rs | 1 + bos-settings/src/ui/views/packages.rs | 13 ++- bos-settings/src/ui/window.rs | 1 + 10 files changed, 176 insertions(+), 31 deletions(-) create mode 100644 bos-settings/src/ui/views/breadsearch.rs diff --git a/Cargo.lock b/Cargo.lock index f29bbea..c88efb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bos-settings" -version = "0.4.0" +version = "0.4.1" dependencies = [ "async-channel", "bread-theme", diff --git a/bos-settings/Cargo.toml b/bos-settings/Cargo.toml index c6e37ac..f024a43 100644 --- a/bos-settings/Cargo.toml +++ b/bos-settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bos-settings" -version = "0.4.0" +version = "0.4.1" edition = "2021" [dependencies] diff --git a/bos-settings/src/config/mod.rs b/bos-settings/src/config/mod.rs index 4b56f1c..4cc3266 100644 --- a/bos-settings/src/config/mod.rs +++ b/bos-settings/src/config/mod.rs @@ -12,13 +12,31 @@ use std::path::{Path, PathBuf}; use toml_edit::{value, Array, DocumentMut, Item, Table, Value}; -/// Load a TOML file into an editable document. A missing or unparseable file -/// yields an empty document so the UI still renders (with defaults). +/// Load a TOML file into an editable document. A missing file yields an +/// empty document so the UI still renders with defaults — normal for a fresh +/// install. A file that *exists* but fails to parse is far more dangerous: +/// falling back to an empty document there means the next Save (see +/// `save_doc`) overwrites it with only the UI-modelled keys, silently +/// destroying anything else in the file (breadpad's calendar credentials, +/// breadcrumbs' saved network passwords, ...). Back up the unparseable file +/// once before falling back, so a bad edit is always recoverable. pub fn load_doc(path: &Path) -> DocumentMut { - std::fs::read_to_string(path) - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or_default() + let Ok(text) = std::fs::read_to_string(path) else { + return DocumentMut::default(); + }; + match text.parse::() { + Ok(doc) => doc, + Err(e) => { + let backup = PathBuf::from(format!("{}.bak", path.display())); + eprintln!( + "bos-settings: {} failed to parse ({e}); backed up to {} before falling back to defaults", + path.display(), + backup.display() + ); + let _ = std::fs::write(&backup, &text); + DocumentMut::default() + } + } } /// Write the document back to disk, creating parent dirs as needed. diff --git a/bos-settings/src/ui/sidebar.rs b/bos-settings/src/ui/sidebar.rs index 4395591..d4185f2 100644 --- a/bos-settings/src/ui/sidebar.rs +++ b/bos-settings/src/ui/sidebar.rs @@ -12,6 +12,7 @@ pub const APPS_ITEMS: &[SidebarItem] = &[ SidebarItem { id: "breadbox", label: "breadbox" }, SidebarItem { id: "breadcrumbs", label: "breadcrumbs" }, SidebarItem { id: "breadpad", label: "breadpad" }, + SidebarItem { id: "breadsearch", label: "breadsearch" }, ]; pub const SYSTEM_ITEMS: &[SidebarItem] = &[ diff --git a/bos-settings/src/ui/views/breadbox.rs b/bos-settings/src/ui/views/breadbox.rs index e34a31b..e4356e2 100644 --- a/bos-settings/src/ui/views/breadbox.rs +++ b/bos-settings/src/ui/views/breadbox.rs @@ -1,7 +1,9 @@ //! breadbox config.toml — launcher contexts. -//! Schema mirrors breadbox-shared (`[[contexts]]` with `name` + `priority`, an -//! ordered list of app/category hints). The contexts array is rewritten on -//! save; any other top-level keys/comments in the file are preserved. +//! Schema mirrors breadbox-shared (`#[serde(rename = "context")]` — the TOML +//! key is `[[context]]`, singular, despite the Rust field being `contexts`), +//! with `name` + `priority`, an ordered list of app/category hints. The +//! context array is rewritten on save; any other top-level keys/comments in +//! the file are preserved. use std::cell::RefCell; use std::rc::Rc; @@ -25,7 +27,7 @@ fn config_path() -> std::path::PathBuf { } fn read_contexts(doc: &DocumentMut) -> Vec { - let Some(aot) = doc.get("contexts").and_then(Item::as_array_of_tables) else { + let Some(aot) = doc.get("context").and_then(Item::as_array_of_tables) else { return Vec::new(); }; aot.iter() @@ -53,7 +55,7 @@ fn write_contexts(doc: &mut DocumentMut, ctxs: &[Context]) { t.insert("priority", value(arr)); aot.push(t); } - doc.as_table_mut().insert("contexts", Item::ArrayOfTables(aot)); + doc.as_table_mut().insert("context", Item::ArrayOfTables(aot)); } fn rebuild_list(list: &ListBox, model: &Rc>>) { diff --git a/bos-settings/src/ui/views/breadsearch.rs b/bos-settings/src/ui/views/breadsearch.rs new file mode 100644 index 0000000..e12a09d --- /dev/null +++ b/bos-settings/src/ui/views/breadsearch.rs @@ -0,0 +1,105 @@ +//! breadsearch/config.toml — semantic search indexer (breadmill) + GUI. +//! Schema mirrors breadsearch-shared::Config ([index], [search], [model], [power]). + +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::Box as GBox; + +use crate::config; +use crate::ui::widgets as w; + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("breadsearch/config.toml") +} + +pub fn build() -> GBox { + let path = config_path(); + let doc = Rc::new(RefCell::new(config::load_doc(&path))); + + let (outer, c) = w::view_scaffold("breadsearch"); + + c.append(&w::section("Power")); + c.append(&w::hint( + "breadmill's embedding step is CPU/NPU/GPU-heavy. Turn it off entirely, \ + or just pause it on battery — it resumes automatically on AC power.", + )); + c.append(&w::switch_row("Enabled", &doc, &["power", "enabled"], true)); + c.append(&w::switch_row( + "Index while on battery", + &doc, + &["power", "run_on_battery"], + false, + )); + + c.append(&w::section("Model")); + c.append(&w::dropdown_row( + "Compute backend", + &doc, + &["model", "backend"], + &["cpu", "npu", "rocm"], + "cpu", + )); + c.append(&w::hint( + "npu needs breadmill built with --features npu (AMD Ryzen AI SDK); \ + rocm needs --features rocm. Falls back to cpu if not compiled in.", + )); + + c.append(&w::section("Index")); + c.append(&w::csv_row( + "Roots", + &doc, + &["index", "roots"], + "~/Documents, ~/Projects", + )); + c.append(&w::csv_row( + "Excludes", + &doc, + &["index", "excludes"], + "~/Projects/some-noisy-repo", + )); + c.append(&w::csv_row( + "Extensions", + &doc, + &["index", "extensions"], + "md, txt, org, pdf, odt, docx", + )); + c.append(&w::spin_f64_row( + "Max file size (MB)", + &doc, + &["index", "max_file_mb"], + 0.1, + 500.0, + 0.5, + 1, + 10.0, + )); + + c.append(&w::section("Search")); + c.append(&w::spin_row( + "Result limit", + &doc, + &["search", "limit"], + 1.0, + 100.0, + 1.0, + 10, + )); + c.append(&w::spin_row( + "Snippet length", + &doc, + &["search", "snippet_len"], + 20.0, + 2000.0, + 20.0, + 200, + )); + + c.append(&w::hint( + "Changes take effect after: systemctl --user restart breadmill", + )); + + outer.append(&w::save_button(&doc, path)); + outer +} diff --git a/bos-settings/src/ui/views/hyprland.rs b/bos-settings/src/ui/views/hyprland.rs index fba4537..b651b25 100644 --- a/bos-settings/src/ui/views/hyprland.rs +++ b/bos-settings/src/ui/views/hyprland.rs @@ -26,6 +26,17 @@ fn hypr_path(name: &str) -> std::path::PathBuf { crate::config::config_dir().join("hypr").join(name) } +/// Open `path` in $EDITOR (nano if unset) inside a terminal window. Spawning +/// an editor directly (no terminal) is a silent no-op for any TUI editor — +/// there's nothing for it to attach to — so it always needs a terminal +/// wrapper. Uses kitty, which is what BOS actually ships (not foot). +fn open_in_terminal(path: &std::path::Path) { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); + if let Ok(mut child) = Command::new("kitty").args(["-e", &editor]).arg(path).spawn() { + std::thread::spawn(move || { let _ = child.wait(); }); + } +} + pub fn build() -> GBox { let vbox = GBox::new(Orientation::Vertical, 12); vbox.add_css_class("view-content"); @@ -55,31 +66,28 @@ pub fn build() -> GBox { } } - let open_btn = Button::with_label("Open hyprland.conf in editor"); + // BOS's Hyprland config is Lua-native (hyprland.lua), not the classic + // hyprland.conf/keybinds.conf pair — those names only ever matched a + // stale, unshipped dotfiles/ directory, so this button opened (or + // silently created) the wrong file entirely. + let open_btn = Button::with_label("Open hyprland.lua in editor"); open_btn.set_margin_top(16); open_btn.set_halign(gtk4::Align::Start); { - let conf_path = hypr_path("hyprland.conf"); - open_btn.connect_clicked(move |_| { - let editor = std::env::var("EDITOR").unwrap_or_else(|_| "foot".to_string()); - if let Ok(mut child) = Command::new(&editor).arg(&conf_path).spawn() { - std::thread::spawn(move || { let _ = child.wait(); }); - } - }); + let conf_path = hypr_path("hyprland.lua"); + open_btn.connect_clicked(move |_| open_in_terminal(&conf_path)); } vbox.append(&open_btn); - let keybinds_btn = Button::with_label("Open keybinds.conf in editor"); + // Keybinds are defined inline in hyprland.lua (no separate file); point + // this at the shipped cheat sheet instead of a keybinds.conf that has + // never existed on BOS. + let keybinds_btn = Button::with_label("View keybinds cheat sheet"); keybinds_btn.set_margin_top(8); keybinds_btn.set_halign(gtk4::Align::Start); { - let kb_path = hypr_path("keybinds.conf"); - keybinds_btn.connect_clicked(move |_| { - let editor = std::env::var("EDITOR").unwrap_or_else(|_| "foot".to_string()); - if let Ok(mut child) = Command::new(&editor).arg(&kb_path).spawn() { - std::thread::spawn(move || { let _ = child.wait(); }); - } - }); + let kb_path = std::path::PathBuf::from("/usr/share/bos/keybinds.txt"); + keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path)); } vbox.append(&keybinds_btn); diff --git a/bos-settings/src/ui/views/mod.rs b/bos-settings/src/ui/views/mod.rs index 67763f0..d29a324 100644 --- a/bos-settings/src/ui/views/mod.rs +++ b/bos-settings/src/ui/views/mod.rs @@ -3,6 +3,7 @@ pub mod breadbar; pub mod breadbox; pub mod breadcrumbs; pub mod breadpad; +pub mod breadsearch; pub mod hyprland; pub mod packages; pub mod snapshots; diff --git a/bos-settings/src/ui/views/packages.rs b/bos-settings/src/ui/views/packages.rs index feee584..79d01d0 100644 --- a/bos-settings/src/ui/views/packages.rs +++ b/bos-settings/src/ui/views/packages.rs @@ -15,11 +15,20 @@ fn read_installed() -> HashMap { let Ok(text) = std::fs::read_to_string(&path) else { return HashMap::new(); }; - let Ok(parsed) = serde_json::from_str::>(&text) else { + let Ok(mut parsed) = serde_json::from_str::(&text) else { + return HashMap::new(); + }; + // installed.json is {"packages": {name: {version, binaries, services}}}, + // not a flat map of package name to metadata — without unwrapping this, + // every install shows a single bogus row named "packages". + let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else { + return HashMap::new(); + }; + let Ok(packages) = serde_json::from_value::>(packages) else { return HashMap::new(); }; - parsed + packages .into_iter() .filter_map(|(name, val)| { let version = val diff --git a/bos-settings/src/ui/window.rs b/bos-settings/src/ui/window.rs index c07a231..cc395cd 100644 --- a/bos-settings/src/ui/window.rs +++ b/bos-settings/src/ui/window.rs @@ -32,6 +32,7 @@ pub fn build_ui(app: &Application) { stack.add_named(&views::breadbox::build(), Some("breadbox")); stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs")); stack.add_named(&views::breadpad::build(), Some("breadpad")); + stack.add_named(&views::breadsearch::build(), Some("breadsearch")); stack.add_named(&views::hyprland::build(), Some("hyprland")); // Default to snapshots view From a8f1592e755d006f896a24aac901855f510633fa Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 13:31:40 +0800 Subject: [PATCH 02/12] Fix install-breaking and live-boot bugs, verified on real hardware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot-critical (each confirmed with a real boot/install cycle, not just code review): - Live ISO's liveuser shell is zsh, but Hyprland autostart was written to .bash_profile (never runs) — moved to .zprofile, and switched to start-hyprland (Hyprland's own watchdog wrapper; raw `exec Hyprland` is no longer the recommended launch method). - copytoram (self-enables on most real hardware: non-optical boot + image < 4GiB + enough free RAM) unmounts /run/archiso/bootmnt, which unpackfs.conf and bos-copy-kernel both hardcoded as their source — broke the installer outright on real hardware, confirmed by forcing copytoram=y. Added a resolver step for unpackfs, and switched the kernel copy to /usr/lib/modules/$(uname -r)/vmlinuz (part of the live squashfs itself, unaffected by copytoram). - BIOS installs got no bootloader — post-install.sh only ever ran the UEFI grub-install path despite BOS shipping bios.syslinux. Added a BIOS branch with disk auto-detection. - @snapshots/@log/@cache were never real: iso/partition.conf's btrfsSubvolumes key isn't part of this Calamares version's partition module schema at all (same class of bug as the userShell fix below — silently ignored). Calamares only natively creates @ and @home. post-install.sh now creates the three subvolumes by hand after unpackfs, migrates existing /var/log + /var/cache content into them before mounting over, and adds the fstab entries — verified end to end on real hardware, including grub-btrfs generating bootable snapshot menu entries. The unmount step in the existing snapper create-config dance also gained retry + lazy-unmount fallback after a real chroot run hit a transient busy-mount race. - Default shell was bash instead of zsh post-install: users.conf's top-level `userShell` key isn't part of this Calamares version's users module schema either — the real key is nested (user.shell). - graphical-session.target ships RefuseManualStart=yes (systemd convention), so the earlier attempt to activate it from hyprland.lua silently failed and breadclipd (WantedBy=graphical-session.target) never started. Starts breadclipd.service directly instead. - /etc/os-release was never set (showed "Arch Linux"); live boot never had quiet/splash/plymouth wired in (raw kernel scroll the whole time) despite BOS already shipping a complete bread-logo+spinner plymouth theme for the installed system. Also: generalized build-local.sh's per-service skel baking (previously only breadd.service was hand-committed; breadbox-sync/breadmill/ breadclipd never shipped), added the four new bakery packages to BREAD_BINS, removed the redundant cliphist/fzf clipboard pipeline in favor of breadclip, mirrored fastfetch's bread-logo config into skel, and fixed a stale bos-update comment. --- build-local.sh | 46 ++++++- .../etc/calamares/modules/partition.conf | 24 ++-- .../modules/shellprocess-resolve-source.conf | 8 ++ .../etc/calamares/modules/unpackfs.conf | 8 +- iso/airootfs/etc/calamares/modules/users.conf | 9 +- iso/airootfs/etc/calamares/post-install.sh | 130 ++++++++++++++++-- iso/airootfs/etc/calamares/settings.conf | 7 + .../etc/mkinitcpio.conf.d/archiso.conf | 2 +- iso/airootfs/etc/os-release | 12 ++ iso/airootfs/etc/plymouth/plymouthd.conf | 2 + .../etc/skel/.config/breadbox/config.toml | 2 +- .../etc/skel/.config/fastfetch/bread.txt | 20 +++ .../etc/skel/.config/fastfetch/config.jsonc | 38 +++++ .../etc/skel/.config/hypr/hyprland.lua | 24 +++- iso/airootfs/usr/local/bin/bos-copy-kernel | 33 ++++- iso/airootfs/usr/local/bin/bos-live-setup | 9 +- .../usr/local/bin/bos-resolve-airootfs | 29 ++++ iso/airootfs/usr/local/bin/bos-session | 7 +- iso/airootfs/usr/local/bin/bos-update | 6 +- .../entries/01-archiso-linux-copytoram.conf | 2 +- .../loader/entries/01-archiso-linux.conf | 2 +- iso/packages.x86_64 | 4 +- iso/profiledef.sh | 1 + iso/syslinux/archiso_sys-linux.cfg | 4 +- 24 files changed, 373 insertions(+), 56 deletions(-) create mode 100644 iso/airootfs/etc/calamares/modules/shellprocess-resolve-source.conf create mode 100644 iso/airootfs/etc/os-release create mode 100644 iso/airootfs/etc/plymouth/plymouthd.conf create mode 100644 iso/airootfs/etc/skel/.config/fastfetch/bread.txt create mode 100644 iso/airootfs/etc/skel/.config/fastfetch/config.jsonc create mode 100644 iso/airootfs/usr/local/bin/bos-resolve-airootfs diff --git a/build-local.sh b/build-local.sh index 28b9e4d..942476a 100755 --- a/build-local.sh +++ b/build-local.sh @@ -49,7 +49,7 @@ grep airootfs_image_tool_options "$STAGE/profiledef.sh" # created from skel (the live user and the installed user) then gets the same # versions `bakery list` reports here, fully offline. Copied at build time so the # binaries never bloat the git repo and always track the current bakery state. -BREAD_BINS=(bakery bread breadd breadman breadbar breadbox breadbox-sync breadcrumbs breadpad breadpaper bread-theme) +BREAD_BINS=(bakery bread breadd breadman breadbar breadbox breadbox-sync breadcrumbs breadpad breadpaper bread-theme breadmon breadsearch breadmill breadclip breadclipd breadshot) LAPTOP_HOME="${LAPTOP_HOME:-$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6)}" BAKERY_BIN="$LAPTOP_HOME/.local/bin" BAKERY_STATE="$LAPTOP_HOME/.local/state/bakery" @@ -69,6 +69,50 @@ install -m 0644 "$BAKERY_STATE/installed.json" "$SKEL/.local/state/bakery/instal install -m 0644 "$BAKERY_CACHE/index.json" "$SKEL/.cache/bakery/index.json" echo "baked: $(ls "$SKEL/.local/bin")" +# --- Bake systemd user services for bakery-managed bread packages ----------- +# Historically only breadd.service was hand-committed to skel; every other +# bakery package's service (breadbox-sync, breadmill, breadclipd, ...) was +# silently left out, so those daemons never start on a fresh install/live +# boot until the user re-runs `bakery install` (which needs network). +# Generalize from the same source of truth as the binary bake above: read +# the services this laptop's bakery actually installed, copy each unit file +# into skel with ExecStart rewritten from this laptop's literal home path to +# the portable `%h` specifier, and recreate whichever *.target.wants enable +# symlink bakery created locally. Units already committed by hand (breadd.service +# carries a RuntimeDirectoryPreserve=yes fix not yet upstreamed — see bread-release-build +# notes) are left alone rather than overwritten. +echo "=== baking bakery service units into skel ===" +SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user" +SKEL_SYSTEMD="$SKEL/.config/systemd/user" +mapfile -t SERVICE_UNITS < <(python3 -c " +import json +with open('$BAKERY_STATE/installed.json') as f: + d = json.load(f) +for pkg in d.get('packages', d).values(): + for s in pkg.get('services', []): + print(s) +") +for unit in "${SERVICE_UNITS[@]}"; do + if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then + echo " $unit already committed in skel, leaving as-is" + continue + fi + src="$SYSTEMD_USER_DIR/$unit" + if [[ ! -f "$src" ]]; then + echo " warning: $unit not found at $src, skipping" + continue + fi + install -d -m 0755 "$SKEL_SYSTEMD" + sed "s#ExecStart=$LAPTOP_HOME/.local/bin/#ExecStart=%h/.local/bin/#" "$src" > "$SKEL_SYSTEMD/$unit" + for wants_dir in "$SYSTEMD_USER_DIR"/*.target.wants; do + [[ -L "$wants_dir/$unit" ]] || continue + target_name="$(basename "$wants_dir")" + install -d -m 0755 "$SKEL_SYSTEMD/$target_name" + ln -sf "../$unit" "$SKEL_SYSTEMD/$target_name/$unit" + done + echo " baked $unit" +done + # mkarchiso resets every airootfs file to 0644, so executables must be declared # in profiledef.sh's file_permissions array or they ship non-executable and the # exec-once launches fail with "permission denied". Inject a 0755 entry for each diff --git a/iso/airootfs/etc/calamares/modules/partition.conf b/iso/airootfs/etc/calamares/modules/partition.conf index a33f199..13e6e1e 100644 --- a/iso/airootfs/etc/calamares/modules/partition.conf +++ b/iso/airootfs/etc/calamares/modules/partition.conf @@ -5,22 +5,14 @@ efiSystemPartitionName: "EFI" defaultFileSystemType: "btrfs" -btrfsSubvolumes: - - mountPoint: / - subvolume: "@" - mountOptions: "noatime,compress=zstd,space_cache=v2" - - mountPoint: /home - subvolume: "@home" - mountOptions: "noatime,compress=zstd,space_cache=v2" - - mountPoint: /.snapshots - subvolume: "@snapshots" - mountOptions: "noatime,compress=zstd,space_cache=v2" - - mountPoint: /var/log - subvolume: "@log" - mountOptions: "noatime,compress=zstd,space_cache=v2" - - mountPoint: /var/cache - subvolume: "@cache" - mountOptions: "noatime,compress=zstd,space_cache=v2" +# NOTE: there is no `btrfsSubvolumes:` key in this Calamares version's +# partition module schema (confirmed against /usr/share/calamares/modules/ +# partition.conf and on real hardware — zero mentions of "subvolume" +# anywhere in the stock reference config). A previous version of this file +# had one; Calamares silently ignored it. Calamares' partition module only +# natively creates @ (root) and @home (home) when btrfs + separate /home is +# selected — nothing else. @snapshots/@log/@cache are created by hand in +# post-install.sh instead, after unpackfs has populated the filesystem. userSwapChoices: - none diff --git a/iso/airootfs/etc/calamares/modules/shellprocess-resolve-source.conf b/iso/airootfs/etc/calamares/modules/shellprocess-resolve-source.conf new file mode 100644 index 0000000..6f0f6f7 --- /dev/null +++ b/iso/airootfs/etc/calamares/modules/shellprocess-resolve-source.conf @@ -0,0 +1,8 @@ +--- +# Runs before unpackfs so it can hand it a source path that survives +# copytoram unmounting /run/archiso/bootmnt. See bos-resolve-airootfs. +dontChroot: true +timeout: 30 + +script: + - "/usr/bin/bash /usr/local/bin/bos-resolve-airootfs" diff --git a/iso/airootfs/etc/calamares/modules/unpackfs.conf b/iso/airootfs/etc/calamares/modules/unpackfs.conf index 7d9589e..f23db91 100644 --- a/iso/airootfs/etc/calamares/modules/unpackfs.conf +++ b/iso/airootfs/etc/calamares/modules/unpackfs.conf @@ -1,7 +1,11 @@ --- # Unpack the live squashfs onto the target partition. -# "arch" matches profiledef.sh install_dir; adjust if that changes. +# The source is a symlink written by shellprocess@resolve-source (see +# bos-resolve-airootfs) rather than a path under /run/archiso/bootmnt directly +# — that mount gets torn down by archiso's own copytoram handling, which +# self-enables on most real hardware, so a hardcoded bootmnt path would point +# at nothing on exactly the machines this install is meant to run on. unpack: - - source: "/run/archiso/bootmnt/arch/x86_64/airootfs.sfs" + - source: "/run/archiso/resolved-airootfs.sfs" sourcefs: "squashfs" destination: "" diff --git a/iso/airootfs/etc/calamares/modules/users.conf b/iso/airootfs/etc/calamares/modules/users.conf index 566fff2..887fe2c 100644 --- a/iso/airootfs/etc/calamares/modules/users.conf +++ b/iso/airootfs/etc/calamares/modules/users.conf @@ -38,4 +38,11 @@ passwordRequirements: - minlen=6 allowWeakPasswords: false -userShell: /bin/zsh + +# `userShell` (top-level) is not a real key in this Calamares version's users +# module schema (3.4.2) — it's silently ignored, and an installed user gets +# whatever the module's own default is (bash), confirmed on real hardware. +# The actual key is nested: user.shell. See /usr/share/calamares/modules/users.conf +# for the documented schema. +user: + shell: /usr/bin/zsh diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index ebfb24a..b7309db 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -79,31 +79,137 @@ fi mkinitcpio -P || echo "WARN: mkinitcpio -P failed" # --------------------------------------------------------------------------- -# Install GRUB (UEFI). /boot now has the kernel + initramfs, and the mount -# module has bind-mounted /proc /sys /dev /run + efivars into this chroot, so +# Install GRUB. /boot now has the kernel + initramfs, and the mount module has +# bind-mounted /proc /sys /dev /run (+ efivars on UEFI) into this chroot, so # both grub-install passes and grub-mkconfig succeed. -# 1. NVRAM entry (EFI/BOS/grubx64.efi + a firmware boot entry) -# 2. --removable copy to EFI/BOOT/BOOTX64.EFI, so firmware that ignores/loses -# the NVRAM entry (the "no boot device / PXE fallback" failure) still finds -# a bootloader. +# +# BOS ships a syslinux BIOS boot mode on the ISO (profiledef.sh bootmodes +# includes bios.syslinux), but this only ever ran the UEFI grub-install path — +# a BIOS install would complete successfully and then have no bootloader at +# all. Branch on /sys/firmware/efi (present only when booted UEFI) and install +# the matching GRUB target; on BIOS, grub-install needs the whole disk, not a +# partition, so it's derived from the mounted root via lsblk. +# UEFI: 1. NVRAM entry (EFI/BOS/grubx64.efi + a firmware boot entry) +# 2. --removable copy to EFI/BOOT/BOOTX64.EFI, so firmware that +# ignores/loses the NVRAM entry still finds a bootloader. +# BIOS: MBR install onto the disk hosting /. # --------------------------------------------------------------------------- if command -v grub-install &>/dev/null; then - grub-install --target=x86_64-efi --efi-directory=/boot/efi \ - --bootloader-id=BOS --recheck \ - || echo "WARN: grub-install (nvram) failed" - grub-install --target=x86_64-efi --efi-directory=/boot/efi \ - --removable --recheck \ - || echo "WARN: grub-install (removable) failed" + if [[ -d /sys/firmware/efi ]]; then + grub-install --target=x86_64-efi --efi-directory=/boot/efi \ + --bootloader-id=BOS --recheck \ + || echo "WARN: grub-install (nvram) failed" + grub-install --target=x86_64-efi --efi-directory=/boot/efi \ + --removable --recheck \ + || echo "WARN: grub-install (removable) failed" + else + ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" + ROOT_DISK="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null)" + if [[ -n "$ROOT_DISK" ]]; then + grub-install --target=i386-pc --recheck "/dev/$ROOT_DISK" \ + || echo "WARN: grub-install (BIOS) failed" + else + echo "WARN: could not determine the disk hosting / (root device: ${ROOT_DEV:-unknown}) — BIOS grub-install skipped" + fi + fi fi if command -v grub-mkconfig &>/dev/null; then grub-mkconfig -o /boot/grub/grub.cfg || echo "WARN: grub-mkconfig failed" fi +# --------------------------------------------------------------------------- +# Create @snapshots, @log, @cache as top-level btrfs subvolumes (peers of @, +# not nested under it — so snapshots of @ don't recursively include +# themselves, and log/cache churn doesn't bloat @'s snapshot history). +# +# iso/partition.conf's `btrfsSubvolumes:` key does NOT do this — verified on +# real hardware that it's not a recognized key in this Calamares version's +# partition module schema at all (same class of bug as the `userShell` fix +# below: Calamares silently ignores unknown top-level keys). Calamares' own +# btrfs support only natively creates @ and @home; everything else has to be +# done by hand, here, after unpackfs has populated / but before anything +# reads/writes /var/log or /var/cache going forward. Existing content in +# those two dirs (real files already unpacked from the squashfs) is migrated +# into the new subvolumes before mounting over them, so nothing is lost — +# just shadowed by the mount, same as any other mountpoint. +# --------------------------------------------------------------------------- +if command -v btrfs &>/dev/null; then + ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" + ROOT_UUID="$(blkid -s UUID -o value "$ROOT_DEV" 2>/dev/null)" + BTRFS_TOP=/.btrfs-top-tmp + + if [[ -z "$ROOT_UUID" ]]; then + echo "WARN: could not determine root filesystem UUID — skipping @snapshots/@log/@cache creation" + else + mkdir -p "$BTRFS_TOP" + if mount -o subvolid=5 "$ROOT_DEV" "$BTRFS_TOP"; then + for sv in @snapshots @log @cache; do + if ! btrfs subvolume show "$BTRFS_TOP/$sv" &>/dev/null; then + btrfs subvolume create "$BTRFS_TOP/$sv" || echo "WARN: creating $sv failed" + fi + done + rsync -aAX /var/log/ "$BTRFS_TOP/@log/" || echo "WARN: migrating /var/log into @log failed" + rsync -aAX /var/cache/ "$BTRFS_TOP/@cache/" || echo "WARN: migrating /var/cache into @cache failed" + umount "$BTRFS_TOP" + rmdir "$BTRFS_TOP" + + OPTS="noatime,compress=zstd,space_cache=v2" + grep -q "@snapshots" /etc/fstab || echo "UUID=$ROOT_UUID /.snapshots btrfs subvol=/@snapshots,$OPTS 0 0" >> /etc/fstab + grep -q "@log" /etc/fstab || echo "UUID=$ROOT_UUID /var/log btrfs subvol=/@log,$OPTS 0 0" >> /etc/fstab + grep -q "@cache" /etc/fstab || echo "UUID=$ROOT_UUID /var/cache btrfs subvol=/@cache,$OPTS 0 0" >> /etc/fstab + + mkdir -p /.snapshots + mount /.snapshots || echo "WARN: mounting /.snapshots failed" + mount /var/log || echo "WARN: mounting /var/log failed" + mount /var/cache || echo "WARN: mounting /var/cache failed" + else + echo "WARN: could not mount btrfs top-level — skipping @snapshots/@log/@cache creation" + fi + fi +fi + # --------------------------------------------------------------------------- # Snapper root config (root is btrfs). +# +# @snapshots is now mounted at /.snapshots (created above) — a dedicated +# top-level subvolume, a peer of @ rather than nested under it, so snapshots +# aren't themselves recursively snapshotted. `snapper create-config` insists +# on creating that subvolume itself and refuses whenever /.snapshots already +# exists, mounted or not — silently, so without this dance every downstream +# sed below is a no-op and BOS ships with its advertised auto-snapshot/ +# rollback feature entirely non-functional. +# Unmount the real subvolume, let snapper create + own its nested one, then +# discard that and remount the real @snapshots in its place (fstab entry was +# added above, right after the subvolume was created). +# +# Confirmed on real hardware: a plain `umount /.snapshots` here can +# transiently fail inside the Calamares chroot (the same mount unmounts +# cleanly moments later once booted normally — a chroot-specific busy-mount +# race, not a logic error), which cascades into snapper create-config +# refusing because .snapshots "already exists". Retry a few times, then +# fall back to a lazy unmount (detaches the mountpoint immediately even if +# something still transiently references it) rather than give up. # --------------------------------------------------------------------------- if command -v snapper &>/dev/null; then + unmounted=0 + for _ in 1 2 3 4 5; do + if umount /.snapshots 2>/dev/null; then + unmounted=1 + break + fi + sleep 1 + done + if [[ "$unmounted" != "1" ]]; then + echo "WARN: umount /.snapshots failed after retries, forcing lazy unmount" + umount -l /.snapshots || echo "WARN: lazy umount /.snapshots also failed" + fi + rmdir /.snapshots || echo "WARN: rmdir /.snapshots failed" snapper -c root create-config / || echo "WARN: snapper create-config failed" + if [[ -d /.snapshots ]]; then + btrfs subvolume delete /.snapshots || echo "WARN: deleting snapper's own .snapshots subvolume failed" + fi + mkdir -p /.snapshots + mount /.snapshots || echo "WARN: remounting the real @snapshots subvolume failed" if [[ -f /etc/snapper/configs/root ]]; then sed -i 's/TIMELINE_CREATE="yes"/TIMELINE_CREATE="no"/' /etc/snapper/configs/root sed -i 's/NUMBER_CLEANUP="no"/NUMBER_CLEANUP="yes"/' /etc/snapper/configs/root diff --git a/iso/airootfs/etc/calamares/settings.conf b/iso/airootfs/etc/calamares/settings.conf index f97ae87..8846a56 100644 --- a/iso/airootfs/etc/calamares/settings.conf +++ b/iso/airootfs/etc/calamares/settings.conf @@ -3,10 +3,16 @@ modules-search: [/etc/calamares/modules, /usr/lib/calamares/modules] # Second shellprocess instance: copies the live kernel into the target /boot # (archiso keeps it out of the squashfs) before the bootloader step runs. +# Third: resolves the live squashfs's real location before unpackfs runs — +# copytoram unmounts /run/archiso/bootmnt, so a hardcoded path there can point +# at nothing depending on how the medium was booted. instances: - id: kernel module: shellprocess config: shellprocess-kernel.conf +- id: resolve-source + module: shellprocess + config: shellprocess-resolve-source.conf sequence: - show: @@ -19,6 +25,7 @@ sequence: - exec: - partition - mount + - shellprocess@resolve-source - unpackfs - machineid - fstab diff --git a/iso/airootfs/etc/mkinitcpio.conf.d/archiso.conf b/iso/airootfs/etc/mkinitcpio.conf.d/archiso.conf index 5c008e5..e53d079 100644 --- a/iso/airootfs/etc/mkinitcpio.conf.d/archiso.conf +++ b/iso/airootfs/etc/mkinitcpio.conf.d/archiso.conf @@ -1,3 +1,3 @@ -HOOKS=(base udev microcode modconf kms memdisk archiso archiso_loop_mnt archiso_pxe_common archiso_pxe_nbd archiso_pxe_http archiso_pxe_nfs block filesystems keyboard) +HOOKS=(base udev plymouth microcode modconf kms memdisk archiso archiso_loop_mnt archiso_pxe_common archiso_pxe_nbd archiso_pxe_http archiso_pxe_nfs block filesystems keyboard) COMPRESSION="xz" COMPRESSION_OPTIONS=(-9e) diff --git a/iso/airootfs/etc/os-release b/iso/airootfs/etc/os-release new file mode 100644 index 0000000..e8cffa1 --- /dev/null +++ b/iso/airootfs/etc/os-release @@ -0,0 +1,12 @@ +NAME="BOS" +PRETTY_NAME="Bread OS" +ID=bos +ID_LIKE=arch +BUILD_ID=rolling +ANSI_COLOR="38;2;23;147;209" +HOME_URL="https://breadway.dev" +DOCUMENTATION_URL="https://wiki.archlinux.org/" +SUPPORT_URL="https://bbs.archlinux.org/" +BUG_REPORT_URL="https://git.breadway.dev/Breadway/bos/issues" +PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/" +LOGO=archlinux-logo diff --git a/iso/airootfs/etc/plymouth/plymouthd.conf b/iso/airootfs/etc/plymouth/plymouthd.conf new file mode 100644 index 0000000..dc44279 --- /dev/null +++ b/iso/airootfs/etc/plymouth/plymouthd.conf @@ -0,0 +1,2 @@ +[Daemon] +Theme=bos diff --git a/iso/airootfs/etc/skel/.config/breadbox/config.toml b/iso/airootfs/etc/skel/.config/breadbox/config.toml index 797b3cc..0b47001 100644 --- a/iso/airootfs/etc/skel/.config/breadbox/config.toml +++ b/iso/airootfs/etc/skel/.config/breadbox/config.toml @@ -1,3 +1,3 @@ [[context]] name = "default" -apps = ["firefox", "foot", "nautilus", "code"] +priority = ["Zen Browser", "kitty", "Files"] diff --git a/iso/airootfs/etc/skel/.config/fastfetch/bread.txt b/iso/airootfs/etc/skel/.config/fastfetch/bread.txt new file mode 100644 index 0000000..a339adb --- /dev/null +++ b/iso/airootfs/etc/skel/.config/fastfetch/bread.txt @@ -0,0 +1,20 @@ +               +      ....',,;;;;,,'....       +   ..'cdkKXWMMMWNNNNNWMMMNKOdc'..     +  ..;xNW0xoc;,'...........',:oxKWNOc..   + .'XWx;..........................,dWN,.  + .xMo..............................cMk.  + .;WK'............................'OM:.  +   'oW0.....d:...................0Md'    +   .kM:...lNMKc................,MO.    +  .dMc.....lXMXl..............:Mk.   +  .xMc.......kMMX'............;Mk.   +  .kM:.....:KMNo..............,M0.   +  .KM,...cXMXl....lllllllll....MX.   + ..NM....,kc......0000OOOOO'...MW..  +  ..MW..........................NM..   +  ..MX..........................KM,.   +  .'MX..........................XM;.   +   .XMKOkxxddddoooooodddddxxkkO0MN.    +      ,:ccclllllooolllllccc::;.      +                         diff --git a/iso/airootfs/etc/skel/.config/fastfetch/config.jsonc b/iso/airootfs/etc/skel/.config/fastfetch/config.jsonc new file mode 100644 index 0000000..d5b8edb --- /dev/null +++ b/iso/airootfs/etc/skel/.config/fastfetch/config.jsonc @@ -0,0 +1,38 @@ +{ + "$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json", + "logo": { + "type": "file", + "source": "~/.config/fastfetch/bread.txt", + "width": 30, + "padding": { + "right": 5 + } + }, + "display": { + "separator": " ", + "color": { + "keys": "cyan", + "title": "bright_blue" + } + }, + "modules": [ + "title", + "separator", + "os", + "host", + "kernel", + "uptime", + "packages", + "shell", + "display", + "wm", + "terminal", + "cpu", + "gpu", + "memory", + "swap", + "disk", + "battery", + "colors" + ] +} diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index d9a6eb8..e9b785d 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -125,7 +125,10 @@ hl.bind(mod .. " + slash", hl.dsp.exec_cmd("bos-keybinds")) hl.bind(mod .. " + L", hl.dsp.exec_cmd("loginctl lock-session")) hl.bind(mod .. " + F", hl.dsp.window.fullscreen({ action = "toggle" })) hl.bind(mod .. " + V", hl.dsp.window.float({ action = "toggle" })) -hl.bind(mod .. " + SHIFT + V", hl.dsp.exec_cmd([[bash -c 'cliphist list | fzf --reverse --prompt="Clipboard > " | cliphist decode | wl-copy']])) +-- breadclip (its own gtk4-layer-shell popup — not a TUI, so no terminal +-- needed). Previously piped cliphist through fzf directly from the +-- compositor with no terminal attached, which was a silent no-op. +hl.bind(mod .. " + SHIFT + V", hl.dsp.exec_cmd("breadclip")) hl.bind(mod .. " + T", hl.dsp.layout("togglesplit")) hl.bind(mod .. " + Tab", hl.dsp.focus({ urgent_or_last = true })) hl.bind(mod .. " + N", hl.dsp.exit()) @@ -198,8 +201,9 @@ hl.on("hyprland.start", function() "gsettings set org.gnome.desktop.interface icon-theme Papirus-Dark", "gsettings set org.gnome.desktop.interface cursor-theme Bibata-Modern-Ice", "gsettings set org.gnome.desktop.interface cursor-size 24", - -- Clipboard history daemon (feeds SUPER+V history picker via wl-paste). - "wl-paste --type text --watch cliphist store", + -- Clipboard history is breadclipd, a bakery-managed systemd --user + -- service (auto-started via skel — see build-local.sh's service bake) + -- rather than an exec-once here. "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1", "awww-daemon", -- set the default wallpaper once the daemon is up (retry until ready) @@ -210,8 +214,20 @@ hl.on("hyprland.start", function() -- to pick it up — that's how it gets HYPRLAND_INSTANCE_SIGNATURE to talk to Hyprland. "dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE", "systemctl --user restart breadd", + -- graphical-session.target ships with RefuseManualStart=yes (systemd + -- convention — only a session manager like uwsm is meant to activate + -- it), confirmed on real hardware: `systemctl --user start + -- graphical-session.target` fails outright ("Operation refused"). + -- BOS doesn't use uwsm, and nothing else activates that target, so + -- breadclipd (WantedBy=graphical-session.target) never started. + -- Start it directly instead. If more graphical-session.target + -- services show up later, add them here too. + "systemctl --user start breadclipd.service", "breadbar", - "breadbox-sync", + -- breadbox-sync is a Type=oneshot systemd --user service + -- (WantedBy=default.target, no Hyprland IPC dependency) — it + -- already runs on login via the unit baked into skel; exec'ing it + -- again here would just start it twice. "hypridle", -- first-boot onboarding (self-gates after the first run) "bos-welcome", diff --git a/iso/airootfs/usr/local/bin/bos-copy-kernel b/iso/airootfs/usr/local/bin/bos-copy-kernel index ee23c30..7f7ad95 100755 --- a/iso/airootfs/usr/local/bin/bos-copy-kernel +++ b/iso/airootfs/usr/local/bin/bos-copy-kernel @@ -7,19 +7,40 @@ # (the stock linux.preset points ALL_kver at /boot/vmlinuz-linux) and before the # `bootloader` module runs grub — otherwise the installed system is unbootable. # -# Runs in the LIVE environment (Calamares shellprocess, dontChroot) so it can -# read /run/archiso/bootmnt; the target root mount point is passed as $1. +# Runs in the LIVE environment (Calamares shellprocess, dontChroot); the +# target root mount point is passed as $1. +# +# The kernel is read from /usr/lib/modules/$(uname -r)/vmlinuz — part of the +# live squashfs itself (the linux package always installs it there), not from +# the ISO's separate arch/boot/x86_64/ dir under /run/archiso/bootmnt. That +# mount gets torn down by archiso's own copytoram handling, which self-enables +# on most real hardware, so depending on it here would leave $ROOT/boot empty +# and the install unbootable on exactly the machines this is meant to run on. set -uo pipefail ROOT="${1:?target root required}" -SRC="/run/archiso/bootmnt/arch/boot/x86_64" +KVER="$(uname -r)" +SRC_KERNEL="/usr/lib/modules/${KVER}/vmlinuz" +SRC_BOOT="/run/archiso/bootmnt/arch/boot/x86_64" install -d -m 0755 "$ROOT/boot" -cp -f "$SRC/vmlinuz-linux" "$ROOT/boot/vmlinuz-linux" -# Microcode, if the live medium carries it (grub-mkconfig picks it up). +if [ -f "$SRC_KERNEL" ]; then + cp -f "$SRC_KERNEL" "$ROOT/boot/vmlinuz-linux" +elif [ -f "$SRC_BOOT/vmlinuz-linux" ]; then + echo "WARN: $SRC_KERNEL missing, falling back to $SRC_BOOT" + cp -f "$SRC_BOOT/vmlinuz-linux" "$ROOT/boot/vmlinuz-linux" +else + echo "ERROR: no kernel image found (checked $SRC_KERNEL and $SRC_BOOT/vmlinuz-linux) — install would be unbootable" >&2 + exit 1 +fi + +# Microcode, if the live medium carries it (grub-mkconfig picks it up). This +# is best-effort: post-install.sh's mkinitcpio `microcode` HOOKS entry embeds +# microcode from /usr/lib/firmware directly, so a missing standalone ucode.img +# here is not boot-critical, unlike the kernel image above. for u in amd-ucode.img intel-ucode.img; do - [ -f "$SRC/$u" ] && cp -f "$SRC/$u" "$ROOT/boot/$u" + [ -f "$SRC_BOOT/$u" ] && cp -f "$SRC_BOOT/$u" "$ROOT/boot/$u" done # Replace the archiso initramfs setup that unpackfs copied from the live medium. diff --git a/iso/airootfs/usr/local/bin/bos-live-setup b/iso/airootfs/usr/local/bin/bos-live-setup index 32a83f6..ad54075 100644 --- a/iso/airootfs/usr/local/bin/bos-live-setup +++ b/iso/airootfs/usr/local/bin/bos-live-setup @@ -34,15 +34,18 @@ fi # Start Hyprland on tty1 login; capture output and fall back to a shell so a # failed compositor start is visible rather than a blank looping cursor. -cat >/home/liveuser/.bash_profile <<'EOF' +# liveuser's shell is zsh (see useradd above), which never sources +# .bash_profile — this must be .zprofile (zsh's login-shell hook) or it never +# runs at all and the live session boots to a bare console. +cat >/home/liveuser/.zprofile <<'EOF' if [[ "$(tty)" == /dev/tty1 ]] && [[ -z "$WAYLAND_DISPLAY" ]]; then export WLR_RENDERER_ALLOW_SOFTWARE=1 export WLR_NO_HARDWARE_CURSORS=1 # Log to a user-writable path (/var/log is root-only; redirecting there # would fail and silently keep the compositor from ever launching). - Hyprland &>/tmp/hyprland-live.log + start-hyprland &>/tmp/hyprland-live.log echo "Hyprland exited (rc=$?). Log: /tmp/hyprland-live.log" - exec bash -i + exec zsh -i fi EOF diff --git a/iso/airootfs/usr/local/bin/bos-resolve-airootfs b/iso/airootfs/usr/local/bin/bos-resolve-airootfs new file mode 100644 index 0000000..3b347f6 --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-resolve-airootfs @@ -0,0 +1,29 @@ +#!/bin/bash +# Resolve the real location of the live squashfs before Calamares' unpackfs +# module runs, and leave a stable symlink for it to unpack from. +# +# archiso's own initcpio hook unmounts /run/archiso/bootmnt once copytoram has +# copied the image to a tmpfs (/run/archiso/copytoram/airootfs.sfs) — and +# copytoram=auto self-enables on most real hardware (non-optical boot device, +# image under 4 GiB, enough free RAM), not just when explicitly requested. A +# Calamares unpackfs.conf source path hardcoded to /run/archiso/bootmnt/... +# would then point at nothing. Check both locations here, in a real script, +# and hand unpackfs.conf one fixed path that always resolves. +set -uo pipefail + +DEST="/run/archiso/resolved-airootfs.sfs" +CANDIDATES=( + "/run/archiso/copytoram/airootfs.sfs" + "/run/archiso/bootmnt/arch/x86_64/airootfs.sfs" +) + +for c in "${CANDIDATES[@]}"; do + if [ -f "$c" ]; then + ln -sf "$c" "$DEST" + echo "resolved live squashfs: $c -> $DEST" + exit 0 + fi +done + +echo "ERROR: could not find the live squashfs in any known location (checked: ${CANDIDATES[*]})" >&2 +exit 1 diff --git a/iso/airootfs/usr/local/bin/bos-session b/iso/airootfs/usr/local/bin/bos-session index 8fecd2c..d7655fc 100644 --- a/iso/airootfs/usr/local/bin/bos-session +++ b/iso/airootfs/usr/local/bin/bos-session @@ -7,9 +7,14 @@ # breadbox-sync, …) would be missing from PATH and the Hyprland `exec-once` # launches would fail. Source the login profile here so PATH is correct, set the # Wayland session hints, then hand off to Hyprland. +# +# Launched via start-hyprland (ships with the hyprland package) rather than the +# raw Hyprland binary — Hyprland upstream no longer recommends exec'ing it +# directly; start-hyprland wraps it in a watchdog process that also gives us +# crash recovery for free. source /etc/profile 2>/dev/null export XDG_SESSION_TYPE=wayland export XDG_CURRENT_DESKTOP=Hyprland -exec Hyprland +exec start-hyprland diff --git a/iso/airootfs/usr/local/bin/bos-update b/iso/airootfs/usr/local/bin/bos-update index 42231ea..53ac3c1 100644 --- a/iso/airootfs/usr/local/bin/bos-update +++ b/iso/airootfs/usr/local/bin/bos-update @@ -5,8 +5,10 @@ # 1. pacman — Arch base/desktop + the [breadway] repo (bos-settings, etc.). # Every transaction is snapshotted by snap-pac, so you can roll # back from the GRUB "snapshots" submenu or BOS Settings. -# 2. bakery — the bread ecosystem apps in ~/.local/bin (bread, breadbar, -# breadbox, breadcrumbs, breadpad, breadman, bread-theme). +# 2. bakery — the bread ecosystem apps in ~/.local/bin (whatever `bakery list` +# reports as installed — bread, breadbar, breadbox, breadcrumbs, +# breadpad, breadman, bread-theme, breadpaper, breadmon, +# breadsearch, breadclip, breadshot, ...). # # Best-effort: a failure in one channel doesn't abort the other. set -uo pipefail diff --git a/iso/efiboot/loader/entries/01-archiso-linux-copytoram.conf b/iso/efiboot/loader/entries/01-archiso-linux-copytoram.conf index 289bd88..96da700 100644 --- a/iso/efiboot/loader/entries/01-archiso-linux-copytoram.conf +++ b/iso/efiboot/loader/entries/01-archiso-linux-copytoram.conf @@ -2,4 +2,4 @@ title Bread OS install medium (copy to RAM, UEFI) sort-key 015 linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux.img -options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% copytoram=y +options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% copytoram=y quiet splash vt.global_cursor_default=0 systemd.show_status=false rd.systemd.show_status=false rd.udev.log_level=3 diff --git a/iso/efiboot/loader/entries/01-archiso-linux.conf b/iso/efiboot/loader/entries/01-archiso-linux.conf index d872e48..c26c61e 100644 --- a/iso/efiboot/loader/entries/01-archiso-linux.conf +++ b/iso/efiboot/loader/entries/01-archiso-linux.conf @@ -2,4 +2,4 @@ title Bread OS install medium (%ARCH%, UEFI) sort-key 01 linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux.img -options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% +options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash vt.global_cursor_default=0 systemd.show_status=false rd.systemd.show_status=false rd.udev.log_level=3 diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index ab0d1e4..41902e1 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -178,10 +178,10 @@ brightnessctl grim slurp # Clipboard (Wayland copy/paste; also clipboard screenshots) and media keys. +# Clipboard history is breadclip/breadclipd (bakery-managed, SUPER+SHIFT+V), +# not cliphist — wl-clipboard is still needed directly by breadclip/breadshot. wl-clipboard playerctl -# Clipboard history daemon (stores wl-clipboard events; breadbox bind replays them). -cliphist # Wallpaper daemon + pywal (drives the bread* colour palette from the wallpaper). awww python-pywal diff --git a/iso/profiledef.sh b/iso/profiledef.sh index 709071a..fc4be34 100644 --- a/iso/profiledef.sh +++ b/iso/profiledef.sh @@ -20,6 +20,7 @@ file_permissions=( ["/usr/local/bin/bos-live-setup"]="0:0:755" ["/usr/local/bin/bos-launch-calamares"]="0:0:755" ["/usr/local/bin/bos-copy-kernel"]="0:0:755" + ["/usr/local/bin/bos-resolve-airootfs"]="0:0:755" ["/usr/local/bin/bos-session"]="0:0:755" ["/usr/local/bin/bos-keybinds"]="0:0:755" ["/usr/local/bin/bos-welcome"]="0:0:755" diff --git a/iso/syslinux/archiso_sys-linux.cfg b/iso/syslinux/archiso_sys-linux.cfg index 0ec10f1..febc966 100644 --- a/iso/syslinux/archiso_sys-linux.cfg +++ b/iso/syslinux/archiso_sys-linux.cfg @@ -6,7 +6,7 @@ ENDTEXT MENU LABEL Bread OS install medium (%ARCH%, BIOS) LINUX /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux INITRD /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux.img -APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% +APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash vt.global_cursor_default=0 systemd.show_status=false rd.systemd.show_status=false rd.udev.log_level=3 # Copy-to-RAM boot option — loads airootfs.sfs entirely into RAM, so the # installer reads from memory rather than a possibly-flaky USB (avoids SquashFS @@ -19,7 +19,7 @@ ENDTEXT MENU LABEL Bread OS install medium (%ARCH%, BIOS) ^copy to RAM LINUX /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux INITRD /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux.img -APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% copytoram=y +APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% copytoram=y quiet splash vt.global_cursor_default=0 systemd.show_status=false rd.systemd.show_status=false rd.udev.log_level=3 # Accessibility boot option LABEL archspeech From d78a2343f4618a5087ff84192893e20fe9b163c6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 22:04:26 +0800 Subject: [PATCH 03/12] Fix boot-critical, session, and UX bugs found in round-3 UX audit Snapshots panel was non-functional (wrong snapper flag); breadgreet picked the wrong session .desktop and skipped bos-session's PATH fixup; Calamares aborted offline installs over an unnecessary packages module; snapshot rollback silently no-op'd on BOS's pinned-subvolume layout (now points at grub-btrfs instead); breadcrumbs was configurable but had no daemon to run it. Also: SUPER+I double-bind, breadpaper/packages panels blocking the GTK main thread, dead polkit rule, and a sweep of smaller drift (default sidebar view, stale wording, missing .desktop launchers, wallpaper daemon not recording its own default). --- bos-settings/src/ui/sidebar.rs | 5 +- bos-settings/src/ui/views/breadcrumbs.rs | 4 +- bos-settings/src/ui/views/breadpaper.rs | 153 +++++++++++++++ bos-settings/src/ui/views/breadsearch.rs | 12 +- bos-settings/src/ui/views/mod.rs | 1 + bos-settings/src/ui/views/packages.rs | 175 ++++++++++++------ bos-settings/src/ui/views/snapshots.rs | 93 ++++++++-- bos-settings/src/ui/window.rs | 6 +- dotfiles/bread/init.lua | 6 +- iso/airootfs/etc/calamares/settings.conf | 7 +- .../etc/greetd/breadgreet.example.toml | 26 +++ iso/airootfs/etc/greetd/breadgreet.toml | 15 ++ iso/airootfs/etc/greetd/config.toml | 10 +- iso/airootfs/etc/os-release | 1 - .../etc/polkit-1/rules.d/10-snapper.rules | 11 -- iso/airootfs/etc/skel/.config/bread/init.lua | 6 +- .../skel/.config/breadcrumbs/breadcrumbs.toml | 3 - .../.config/breadlock/breadlock.example.toml | 22 +++ .../etc/skel/.config/hypr/hypridle.conf | 2 +- .../etc/skel/.config/hypr/hyprland.lua | 16 +- .../etc/skel/.config/hypr/hyprlock.conf | 28 --- .../share/applications/breadclip.desktop | 9 + .../share/applications/breadman.desktop | 9 + .../share/applications/breadmon.desktop | 9 + .../share/applications/breadsearch.desktop | 9 + iso/airootfs/root/.bash_profile | 19 -- iso/airootfs/root/.config/hypr/hyprland.conf | 28 --- iso/airootfs/usr/local/bin/bos-live-setup | 10 +- iso/airootfs/usr/local/bin/bos-welcome | 42 +++-- iso/airootfs/usr/share/bos/keybinds.txt | 9 +- .../usr/share/wayland-sessions/bos.desktop | 5 + iso/packages.x86_64 | 11 +- 32 files changed, 553 insertions(+), 209 deletions(-) create mode 100644 bos-settings/src/ui/views/breadpaper.rs create mode 100644 iso/airootfs/etc/greetd/breadgreet.example.toml create mode 100644 iso/airootfs/etc/greetd/breadgreet.toml delete mode 100644 iso/airootfs/etc/polkit-1/rules.d/10-snapper.rules delete mode 100644 iso/airootfs/etc/skel/.config/breadcrumbs/breadcrumbs.toml create mode 100644 iso/airootfs/etc/skel/.config/breadlock/breadlock.example.toml delete mode 100644 iso/airootfs/etc/skel/.config/hypr/hyprlock.conf create mode 100644 iso/airootfs/etc/skel/.local/share/applications/breadclip.desktop create mode 100644 iso/airootfs/etc/skel/.local/share/applications/breadman.desktop create mode 100644 iso/airootfs/etc/skel/.local/share/applications/breadmon.desktop create mode 100644 iso/airootfs/etc/skel/.local/share/applications/breadsearch.desktop delete mode 100644 iso/airootfs/root/.bash_profile delete mode 100644 iso/airootfs/root/.config/hypr/hyprland.conf create mode 100644 iso/airootfs/usr/share/wayland-sessions/bos.desktop diff --git a/bos-settings/src/ui/sidebar.rs b/bos-settings/src/ui/sidebar.rs index d4185f2..0ae4e3d 100644 --- a/bos-settings/src/ui/sidebar.rs +++ b/bos-settings/src/ui/sidebar.rs @@ -12,6 +12,7 @@ pub const APPS_ITEMS: &[SidebarItem] = &[ SidebarItem { id: "breadbox", label: "breadbox" }, SidebarItem { id: "breadcrumbs", label: "breadcrumbs" }, SidebarItem { id: "breadpad", label: "breadpad" }, + SidebarItem { id: "breadpaper", label: "breadpaper" }, SidebarItem { id: "breadsearch", label: "breadsearch" }, ]; @@ -33,12 +34,12 @@ pub fn build() -> (GBox, ListBox) { append_section(&list, "Apps", APPS_ITEMS); append_section(&list, "System", SYSTEM_ITEMS); - // Select the snapshots row so it matches the default stack page + // Select the bread row so it matches the default stack page let mut i = 0; loop { match list.row_at_index(i) { None => break, - Some(row) if row.widget_name() == "snapshots" => { + Some(row) if row.widget_name() == "bread" => { list.select_row(Some(&row)); break; } diff --git a/bos-settings/src/ui/views/breadcrumbs.rs b/bos-settings/src/ui/views/breadcrumbs.rs index f479a13..41761ab 100644 --- a/bos-settings/src/ui/views/breadcrumbs.rs +++ b/bos-settings/src/ui/views/breadcrumbs.rs @@ -356,7 +356,9 @@ pub fn build() -> GBox { &doc, &["settings", "default_profile"], &["home", "away"], - "home", + // breadcrumbs' own default_profile_name() is "away", not "home" — + // this was showing the wrong value for an unset key. + "away", )); content.append(&w::entry_row("DNS", &doc, &["settings", "dns"], "1.1.1.1", "")); content.append(&w::entry_row( diff --git a/bos-settings/src/ui/views/breadpaper.rs b/bos-settings/src/ui/views/breadpaper.rs new file mode 100644 index 0000000..e1d7c18 --- /dev/null +++ b/bos-settings/src/ui/views/breadpaper.rs @@ -0,0 +1,153 @@ +//! breadpaper — wallpaper manager. No config file to edit here; breadpaper +//! takes no persistent settings, just an image path via its CLI (`breadpaper +//! set ` / `breadpaper get`). This panel is a thin GUI front-end for +//! that CLI so wallpaper (and the pywal-driven theme it generates) has a +//! discoverable home in BOS Settings instead of only being reachable from a +//! terminal. + +use std::path::PathBuf; +use std::process::Command; + +use gtk4::prelude::*; +use gtk4::{ + Box as GBox, Button, FileChooserAction, FileChooserDialog, Image, Label, Orientation, + ResponseType, +}; + +use crate::ui::widgets as w; + +fn current_wallpaper() -> Option { + let out = Command::new("breadpaper").arg("get").output().ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { + None + } else { + Some(PathBuf::from(s)) + } +} + +fn refresh_preview(preview: &Image, path_lbl: &Label) { + match current_wallpaper() { + Some(path) => { + path_lbl.set_text(&path.display().to_string()); + preview.set_from_file(Some(&path)); + } + None => { + path_lbl.set_text("No wallpaper set"); + preview.set_icon_name(Some("image-missing")); + } + } +} + +pub fn build() -> GBox { + let (outer, c) = w::view_scaffold("breadpaper"); + + c.append(&w::hint( + "Sets the desktop wallpaper, generates a matching pywal palette, and \ + reloads the shared bread-theme stylesheet — the wallpaper drives \ + the whole desktop's accent colors.", + )); + + let preview = Image::new(); + preview.set_pixel_size(320); + preview.set_margin_top(8); + preview.set_margin_bottom(8); + c.append(&preview); + + let path_lbl = Label::new(None); + path_lbl.set_wrap(true); + path_lbl.add_css_class("dim-label"); + c.append(&path_lbl); + + refresh_preview(&preview, &path_lbl); + + let btn_row = GBox::new(Orientation::Horizontal, 8); + btn_row.set_margin_top(8); + + let choose_btn = Button::with_label("Choose image..."); + let status = Label::new(None); + status.add_css_class("dim-label"); + + { + let preview = preview.clone(); + let path_lbl = path_lbl.clone(); + let status = status.clone(); + choose_btn.connect_clicked(move |btn| { + let window = btn.root().and_then(|r| r.downcast::().ok()); + let dialog = FileChooserDialog::new( + Some("Choose a wallpaper"), + window.as_ref(), + FileChooserAction::Open, + &[("Cancel", ResponseType::Cancel), ("Set", ResponseType::Accept)], + ); + // Restricted to what breadpaper's own validate() actually + // accepts (png/jpg/jpeg/webp/gif/bmp) — add_pixbuf_formats() + // also offers svg/tiff/etc. that breadpaper rejects outright. + let filter = gtk4::FileFilter::new(); + for ext in ["png", "jpg", "jpeg", "webp", "gif", "bmp"] { + filter.add_suffix(ext); + } + filter.set_name(Some("Images")); + dialog.add_filter(&filter); + + let preview = preview.clone(); + let path_lbl = path_lbl.clone(); + let status = status.clone(); + dialog.connect_response(move |dialog, response| { + if response == ResponseType::Accept { + if let Some(file) = dialog.file() { + if let Some(path) = file.path() { + // breadpaper set runs `awww img` + pywal palette + // generation, which is routinely 1-3s (pywal + // spawns Python + an ImageMagick backend) — not + // the "sub-second" call this used to assume. + // GTK widgets aren't Send, so run it in a thread + // and hand the result back over a channel + // (same pattern as snapshots.rs). + status.set_text("Setting..."); + let (tx, rx) = async_channel::bounded::(1); + std::thread::spawn(move || { + let ok = Command::new("breadpaper") + .arg("set") + .arg(&path) + .status() + .map(|s| s.success()) + .unwrap_or(false); + let _ = tx.send_blocking(ok); + }); + + let preview = preview.clone(); + let path_lbl = path_lbl.clone(); + let status = status.clone(); + glib::spawn_future_local(async move { + let ok = rx.recv().await.unwrap_or(false); + if ok { + refresh_preview(&preview, &path_lbl); + status.set_text("Wallpaper set"); + } else { + status.set_text("breadpaper failed — see terminal/journal"); + } + let lbl = status.clone(); + glib::timeout_add_seconds_local(3, move || { + lbl.set_text(""); + glib::ControlFlow::Break + }); + }); + } + } + } + dialog.close(); + }); + dialog.show(); + }); + } + + btn_row.append(&choose_btn); + btn_row.append(&status); + c.append(&btn_row); + + outer +} diff --git a/bos-settings/src/ui/views/breadsearch.rs b/bos-settings/src/ui/views/breadsearch.rs index e12a09d..4c292c4 100644 --- a/bos-settings/src/ui/views/breadsearch.rs +++ b/bos-settings/src/ui/views/breadsearch.rs @@ -34,16 +34,22 @@ pub fn build() -> GBox { )); c.append(&w::section("Model")); + // breadmill supports npu/rocm backends in its own codebase, but the + // prebuilt binary bakery actually ships is CPU-only (no --features + // npu/rocm in its release build) — offering them here would just be a + // dropdown option that silently falls back to cpu. Re-add once a build + // with those features is published. c.append(&w::dropdown_row( "Compute backend", &doc, &["model", "backend"], - &["cpu", "npu", "rocm"], + &["cpu"], "cpu", )); c.append(&w::hint( - "npu needs breadmill built with --features npu (AMD Ryzen AI SDK); \ - rocm needs --features rocm. Falls back to cpu if not compiled in.", + "The bakery-published breadmill is CPU-only for now. NPU (AMD Ryzen \ + AI) and ROCm backends exist in breadmill's own code but need a \ + separately-built binary with those features enabled.", )); c.append(&w::section("Index")); diff --git a/bos-settings/src/ui/views/mod.rs b/bos-settings/src/ui/views/mod.rs index d29a324..ef9936c 100644 --- a/bos-settings/src/ui/views/mod.rs +++ b/bos-settings/src/ui/views/mod.rs @@ -3,6 +3,7 @@ pub mod breadbar; pub mod breadbox; pub mod breadcrumbs; pub mod breadpad; +pub mod breadpaper; pub mod breadsearch; pub mod hyprland; pub mod packages; diff --git a/bos-settings/src/ui/views/packages.rs b/bos-settings/src/ui/views/packages.rs index 79d01d0..e860b80 100644 --- a/bos-settings/src/ui/views/packages.rs +++ b/bos-settings/src/ui/views/packages.rs @@ -7,6 +7,8 @@ use std::collections::HashMap; use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; +use crate::ui::widgets as w; + fn read_installed() -> HashMap { let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); let path = std::path::Path::new(&home) @@ -42,6 +44,14 @@ fn read_installed() -> HashMap { } fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { + stream_command_then(args, log_buf, || {}); +} + +/// Same as stream_command, but runs `on_done` once the process's output +/// stream ends (i.e. the child has exited) — the channel closes when both +/// the stdout- and stderr-forwarding threads drop their sender, which only +/// happens after `child.wait()` returns. +fn stream_command_then(args: &[&str], log_buf: gtk4::TextBuffer, on_done: impl FnOnce() + 'static) { let (sender, receiver) = async_channel::bounded::(256); let args: Vec = args.iter().map(|s| s.to_string()).collect(); @@ -65,7 +75,7 @@ fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { let stderr = child.stderr.take().expect("stderr piped"); let tx2 = sender.clone(); - std::thread::spawn(move || { + let stderr_thread = std::thread::spawn(move || { for line in BufReader::new(stderr).lines().flatten() { let _ = tx2.send_blocking(line); } @@ -75,6 +85,7 @@ fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { let _ = sender.send_blocking(line); } let _ = child.wait(); + let _ = stderr_thread.join(); }); glib::spawn_future_local(async move { @@ -82,25 +93,14 @@ fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { let mut end = log_buf.end_iter(); log_buf.insert(&mut end, &format!("{line}\n")); } + on_done(); }); } -pub fn build() -> GBox { - let vbox = GBox::new(Orientation::Vertical, 0); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("Packages")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some("Bread ecosystem packages installed via bakery.")); - subtitle.set_xalign(0.0); - subtitle.set_margin_bottom(16); - vbox.append(&subtitle); - - let list = ListBox::new(); - list.set_selection_mode(gtk4::SelectionMode::None); +fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { + while let Some(child) = list.first_child() { + list.remove(&child); + } let packages = read_installed(); if packages.is_empty() { @@ -114,52 +114,82 @@ pub fn build() -> GBox { lbl.set_margin_start(8); row.set_child(Some(&lbl)); list.append(&row); - } else { - let mut names: Vec<_> = packages.iter().collect(); - names.sort_by_key(|(k, _)| k.as_str()); - - for (name, version) in names { - let row = ListBoxRow::new(); - row.set_selectable(false); - let hbox = GBox::new(Orientation::Horizontal, 16); - hbox.set_margin_top(6); - hbox.set_margin_bottom(6); - hbox.set_margin_start(8); - hbox.set_margin_end(8); - - let name_lbl = Label::new(Some(name)); - name_lbl.set_hexpand(true); - name_lbl.set_xalign(0.0); - - let ver_lbl = Label::new(Some(version)); - ver_lbl.set_xalign(1.0); - - // Spawn a thread to reap the child process — no zombies - let pkg_name = name.clone(); - let update_btn = Button::with_label("Update"); - update_btn.connect_clicked(move |_| { - match Command::new("bakery").args(["update", &pkg_name]).spawn() { - Ok(mut child) => { - std::thread::spawn(move || { let _ = child.wait(); }); - } - Err(_) => {} // bakery not found; button is a no-op - } - }); - - hbox.append(&name_lbl); - hbox.append(&ver_lbl); - hbox.append(&update_btn); - row.set_child(Some(&hbox)); - list.append(&row); - } + return; } + let mut names: Vec<_> = packages.iter().collect(); + names.sort_by_key(|(k, _)| k.as_str()); + + for (name, version) in names { + let row = ListBoxRow::new(); + row.set_selectable(false); + let hbox = GBox::new(Orientation::Horizontal, 16); + hbox.set_margin_top(6); + hbox.set_margin_bottom(6); + hbox.set_margin_start(8); + hbox.set_margin_end(8); + + let name_lbl = Label::new(Some(name)); + name_lbl.set_hexpand(true); + name_lbl.set_xalign(0.0); + + let ver_lbl = Label::new(Some(version)); + ver_lbl.set_xalign(1.0); + + let pkg_name = name.clone(); + let update_btn = Button::with_label("Update"); + { + let log_buf = log_buf.clone(); + let list = list.clone(); + update_btn.connect_clicked(move |_| { + log_buf.set_text(""); + let list2 = list.clone(); + let log_buf2 = log_buf.clone(); + // Route through stream_command (like the other buttons) so + // output is visible and the row refreshes with the new + // version once the update actually finishes — previously + // this was fire-and-forget with the Err case silently + // swallowed, so a missing `bakery` binary made the button + // look broken with zero feedback either way. + stream_command_then(&["bakery", "update", &pkg_name], log_buf.clone(), move || { + populate_packages(&list2, &log_buf2); + }); + }); + } + + hbox.append(&name_lbl); + hbox.append(&ver_lbl); + hbox.append(&update_btn); + row.set_child(Some(&hbox)); + list.append(&row); + } +} + +pub fn build() -> GBox { + let vbox = GBox::new(Orientation::Vertical, 0); + vbox.add_css_class("view-content"); + + let title = Label::new(Some("Packages")); + title.add_css_class("title"); + title.set_xalign(0.0); + vbox.append(&title); + + let subtitle = Label::new(Some("Bread ecosystem packages installed via bakery, and system packages via pacman below.")); + subtitle.set_xalign(0.0); + subtitle.set_margin_bottom(16); + vbox.append(&subtitle); + + let list = ListBox::new(); + list.set_selection_mode(gtk4::SelectionMode::None); + + let log_buf = gtk4::TextBuffer::new(None); + populate_packages(&list, &log_buf); + let scroll = ScrolledWindow::new(); scroll.set_vexpand(true); scroll.set_child(Some(&list)); vbox.append(&scroll); - let log_buf = gtk4::TextBuffer::new(None); let log_view = TextView::with_buffer(&log_buf); log_view.set_editable(false); log_view.set_monospace(true); @@ -169,7 +199,9 @@ pub fn build() -> GBox { let btn_row = GBox::new(Orientation::Horizontal, 8); btn_row.set_margin_top(12); - let check_btn = Button::with_label("Check for updates"); + // Labeled "List installed", not "Check for updates" — bakery list is a + // listing of installed packages, it doesn't check for available updates. + let check_btn = Button::with_label("List installed"); let update_all_btn = Button::with_label("Update all"); { @@ -191,6 +223,35 @@ pub fn build() -> GBox { btn_row.append(&check_btn); btn_row.append(&update_all_btn); vbox.append(&btn_row); + + // --------------------------------------------------------------------- + // System packages (pacman) — the other update channel. bakery only + // covers the userspace bread apps; base system/kernel/bos-settings/AUR + // republished packages come from pacman + the [breadway] repo, and + // bos-update (the CLI) already updates both — this panel previously + // only exposed the bakery half, so a user relying on it alone would + // never get base-system updates through the GUI. + // --------------------------------------------------------------------- + vbox.append(&w::section("System packages (pacman)")); + vbox.append(&w::hint( + "Base system, kernel, bos-settings, and republished AUR packages — \ + the other half of what `bos-update` covers. Needs your password \ + (polkit) since pacman requires root.", + )); + + let pacman_btn_row = GBox::new(Orientation::Horizontal, 8); + pacman_btn_row.set_margin_top(8); + let pacman_update_btn = Button::with_label("Update system (pacman -Syu)"); + { + let log_buf = log_buf.clone(); + pacman_update_btn.connect_clicked(move |_| { + log_buf.set_text(""); + stream_command(&["pkexec", "pacman", "-Syu", "--noconfirm"], log_buf.clone()); + }); + } + pacman_btn_row.append(&pacman_update_btn); + vbox.append(&pacman_btn_row); + vbox.append(&log_view); vbox diff --git a/bos-settings/src/ui/views/snapshots.rs b/bos-settings/src/ui/views/snapshots.rs index d3ac8b7..dd7bbf7 100644 --- a/bos-settings/src/ui/views/snapshots.rs +++ b/bos-settings/src/ui/views/snapshots.rs @@ -12,20 +12,37 @@ struct SnapshotRow { } fn list_snapshots() -> Vec { + // NOTE: the real flag is --columns, not --output-cols (which snapper + // rejects outright with "Unknown option") — confirmed against snapper + // 0.13's own --help. With the wrong flag this always failed and the + // panel silently showed "No snapshots found" on every install. let Ok(output) = Command::new("snapper") - .args(["list", "--output-cols", "number,date,description"]) + .args(["list", "--columns", "number,date,description"]) .output() else { return Vec::new(); }; + if !output.status.success() { + eprintln!( + "bos-settings: snapper list failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + return Vec::new(); + } let text = String::from_utf8_lossy(&output.stdout); text.lines() .skip(2) // header + separator .filter_map(|line| { let mut cols = line.splitn(3, '|'); + let number = cols.next()?.trim().to_string(); + // Snapshot 0 ("current") always exists, can't be rolled back to + // or deleted, and isn't a real snapshot — filter it out. + if number == "0" { + return None; + } Some(SnapshotRow { - number: cols.next()?.trim().to_string(), + number, date: cols.next()?.trim().to_string(), description: cols.next()?.trim().to_string(), }) @@ -89,7 +106,8 @@ pub fn build() -> GBox { vbox.append(&title); let subtitle = Label::new(Some( - "System snapshots created by snap-pac on each pacman transaction.", + "System snapshots created by snap-pac on each pacman transaction. \ + Boot into one from the GRUB menu to recover; delete old ones here.", )); subtitle.set_xalign(0.0); subtitle.set_margin_bottom(16); @@ -108,7 +126,7 @@ pub fn build() -> GBox { btn_row.set_margin_top(12); let refresh_btn = Button::with_label("Refresh"); - let rollback_btn = Button::with_label("Rollback to selected"); + let rollback_btn = Button::with_label("Boot into selected..."); let delete_btn = Button::with_label("Delete selected"); delete_btn.add_css_class("destructive-action"); @@ -130,23 +148,29 @@ pub fn build() -> GBox { .root() .and_then(|r| r.downcast::().ok()); + // BOS boots with root pinned to a named subvolume (grub emits + // rootflags=subvol=@), so `snapper rollback`'s usual mechanism — + // switching the btrfs *default* subvolume — has no effect here; + // grub never consults it. The real, working way to get back to a + // snapshot on this layout is grub-btrfs (already installed + + // running via grub-btrfsd.service): it generates a GRUB submenu + // entry per snapshot, bootable directly. So this button doesn't + // touch the filesystem at all — it just points you at that menu. let dialog = AlertDialog::builder() - .message(&format!("Roll back to snapshot #{number}?")) - .detail("The current system state will be replaced on next boot. \ - A polkit prompt will ask for your password.") - .buttons(["Cancel", "Roll back"]) + .message(&format!("Boot into snapshot #{number}?")) + .detail("Snapshots on BOS are booted directly from the GRUB \ + menu (under \"BOS snapshots\"), not rolled back in \ + place. Reboot now and pick this snapshot there, or \ + later if you'd rather keep working — the menu entry \ + will still be there.") + .buttons(["Later", "Reboot now"]) .cancel_button(0) .default_button(0) .build(); dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { if result == Ok(1) { - // pkexec so polkit handles the privilege escalation - std::thread::spawn(move || { - let _ = Command::new("pkexec") - .args(["snapper", "rollback", &number]) - .status(); - }); + let _ = Command::new("systemctl").args(["reboot"]).spawn(); } }); }); @@ -163,7 +187,6 @@ pub fn build() -> GBox { .root() .and_then(|r| r.downcast::().ok()); - let list = list.clone(); let dialog = AlertDialog::builder() .message(&format!("Delete snapshot #{number}?")) .detail("This cannot be undone.") @@ -172,11 +195,43 @@ pub fn build() -> GBox { .default_button(0) .build(); + let window2 = window.clone(); + let list2 = list.clone(); dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { - if result == Ok(1) { - let _ = Command::new("snapper").args(["delete", &number]).status(); - populate_list(&list); - } + if result != Ok(1) { return } + + // snapper's DBus path authorizes via ALLOW_USERS, not pkexec — + // this only works because post-install.sh seeds that config + // key, but if it's ever missing this fails silently unless we + // check the exit status. GTK widgets aren't Send, so hand the + // outcome back over a channel rather than touching them from + // the thread (same pattern as the rollback flow used to). + let (tx, rx) = async_channel::bounded::(1); + std::thread::spawn(move || { + let ok = Command::new("snapper") + .args(["delete", &number]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + let _ = tx.send_blocking(ok); + }); + + let list = list2.clone(); + let window = window2.clone(); + glib::spawn_future_local(async move { + let ok = rx.recv().await.unwrap_or(false); + if ok { + populate_list(&list); + } else { + let err = AlertDialog::builder() + .message("Delete failed") + .detail("snapper delete exited with an error — the \ + snapshot wasn't removed.") + .buttons(["OK"]) + .build(); + err.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, |_| {}); + } + }); }); }); } diff --git a/bos-settings/src/ui/window.rs b/bos-settings/src/ui/window.rs index cc395cd..bdcf89e 100644 --- a/bos-settings/src/ui/window.rs +++ b/bos-settings/src/ui/window.rs @@ -32,11 +32,13 @@ pub fn build_ui(app: &Application) { stack.add_named(&views::breadbox::build(), Some("breadbox")); stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs")); stack.add_named(&views::breadpad::build(), Some("breadpad")); + stack.add_named(&views::breadpaper::build(), Some("breadpaper")); stack.add_named(&views::breadsearch::build(), Some("breadsearch")); stack.add_named(&views::hyprland::build(), Some("hyprland")); - // Default to snapshots view - stack.set_visible_child_name("snapshots"); + // Default to the bread panel — Snapshots was previously first, an odd + // first impression for a settings app named after the bread ecosystem. + stack.set_visible_child_name("bread"); { let stack = stack.clone(); diff --git a/dotfiles/bread/init.lua b/dotfiles/bread/init.lua index 270ee7e..4ad682a 100644 --- a/dotfiles/bread/init.lua +++ b/dotfiles/bread/init.lua @@ -1 +1,5 @@ -bread.activate_profile("default") +bread.once("bread.system.startup", function() + bread.profile.activate("default") +end) + +return bread diff --git a/iso/airootfs/etc/calamares/settings.conf b/iso/airootfs/etc/calamares/settings.conf index 8846a56..8872182 100644 --- a/iso/airootfs/etc/calamares/settings.conf +++ b/iso/airootfs/etc/calamares/settings.conf @@ -35,7 +35,12 @@ sequence: - users - networkcfg - hwclock - - packages + # packages module removed: it set update_db:true with no + # skip_if_no_internet/ignore_update_db_error, so an offline install (the + # exact case bos-welcome's nmtui step exists for) aborted here with a + # fatal pacman -Sy failure. Its only try_install packages (pipewire-pulse, + # pipewire-alsa) are already in packages.x86_64 and installed by + # unpackfs, so the step did nothing useful even when it succeeded. # archiso strips the kernel from the squashfs; stage it, drop the archiso # initramfs config, and write a stock mkinitcpio preset before initcpio runs. - shellprocess@kernel diff --git a/iso/airootfs/etc/greetd/breadgreet.example.toml b/iso/airootfs/etc/greetd/breadgreet.example.toml new file mode 100644 index 0000000..c5f2093 --- /dev/null +++ b/iso/airootfs/etc/greetd/breadgreet.example.toml @@ -0,0 +1,26 @@ +# breadgreet commonly runs as the dedicated `greeter` system user (per +# greetd's own convention), so it checks /etc/greetd/breadgreet.toml first; +# copy this there for a real install. For local dev/testing under a normal +# user session it falls back to ~/.config/breadgreet/breadgreet.toml. +# +# Every field is optional and defaults to the value shown here. + +[background] +mode = "color" +path = "" +blur = false + +[clock] +format = "%H:%M" + +[font] +family = "Varela Round" + +[sessions] +# Directories scanned for .desktop session entries, in order. +wayland_dirs = ["/usr/share/wayland-sessions"] +xsessions_dirs = ["/usr/share/xsessions"] +# .desktop file stem (without extension) to auto-select. Falls back to the +# first entry found if this isn't present. v1 has no session picker UI — +# BOS only ships one session (Hyprland) today. +default = "hyprland" diff --git a/iso/airootfs/etc/greetd/breadgreet.toml b/iso/airootfs/etc/greetd/breadgreet.toml new file mode 100644 index 0000000..311dfad --- /dev/null +++ b/iso/airootfs/etc/greetd/breadgreet.toml @@ -0,0 +1,15 @@ +# BOS's real breadgreet config — see breadgreet.example.toml in this same +# directory for the full set of options with explanations. +# +# The one setting that actually matters here: `sessions.default` MUST be +# "bos", not breadgreet's compiled-in default of "hyprland". The `hyprland` +# pacman package installs its own /usr/share/wayland-sessions/hyprland.desktop +# alongside BOS's own bos.desktop, and breadgreet's session picker matches by +# .desktop file stem — with no override it picks "hyprland.desktop" over +# "bos.desktop", which skips bos-session's PATH fixup (adds ~/.local/bin for +# the bakery bread apps; greetd starts no login shell, so /etc/profile.d is +# never sourced any other way). Confirmed via breadgreet's own test suite +# (sessions.rs: discover_prefers_configured_default_over_first_entry). + +[sessions] +default = "bos" diff --git a/iso/airootfs/etc/greetd/config.toml b/iso/airootfs/etc/greetd/config.toml index 4488779..6d514c2 100644 --- a/iso/airootfs/etc/greetd/config.toml +++ b/iso/airootfs/etc/greetd/config.toml @@ -2,11 +2,15 @@ # squashfs and enabled by post-install.sh; the live ISO instead autologins # liveuser on tty1 (see getty@tty1 drop-in) so the installer comes straight up. # -# tuigreet shows a minimal greeter, then launches the BOS Hyprland session via -# bos-session (which fixes up PATH for the bakery bread apps). +# breadgreet (bread-ecosystem's own greeter) is hosted under cage — a single- +# client kiosk Wayland compositor, since greetd itself has no display of its +# own — and speaks greetd's IPC directly. It resolves the session to launch +# from /usr/share/wayland-sessions/bos.desktop, which points at bos-session +# (fixes up PATH for the bakery bread apps; greetd starts no login shell, so +# /etc/profile.d is never sourced otherwise). [terminal] vt = 1 [default_session] -command = "tuigreet --remember --time --cmd /usr/local/bin/bos-session" +command = "cage -s -- breadgreet" user = "greeter" diff --git a/iso/airootfs/etc/os-release b/iso/airootfs/etc/os-release index e8cffa1..4f28e07 100644 --- a/iso/airootfs/etc/os-release +++ b/iso/airootfs/etc/os-release @@ -9,4 +9,3 @@ DOCUMENTATION_URL="https://wiki.archlinux.org/" SUPPORT_URL="https://bbs.archlinux.org/" BUG_REPORT_URL="https://git.breadway.dev/Breadway/bos/issues" PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/" -LOGO=archlinux-logo diff --git a/iso/airootfs/etc/polkit-1/rules.d/10-snapper.rules b/iso/airootfs/etc/polkit-1/rules.d/10-snapper.rules deleted file mode 100644 index dc2b538..0000000 --- a/iso/airootfs/etc/polkit-1/rules.d/10-snapper.rules +++ /dev/null @@ -1,11 +0,0 @@ -// Allow members of the wheel group to perform snapper rollback via pkexec -// without a password prompt. Other snapper operations (list/create/delete) -// are controlled by ALLOW_USERS in /etc/snapper/configs/root. -polkit.addRule(function(action, subject) { - if (action.id == "io.opensuse.Snapper.Rollback" && - subject.local && - subject.active && - subject.isInGroup("wheel")) { - return polkit.Result.YES; - } -}); diff --git a/iso/airootfs/etc/skel/.config/bread/init.lua b/iso/airootfs/etc/skel/.config/bread/init.lua index 270ee7e..4ad682a 100644 --- a/iso/airootfs/etc/skel/.config/bread/init.lua +++ b/iso/airootfs/etc/skel/.config/bread/init.lua @@ -1 +1,5 @@ -bread.activate_profile("default") +bread.once("bread.system.startup", function() + bread.profile.activate("default") +end) + +return bread diff --git a/iso/airootfs/etc/skel/.config/breadcrumbs/breadcrumbs.toml b/iso/airootfs/etc/skel/.config/breadcrumbs/breadcrumbs.toml deleted file mode 100644 index 46e26f3..0000000 --- a/iso/airootfs/etc/skel/.config/breadcrumbs/breadcrumbs.toml +++ /dev/null @@ -1,3 +0,0 @@ -[[profile]] -name = "home" -ssids = [] diff --git a/iso/airootfs/etc/skel/.config/breadlock/breadlock.example.toml b/iso/airootfs/etc/skel/.config/breadlock/breadlock.example.toml new file mode 100644 index 0000000..223c563 --- /dev/null +++ b/iso/airootfs/etc/skel/.config/breadlock/breadlock.example.toml @@ -0,0 +1,22 @@ +# Copy to ~/.config/breadlock/breadlock.toml — every field is optional and +# defaults to the value shown here if omitted or the file doesn't exist. + +[background] +# "color" (bread-theme palette background) or "image" (a PNG, cover-fit) +mode = "color" +path = "" +# v2 feature — accepted but currently just logs a warning and shows the +# background unblurred (needs a wlr-screencopy capture, not implemented yet). +blur = false + +[clock] +# strftime format +format = "%H:%M" + +[font] +family = "Varela Round" + +[input] +# How long the "wrong password" state (red pill) shows before input +# re-enables, in milliseconds. +fail_timeout_ms = 800 diff --git a/iso/airootfs/etc/skel/.config/hypr/hypridle.conf b/iso/airootfs/etc/skel/.config/hypr/hypridle.conf index aacd512..021545c 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hypridle.conf +++ b/iso/airootfs/etc/skel/.config/hypr/hypridle.conf @@ -3,7 +3,7 @@ # playback, etc. won't dim or suspend the machine). Vendor-neutral: nothing here # is Intel/AMD specific. general { - lock_cmd = pidof hyprlock || hyprlock + lock_cmd = pidof breadlock || breadlock before_sleep_cmd = loginctl lock-session after_sleep_cmd = hyprctl dispatch dpms on ignore_dbus_inhibit = false diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index e9b785d..9928148 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -124,10 +124,15 @@ hl.bind(mod .. " + comma", hl.dsp.exec_cmd("bos-settings")) hl.bind(mod .. " + slash", hl.dsp.exec_cmd("bos-keybinds")) hl.bind(mod .. " + L", hl.dsp.exec_cmd("loginctl lock-session")) hl.bind(mod .. " + F", hl.dsp.window.fullscreen({ action = "toggle" })) -hl.bind(mod .. " + V", hl.dsp.window.float({ action = "toggle" })) +hl.bind(mod .. " + I", hl.dsp.window.float({ action = "toggle" })) +hl.bind(mod .. " + P", hl.dsp.window.pseudo({ action = "toggle" })) +hl.bind(mod .. " + R", hl.dsp.window.resize()) -- breadclip (its own gtk4-layer-shell popup — not a TUI, so no terminal -- needed). Previously piped cliphist through fzf directly from the -- compositor with no terminal attached, which was a silent no-op. +-- Bound on both V (breadclip's own suggested default, freed up now that +-- float toggle moved to I) and SHIFT+V (kept for muscle memory). +hl.bind(mod .. " + V", hl.dsp.exec_cmd("breadclip")) hl.bind(mod .. " + SHIFT + V", hl.dsp.exec_cmd("breadclip")) hl.bind(mod .. " + T", hl.dsp.layout("togglesplit")) hl.bind(mod .. " + Tab", hl.dsp.focus({ urgent_or_last = true })) @@ -185,6 +190,7 @@ hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-" hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true }) hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true }) hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) +hl.bind("XF86Calculator", hl.dsp.exec_cmd("gnome-calculator")) -- --------------------------------------------------------------------------- -- Autostart. polkit agent + the bread ecosystem + idle daemon + wallpaper. @@ -206,8 +212,12 @@ hl.on("hyprland.start", function() -- rather than an exec-once here. "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1", "awww-daemon", - -- set the default wallpaper once the daemon is up (retry until ready) - [[bash -c 'until awww img /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']], + -- Set the default wallpaper once the daemon is up (retry until + -- ready) via `breadpaper set`, not raw `awww img` — breadpaper also + -- generates the pywal palette and reloads bread-theme, and records + -- the path so `breadpaper get` (and its bos-settings panel) show + -- the real default instead of "No wallpaper set" on a fresh install. + [[bash -c 'until breadpaper set /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']], -- breadd runs as a systemd user service (~/.config/systemd/user/breadd.service, -- enabled in skel). It autostarts at login but before Hyprland exists, so -- push the compositor's Wayland env into the user manager and restart breadd diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprlock.conf b/iso/airootfs/etc/skel/.config/hypr/hyprlock.conf deleted file mode 100644 index 30903df..0000000 --- a/iso/airootfs/etc/skel/.config/hypr/hyprlock.conf +++ /dev/null @@ -1,28 +0,0 @@ -# Lock screen (hyprlock). Solid dark background (no runtime-generated wallpaper -# dependency), accent matched to the Hyprland border colour. -general { - hide_cursor = true -} - -background { - monitor = - color = rgba(46, 52, 64, 1.0) - blur_passes = 0 -} - -input-field { - monitor = - size = 20%, 5% - outline_thickness = 3 - inner_color = rgba(0, 0, 0, 0.2) - outer_color = rgba(136, 192, 208, 0.8) - check_color = rgba(120, 220, 140, 0.95) - fail_color = rgba(255, 90, 90, 0.95) - font_color = rgba(255, 255, 255, 0.95) - fade_on_empty = false - rounding = 12 - placeholder_text = Password… - position = 0, -20 - halign = center - valign = center -} diff --git a/iso/airootfs/etc/skel/.local/share/applications/breadclip.desktop b/iso/airootfs/etc/skel/.local/share/applications/breadclip.desktop new file mode 100644 index 0000000..f13f8b3 --- /dev/null +++ b/iso/airootfs/etc/skel/.local/share/applications/breadclip.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=breadclip +Comment=Clipboard history +Exec=breadclip +Icon=edit-paste +Terminal=false +Type=Application +Categories=Utility; +StartupWMClass=breadclip diff --git a/iso/airootfs/etc/skel/.local/share/applications/breadman.desktop b/iso/airootfs/etc/skel/.local/share/applications/breadman.desktop new file mode 100644 index 0000000..2c4434a --- /dev/null +++ b/iso/airootfs/etc/skel/.local/share/applications/breadman.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=breadman +Comment=Notes and reminders manager +Exec=breadman +Icon=accessories-text-editor +Terminal=false +Type=Application +Categories=Utility;Office; +StartupWMClass=breadman diff --git a/iso/airootfs/etc/skel/.local/share/applications/breadmon.desktop b/iso/airootfs/etc/skel/.local/share/applications/breadmon.desktop new file mode 100644 index 0000000..a7d143d --- /dev/null +++ b/iso/airootfs/etc/skel/.local/share/applications/breadmon.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=breadmon +Comment=Monitor layout manager (TUI) +Exec=breadmon +Icon=video-display +Terminal=true +Type=Application +Categories=Settings;System; +StartupWMClass=breadmon diff --git a/iso/airootfs/etc/skel/.local/share/applications/breadsearch.desktop b/iso/airootfs/etc/skel/.local/share/applications/breadsearch.desktop new file mode 100644 index 0000000..d6e59fb --- /dev/null +++ b/iso/airootfs/etc/skel/.local/share/applications/breadsearch.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=breadsearch +Comment=Semantic system-wide search +Exec=breadsearch +Icon=system-search +Terminal=false +Type=Application +Categories=Utility; +StartupWMClass=breadsearch diff --git a/iso/airootfs/root/.bash_profile b/iso/airootfs/root/.bash_profile deleted file mode 100644 index 390a22d..0000000 --- a/iso/airootfs/root/.bash_profile +++ /dev/null @@ -1,19 +0,0 @@ -# Auto-start Hyprland on tty1 in the live session -if [[ "$(tty)" == "/dev/tty1" ]] && [[ -z "$WAYLAND_DISPLAY" ]]; then - # Allow a software-rendering fallback so the live session comes up even - # without a GPU (VMs, headless, exotic hardware). On real hardware wlroots - # still selects the hardware renderer; this only permits llvmpipe when no - # GPU renderer is available. Must be exported before Hyprland starts — - # wlroots reads it at renderer init, earlier than any Hyprland `env=` line. - export WLR_RENDERER_ALLOW_SOFTWARE=1 - # Software cursors: hardware-cursor planes are often unusable in VMs and - # show as invisible/garbled; this is the reliable choice for a live medium. - export WLR_NO_HARDWARE_CURSORS=1 - # Run the compositor, capturing its output so a failed live boot is - # diagnosable (Hyprland also keeps its own log under $XDG_RUNTIME_DIR/hypr/). - # On exit, drop to an interactive shell with the error in view instead of - # letting the getty autologin respawn-loop hide it behind a blank cursor. - Hyprland &>/var/log/hyprland-live.log - echo "Hyprland exited (rc=$?). Log: /var/log/hyprland-live.log" - exec bash -i -fi diff --git a/iso/airootfs/root/.config/hypr/hyprland.conf b/iso/airootfs/root/.config/hypr/hyprland.conf deleted file mode 100644 index 8adb80a..0000000 --- a/iso/airootfs/root/.config/hypr/hyprland.conf +++ /dev/null @@ -1,28 +0,0 @@ -# Live-session Hyprland config — launches Calamares on start. -# This is NOT the installed system config; that lives in dotfiles/hypr/. - -monitor=,preferred,auto,1 - -exec-once = calamares - -general { - border_size = 2 - col.active_border = rgba(88c0d0ff) - col.inactive_border = rgba(4c566aff) -} - -decoration { - rounding = 4 -} - -input { - kb_layout = us - follow_mouse = 1 -} - -misc { - disable_hyprland_logo = true - disable_splash_rendering = true - # Keep compositor running if calamares exits (user can relaunch) - exit_window_request_force = false -} diff --git a/iso/airootfs/usr/local/bin/bos-live-setup b/iso/airootfs/usr/local/bin/bos-live-setup index ad54075..f7b1d26 100644 --- a/iso/airootfs/usr/local/bin/bos-live-setup +++ b/iso/airootfs/usr/local/bin/bos-live-setup @@ -18,16 +18,18 @@ if ! id liveuser &>/dev/null; then passwd -d liveuser >/dev/null fi -# Layer the installer onto the live desktop: auto-launch it, and bind Super+I to -# relaunch it after it's been closed. Appended (in Lua) to the skel hyprland.lua -# native config so the full desktop stays intact. +# Layer the installer onto the live desktop: auto-launch it, and bind +# Super+Shift+I to relaunch it after it's been closed. Appended (in Lua) to +# the skel hyprland.lua native config so the full desktop stays intact. +# NOTE: plain SUPER+I is float-toggle in the skel config — don't reuse it +# here, it was tried once and silently double-bound both actions. HYPR=/home/liveuser/.config/hypr/hyprland.lua install -d -m 0755 -o liveuser -g liveuser /home/liveuser/.config/hypr if ! grep -q bos-launch-calamares "$HYPR" 2>/dev/null; then cat >>"$HYPR" <<'EOF' -- --- live-media installer (added by bos-live-setup; absent on installed system) --- -hl.bind("SUPER + I", hl.dsp.exec_cmd("bos-launch-calamares")) +hl.bind("SUPER + SHIFT + I", hl.dsp.exec_cmd("bos-launch-calamares")) hl.on("hyprland.start", function() hl.dispatch(hl.dsp.exec_cmd("bos-launch-calamares")) end) EOF fi diff --git a/iso/airootfs/usr/local/bin/bos-welcome b/iso/airootfs/usr/local/bin/bos-welcome index ef0c2ed..92d2c2c 100644 --- a/iso/airootfs/usr/local/bin/bos-welcome +++ b/iso/airootfs/usr/local/bin/bos-welcome @@ -1,24 +1,36 @@ #!/bin/bash -# First-run welcome. Shows a short getting-started message once, then drops a -# marker so it never shows again. Launched from the Hyprland autostart; the -# bos-welcome window class is floated/centred by a Hyprland window rule. +# First-run welcome + first-run/every-login network check. Launched from the +# Hyprland autostart; the bos-welcome window class is floated/centred by a +# Hyprland window rule. set -u # Never run in the live/installer session — only on an installed system. [[ "$(id -un)" == "liveuser" ]] && exit 0 -marker="${XDG_CONFIG_HOME:-$HOME/.config}/bos/.welcomed" -[[ -f "$marker" ]] && exit 0 -mkdir -p "$(dirname "$marker")" +welcomed_marker="${XDG_CONFIG_HOME:-$HOME/.config}/bos/.welcomed" +mkdir -p "$(dirname "$welcomed_marker")" -# First-run network check. A fresh install usually boots with no connection -# (Wi-Fi isn't configured during install), and the first `bos-update`/pacman run +# Network check. A fresh install usually boots with no connection (Wi-Fi +# isn't configured during install), and the first `bos-update`/pacman run # then fails with confusing DNS/"could not resolve host" errors. If -# NetworkManager reports we're not fully online, open nmtui so the user can join -# a network before anything else. Best-effort: missing nmcli/nmtui/kitty, or the -# user quitting nmtui, must never block the welcome below. +# NetworkManager reports we're not fully online, open nmtui so the user can +# join a network before anything else. This runs on EVERY login, not just +# the first, and isn't gated by any marker — it keeps re-prompting until the +# machine is actually online, then naturally stops (the "full" check below +# short-circuits). Best-effort: missing nmcli/nmtui/kitty, or the user +# quitting nmtui, must never block the welcome text below. if command -v nmcli &>/dev/null; then conn="$(nmcli networking connectivity check 2>/dev/null)" + # NetworkManager may still be associating right at compositor start — + # give it a few short retries before concluding we're actually offline, + # so a machine with working Wi-Fi doesn't get a spurious nmtui popup. + tries=0 + while [[ "$conn" != "full" && "$tries" -lt 3 ]]; do + sleep 1 + conn="$(nmcli networking connectivity check 2>/dev/null)" + tries=$((tries + 1)) + done + if [[ "$conn" != "full" ]]; then notify-send -u normal "BOS" "No internet yet — opening network setup so updates work." 2>/dev/null || true if command -v nmtui &>/dev/null; then @@ -27,8 +39,10 @@ if command -v nmcli &>/dev/null; then fi fi -# Mark welcomed only now, so an interrupted/aborted network step still re-prompts -# next login rather than being suppressed forever. -touch "$marker" +# Welcome text: shown once ever, independent of network status above (an +# offline machine still gets useful onboarding text — it just also keeps +# getting the network prompt on future logins until it connects). +[[ -f "$welcomed_marker" ]] && exit 0 +touch "$welcomed_marker" exec kitty --class bos-welcome --title "Welcome to BOS" -- less -R /usr/share/bos/welcome.txt diff --git a/iso/airootfs/usr/share/bos/keybinds.txt b/iso/airootfs/usr/share/bos/keybinds.txt index 4ecf0b7..1c1649a 100644 --- a/iso/airootfs/usr/share/bos/keybinds.txt +++ b/iso/airootfs/usr/share/bos/keybinds.txt @@ -10,14 +10,16 @@ SUPER + E files (nautilus) SUPER + B browser (zen) SUPER + U notes / reminders (breadpad) - SUPER + M package manager (breadman) + SUPER + M notes / task manager (breadman) SUPER + , BOS Settings SUPER + / this keybind cheatsheet SUPER + L lock screen SUPER + Backspace close window SUPER + F fullscreen - SUPER + V toggle floating - SUPER + Shift + V clipboard history + SUPER + I toggle floating + SUPER + P toggle pseudotile + SUPER + R resize mode + SUPER + V / Shift + V clipboard history (breadclip) SUPER + T toggle split direction SUPER + Tab last window SUPER + N exit Hyprland (log out) @@ -45,6 +47,7 @@ MEDIA & HARDWARE KEYS volume / brightness / play-pause / next / prev (work on lock screen) + calculator key opens gnome-calculator ────────────────────────────────────────────────────────── Press q to close. Configure everything in BOS Settings (SUPER + ,). diff --git a/iso/airootfs/usr/share/wayland-sessions/bos.desktop b/iso/airootfs/usr/share/wayland-sessions/bos.desktop new file mode 100644 index 0000000..0bbecee --- /dev/null +++ b/iso/airootfs/usr/share/wayland-sessions/bos.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Name=BOS +Comment=Bread OS (Hyprland) +Exec=/usr/local/bin/bos-session +Type=Application diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index 41902e1..058aac7 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -11,14 +11,18 @@ amd-ucode intel-ucode # Power management (vendor-neutral; works on Intel and AMD). tlp auto-tunes power -# by AC/battery and CPU driver; hypridle/hyprlock handle idle dim/off/lock/suspend +# by AC/battery and CPU driver; hypridle/breadlock handle idle dim/off/lock/suspend # and the lock screen; upower exposes battery state. NOT power-profiles-daemon — # it conflicts with tlp. tlp tlp-rdw upower hypridle -hyprlock +# breadlock replaces hyprlock (locker) and tuigreet (greetd's greeter) — see +# /etc/greetd/config.toml and skel's hypridle.conf lock_cmd. cage hosts +# breadgreet as a single-client kiosk compositor under greetd. +breadlock +cage # Bootloader + filesystem grub @@ -56,9 +60,8 @@ xdg-desktop-portal-hyprland # and Firefox-based apps (Zen). Without it those apps get no file dialog. xdg-desktop-portal-gtk # Login manager for the installed system (Wayland-native; enabled by -# post-install.sh, launches the Hyprland session via tuigreet → bos-session). +# post-install.sh, launches the Hyprland session via breadgreet → bos-session). greetd -greetd-tuigreet xdg-utils xdg-user-dirs polkit From 9119d99ba90b5f0380624ef611e80c2a1d439a68 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 22:35:50 +0800 Subject: [PATCH 04/12] Fix pywal-brown theme regression: stop clobbering the curated black palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3's autostart fix (breadpaper set instead of raw awww img) had a side effect I missed: breadpaper set always runs real pywal against the wallpaper image, which overwrote the curated colors.json baked into skel (#0c0c0c black background, bread-toned browns reserved for accent slots only) with colors genuinely extracted from bread-background.png — an all-beige photo, so every bread-theme app (breadbar included) turned brown on first boot. Revert autostart to plain `awww img`. Bake .cache/wal/wal (pywal's own "last image" marker — the only thing breadpaper get/the bos-settings panel actually read) alongside the existing colors.json instead, so the panel still shows the real default without ever running pywal for real. pywal only generates a genuine palette once the user picks their own wallpaper. --- iso/airootfs/etc/skel/.cache/wal/wal | 1 + .../etc/skel/.config/hypr/hyprland.lua | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 iso/airootfs/etc/skel/.cache/wal/wal diff --git a/iso/airootfs/etc/skel/.cache/wal/wal b/iso/airootfs/etc/skel/.cache/wal/wal new file mode 100644 index 0000000..69adeb9 --- /dev/null +++ b/iso/airootfs/etc/skel/.cache/wal/wal @@ -0,0 +1 @@ +/usr/share/backgrounds/bos/bread-background.png \ No newline at end of file diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index 9928148..3b9c69c 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -212,12 +212,18 @@ hl.on("hyprland.start", function() -- rather than an exec-once here. "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1", "awww-daemon", - -- Set the default wallpaper once the daemon is up (retry until - -- ready) via `breadpaper set`, not raw `awww img` — breadpaper also - -- generates the pywal palette and reloads bread-theme, and records - -- the path so `breadpaper get` (and its bos-settings panel) show - -- the real default instead of "No wallpaper set" on a fresh install. - [[bash -c 'until breadpaper set /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']], + -- Set the default wallpaper once the daemon is up (retry until ready). + -- Raw `awww img`, NOT `breadpaper set` — breadpaper set also runs real + -- pywal against the image, which would clobber the curated black-base + -- colors.json baked into skel (.cache/wal/colors.json: #0c0c0c bg, + -- bread-toned browns reserved for accent slots only) with colors + -- actually extracted from bread-background.png — which is an all-beige + -- photo, so every bread-theme app (breadbar included) turns brown. + -- `breadpaper get` still works on a fresh install without ever running + -- pywal: .cache/wal/wal (pywal's own "last image" marker, which is all + -- breadpaper reads) is baked into skel too, right beside colors.json. + -- pywal only runs for real once the user picks a wallpaper themselves. + [[bash -c 'until awww img /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']], -- breadd runs as a systemd user service (~/.config/systemd/user/breadd.service, -- enabled in skel). It autostarts at login but before Hyprland exists, so -- push the compositor's Wayland env into the user manager and restart breadd From 41e7aaf9fcf08d62d4932faf02b0ae2d4d84a5c7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 22:38:59 +0800 Subject: [PATCH 05/12] bos-settings: expose npu/rocm/cuda in breadsearch compute backend dropdown breadsearch v0.2.1+ ships breadmill built with --features full (npu + rocm + cuda in one binary via ort's load-dynamic/dlopen mode), so the earlier CPU-only restriction here no longer applies. Hint explains what each backend needs and how to confirm it actually took effect, since a failed GPU EP registration falls back to CPU silently at the ONNX Runtime level. --- bos-settings/src/ui/views/breadsearch.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/bos-settings/src/ui/views/breadsearch.rs b/bos-settings/src/ui/views/breadsearch.rs index 4c292c4..de3cea7 100644 --- a/bos-settings/src/ui/views/breadsearch.rs +++ b/bos-settings/src/ui/views/breadsearch.rs @@ -34,22 +34,27 @@ pub fn build() -> GBox { )); c.append(&w::section("Model")); - // breadmill supports npu/rocm backends in its own codebase, but the - // prebuilt binary bakery actually ships is CPU-only (no --features - // npu/rocm in its release build) — offering them here would just be a - // dropdown option that silently falls back to cpu. Re-add once a build - // with those features is published. + // breadsearch v0.2.1+ publishes breadmill built with --features full + // (npu + rocm + cuda all in one binary, ort load-dynamic/dlopen — no + // separate build needed per backend). Selecting a GPU/NPU backend here + // only takes effect if the matching ONNX Runtime is actually present on + // this machine; breadmill logs which EP really registered at startup. c.append(&w::dropdown_row( "Compute backend", &doc, &["model", "backend"], - &["cpu"], + &["cpu", "npu", "rocm", "cuda"], "cpu", )); c.append(&w::hint( - "The bakery-published breadmill is CPU-only for now. NPU (AMD Ryzen \ - AI) and ROCm backends exist in breadmill's own code but need a \ - separately-built binary with those features enabled.", + "npu = AMD Ryzen AI (XDNA), rocm = AMD GPU (via MIGraphX), cuda = \ + NVIDIA GPU. Each needs the matching ONNX Runtime installed and \ + visible to breadmill (system package, or ORT_DYLIB_PATH) — check \ + `journalctl --user -u breadmill` after restarting for a \ + \"Successfully registered\" line confirming it actually took. \ + ROCm additionally needs ORT_MIGRAPHX_MODEL_CACHE_PATH set (bakery's \ + breadmill.service sets this by default) or the first query after \ + each backend/model change costs a one-time ~1-2 minute GPU compile.", )); c.append(&w::section("Index")); From f43bfbb680a14578513d5eb592a7f27e74b95863 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 22:52:21 +0800 Subject: [PATCH 06/12] bos-settings 0.4.2: bump bread-theme to v0.2.10 (fixed dark background/surface) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the fix for panels turning brown/off-hue when pywal extracts a light or muddy background from the wallpaper — background/surface now stay BOS's fixed dark constants regardless of what the wallpaper looks like. --- Cargo.lock | 4 ++-- bos-settings/Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c88efb6..b2a4508 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bos-settings" -version = "0.4.1" +version = "0.4.2" dependencies = [ "async-channel", "bread-theme", @@ -43,7 +43,7 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780" dependencies = [ "dirs", "gtk4", diff --git a/bos-settings/Cargo.toml b/bos-settings/Cargo.toml index f024a43..968310d 100644 --- a/bos-settings/Cargo.toml +++ b/bos-settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bos-settings" -version = "0.4.1" +version = "0.4.2" edition = "2021" [dependencies] @@ -8,7 +8,7 @@ gtk4 = { version = "0.11", features = ["v4_12"] } glib = "0.22" # Shared ecosystem theming — bos-settings loads the same generated stylesheet as # breadbar/breadbox/breadpad so the whole desktop looks consistent. -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" From eb740a37243eb3610546855ad20b069f08c760a1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 23:50:26 +0800 Subject: [PATCH 07/12] bos-settings 0.5.0: GNOME-Settings-style redesign + live app control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New panels: About, Network (Wi-Fi/Ethernet via nmcli), Sound (PipeWire volume/device via pactl), Date & Time (timedatectl), and Clipboard (breadclip previously had no bos-settings presence at all despite running its own daemon). Layout: every panel now shares one scaffold (6 views were hand-rolling their own, inconsistently) with a centered, width-capped content column instead of rows stretching edge-to-edge on a wide window with the control stranded far from its label. Rows render as individual cards. Sidebar got human labels + icons + task-based grouping (System/Personalization/Maintenance/About) instead of raw binary names under "Apps"/"System". New capability: a shared service_control() widget gives every bread app backed by a systemd --user daemon (breadd, breadbox-sync, breadcrumbs, breadmill, breadclipd) live status plus Start/Stop/Restart/View logs — previously these panels only ever edited a TOML file and hoped the running process picked it up. breadbar (no systemd unit) gets an equivalent: Save now actually sends the SIGHUP its own reload mechanism needs, instead of just claiming to in the hint text. Fixed two real bugs surfaced while building About: CPU model string kept a stray leading tab+colon (trim_start_matches was missing '\t'), and GPU showed "unknown" on hardware whose lspci entry says "Display controller" instead of "VGA compatible controller". Local CSS patches (bos-settings/src/theme.rs) for two upstream bread-theme gaps: suggested/destructive buttons and scale widgets don't clear Adwaita's default background-image, so flat colour overrides were invisible; and destructive-action red must not be pywal-derived (it can land on gold depending on the wallpaper, indistinguishable from a primary action). --- Cargo.lock | 2 +- bos-settings/Cargo.toml | 2 +- bos-settings/src/theme.rs | 20 ++ bos-settings/src/ui/sidebar.rs | 122 ++++++--- bos-settings/src/ui/views/about.rs | 172 ++++++++++++ bos-settings/src/ui/views/bread.rs | 4 +- bos-settings/src/ui/views/breadbar.rs | 54 ++-- bos-settings/src/ui/views/breadbox.rs | 34 +-- bos-settings/src/ui/views/breadclip.rs | 24 ++ bos-settings/src/ui/views/breadcrumbs.rs | 18 +- bos-settings/src/ui/views/breadpad.rs | 2 +- bos-settings/src/ui/views/breadpaper.rs | 22 +- bos-settings/src/ui/views/breadsearch.rs | 23 +- bos-settings/src/ui/views/datetime.rs | 107 ++++++++ bos-settings/src/ui/views/hyprland.rs | 48 ++-- bos-settings/src/ui/views/mod.rs | 5 + bos-settings/src/ui/views/network.rs | 324 +++++++++++++++++++++++ bos-settings/src/ui/views/packages.rs | 128 +++------ bos-settings/src/ui/views/snapshots.rs | 61 +++-- bos-settings/src/ui/views/sound.rs | 167 ++++++++++++ bos-settings/src/ui/widgets.rs | 208 ++++++++++++++- bos-settings/src/ui/window.rs | 13 +- 22 files changed, 1310 insertions(+), 250 deletions(-) create mode 100644 bos-settings/src/ui/views/about.rs create mode 100644 bos-settings/src/ui/views/breadclip.rs create mode 100644 bos-settings/src/ui/views/datetime.rs create mode 100644 bos-settings/src/ui/views/network.rs create mode 100644 bos-settings/src/ui/views/sound.rs diff --git a/Cargo.lock b/Cargo.lock index b2a4508..b126d42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bos-settings" -version = "0.4.2" +version = "0.5.0" dependencies = [ "async-channel", "bread-theme", diff --git a/bos-settings/Cargo.toml b/bos-settings/Cargo.toml index 968310d..eaf8db9 100644 --- a/bos-settings/Cargo.toml +++ b/bos-settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bos-settings" -version = "0.4.2" +version = "0.5.0" edition = "2021" [dependencies] diff --git a/bos-settings/src/theme.rs b/bos-settings/src/theme.rs index 4bd254e..0b98e07 100644 --- a/bos-settings/src/theme.rs +++ b/bos-settings/src/theme.rs @@ -14,6 +14,26 @@ use std::cell::RefCell; const APP_CSS: &str = "\ .view-content { padding: 24px; }\n\ .view-content > label.title { margin-bottom: 16px; }\n\ +/* Sidebar row sub-labels (the underlying binary/config name under a row's \ + human label) — smaller than the shared sheet's default dim-label size. */\n\ +.caption { font-size: 11px; }\n\ +/* bread-theme's shared sheet only overrides background-color on \ + suggested/destructive buttons, not background-image — so Adwaita's \ + built-in gradient (bright blue/red) paints over our flat colour \ + underneath it. Belongs upstream in bread-theme; patched locally here \ + until that's worth its own release. */\n\ +button.suggested-action, button.destructive-action { background-image: none; }\n\ +/* Same upstream gap for scale (Sound's volume sliders) — the shared sheet \ + has no `scale` rules at all, so they render in Adwaita's default blue. */\n\ +scale trough { background-color: alpha(@on-surface, 0.15); border-radius: 999px; min-height: 6px; background-image: none; }\n\ +scale highlight { background-color: @accent; background-image: none; border-radius: 999px; }\n\ +scale slider { background-color: @on-surface; border-radius: 999px; }\n\ +/* Destructive actions must not follow the wallpaper palette: @red is \ + pywal's color1, which can land on gold/yellow/anything depending on the \ + wallpaper (it did, this session) — making Delete/Remove look like a \ + primary action instead of a dangerous one. Fixed regardless of palette. */\n\ +button.destructive-action { background-color: #c0392b; color: #ffffff; }\n\ +button.destructive-action:hover { background-color: #d64535; }\n\ "; thread_local! { diff --git a/bos-settings/src/ui/sidebar.rs b/bos-settings/src/ui/sidebar.rs index 0ae4e3d..70d3e09 100644 --- a/bos-settings/src/ui/sidebar.rs +++ b/bos-settings/src/ui/sidebar.rs @@ -1,45 +1,80 @@ use gtk4::prelude::*; -use gtk4::{Box as GBox, Label, ListBox, ListBoxRow, Orientation}; +use gtk4::{Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation}; pub struct SidebarItem { + /// Must match the `Stack` page name registered in `window.rs`. pub id: &'static str, pub label: &'static str, + /// Dim second line — the underlying binary/config name, for items whose + /// human label doesn't already make that obvious. + pub sublabel: Option<&'static str>, + /// A `-symbolic` icon name from the system icon theme (Papirus-Dark ships + /// the full Adwaita-compatible symbolic set this app relies on). + pub icon: &'static str, } -pub const APPS_ITEMS: &[SidebarItem] = &[ - SidebarItem { id: "bread", label: "bread" }, - SidebarItem { id: "breadbar", label: "breadbar" }, - SidebarItem { id: "breadbox", label: "breadbox" }, - SidebarItem { id: "breadcrumbs", label: "breadcrumbs" }, - SidebarItem { id: "breadpad", label: "breadpad" }, - SidebarItem { id: "breadpaper", label: "breadpaper" }, - SidebarItem { id: "breadsearch", label: "breadsearch" }, +const fn item(id: &'static str, label: &'static str, icon: &'static str) -> SidebarItem { + SidebarItem { id, label, sublabel: None, icon } +} + +const fn item_sub( + id: &'static str, + label: &'static str, + sublabel: &'static str, + icon: &'static str, +) -> SidebarItem { + SidebarItem { id, label, sublabel: Some(sublabel), icon } +} + +// Grouped by task, not by "app vs system internals" — a user thinks "I want +// to change my Wi-Fi" or "I want to change my wallpaper", not "which of +// these is a bread-ecosystem app". breadcrumbs (Wi-Fi profiles) moves out of +// the old "Apps" bucket into System for the same reason. +pub const SYSTEM_ITEMS: &[SidebarItem] = &[ + item("network", "Network", "network-wireless-symbolic"), + item_sub("breadcrumbs", "Wi-Fi Profiles", "breadcrumbs", "network-workgroup-symbolic"), + item("sound", "Sound", "audio-volume-high-symbolic"), + item("datetime", "Date & Time", "preferences-system-time-symbolic"), + item_sub("hyprland", "Display", "hyprland.lua", "video-display-symbolic"), ]; -pub const SYSTEM_ITEMS: &[SidebarItem] = &[ - SidebarItem { id: "snapshots", label: "Snapshots" }, - SidebarItem { id: "packages", label: "Packages" }, - SidebarItem { id: "hyprland", label: "Hyprland" }, +pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[ + item_sub("breadpaper", "Wallpaper", "breadpaper", "preferences-desktop-wallpaper-symbolic"), + item_sub("breadbar", "Bar", "breadbar", "view-grid-symbolic"), + item_sub("breadbox", "Launcher", "breadbox", "view-app-grid-symbolic"), + item_sub("breadclip", "Clipboard", "breadclipd", "edit-paste-symbolic"), + item_sub("breadpad", "Notes", "breadpad", "text-editor-symbolic"), + item_sub("breadsearch", "File Search", "breadsearch", "system-search-symbolic"), + item_sub("bread", "Daemon", "breadd", "applications-system-symbolic"), ]; +pub const MAINTENANCE_ITEMS: &[SidebarItem] = &[ + item("packages", "Packages", "package-x-generic-symbolic"), + item("snapshots", "Snapshots", "document-open-recent-symbolic"), +]; + +pub const ABOUT_ITEMS: &[SidebarItem] = &[item("about", "About", "help-about-symbolic")]; + pub fn build() -> (GBox, ListBox) { let vbox = GBox::new(Orientation::Vertical, 0); vbox.add_css_class("sidebar"); - vbox.set_width_request(190); + vbox.set_width_request(210); let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::Single); list.add_css_class("sidebar"); - append_section(&list, "Apps", APPS_ITEMS); append_section(&list, "System", SYSTEM_ITEMS); + append_section(&list, "Personalization", PERSONALIZATION_ITEMS); + append_section(&list, "Maintenance", MAINTENANCE_ITEMS); + append_section(&list, None, ABOUT_ITEMS); - // Select the bread row so it matches the default stack page + // Select About so it matches the default stack page. let mut i = 0; loop { match list.row_at_index(i) { None => break, - Some(row) if row.widget_name() == "bread" => { + Some(row) if row.widget_name() == "about" => { list.select_row(Some(&row)); break; } @@ -51,24 +86,47 @@ pub fn build() -> (GBox, ListBox) { (vbox, list) } -fn append_section(list: &ListBox, title: &str, items: &[SidebarItem]) { - let header_row = ListBoxRow::new(); - header_row.set_selectable(false); - header_row.set_activatable(false); - let header_lbl = Label::new(Some(title)); - header_lbl.add_css_class("section-header"); - header_lbl.set_xalign(0.0); - header_row.set_child(Some(&header_lbl)); - list.append(&header_row); +fn append_section(list: &ListBox, title: impl Into>, items: &[SidebarItem]) { + if let Some(title) = title.into() { + let header_row = ListBoxRow::new(); + header_row.set_selectable(false); + header_row.set_activatable(false); + let header_lbl = Label::new(Some(title)); + header_lbl.add_css_class("section-header"); + header_lbl.set_xalign(0.0); + header_row.set_child(Some(&header_lbl)); + list.append(&header_row); + } - for item in items { + for entry in items { let row = ListBoxRow::new(); - row.set_widget_name(item.id); - let lbl = Label::new(Some(item.label)); + row.set_widget_name(entry.id); + + let hbox = GBox::new(Orientation::Horizontal, 10); + hbox.set_margin_top(4); + hbox.set_margin_bottom(4); + + let icon = Image::from_icon_name(entry.icon); + icon.set_pixel_size(16); + hbox.append(&icon); + + let labels = GBox::new(Orientation::Vertical, 0); + let lbl = Label::new(Some(entry.label)); lbl.set_xalign(0.0); - lbl.set_margin_top(2); - lbl.set_margin_bottom(2); - row.set_child(Some(&lbl)); + labels.append(&lbl); + if let Some(sub) = entry.sublabel { + let sub_lbl = Label::new(Some(sub)); + sub_lbl.add_css_class("dim-label"); + sub_lbl.set_xalign(0.0); + // Match the sidebar's smaller "section-header" scale rather than + // the shared sheet's default dim-label size, so it reads as a + // caption under the row label, not a second full-size label. + sub_lbl.add_css_class("caption"); + labels.append(&sub_lbl); + } + hbox.append(&labels); + + row.set_child(Some(&hbox)); list.append(&row); } } diff --git a/bos-settings/src/ui/views/about.rs b/bos-settings/src/ui/views/about.rs new file mode 100644 index 0000000..8f4cdcb --- /dev/null +++ b/bos-settings/src/ui/views/about.rs @@ -0,0 +1,172 @@ +//! Read-only system info, plus the one thing worth making writable: hostname. +//! BOS is a rolling release (no fixed version number to show — `os-release` +//! ships `BUILD_ID=rolling` on purpose), so there's no "BOS 1.2.3" readout +//! here the way a point-release distro's About panel would have one. + +use gtk4::prelude::*; +use gtk4::{Box as GBox, Button, Entry, Label, Orientation}; +use std::fs; +use std::process::Command; + +use crate::ui::widgets as w; + +fn os_pretty_name() -> String { + fs::read_to_string("/etc/os-release") + .ok() + .and_then(|s| { + s.lines() + .find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string())) + }) + .unwrap_or_else(|| "BOS".to_string()) +} + +fn hostname() -> String { + fs::read_to_string("/etc/hostname") + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "unknown".to_string()) +} + +fn kernel() -> String { + Command::new("uname") + .arg("-r") + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn cpu() -> String { + let model = fs::read_to_string("/proc/cpuinfo") + .ok() + .and_then(|s| { + s.lines() + .find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string())) + }) + .unwrap_or_else(|| "unknown".to_string()); + let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0); + if cores > 0 { + format!("{model} ({cores} threads)") + } else { + model + } +} + +fn memory() -> String { + let kb = fs::read_to_string("/proc/meminfo") + .ok() + .and_then(|s| { + s.lines() + .find(|l| l.starts_with("MemTotal:")) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|v| v.parse::().ok()) + }); + match kb { + Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0), + None => "unknown".to_string(), + } +} + +fn gpu() -> String { + let Ok(output) = Command::new("lspci").output() else { + return "unknown".to_string(); + }; + let text = String::from_utf8_lossy(&output.stdout); + text.lines() + // "Display controller" covers integrated GPUs some laptop chipsets + // (this dev laptop's AMD Radeon 860M included) report under instead + // of "VGA compatible controller" — without it those show "unknown". + .find(|l| { + l.contains("VGA compatible controller") + || l.contains("3D controller") + || l.contains("Display controller") + }) + .and_then(|l| l.split(": ").nth(1)) + .unwrap_or("unknown") + .to_string() +} + +fn disk_usage() -> String { + let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output() else { + return "unknown".to_string(); + }; + let text = String::from_utf8_lossy(&output.stdout); + text.lines() + .nth(1) + .map(|l| { + let cols: Vec<&str> = l.split_whitespace().collect(); + match cols.as_slice() { + [used, size, pcent] => format!("{used} of {size} used ({pcent})"), + _ => l.trim().to_string(), + } + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn uptime() -> String { + Command::new("uptime") + .arg("-p") + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +pub fn build() -> GBox { + let (outer, content) = w::view_scaffold("About"); + + content.append(&w::info_row("Operating system", &os_pretty_name())); + content.append(&w::info_row("Kernel", &kernel())); + content.append(&w::info_row("CPU", &cpu())); + content.append(&w::info_row("GPU", &gpu())); + content.append(&w::info_row("Memory", &memory())); + content.append(&w::info_row("Disk (/)", &disk_usage())); + content.append(&w::info_row("Uptime", &uptime())); + + content.append(&w::section("Hostname")); + content.append(&w::hint( + "Changes the machine's network name. Takes effect immediately; \ + needs your password (polkit).", + )); + + let hn_row = GBox::new(Orientation::Horizontal, 12); + let entry = Entry::new(); + entry.set_text(&hostname()); + entry.set_hexpand(true); + let apply_btn = Button::with_label("Apply"); + let status = Label::new(None); + status.add_css_class("dim-label"); + + { + let entry = entry.clone(); + let status = status.clone(); + apply_btn.connect_clicked(move |_| { + let name = entry.text().to_string(); + if name.trim().is_empty() { + status.set_text("Hostname can't be empty"); + return; + } + let log_buf = gtk4::TextBuffer::new(None); + let status2 = status.clone(); + status.set_text("Applying…"); + w::stream_command_then( + &["pkexec", "hostnamectl", "set-hostname", name.trim()], + log_buf.clone(), + move || { + let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false); + if text.trim().is_empty() { + status2.set_text("Applied"); + } else { + status2.set_text(&format!("Error: {}", text.trim())); + } + }, + ); + }); + } + + hn_row.append(&entry); + hn_row.append(&apply_btn); + content.append(&hn_row); + content.append(&status); + + outer +} diff --git a/bos-settings/src/ui/views/bread.rs b/bos-settings/src/ui/views/bread.rs index 7560e73..e3896fe 100644 --- a/bos-settings/src/ui/views/bread.rs +++ b/bos-settings/src/ui/views/bread.rs @@ -19,7 +19,9 @@ pub fn build() -> GBox { let path = config_path(); let doc = Rc::new(RefCell::new(config::load_doc(&path))); - let (outer, c) = w::view_scaffold("bread"); + let (outer, c) = w::view_scaffold("Daemon"); + + c.append(&w::service_control("breadd.service")); c.append(&w::section("Daemon")); c.append(&w::dropdown_row( diff --git a/bos-settings/src/ui/views/breadbar.rs b/bos-settings/src/ui/views/breadbar.rs index 355890a..1f49c43 100644 --- a/bos-settings/src/ui/views/breadbar.rs +++ b/bos-settings/src/ui/views/breadbar.rs @@ -2,6 +2,8 @@ use gtk4::prelude::*; use gtk4::{Box as GBox, Button, Label, Orientation, ScrolledWindow, TextView}; use std::path::PathBuf; +use crate::ui::widgets as w; + fn css_path() -> PathBuf { crate::config::config_dir().join("breadbar/style.css") } @@ -10,37 +12,42 @@ pub fn build() -> GBox { let path = css_path(); let existing_css = std::fs::read_to_string(&path).unwrap_or_default(); - let vbox = GBox::new(Orientation::Vertical, 12); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("breadbar")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some( - "CSS overrides for breadbar. Leave empty to use the default bread theme.", + let (outer, content) = w::view_scaffold("Bar"); + content.append(&w::hint( + "CSS overrides for breadbar. Leave empty to use the default bread theme. \ + Reloads live on save — no need to restart the bar.", )); - subtitle.set_xalign(0.0); - subtitle.set_margin_bottom(8); - subtitle.set_wrap(true); - vbox.append(&subtitle); let buf = gtk4::TextBuffer::new(None); buf.set_text(&existing_css); let text_view = TextView::with_buffer(&buf); text_view.set_monospace(true); + text_view.set_top_margin(12); + text_view.set_bottom_margin(12); + text_view.set_left_margin(12); + text_view.set_right_margin(12); + + // A borderless full-bleed textview reads as a rendering bug, not an + // editor — give it the same card framing every other panel's content + // gets, and cap its height so it doesn't compete with the button row + // for the one screen's worth of space. + let editor_card = GBox::new(Orientation::Vertical, 0); + editor_card.add_css_class("card"); + editor_card.set_vexpand(true); let scroll = ScrolledWindow::new(); scroll.set_vexpand(true); + scroll.set_min_content_height(360); scroll.set_child(Some(&text_view)); - vbox.append(&scroll); + editor_card.append(&scroll); + content.append(&editor_card); let btn_row = GBox::new(Orientation::Horizontal, 12); btn_row.set_margin_top(12); let save_btn = Button::with_label("Save"); + save_btn.add_css_class("suggested-action"); let status_lbl = Label::new(None); status_lbl.add_css_class("dim-label"); @@ -55,7 +62,18 @@ pub fn build() -> GBox { } match std::fs::write(&path, text.as_str()) { Ok(()) => { - status_lbl.set_text("Saved"); + // breadbar has no systemd unit (it's launched directly by + // hyprland.lua's exec-once) — SIGHUP is its own documented + // live-reload mechanism (see this file's own header + // comment: "reload live: kill -HUP $(pidof breadbar)"). + // -x pkill exits non-zero if breadbar isn't running, + // which is fine — the file's still saved either way. + let reloaded = std::process::Command::new("pkill") + .args(["-HUP", "-x", "breadbar"]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + status_lbl.set_text(if reloaded { "Saved & reloaded" } else { "Saved" }); let lbl = status_lbl.clone(); glib::timeout_add_seconds_local(3, move || { lbl.set_text(""); @@ -69,7 +87,7 @@ pub fn build() -> GBox { btn_row.append(&save_btn); btn_row.append(&status_lbl); - vbox.append(&btn_row); + outer.append(&btn_row); - vbox + outer } diff --git a/bos-settings/src/ui/views/breadbox.rs b/bos-settings/src/ui/views/breadbox.rs index e4356e2..24c82b4 100644 --- a/bos-settings/src/ui/views/breadbox.rs +++ b/bos-settings/src/ui/views/breadbox.rs @@ -15,6 +15,7 @@ use gtk4::{ use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; use crate::config; +use crate::ui::widgets as w; #[derive(Clone, Default)] struct Context { @@ -62,6 +63,17 @@ fn rebuild_list(list: &ListBox, model: &Rc>>) { while let Some(child) = list.first_child() { list.remove(&child); } + if model.borrow().is_empty() { + let row = ListBoxRow::new(); + row.set_selectable(false); + row.set_child(Some(&w::empty_state( + "view-app-grid-symbolic", + "No launcher contexts yet", + "Add one to control which apps/categories breadbox surfaces first.", + ))); + list.append(&row); + return; + } for (i, ctx) in model.borrow().iter().enumerate() { let row = ListBoxRow::new(); row.set_selectable(false); @@ -128,21 +140,13 @@ pub fn build() -> GBox { let doc = Rc::new(RefCell::new(config::load_doc(&path))); let model = Rc::new(RefCell::new(read_contexts(&doc.borrow()))); - let vbox = GBox::new(Orientation::Vertical, 12); - vbox.add_css_class("view-content"); + let (outer, content) = w::view_scaffold("Launcher"); + content.append(&w::service_control("breadbox-sync.service")); - let title = Label::new(Some("breadbox")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some( + content.append(&w::section("Contexts")); + content.append(&w::hint( "Launcher contexts — each lists, in priority order, the apps/categories surfaced first.", )); - subtitle.set_xalign(0.0); - subtitle.set_wrap(true); - subtitle.set_margin_bottom(8); - vbox.append(&subtitle); let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::None); @@ -151,7 +155,7 @@ pub fn build() -> GBox { let scroll = ScrolledWindow::new(); scroll.set_vexpand(true); scroll.set_child(Some(&list)); - vbox.append(&scroll); + content.append(&scroll); let btn_row = GBox::new(Orientation::Horizontal, 8); btn_row.set_margin_top(8); @@ -198,7 +202,7 @@ pub fn build() -> GBox { btn_row.append(&add_btn); btn_row.append(&save_btn); btn_row.append(&status_lbl); - vbox.append(&btn_row); + outer.append(&btn_row); - vbox + outer } diff --git a/bos-settings/src/ui/views/breadclip.rs b/bos-settings/src/ui/views/breadclip.rs new file mode 100644 index 0000000..bbb80da --- /dev/null +++ b/bos-settings/src/ui/views/breadclip.rs @@ -0,0 +1,24 @@ +//! breadclip — clipboard history popup + its background daemon (breadclipd). +//! No config file to edit: breadclip takes no persistent settings. This +//! panel exists purely so the daemon backing it is visible/controllable +//! from bos-settings instead of only from a terminal — previously breadclip +//! had no presence in Settings at all despite running its own service. + +use gtk4::prelude::*; +use gtk4::Box as GBox; + +use crate::ui::widgets as w; + +pub fn build() -> GBox { + let (outer, c) = w::view_scaffold("Clipboard"); + + c.append(&w::hint( + "breadclip keeps a history of copied text/images and shows it as a \ + popup (SUPER+V or SUPER+SHIFT+V). breadclipd is the background \ + daemon that actually watches the clipboard — breadclip itself is \ + just the popup UI, launched on demand.", + )); + c.append(&w::service_control("breadclipd.service")); + + outer +} diff --git a/bos-settings/src/ui/views/breadcrumbs.rs b/bos-settings/src/ui/views/breadcrumbs.rs index 41761ab..52bacde 100644 --- a/bos-settings/src/ui/views/breadcrumbs.rs +++ b/bos-settings/src/ui/views/breadcrumbs.rs @@ -11,7 +11,7 @@ use std::rc::Rc; use gtk4::prelude::*; use gtk4::{ - Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch, + Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, Switch, }; use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; @@ -334,20 +334,8 @@ pub fn build() -> GBox { let nets = Rc::new(RefCell::new(read_networks(&doc.borrow()))); let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow()))); - let outer = GBox::new(Orientation::Vertical, 8); - outer.add_css_class("view-content"); - - let title = Label::new(Some("breadcrumbs")); - title.add_css_class("title"); - title.set_xalign(0.0); - outer.append(&title); - - let content = GBox::new(Orientation::Vertical, 8); - let scroll = ScrolledWindow::new(); - scroll.set_vexpand(true); - scroll.set_hscrollbar_policy(gtk4::PolicyType::Never); - scroll.set_child(Some(&content)); - outer.append(&scroll); + let (outer, content) = w::view_scaffold("Wi-Fi Profiles"); + content.append(&w::service_control("breadcrumbs.service")); // [settings] — edited in place on the shared doc content.append(&w::section("Settings")); diff --git a/bos-settings/src/ui/views/breadpad.rs b/bos-settings/src/ui/views/breadpad.rs index 6fe5268..25a0519 100644 --- a/bos-settings/src/ui/views/breadpad.rs +++ b/bos-settings/src/ui/views/breadpad.rs @@ -20,7 +20,7 @@ pub fn build() -> GBox { let path = config_path(); let doc = Rc::new(RefCell::new(config::load_doc(&path))); - let (outer, c) = w::view_scaffold("breadpad"); + let (outer, c) = w::view_scaffold("Notes"); c.append(&w::section("Capture")); c.append(&w::dropdown_row( diff --git a/bos-settings/src/ui/views/breadpaper.rs b/bos-settings/src/ui/views/breadpaper.rs index e1d7c18..8068762 100644 --- a/bos-settings/src/ui/views/breadpaper.rs +++ b/bos-settings/src/ui/views/breadpaper.rs @@ -32,18 +32,21 @@ fn current_wallpaper() -> Option { fn refresh_preview(preview: &Image, path_lbl: &Label) { match current_wallpaper() { Some(path) => { - path_lbl.set_text(&path.display().to_string()); + let filename = path.file_name().map(|f| f.to_string_lossy().to_string()); + path_lbl.set_text(filename.as_deref().unwrap_or("(unknown filename)")); + path_lbl.set_tooltip_text(Some(&path.display().to_string())); preview.set_from_file(Some(&path)); } None => { path_lbl.set_text("No wallpaper set"); + path_lbl.set_tooltip_text(None); preview.set_icon_name(Some("image-missing")); } } } pub fn build() -> GBox { - let (outer, c) = w::view_scaffold("breadpaper"); + let (outer, c) = w::view_scaffold("Wallpaper"); c.append(&w::hint( "Sets the desktop wallpaper, generates a matching pywal palette, and \ @@ -51,23 +54,30 @@ pub fn build() -> GBox { the whole desktop's accent colors.", )); + let preview_card = GBox::new(Orientation::Vertical, 8); + preview_card.add_css_class("card"); + preview_card.set_halign(gtk4::Align::Center); + preview_card.set_margin_top(8); + preview_card.set_margin_bottom(8); + let preview = Image::new(); preview.set_pixel_size(320); - preview.set_margin_top(8); - preview.set_margin_bottom(8); - c.append(&preview); + preview_card.append(&preview); let path_lbl = Label::new(None); path_lbl.set_wrap(true); path_lbl.add_css_class("dim-label"); - c.append(&path_lbl); + preview_card.append(&path_lbl); + c.append(&preview_card); refresh_preview(&preview, &path_lbl); let btn_row = GBox::new(Orientation::Horizontal, 8); btn_row.set_margin_top(8); + btn_row.set_halign(gtk4::Align::Center); let choose_btn = Button::with_label("Choose image..."); + choose_btn.add_css_class("suggested-action"); let status = Label::new(None); status.add_css_class("dim-label"); diff --git a/bos-settings/src/ui/views/breadsearch.rs b/bos-settings/src/ui/views/breadsearch.rs index de3cea7..0ec0628 100644 --- a/bos-settings/src/ui/views/breadsearch.rs +++ b/bos-settings/src/ui/views/breadsearch.rs @@ -18,7 +18,8 @@ pub fn build() -> GBox { let path = config_path(); let doc = Rc::new(RefCell::new(config::load_doc(&path))); - let (outer, c) = w::view_scaffold("breadsearch"); + let (outer, c) = w::view_scaffold("File Search"); + c.append(&w::service_control("breadmill.service")); c.append(&w::section("Power")); c.append(&w::hint( @@ -34,22 +35,24 @@ pub fn build() -> GBox { )); c.append(&w::section("Model")); - // breadsearch v0.2.1+ publishes breadmill built with --features full - // (npu + rocm + cuda all in one binary, ort load-dynamic/dlopen — no - // separate build needed per backend). Selecting a GPU/NPU backend here - // only takes effect if the matching ONNX Runtime is actually present on - // this machine; breadmill logs which EP really registered at startup. + // breadsearch v0.2.3+ publishes breadmill built with --features full + // (npu + rocm + cuda + openvino all in one binary, ort load-dynamic/ + // dlopen — no separate build needed per backend). Selecting a GPU/NPU + // backend here only takes effect if the matching ONNX Runtime is + // actually present on this machine; breadmill logs which EP really + // registered at startup. c.append(&w::dropdown_row( "Compute backend", &doc, &["model", "backend"], - &["cpu", "npu", "rocm", "cuda"], + &["cpu", "npu", "rocm", "cuda", "openvino"], "cpu", )); c.append(&w::hint( "npu = AMD Ryzen AI (XDNA), rocm = AMD GPU (via MIGraphX), cuda = \ - NVIDIA GPU. Each needs the matching ONNX Runtime installed and \ - visible to breadmill (system package, or ORT_DYLIB_PATH) — check \ + NVIDIA GPU, openvino = Intel iGPU/Arc GPU. Each needs the matching \ + ONNX Runtime installed and visible to breadmill (system package, \ + or ORT_DYLIB_PATH) — check \ `journalctl --user -u breadmill` after restarting for a \ \"Successfully registered\" line confirming it actually took. \ ROCm additionally needs ORT_MIGRAPHX_MODEL_CACHE_PATH set (bakery's \ @@ -108,7 +111,7 @@ pub fn build() -> GBox { )); c.append(&w::hint( - "Changes take effect after: systemctl --user restart breadmill", + "Changes take effect after a restart — use the Restart button above.", )); outer.append(&w::save_button(&doc, path)); diff --git a/bos-settings/src/ui/views/datetime.rs b/bos-settings/src/ui/views/datetime.rs new file mode 100644 index 0000000..a721b4e --- /dev/null +++ b/bos-settings/src/ui/views/datetime.rs @@ -0,0 +1,107 @@ +//! Timezone + NTP, over `timedatectl`. `systemd-timesyncd` is enabled by +//! default (see `post-install.sh`), so NTP sync is on out of the box — this +//! panel is mostly for picking a timezone and confirming sync is healthy. + +use gtk4::prelude::*; +use gtk4::{Box as GBox, DropDown, Expression, Label, StringList, Switch}; +use std::process::Command; + +use crate::ui::widgets as w; + +fn show_property(prop: &str) -> String { + Command::new("timedatectl") + .args(["show", &format!("--property={prop}")]) + .output() + .ok() + .and_then(|o| { + String::from_utf8_lossy(&o.stdout) + .trim() + .strip_prefix(&format!("{prop}=")) + .map(str::to_string) + }) + .unwrap_or_default() +} + +fn list_timezones() -> Vec { + Command::new("timedatectl") + .arg("list-timezones") + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect()) + .unwrap_or_default() +} + +fn current_time_label() -> String { + Command::new("date") + .arg("+%A, %d %B %Y %H:%M") + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default() +} + +pub fn build() -> GBox { + let (outer, content) = w::view_scaffold("Date & Time"); + + content.append(&w::info_row("Current time", ¤t_time_label())); + + content.append(&w::section("Timezone")); + let zones = list_timezones(); + let current_tz = show_property("Timezone"); + if zones.is_empty() { + content.append(&w::hint("Couldn't list timezones (is timedatectl available?).")); + } else { + let labels: Vec<&str> = zones.iter().map(String::as_str).collect(); + let model = StringList::new(&labels); + let dd = DropDown::new(Some(model), Expression::NONE); + let sel = zones.iter().position(|z| *z == current_tz).unwrap_or(0); + dd.set_selected(sel as u32); + + let status = Label::new(None); + status.add_css_class("dim-label"); + + { + let zones = zones.clone(); + let status = status.clone(); + dd.connect_selected_notify(move |dd| { + let Some(tz) = zones.get(dd.selected() as usize) else { return }; + status.set_text("Applying…"); + let log_buf = gtk4::TextBuffer::new(None); + let status2 = status.clone(); + w::stream_command_then( + &["pkexec", "timedatectl", "set-timezone", tz], + log_buf.clone(), + move || { + let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false); + status2.set_text(if text.trim().is_empty() { + "Applied" + } else { + "Error — check the timezone name" + }); + }, + ); + }); + } + + content.append(&w::row("Timezone", &dd)); + content.append(&status); + } + + content.append(&w::section("Network time")); + let ntp_sw = Switch::new(); + ntp_sw.set_active(show_property("NTP") == "yes"); + ntp_sw.connect_active_notify(|s| { + let val = if s.is_active() { "true" } else { "false" }; + let _ = Command::new("pkexec").args(["timedatectl", "set-ntp", val]).spawn(); + }); + content.append(&w::row("Synchronize automatically", &ntp_sw)); + + let synced = show_property("NTPSynchronized") == "yes"; + content.append(&w::hint(if synced { + "Synchronized." + } else { + "Not synchronized yet (needs network)." + })); + + outer +} diff --git a/bos-settings/src/ui/views/hyprland.rs b/bos-settings/src/ui/views/hyprland.rs index b651b25..72ef215 100644 --- a/bos-settings/src/ui/views/hyprland.rs +++ b/bos-settings/src/ui/views/hyprland.rs @@ -1,8 +1,10 @@ use gtk4::prelude::*; -use gtk4::{Box as GBox, Button, Label, Orientation}; +use gtk4::{Box as GBox, Button}; use std::process::Command; -fn get_monitors() -> Vec { +use crate::ui::widgets as w; + +fn get_monitors() -> Vec<(String, String)> { let Ok(output) = Command::new("hyprctl").args(["monitors", "-j"]).output() else { return Vec::new(); }; @@ -17,7 +19,7 @@ fn get_monitors() -> Vec { let w = m.get("width")?.as_u64()?; let h = m.get("height")?.as_u64()?; let refresh = m.get("refreshRate")?.as_f64()?; - Some(format!("{name} {w}x{h} @ {refresh:.0}Hz")) + Some((name.to_string(), format!("{w}x{h} @ {refresh:.0}Hz"))) }) .collect() } @@ -38,58 +40,46 @@ fn open_in_terminal(path: &std::path::Path) { } pub fn build() -> GBox { - let vbox = GBox::new(Orientation::Vertical, 12); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("Hyprland")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let monitors_lbl = Label::new(Some("Connected monitors")); - monitors_lbl.set_xalign(0.0); - monitors_lbl.set_margin_top(8); - monitors_lbl.set_margin_bottom(4); - vbox.append(&monitors_lbl); + let (outer, content) = w::view_scaffold("Display"); + content.append(&w::section("Connected monitors")); let monitors = get_monitors(); if monitors.is_empty() { - let lbl = Label::new(Some("No monitors detected (is Hyprland running?)")); - lbl.set_xalign(0.0); - vbox.append(&lbl); + content.append(&w::hint("No monitors detected (is Hyprland running?)")); } else { - for mon in &monitors { - let lbl = Label::new(Some(mon)); - lbl.set_xalign(0.0); - lbl.add_css_class("monospace"); - vbox.append(&lbl); + for (name, mode) in &monitors { + content.append(&w::info_row(name, mode)); } } + content.append(&w::section("Configuration")); + content.append(&w::hint( + "Monitor layout, keyboard/input, and workspace rules are configured \ + directly in hyprland.lua — there's no live editor for them here yet.", + )); + // BOS's Hyprland config is Lua-native (hyprland.lua), not the classic // hyprland.conf/keybinds.conf pair — those names only ever matched a // stale, unshipped dotfiles/ directory, so this button opened (or // silently created) the wrong file entirely. let open_btn = Button::with_label("Open hyprland.lua in editor"); - open_btn.set_margin_top(16); open_btn.set_halign(gtk4::Align::Start); { let conf_path = hypr_path("hyprland.lua"); open_btn.connect_clicked(move |_| open_in_terminal(&conf_path)); } - vbox.append(&open_btn); + content.append(&open_btn); // Keybinds are defined inline in hyprland.lua (no separate file); point // this at the shipped cheat sheet instead of a keybinds.conf that has // never existed on BOS. let keybinds_btn = Button::with_label("View keybinds cheat sheet"); - keybinds_btn.set_margin_top(8); keybinds_btn.set_halign(gtk4::Align::Start); { let kb_path = std::path::PathBuf::from("/usr/share/bos/keybinds.txt"); keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path)); } - vbox.append(&keybinds_btn); + content.append(&keybinds_btn); - vbox + outer } diff --git a/bos-settings/src/ui/views/mod.rs b/bos-settings/src/ui/views/mod.rs index ef9936c..550fa4e 100644 --- a/bos-settings/src/ui/views/mod.rs +++ b/bos-settings/src/ui/views/mod.rs @@ -1,10 +1,15 @@ +pub mod about; pub mod bread; pub mod breadbar; pub mod breadbox; +pub mod breadclip; pub mod breadcrumbs; pub mod breadpad; pub mod breadpaper; pub mod breadsearch; +pub mod datetime; pub mod hyprland; +pub mod network; pub mod packages; pub mod snapshots; +pub mod sound; diff --git a/bos-settings/src/ui/views/network.rs b/bos-settings/src/ui/views/network.rs new file mode 100644 index 0000000..d7e9703 --- /dev/null +++ b/bos-settings/src/ui/views/network.rs @@ -0,0 +1,324 @@ +//! Wi-Fi + Ethernet over `nmcli`. NetworkManager lets the active session user +//! manage connections via polkit already, so the common paths (scan, connect, +//! toggle radio) need no `pkexec`. VPN import, 802.1x, and other edge cases +//! are punted to `nm-connection-editor` via the Advanced button rather than +//! reimplemented here. +//! +//! The scan is deliberately NOT run in `build()` — every view is constructed +//! eagerly at app launch (see `window.rs`), and a Wi-Fi scan takes seconds; +//! doing it here would add that latency to every bos-settings launch. It +//! only runs when the user clicks Scan. + +use gtk4::prelude::*; +use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch}; +use std::cell::RefCell; +use std::collections::HashSet; +use std::process::Command; +use std::rc::Rc; + +use crate::ui::widgets as w; + +#[derive(Clone)] +struct WifiNetwork { + ssid: String, + signal: i32, + secured: bool, + active: bool, +} + +fn radio_enabled() -> bool { + Command::new("nmcli") + .args(["radio", "wifi"]) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "enabled") + .unwrap_or(false) +} + +fn ethernet_status() -> Option { + let out = Command::new("nmcli").args(["-t", "-f", "DEVICE,TYPE,STATE"]).arg("dev").output().ok()?; + let text = String::from_utf8_lossy(&out.stdout); + text.lines().find_map(|l| { + let mut cols = l.splitn(3, ':'); + let (dev, ty, state) = (cols.next()?, cols.next()?, cols.next()?); + (ty == "ethernet").then(|| format!("{dev}: {state}")) + }) +} + +fn known_connection_names() -> HashSet { + let Ok(out) = Command::new("nmcli").args(["-t", "-f", "NAME"]).arg("con").arg("show").output() else { + return HashSet::new(); + }; + String::from_utf8_lossy(&out.stdout).lines().map(str::to_string).collect() +} + +/// Scan + list Wi-Fi networks, deduplicated by SSID (keeping the strongest +/// signal — the same AP shows once per band/BSSID otherwise). +fn scan_wifi() -> Vec { + let _ = Command::new("nmcli").args(["dev", "wifi", "rescan"]).output(); + std::thread::sleep(std::time::Duration::from_secs(2)); + let Ok(out) = Command::new("nmcli") + .args(["-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi", "list"]) + .output() + else { + return Vec::new(); + }; + let text = String::from_utf8_lossy(&out.stdout); + let mut by_ssid: std::collections::HashMap = std::collections::HashMap::new(); + for line in text.lines() { + let mut cols = line.splitn(4, ':'); + let (ssid, signal, security, in_use) = (cols.next(), cols.next(), cols.next(), cols.next()); + let Some(ssid) = ssid.filter(|s| !s.is_empty()) else { continue }; + let signal: i32 = signal.and_then(|s| s.parse().ok()).unwrap_or(0); + let net = WifiNetwork { + ssid: ssid.to_string(), + signal, + secured: security.map(|s| !s.is_empty()).unwrap_or(false), + active: in_use == Some("*"), + }; + by_ssid + .entry(ssid.to_string()) + .and_modify(|existing| if net.signal > existing.signal { *existing = net.clone() }) + .or_insert(net); + } + let mut list: Vec<_> = by_ssid.into_values().collect(); + list.sort_by(|a, b| b.signal.cmp(&a.signal)); + list +} + +pub fn build() -> GBox { + let (outer, content) = w::view_scaffold("Network"); + + content.append(&w::section("Wi-Fi")); + let radio_sw = Switch::new(); + radio_sw.set_active(radio_enabled()); + radio_sw.connect_active_notify(|s| { + let val = if s.is_active() { "on" } else { "off" }; + let _ = Command::new("nmcli").args(["radio", "wifi", val]).spawn(); + }); + content.append(&w::row("Wi-Fi radio", &radio_sw)); + + let status = Label::new(None); + status.add_css_class("dim-label"); + status.set_xalign(0.0); + + let list = ListBox::new(); + list.set_selection_mode(gtk4::SelectionMode::None); + let scroll = ScrolledWindow::new(); + scroll.set_min_content_height(220); + scroll.set_child(Some(&list)); + // Hidden until a scan actually produces results — a 220px empty scroll + // area between the radio row and the Scan button was dead space on + // every fresh open of this panel. + scroll.set_visible(false); + content.append(&scroll); + + let not_scanned = w::hint("Not scanned yet — press Scan to see nearby networks."); + content.append(¬_scanned); + + // Password entry, shown only while connecting to a secured, unknown + // network — set visible/hidden rather than a modal dialog, to keep this + // panel's async flow in one place instead of a nested dialog callback. + let pw_row = GBox::new(Orientation::Horizontal, 8); + let pw_entry = Entry::new(); + pw_entry.set_visibility(false); + pw_entry.set_hexpand(true); + pw_entry.set_placeholder_text(Some("Password")); + let pw_connect_btn = Button::with_label("Connect"); + pw_row.append(&pw_entry); + pw_row.append(&pw_connect_btn); + pw_row.set_visible(false); + content.append(&pw_row); + content.append(&status); + + let pending_ssid: Rc>> = Rc::new(RefCell::new(None)); + + let connect_open_or_known = { + let status = status.clone(); + move |ssid: String| { + status.set_text(&format!("Connecting to {ssid}…")); + let log_buf = gtk4::TextBuffer::new(None); + let status2 = status.clone(); + let ssid2 = ssid.clone(); + let known = known_connection_names(); + let args: Vec = if known.contains(&ssid) { + vec!["nmcli".into(), "con".into(), "up".into(), ssid.clone()] + } else { + vec!["nmcli".into(), "dev".into(), "wifi".into(), "connect".into(), ssid.clone()] + }; + let args_ref: Vec<&str> = args.iter().map(String::as_str).collect(); + w::stream_command_then(&args_ref, log_buf.clone(), move || { + let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false); + if text.to_lowercase().contains("error") { + status2.set_text(&format!("Failed to connect to {ssid2}: {}", text.trim())); + } else { + status2.set_text(&format!("Connected to {ssid2}")); + } + }); + } + }; + + { + let pw_row = pw_row.clone(); + let pw_entry = pw_entry.clone(); + let pending_ssid = pending_ssid.clone(); + let status = status.clone(); + pw_connect_btn.connect_clicked(move |_| { + let Some(ssid) = pending_ssid.borrow_mut().take() else { return }; + let password = pw_entry.text().to_string(); + pw_row.set_visible(false); + pw_entry.set_text(""); + status.set_text(&format!("Connecting to {ssid}…")); + let log_buf = gtk4::TextBuffer::new(None); + let status2 = status.clone(); + let ssid2 = ssid.clone(); + w::stream_command_then( + &["nmcli", "dev", "wifi", "connect", &ssid, "password", &password], + log_buf.clone(), + move || { + let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false); + if text.to_lowercase().contains("error") { + status2.set_text(&format!("Failed to connect to {ssid2}: wrong password?")); + } else { + status2.set_text(&format!("Connected to {ssid2}")); + } + }, + ); + }); + } + + let populate_list = { + let list = list.clone(); + let scroll = scroll.clone(); + let not_scanned = not_scanned.clone(); + let pw_row = pw_row.clone(); + let pending_ssid = pending_ssid.clone(); + let connect_open_or_known = connect_open_or_known.clone(); + move |networks: Vec| { + while let Some(child) = list.first_child() { + list.remove(&child); + } + not_scanned.set_visible(false); + scroll.set_visible(true); + if networks.is_empty() { + let row = ListBoxRow::new(); + row.set_selectable(false); + row.set_child(Some(&w::empty_state( + "network-wireless-offline-symbolic", + "No networks found", + "Try Scan again, or check Wi-Fi radio is on.", + ))); + list.append(&row); + return; + } + let known = known_connection_names(); + for net in networks { + let row = ListBoxRow::new(); + let hbox = GBox::new(Orientation::Horizontal, 12); + hbox.set_margin_top(4); + hbox.set_margin_bottom(4); + hbox.set_margin_start(8); + hbox.set_margin_end(8); + + let name = if net.active { + format!("{} (connected)", net.ssid) + } else { + net.ssid.clone() + }; + let name_lbl = Label::new(Some(&name)); + name_lbl.set_hexpand(true); + name_lbl.set_xalign(0.0); + if net.active { + name_lbl.add_css_class("heading"); + } + + let signal_icon_name = match net.signal.clamp(0, 100) { + 0..=24 => "network-wireless-signal-weak-symbolic", + 25..=49 => "network-wireless-signal-ok-symbolic", + 50..=74 => "network-wireless-signal-good-symbolic", + _ => "network-wireless-signal-excellent-symbolic", + }; + let signal_icon = gtk4::Image::from_icon_name(signal_icon_name); + signal_icon.add_css_class("dim-label"); + + let meta_lbl = Label::new(Some(&format!("{}%", net.signal.clamp(0, 100)))); + meta_lbl.add_css_class("dim-label"); + + hbox.append(&name_lbl); + if net.secured { + let lock_icon = gtk4::Image::from_icon_name("channel-secure-symbolic"); + lock_icon.add_css_class("dim-label"); + hbox.append(&lock_icon); + } + hbox.append(&signal_icon); + hbox.append(&meta_lbl); + + if !net.active { + let connect_btn = Button::with_label("Connect"); + let ssid = net.ssid.clone(); + let secured = net.secured; + let already_known = known.contains(&net.ssid); + let pw_row = pw_row.clone(); + let pending_ssid = pending_ssid.clone(); + let connect_open_or_known = connect_open_or_known.clone(); + connect_btn.connect_clicked(move |_| { + if secured && !already_known { + *pending_ssid.borrow_mut() = Some(ssid.clone()); + pw_row.set_visible(true); + } else { + pw_row.set_visible(false); + connect_open_or_known(ssid.clone()); + } + }); + hbox.append(&connect_btn); + } + + row.set_child(Some(&hbox)); + list.append(&row); + } + } + }; + + let scan_btn = Button::with_label("Scan"); + { + let status = status.clone(); + let populate_list = populate_list.clone(); + scan_btn.connect_clicked(move |btn| { + btn.set_sensitive(false); + status.set_text("Scanning…"); + let (tx, rx) = async_channel::bounded::>(1); + std::thread::spawn(move || { + let nets = scan_wifi(); + let _ = tx.send_blocking(nets); + }); + let btn = btn.clone(); + let status = status.clone(); + let populate_list = populate_list.clone(); + glib::spawn_future_local(async move { + if let Ok(nets) = rx.recv().await { + let count = nets.len(); + populate_list(nets); + status.set_text(&format!("Found {count} network(s)")); + } + btn.set_sensitive(true); + }); + }); + } + content.append(&scan_btn); + + if let Some(eth) = ethernet_status() { + content.append(&w::section("Ethernet")); + content.append(&w::info_row("Status", ð)); + } + + content.append(&w::section("Advanced")); + content.append(&w::hint("VPN, 802.1x, and static IP configuration aren't covered here.")); + let adv_btn = Button::with_label("Open connection editor"); + adv_btn.set_halign(gtk4::Align::Start); + adv_btn.connect_clicked(|_| { + let _ = Command::new("nm-connection-editor").spawn(); + }); + content.append(&adv_btn); + + outer +} diff --git a/bos-settings/src/ui/views/packages.rs b/bos-settings/src/ui/views/packages.rs index e860b80..039d9e6 100644 --- a/bos-settings/src/ui/views/packages.rs +++ b/bos-settings/src/ui/views/packages.rs @@ -1,13 +1,11 @@ -use async_channel; use gtk4::prelude::*; use gtk4::{ Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView, }; use std::collections::HashMap; -use std::io::{BufRead, BufReader}; -use std::process::{Command, Stdio}; use crate::ui::widgets as w; +use crate::ui::widgets::{stream_command, stream_command_then}; fn read_installed() -> HashMap { let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); @@ -43,61 +41,7 @@ fn read_installed() -> HashMap { .collect() } -fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { - stream_command_then(args, log_buf, || {}); -} - -/// Same as stream_command, but runs `on_done` once the process's output -/// stream ends (i.e. the child has exited) — the channel closes when both -/// the stdout- and stderr-forwarding threads drop their sender, which only -/// happens after `child.wait()` returns. -fn stream_command_then(args: &[&str], log_buf: gtk4::TextBuffer, on_done: impl FnOnce() + 'static) { - let (sender, receiver) = async_channel::bounded::(256); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - - std::thread::spawn(move || { - let mut child = match Command::new(&args[0]) - .args(&args[1..]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - { - Ok(c) => c, - Err(e) => { - let _ = sender.send_blocking(format!("Error: {e}")); - return; - } - }; - - // Merge stderr into the channel too. - // Both are Some because we spawned with Stdio::piped() above. - let stdout = child.stdout.take().expect("stdout piped"); - let stderr = child.stderr.take().expect("stderr piped"); - - let tx2 = sender.clone(); - let stderr_thread = std::thread::spawn(move || { - for line in BufReader::new(stderr).lines().flatten() { - let _ = tx2.send_blocking(line); - } - }); - - for line in BufReader::new(stdout).lines().flatten() { - let _ = sender.send_blocking(line); - } - let _ = child.wait(); - let _ = stderr_thread.join(); - }); - - glib::spawn_future_local(async move { - while let Ok(line) = receiver.recv().await { - let mut end = log_buf.end_iter(); - log_buf.insert(&mut end, &format!("{line}\n")); - } - on_done(); - }); -} - -fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { +fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer, log_view: &TextView) { while let Some(child) = list.first_child() { list.remove(&child); } @@ -106,13 +50,11 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { if packages.is_empty() { let row = ListBoxRow::new(); row.set_selectable(false); - let lbl = Label::new(Some( - "No bakery packages found (~/.local/state/bakery/installed.json)", - )); - lbl.set_margin_top(8); - lbl.set_margin_bottom(8); - lbl.set_margin_start(8); - row.set_child(Some(&lbl)); + row.set_child(Some(&w::empty_state( + "package-x-generic-symbolic", + "No bakery packages found", + "~/.local/state/bakery/installed.json is missing or empty.", + ))); list.append(&row); return; } @@ -140,11 +82,14 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { let update_btn = Button::with_label("Update"); { let log_buf = log_buf.clone(); + let log_view = log_view.clone(); let list = list.clone(); update_btn.connect_clicked(move |_| { log_buf.set_text(""); + log_view.set_visible(true); let list2 = list.clone(); let log_buf2 = log_buf.clone(); + let log_view2 = log_view.clone(); // Route through stream_command (like the other buttons) so // output is visible and the row refreshes with the new // version once the update actually finishes — previously @@ -152,7 +97,7 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { // swallowed, so a missing `bakery` binary made the button // look broken with zero feedback either way. stream_command_then(&["bakery", "update", &pkg_name], log_buf.clone(), move || { - populate_packages(&list2, &log_buf2); + populate_packages(&list2, &log_buf2, &log_view2); }); }); } @@ -166,35 +111,32 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { } pub fn build() -> GBox { - let vbox = GBox::new(Orientation::Vertical, 0); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("Packages")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some("Bread ecosystem packages installed via bakery, and system packages via pacman below.")); - subtitle.set_xalign(0.0); - subtitle.set_margin_bottom(16); - vbox.append(&subtitle); + let (outer, content) = w::view_scaffold("Packages"); + content.append(&w::hint( + "Bread ecosystem packages installed via bakery, and system packages via pacman below.", + )); let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::None); let log_buf = gtk4::TextBuffer::new(None); - populate_packages(&list, &log_buf); - - let scroll = ScrolledWindow::new(); - scroll.set_vexpand(true); - scroll.set_child(Some(&list)); - vbox.append(&scroll); + // Hidden until a command actually produces output — an always-visible + // empty log box below a short package list was the single biggest + // "dead space" offender in the app. let log_view = TextView::with_buffer(&log_buf); log_view.set_editable(false); log_view.set_monospace(true); log_view.set_height_request(140); log_view.set_margin_top(8); + log_view.set_visible(false); + + populate_packages(&list, &log_buf, &log_view); + + let scroll = ScrolledWindow::new(); + scroll.set_vexpand(true); + scroll.set_child(Some(&list)); + content.append(&scroll); let btn_row = GBox::new(Orientation::Horizontal, 8); btn_row.set_margin_top(12); @@ -206,23 +148,27 @@ pub fn build() -> GBox { { let log_buf = log_buf.clone(); + let log_view = log_view.clone(); check_btn.connect_clicked(move |_| { log_buf.set_text(""); + log_view.set_visible(true); stream_command(&["bakery", "list"], log_buf.clone()); }); } { let log_buf = log_buf.clone(); + let log_view = log_view.clone(); update_all_btn.connect_clicked(move |_| { log_buf.set_text(""); + log_view.set_visible(true); stream_command(&["bakery", "update", "--all"], log_buf.clone()); }); } btn_row.append(&check_btn); btn_row.append(&update_all_btn); - vbox.append(&btn_row); + content.append(&btn_row); // --------------------------------------------------------------------- // System packages (pacman) — the other update channel. bakery only @@ -232,8 +178,8 @@ pub fn build() -> GBox { // only exposed the bakery half, so a user relying on it alone would // never get base-system updates through the GUI. // --------------------------------------------------------------------- - vbox.append(&w::section("System packages (pacman)")); - vbox.append(&w::hint( + content.append(&w::section("System packages (pacman)")); + content.append(&w::hint( "Base system, kernel, bos-settings, and republished AUR packages — \ the other half of what `bos-update` covers. Needs your password \ (polkit) since pacman requires root.", @@ -244,15 +190,17 @@ pub fn build() -> GBox { let pacman_update_btn = Button::with_label("Update system (pacman -Syu)"); { let log_buf = log_buf.clone(); + let log_view = log_view.clone(); pacman_update_btn.connect_clicked(move |_| { log_buf.set_text(""); + log_view.set_visible(true); stream_command(&["pkexec", "pacman", "-Syu", "--noconfirm"], log_buf.clone()); }); } pacman_btn_row.append(&pacman_update_btn); - vbox.append(&pacman_btn_row); + content.append(&pacman_btn_row); - vbox.append(&log_view); + content.append(&log_view); - vbox + outer } diff --git a/bos-settings/src/ui/views/snapshots.rs b/bos-settings/src/ui/views/snapshots.rs index dd7bbf7..76d59b7 100644 --- a/bos-settings/src/ui/views/snapshots.rs +++ b/bos-settings/src/ui/views/snapshots.rs @@ -4,6 +4,8 @@ use gtk4::{ }; use std::process::Command; +use crate::ui::widgets as w; + #[derive(Clone)] struct SnapshotRow { number: String, @@ -50,7 +52,9 @@ fn list_snapshots() -> Vec { .collect() } -fn populate_list(list: &ListBox) { +/// Returns whether the list ended up empty, so callers can disable the +/// selection-dependent buttons instead of leaving them clickable no-ops. +fn populate_list(list: &ListBox) -> bool { while let Some(child) = list.first_child() { list.remove(&child); } @@ -58,13 +62,14 @@ fn populate_list(list: &ListBox) { if snapshots.is_empty() { let row = ListBoxRow::new(); row.set_selectable(false); - let lbl = Label::new(Some("No snapshots found (snapper may not be configured yet)")); - lbl.set_margin_top(8); - lbl.set_margin_bottom(8); - lbl.set_margin_start(8); - row.set_child(Some(&lbl)); + row.set_child(Some(&w::empty_state( + "document-open-recent-symbolic", + "No snapshots yet", + "Snapshots are created automatically on every pacman transaction \ + (snapper may not be configured yet).", + ))); list.append(&row); - return; + return true; } for snap in &snapshots { let row = ListBoxRow::new(); @@ -94,33 +99,24 @@ fn populate_list(list: &ListBox) { row.set_child(Some(&hbox)); list.append(&row); } + false } pub fn build() -> GBox { - let vbox = GBox::new(Orientation::Vertical, 0); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("Snapshots")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some( + let (outer, content) = w::view_scaffold("Snapshots"); + content.append(&w::hint( "System snapshots created by snap-pac on each pacman transaction. \ Boot into one from the GRUB menu to recover; delete old ones here.", )); - subtitle.set_xalign(0.0); - subtitle.set_margin_bottom(16); - vbox.append(&subtitle); let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::Single); - populate_list(&list); + let empty = populate_list(&list); let scroll = ScrolledWindow::new(); scroll.set_vexpand(true); scroll.set_child(Some(&list)); - vbox.append(&scroll); + content.append(&scroll); let btn_row = GBox::new(Orientation::Horizontal, 8); btn_row.set_margin_top(12); @@ -129,11 +125,17 @@ pub fn build() -> GBox { let rollback_btn = Button::with_label("Boot into selected..."); let delete_btn = Button::with_label("Delete selected"); delete_btn.add_css_class("destructive-action"); + rollback_btn.set_sensitive(!empty); + delete_btn.set_sensitive(!empty); { let list = list.clone(); + let rollback_btn = rollback_btn.clone(); + let delete_btn = delete_btn.clone(); refresh_btn.connect_clicked(move |_| { - populate_list(&list); + let empty = populate_list(&list); + rollback_btn.set_sensitive(!empty); + delete_btn.set_sensitive(!empty); }); } @@ -178,11 +180,14 @@ pub fn build() -> GBox { { let list = list.clone(); + let rollback_btn = rollback_btn.clone(); delete_btn.connect_clicked(move |btn| { let Some(row) = list.selected_row() else { return }; let number = row.widget_name().to_string(); if number.is_empty() { return } + let rollback_btn = rollback_btn.clone(); + let delete_btn = btn.clone(); let window = btn .root() .and_then(|r| r.downcast::().ok()); @@ -197,6 +202,8 @@ pub fn build() -> GBox { let window2 = window.clone(); let list2 = list.clone(); + let rollback_btn2 = rollback_btn.clone(); + let delete_btn2 = delete_btn.clone(); dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { if result != Ok(1) { return } @@ -218,10 +225,14 @@ pub fn build() -> GBox { let list = list2.clone(); let window = window2.clone(); + let rollback_btn = rollback_btn2.clone(); + let delete_btn = delete_btn2.clone(); glib::spawn_future_local(async move { let ok = rx.recv().await.unwrap_or(false); if ok { - populate_list(&list); + let empty = populate_list(&list); + rollback_btn.set_sensitive(!empty); + delete_btn.set_sensitive(!empty); } else { let err = AlertDialog::builder() .message("Delete failed") @@ -239,7 +250,7 @@ pub fn build() -> GBox { btn_row.append(&refresh_btn); btn_row.append(&rollback_btn); btn_row.append(&delete_btn); - vbox.append(&btn_row); + outer.append(&btn_row); - vbox + outer } diff --git a/bos-settings/src/ui/views/sound.rs b/bos-settings/src/ui/views/sound.rs new file mode 100644 index 0000000..14dddf1 --- /dev/null +++ b/bos-settings/src/ui/views/sound.rs @@ -0,0 +1,167 @@ +//! Output/input volume and device selection over PipeWire's pulse +//! compatibility layer (`pactl`) — the same surface `hyprland.lua`'s media +//! keys already use via `wpctl`. `pactl` is used here instead of `wpctl` +//! because it can enumerate devices with human-readable descriptions and +//! switch the default in one command; `wpctl` cannot easily do either. + +use gtk4::prelude::*; +use gtk4::{Box as GBox, Button, DropDown, Expression, Orientation, Scale, StringList, Switch}; +use serde::Deserialize; +use std::process::Command; + +use crate::ui::widgets as w; + +#[derive(Deserialize, Clone)] +struct Device { + name: String, + description: String, + mute: bool, + volume: std::collections::HashMap, +} + +#[derive(Deserialize, Clone)] +struct VolumeChannel { + value_percent: String, +} + +impl Device { + fn percent(&self) -> f64 { + self.volume + .values() + .next() + .and_then(|v| v.value_percent.trim_end_matches('%').trim().parse::().ok()) + .unwrap_or(0.0) + } +} + +fn list_devices(kind: &str) -> Vec { + let Ok(output) = Command::new("pactl").args(["-f", "json", "list", kind]).output() else { + return Vec::new(); + }; + serde_json::from_slice(&output.stdout).unwrap_or_default() +} + +fn default_device_name(kind: &str) -> Option { + let flag = if kind == "sinks" { "get-default-sink" } else { "get-default-source" }; + Command::new("pactl") + .arg(flag) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Build one section (Output or Input): device dropdown + volume slider + mute +/// switch. `kind` is "sinks" or "sources"; `set_default_flag` is the pactl +/// verb ("set-default-sink"/"set-default-source"); `vol_flag`/`mute_flag` +/// likewise. +fn build_section( + title: &str, + kind: &'static str, + set_default_flag: &'static str, + vol_flag: &'static str, + mute_flag: &'static str, +) -> GBox { + let section = GBox::new(Orientation::Vertical, 4); + section.append(&w::section(title)); + + let devices = list_devices(kind); + if devices.is_empty() { + section.append(&w::hint("No devices found.")); + return section; + } + + let current_name = default_device_name(kind); + let selected_idx = current_name + .as_ref() + .and_then(|n| devices.iter().position(|d| &d.name == n)) + .unwrap_or(0); + let current = devices[selected_idx].clone(); + + let labels: Vec<&str> = devices.iter().map(|d| d.description.as_str()).collect(); + let model = StringList::new(&labels); + let dd = DropDown::new(Some(model), Expression::NONE); + dd.set_selected(selected_idx as u32); + section.append(&w::row("Device", &dd)); + + let scale = Scale::with_range(Orientation::Horizontal, 0.0, 150.0, 1.0); + scale.set_value(current.percent()); + scale.set_size_request(180, -1); + scale.set_draw_value(true); + scale.set_value_pos(gtk4::PositionType::Right); + section.append(&w::row("Volume", &scale)); + + let mute_sw = Switch::new(); + mute_sw.set_active(current.mute); + section.append(&w::row("Mute", &mute_sw)); + + // Device switch: fire-and-forget pactl call, then resync the slider/mute + // switch to whatever the newly-default device's actual state is. + { + let devices = devices.clone(); + let scale = scale.clone(); + let mute_sw = mute_sw.clone(); + dd.connect_selected_notify(move |dd| { + let Some(d) = devices.get(dd.selected() as usize) else { return }; + let _ = Command::new("pactl").args([set_default_flag, &d.name]).spawn(); + scale.set_value(d.percent()); + mute_sw.set_active(d.mute); + }); + } + + // Volume: target whichever device is currently selected in the dropdown, + // not necessarily the system default at the time this closure was built. + { + let devices = devices.clone(); + let dd = dd.clone(); + scale.connect_value_changed(move |s| { + let Some(d) = devices.get(dd.selected() as usize) else { return }; + let pct = format!("{}%", s.value() as i64); + let _ = Command::new("pactl").args([vol_flag, &d.name, &pct]).spawn(); + }); + } + + { + let devices = devices.clone(); + let dd = dd.clone(); + mute_sw.connect_active_notify(move |s| { + let Some(d) = devices.get(dd.selected() as usize) else { return }; + let val = if s.is_active() { "1" } else { "0" }; + let _ = Command::new("pactl").args([mute_flag, &d.name, val]).spawn(); + }); + } + + section +} + +pub fn build() -> GBox { + let (outer, content) = w::view_scaffold("Sound"); + + content.append(&build_section( + "Output", + "sinks", + "set-default-sink", + "set-sink-volume", + "set-sink-mute", + )); + content.append(&build_section( + "Input", + "sources", + "set-default-source", + "set-source-volume", + "set-source-mute", + )); + + content.append(&w::section("Advanced")); + content.append(&w::hint( + "Per-app volume, port selection, and profile switching aren't covered here.", + )); + let mixer_btn = Button::with_label("Open advanced mixer (pavucontrol)"); + mixer_btn.set_halign(gtk4::Align::Start); + mixer_btn.connect_clicked(|_| { + let _ = Command::new("pavucontrol").spawn(); + }); + content.append(&mixer_btn); + + outer +} diff --git a/bos-settings/src/ui/widgets.rs b/bos-settings/src/ui/widgets.rs index ca207da..e35d795 100644 --- a/bos-settings/src/ui/widgets.rs +++ b/bos-settings/src/ui/widgets.rs @@ -31,8 +31,20 @@ fn field_label(text: &str) -> Label { lbl } -fn row(label: &str, control: &impl IsA) -> GBox { +/// A label + control row, the same layout every `*_row` helper below uses. +/// Styled as its own small card (background + padding + rounded corners) so +/// a page of settings reads as a list of distinct rows, not a flat column of +/// labels with the control floating far away at the window's edge — which is +/// what plain edge-aligned rows look like once [`view_scaffold`] stops +/// letting the content column stretch to the full window width. +/// Exposed so panels that front live system state (not a `Doc`) — About, +/// Sound, Date & Time, Network — can still lay out rows consistently with +/// the TOML-editor panels. +pub fn row(label: &str, control: &impl IsA) -> GBox { let row = GBox::new(Orientation::Horizontal, 16); + row.add_css_class("card"); + row.set_margin_top(3); + row.set_margin_bottom(3); row.append(&field_label(label)); control.set_halign(gtk4::Align::End); control.set_valign(gtk4::Align::Center); @@ -40,6 +52,15 @@ fn row(label: &str, control: &impl IsA) -> GBox { row } +/// A label + read-only value label, for panels that only display state +/// (About's CPU/memory/disk readouts, etc). +pub fn info_row(label: &str, value: &str) -> GBox { + let value_lbl = Label::new(Some(value)); + value_lbl.add_css_class("dim-label"); + value_lbl.set_selectable(true); + row(label, &value_lbl) +} + /// A bold section heading with spacing above it. pub fn section(text: &str) -> Label { let lbl = Label::new(Some(text)); @@ -60,6 +81,41 @@ pub fn hint(text: &str) -> Label { lbl } +/// Widest a settings column is allowed to grow. Without a cap, `content` +/// inherits hexpand from its rows' hexpand-ing labels and the ScrolledWindow +/// stretches it to the full window width — on a maximized/ultrawide window +/// that leaves the control on every row stranded ~1500px from its label. +const CONTENT_MAX_WIDTH: i32 = 760; + +/// A centered placeholder for a panel's empty state (no snapshots yet, no +/// scan results yet, etc) — a dim icon + title + hint, instead of a single +/// small sentence lost in an otherwise-empty scroll area. +pub fn empty_state(icon_name: &str, title: &str, hint_text: &str) -> GBox { + let wrapper = GBox::new(Orientation::Vertical, 6); + wrapper.set_valign(gtk4::Align::Center); + wrapper.set_halign(gtk4::Align::Center); + wrapper.set_vexpand(true); + wrapper.set_margin_top(32); + wrapper.set_margin_bottom(32); + + let icon = gtk4::Image::from_icon_name(icon_name); + icon.set_pixel_size(48); + icon.add_css_class("dim-label"); + wrapper.append(&icon); + + let title_lbl = Label::new(Some(title)); + title_lbl.add_css_class("heading"); + wrapper.append(&title_lbl); + + let hint_lbl = Label::new(Some(hint_text)); + hint_lbl.add_css_class("dim-label"); + hint_lbl.set_justify(gtk4::Justification::Center); + hint_lbl.set_wrap(true); + wrapper.append(&hint_lbl); + + wrapper +} + /// Standard view scaffold: an outer vertical box with a title and a scrollable /// content area. Append setting rows to the returned `content`, then append a /// [`save_button`] to `outer`. Returns `(outer, content)`. @@ -72,7 +128,19 @@ pub fn view_scaffold(title: &str) -> (GBox, GBox) { title_lbl.set_xalign(0.0); outer.append(&title_lbl); - let content = GBox::new(Orientation::Vertical, 8); + let content = GBox::new(Orientation::Vertical, 4); + // Explicit hexpand(false) overrides the auto-computed value GTK would + // otherwise derive from the hexpand-ing labels inside every row, and + // Start keeps content pinned under the left-aligned title instead of + // stretching (Fill) or floating in the middle of a wide pane (Center). + content.set_hexpand(false); + // Center rather than left-align: on a wide/maximized window, a + // left-hugging column just moves the dead space from "right of every + // row" to "right of the whole column" — centering distributes it evenly + // either side instead, which reads as deliberate rather than stranded. + content.set_halign(gtk4::Align::Center); + content.set_size_request(CONTENT_MAX_WIDTH, -1); + let scroll = gtk4::ScrolledWindow::new(); scroll.set_vexpand(true); scroll.set_hscrollbar_policy(gtk4::PolicyType::Never); @@ -205,6 +273,142 @@ pub fn csv_row(label: &str, doc: &Doc, path: Path, placeholder: &str) -> GBox { row(label, &entry) } +/// Run `args` as a subprocess, streaming stdout+stderr into `log_buf` line by +/// line, then call `on_done` once the process exits. GTK widgets aren't +/// `Send`, so the child runs on its own thread and results come back over an +/// `async_channel` to a `glib::spawn_future_local` task that touches the +/// widget. Shared by every panel that shells out to a CLI tool and wants live +/// output (Packages, Network, Firewall, Firmware, ...). +pub fn stream_command_then( + args: &[&str], + log_buf: gtk4::TextBuffer, + on_done: impl FnOnce() + 'static, +) { + let (sender, receiver) = async_channel::bounded::(256); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + + std::thread::spawn(move || { + let mut child = match std::process::Command::new(&args[0]) + .args(&args[1..]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + let _ = sender.send_blocking(format!("Error: {e}")); + return; + } + }; + + // Both are Some because we spawned with Stdio::piped() above. + let stdout = child.stdout.take().expect("stdout piped"); + let stderr = child.stderr.take().expect("stderr piped"); + + let tx2 = sender.clone(); + let stderr_thread = std::thread::spawn(move || { + for line in std::io::BufRead::lines(std::io::BufReader::new(stderr)).flatten() { + let _ = tx2.send_blocking(line); + } + }); + + for line in std::io::BufRead::lines(std::io::BufReader::new(stdout)).flatten() { + let _ = sender.send_blocking(line); + } + let _ = child.wait(); + let _ = stderr_thread.join(); + }); + + glib::spawn_future_local(async move { + while let Ok(line) = receiver.recv().await { + let mut end = log_buf.end_iter(); + log_buf.insert(&mut end, &format!("{line}\n")); + } + on_done(); + }); +} + +/// [`stream_command_then`] with no completion callback. +pub fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { + stream_command_then(args, log_buf, || {}); +} + +fn systemctl_active(unit: &str) -> bool { + std::process::Command::new("systemctl") + .args(["--user", "is-active", "--quiet", unit]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Live systemd `--user` unit status plus Start/Stop/Restart/Logs controls — +/// every bread-ecosystem panel whose app is actually a daemon (not just a +/// config file) gets this, instead of only ever being able to edit the TOML +/// and hope the running process picks it up. +pub fn service_control(unit: &'static str) -> GBox { + let wrapper = GBox::new(Orientation::Vertical, 4); + wrapper.append(§ion("Service")); + + let status_lbl = Label::new(None); + status_lbl.add_css_class("dim-label"); + status_lbl.set_selectable(true); + wrapper.append(&row(unit, &status_lbl)); + + let btn_row = GBox::new(Orientation::Horizontal, 8); + btn_row.set_margin_top(4); + let toggle_btn = Button::new(); + let restart_btn = Button::with_label("Restart"); + let logs_btn = Button::with_label("View logs"); + + // Rc rather than a plain closure: three different button + // handlers all need to re-run this after their action completes, and + // plain closures aren't Clone. + let refresh: Rc = { + let status_lbl = status_lbl.clone(); + let toggle_btn = toggle_btn.clone(); + Rc::new(move || { + let active = systemctl_active(unit); + status_lbl.set_text(if active { "Running" } else { "Stopped" }); + toggle_btn.set_label(if active { "Stop" } else { "Start" }); + }) + }; + refresh(); + + { + let refresh = refresh.clone(); + toggle_btn.connect_clicked(move |_| { + let verb = if systemctl_active(unit) { "stop" } else { "start" }; + let log_buf = gtk4::TextBuffer::new(None); + let refresh = refresh.clone(); + stream_command_then(&["systemctl", "--user", verb, unit], log_buf, move || { + refresh(); + }); + }); + } + { + let refresh = refresh.clone(); + restart_btn.connect_clicked(move |_| { + let log_buf = gtk4::TextBuffer::new(None); + let refresh = refresh.clone(); + stream_command_then(&["systemctl", "--user", "restart", unit], log_buf, move || { + refresh(); + }); + }); + } + logs_btn.connect_clicked(move |_| { + let _ = std::process::Command::new("kitty") + .args(["-e", "journalctl", "--user", "-u", unit, "-f"]) + .spawn(); + }); + + btn_row.append(&toggle_btn); + btn_row.append(&restart_btn); + btn_row.append(&logs_btn); + wrapper.append(&btn_row); + + wrapper +} + /// A Save button + transient status label that persists the document to `path`. pub fn save_button(doc: &Doc, path: PathBuf) -> GBox { let btn_row = GBox::new(Orientation::Horizontal, 12); diff --git a/bos-settings/src/ui/window.rs b/bos-settings/src/ui/window.rs index bdcf89e..c74063e 100644 --- a/bos-settings/src/ui/window.rs +++ b/bos-settings/src/ui/window.rs @@ -15,7 +15,7 @@ pub fn build_ui(app: &Application) { crate::theme::load(&WidgetExt::display(&window)); let hpaned = Paned::new(Orientation::Horizontal); - hpaned.set_position(190); + hpaned.set_position(210); hpaned.set_shrink_start_child(false); hpaned.set_resize_start_child(false); @@ -25,20 +25,25 @@ pub fn build_ui(app: &Application) { stack.set_hexpand(true); stack.set_vexpand(true); + stack.add_named(&views::about::build(), Some("about")); + stack.add_named(&views::network::build(), Some("network")); + stack.add_named(&views::sound::build(), Some("sound")); + stack.add_named(&views::datetime::build(), Some("datetime")); stack.add_named(&views::snapshots::build(), Some("snapshots")); stack.add_named(&views::packages::build(), Some("packages")); stack.add_named(&views::bread::build(), Some("bread")); stack.add_named(&views::breadbar::build(), Some("breadbar")); stack.add_named(&views::breadbox::build(), Some("breadbox")); + stack.add_named(&views::breadclip::build(), Some("breadclip")); stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs")); stack.add_named(&views::breadpad::build(), Some("breadpad")); stack.add_named(&views::breadpaper::build(), Some("breadpaper")); stack.add_named(&views::breadsearch::build(), Some("breadsearch")); stack.add_named(&views::hyprland::build(), Some("hyprland")); - // Default to the bread panel — Snapshots was previously first, an odd - // first impression for a settings app named after the bread ecosystem. - stack.set_visible_child_name("bread"); + // Default to About — a settings app's most GNOME-Settings-like landing + // page: identifies the machine, no config editor thrown at you first. + stack.set_visible_child_name("about"); { let stack = stack.clone(); From 9c2c95089c5fe5c91552caa9ff6de9c4f15f6942 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 4 Jul 2026 00:00:10 +0800 Subject: [PATCH 08/12] bos-settings: single-source-of-truth default page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sidebar.rs and window.rs each independently hardcoded "about" as the default selected/visible page — harmless while both literals happened to match, but a real latent bug: changing one without the other silently desyncs the sidebar highlight from the actually-displayed panel. Found while capturing screenshots of every panel for a design review, where exactly that happened. window.rs now owns DEFAULT_PAGE and passes it to sidebar::build(). --- bos-settings/src/ui/sidebar.rs | 10 +++++++--- bos-settings/src/ui/window.rs | 12 ++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/bos-settings/src/ui/sidebar.rs b/bos-settings/src/ui/sidebar.rs index 70d3e09..bbc4fb1 100644 --- a/bos-settings/src/ui/sidebar.rs +++ b/bos-settings/src/ui/sidebar.rs @@ -55,7 +55,12 @@ pub const MAINTENANCE_ITEMS: &[SidebarItem] = &[ pub const ABOUT_ITEMS: &[SidebarItem] = &[item("about", "About", "help-about-symbolic")]; -pub fn build() -> (GBox, ListBox) { +/// `default_id` must match whatever page `window.rs` sets as the `Stack`'s +/// initial visible child — previously these were two independent hardcoded +/// "about" literals in different files with no link between them, so +/// changing one without the other silently desynced the sidebar highlight +/// from the actually-displayed page. +pub fn build(default_id: &str) -> (GBox, ListBox) { let vbox = GBox::new(Orientation::Vertical, 0); vbox.add_css_class("sidebar"); vbox.set_width_request(210); @@ -69,12 +74,11 @@ pub fn build() -> (GBox, ListBox) { append_section(&list, "Maintenance", MAINTENANCE_ITEMS); append_section(&list, None, ABOUT_ITEMS); - // Select About so it matches the default stack page. let mut i = 0; loop { match list.row_at_index(i) { None => break, - Some(row) if row.widget_name() == "about" => { + Some(row) if row.widget_name() == default_id => { list.select_row(Some(&row)); break; } diff --git a/bos-settings/src/ui/window.rs b/bos-settings/src/ui/window.rs index c74063e..67d62e0 100644 --- a/bos-settings/src/ui/window.rs +++ b/bos-settings/src/ui/window.rs @@ -4,6 +4,12 @@ use gtk4::{Application, ApplicationWindow, Orientation, Paned, Stack}; use super::sidebar; use super::views; +// About is a settings app's most GNOME-Settings-like landing page — +// identifies the machine, no config editor thrown at you first. Defined +// once here and threaded through to both the `Stack` and the sidebar's +// initial selection, so the two can't independently drift out of sync. +const DEFAULT_PAGE: &str = "about"; + pub fn build_ui(app: &Application) { let window = ApplicationWindow::builder() .application(app) @@ -19,7 +25,7 @@ pub fn build_ui(app: &Application) { hpaned.set_shrink_start_child(false); hpaned.set_resize_start_child(false); - let (sidebar_box, list) = sidebar::build(); + let (sidebar_box, list) = sidebar::build(DEFAULT_PAGE); let stack = Stack::new(); stack.set_hexpand(true); @@ -41,9 +47,7 @@ pub fn build_ui(app: &Application) { stack.add_named(&views::breadsearch::build(), Some("breadsearch")); stack.add_named(&views::hyprland::build(), Some("hyprland")); - // Default to About — a settings app's most GNOME-Settings-like landing - // page: identifies the machine, no config editor thrown at you first. - stack.set_visible_child_name("about"); + stack.set_visible_child_name(DEFAULT_PAGE); { let stack = stack.clone(); From 489d47224099a2c6a3b6c941503520769d673888 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 4 Jul 2026 10:09:59 +0800 Subject: [PATCH 09/12] bos-settings: round-2 GUI review fixes (stable column width, service UX, switch styling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of two panels rendering full-bleed instead of the 760px column (breadsearch, breadclip): hint()'s long unwrapped text reported an unbounded natural width, and CONTENT_MAX_WIDTH is a floor (set_size_request), not a cap — nothing was actually capping width. hint() and empty_state() now bound their labels with set_max_width_chars, which stabilizes every panel's column at a consistent width and left edge for the first time. service_control(): added a second status line (enabled-at-boot vs just-running), a confirm dialog on Stop for critical units (breadd — the whole desktop's event backbone was one accidental click from stopping, styled identically to optional helpers), and a consistent "Save only writes the file, Restart applies it" hint on panels that have both a service and a config file (previously only breadsearch said this, in its own inconsistent wording). Consistency sweep: Network's Scan button and empty-state treatment now match the rest of the app (was full-width where every other secondary action is auto-width; "not scanned" was a hint while "no results" was an empty_state card — now both empty_state). breadclip gets an actual "Open history" action instead of reading as an unfinished stub. breadbox's Save button pulled out of a mixed row into its own row like every other panel. Packages list rows now get the same card styling as every other row in the app. Panel title now shares view_scaffold's width+center treatment with the content column, so it actually heads the column instead of sitting ~440px to its left. Also: switch widgets had a stray box-shadow/outline/border showing as a faint ring around the knob, and bread-theme's switch slider color (@on-surface) was being masked by Adwaita's default gloss background-image — clearing it reveals the correctly-themed near-white knob against the dark surface. --- bos-settings/src/theme.rs | 6 ++ bos-settings/src/ui/views/bread.rs | 2 +- bos-settings/src/ui/views/breadbox.rs | 16 +++-- bos-settings/src/ui/views/breadclip.rs | 21 ++++-- bos-settings/src/ui/views/breadcrumbs.rs | 2 +- bos-settings/src/ui/views/breadsearch.rs | 6 +- bos-settings/src/ui/views/network.rs | 26 ++++--- bos-settings/src/ui/views/packages.rs | 7 +- bos-settings/src/ui/widgets.rs | 87 +++++++++++++++++++++--- 9 files changed, 128 insertions(+), 45 deletions(-) diff --git a/bos-settings/src/theme.rs b/bos-settings/src/theme.rs index 0b98e07..5b57ca8 100644 --- a/bos-settings/src/theme.rs +++ b/bos-settings/src/theme.rs @@ -34,6 +34,12 @@ scale slider { background-color: @on-surface; border-radius: 999px; }\n\ primary action instead of a dangerous one. Fixed regardless of palette. */\n\ button.destructive-action { background-color: #c0392b; color: #ffffff; }\n\ button.destructive-action:hover { background-color: #d64535; }\n\ +/* Adwaita's default switch slider (the knob) carries a box-shadow used for \ + its 3D bevel look — bread-theme's override only sets background-color, \ + so that shadow still renders as a pale ring around the knob on top of \ + our flat colour. */\n\ +switch slider { box-shadow: none; outline: none; border: none; background-image: none; }\n\ +switch { box-shadow: none; outline: none; border: none; background-image: none; }\n\ "; thread_local! { diff --git a/bos-settings/src/ui/views/bread.rs b/bos-settings/src/ui/views/bread.rs index e3896fe..77d3294 100644 --- a/bos-settings/src/ui/views/bread.rs +++ b/bos-settings/src/ui/views/bread.rs @@ -21,7 +21,7 @@ pub fn build() -> GBox { let (outer, c) = w::view_scaffold("Daemon"); - c.append(&w::service_control("breadd.service")); + c.append(&w::service_control("breadd.service", true, true)); c.append(&w::section("Daemon")); c.append(&w::dropdown_row( diff --git a/bos-settings/src/ui/views/breadbox.rs b/bos-settings/src/ui/views/breadbox.rs index 24c82b4..52b0321 100644 --- a/bos-settings/src/ui/views/breadbox.rs +++ b/bos-settings/src/ui/views/breadbox.rs @@ -141,7 +141,7 @@ pub fn build() -> GBox { let model = Rc::new(RefCell::new(read_contexts(&doc.borrow()))); let (outer, content) = w::view_scaffold("Launcher"); - content.append(&w::service_control("breadbox-sync.service")); + content.append(&w::service_control("breadbox-sync.service", false, true)); content.append(&w::section("Contexts")); content.append(&w::hint( @@ -157,10 +157,12 @@ pub fn build() -> GBox { scroll.set_child(Some(&list)); content.append(&scroll); - let btn_row = GBox::new(Orientation::Horizontal, 8); - btn_row.set_margin_top(8); - + // Add-row button lives with the list it adds to, halign Start like + // breadcrumbs' "Add network"/"Add profile" — Save stays alone in its + // own row at the bottom, consistent with every other panel. let add_btn = Button::with_label("Add context"); + add_btn.set_halign(gtk4::Align::Start); + add_btn.set_margin_top(8); { let model = model.clone(); let list = list.clone(); @@ -172,12 +174,14 @@ pub fn build() -> GBox { rebuild_list(&list, &model); }); } + content.append(&add_btn); + let btn_row = GBox::new(Orientation::Horizontal, 12); + btn_row.set_margin_top(16); let save_btn = Button::with_label("Save"); save_btn.add_css_class("suggested-action"); let status_lbl = Label::new(None); status_lbl.add_css_class("dim-label"); - { let doc = doc.clone(); let model = model.clone(); @@ -198,8 +202,6 @@ pub fn build() -> GBox { } }); } - - btn_row.append(&add_btn); btn_row.append(&save_btn); btn_row.append(&status_lbl); outer.append(&btn_row); diff --git a/bos-settings/src/ui/views/breadclip.rs b/bos-settings/src/ui/views/breadclip.rs index bbb80da..896a2f1 100644 --- a/bos-settings/src/ui/views/breadclip.rs +++ b/bos-settings/src/ui/views/breadclip.rs @@ -5,7 +5,7 @@ //! had no presence in Settings at all despite running its own service. use gtk4::prelude::*; -use gtk4::Box as GBox; +use gtk4::{Box as GBox, Button}; use crate::ui::widgets as w; @@ -13,12 +13,21 @@ pub fn build() -> GBox { let (outer, c) = w::view_scaffold("Clipboard"); c.append(&w::hint( - "breadclip keeps a history of copied text/images and shows it as a \ - popup (SUPER+V or SUPER+SHIFT+V). breadclipd is the background \ - daemon that actually watches the clipboard — breadclip itself is \ - just the popup UI, launched on demand.", + "Keeps a history of copied text/images and shows it as a popup. \ + breadclipd (below) is the background daemon that watches the \ + clipboard — breadclip itself is just the popup UI, launched on \ + demand by the keybind or the button below.", )); - c.append(&w::service_control("breadclipd.service")); + + let open_btn = Button::with_label("Open history (SUPER+V)"); + open_btn.add_css_class("suggested-action"); + open_btn.set_halign(gtk4::Align::Start); + open_btn.connect_clicked(|_| { + let _ = std::process::Command::new("breadclip").spawn(); + }); + c.append(&open_btn); + + c.append(&w::service_control("breadclipd.service", false, false)); outer } diff --git a/bos-settings/src/ui/views/breadcrumbs.rs b/bos-settings/src/ui/views/breadcrumbs.rs index 52bacde..a54b946 100644 --- a/bos-settings/src/ui/views/breadcrumbs.rs +++ b/bos-settings/src/ui/views/breadcrumbs.rs @@ -335,7 +335,7 @@ pub fn build() -> GBox { let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow()))); let (outer, content) = w::view_scaffold("Wi-Fi Profiles"); - content.append(&w::service_control("breadcrumbs.service")); + content.append(&w::service_control("breadcrumbs.service", false, true)); // [settings] — edited in place on the shared doc content.append(&w::section("Settings")); diff --git a/bos-settings/src/ui/views/breadsearch.rs b/bos-settings/src/ui/views/breadsearch.rs index 0ec0628..4245339 100644 --- a/bos-settings/src/ui/views/breadsearch.rs +++ b/bos-settings/src/ui/views/breadsearch.rs @@ -19,7 +19,7 @@ pub fn build() -> GBox { let doc = Rc::new(RefCell::new(config::load_doc(&path))); let (outer, c) = w::view_scaffold("File Search"); - c.append(&w::service_control("breadmill.service")); + c.append(&w::service_control("breadmill.service", false, true)); c.append(&w::section("Power")); c.append(&w::hint( @@ -110,10 +110,6 @@ pub fn build() -> GBox { 200, )); - c.append(&w::hint( - "Changes take effect after a restart — use the Restart button above.", - )); - outer.append(&w::save_button(&doc, path)); outer } diff --git a/bos-settings/src/ui/views/network.rs b/bos-settings/src/ui/views/network.rs index d7e9703..0ed75e9 100644 --- a/bos-settings/src/ui/views/network.rs +++ b/bos-settings/src/ui/views/network.rs @@ -104,18 +104,25 @@ pub fn build() -> GBox { let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::None); + // Starts with an empty_state row ("Not scanned yet") rather than being + // hidden — the "no results yet" and "scanned, found nothing" states are + // the same idea and should look the same, not one a hint label and the + // other an empty_state card. + { + let row = ListBoxRow::new(); + row.set_selectable(false); + row.set_child(Some(&w::empty_state( + "network-wireless-symbolic", + "Not scanned yet", + "Press Scan to see nearby networks.", + ))); + list.append(&row); + } let scroll = ScrolledWindow::new(); scroll.set_min_content_height(220); scroll.set_child(Some(&list)); - // Hidden until a scan actually produces results — a 220px empty scroll - // area between the radio row and the Scan button was dead space on - // every fresh open of this panel. - scroll.set_visible(false); content.append(&scroll); - let not_scanned = w::hint("Not scanned yet — press Scan to see nearby networks."); - content.append(¬_scanned); - // Password entry, shown only while connecting to a secured, unknown // network — set visible/hidden rather than a modal dialog, to keep this // panel's async flow in one place instead of a nested dialog callback. @@ -189,8 +196,6 @@ pub fn build() -> GBox { let populate_list = { let list = list.clone(); - let scroll = scroll.clone(); - let not_scanned = not_scanned.clone(); let pw_row = pw_row.clone(); let pending_ssid = pending_ssid.clone(); let connect_open_or_known = connect_open_or_known.clone(); @@ -198,8 +203,6 @@ pub fn build() -> GBox { while let Some(child) = list.first_child() { list.remove(&child); } - not_scanned.set_visible(false); - scroll.set_visible(true); if networks.is_empty() { let row = ListBoxRow::new(); row.set_selectable(false); @@ -280,6 +283,7 @@ pub fn build() -> GBox { }; let scan_btn = Button::with_label("Scan"); + scan_btn.set_halign(gtk4::Align::Start); { let status = status.clone(); let populate_list = populate_list.clone(); diff --git a/bos-settings/src/ui/views/packages.rs b/bos-settings/src/ui/views/packages.rs index 039d9e6..9768953 100644 --- a/bos-settings/src/ui/views/packages.rs +++ b/bos-settings/src/ui/views/packages.rs @@ -66,10 +66,9 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer, log_view: &Text let row = ListBoxRow::new(); row.set_selectable(false); let hbox = GBox::new(Orientation::Horizontal, 16); - hbox.set_margin_top(6); - hbox.set_margin_bottom(6); - hbox.set_margin_start(8); - hbox.set_margin_end(8); + hbox.add_css_class("card"); + hbox.set_margin_top(3); + hbox.set_margin_bottom(3); let name_lbl = Label::new(Some(name)); name_lbl.set_hexpand(true); diff --git a/bos-settings/src/ui/widgets.rs b/bos-settings/src/ui/widgets.rs index e35d795..eda3721 100644 --- a/bos-settings/src/ui/widgets.rs +++ b/bos-settings/src/ui/widgets.rs @@ -11,7 +11,7 @@ use std::rc::Rc; use gtk4::prelude::*; use gtk4::{ - Adjustment, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation, + Adjustment, AlertDialog, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation, SpinButton, StringList, Switch, }; use toml_edit::DocumentMut; @@ -77,6 +77,13 @@ pub fn hint(text: &str) -> Label { lbl.add_css_class("dim-label"); lbl.set_xalign(0.0); lbl.set_wrap(true); + // set_wrap alone doesn't cap a label's *natural* width — a long enough + // hint reports a natural width of its whole unwrapped string, which + // `view_scaffold`'s content column (sized to its widest child, floored + // not capped at CONTENT_MAX_WIDTH) then inherits, blowing the entire + // panel full-width. This is what makes breadsearch/breadclip render + // edge-to-edge while every other panel sits in a stable ~760px column. + lbl.set_max_width_chars(64); lbl.set_margin_bottom(4); lbl } @@ -111,6 +118,7 @@ pub fn empty_state(icon_name: &str, title: &str, hint_text: &str) -> GBox { hint_lbl.add_css_class("dim-label"); hint_lbl.set_justify(gtk4::Justification::Center); hint_lbl.set_wrap(true); + hint_lbl.set_max_width_chars(48); wrapper.append(&hint_lbl); wrapper @@ -126,18 +134,19 @@ pub fn view_scaffold(title: &str) -> (GBox, GBox) { let title_lbl = Label::new(Some(title)); title_lbl.add_css_class("title"); title_lbl.set_xalign(0.0); + // Same width+center treatment as `content` below, so the title's left + // edge lines up with the column it's heading instead of sitting flush + // against the panel edge while the column centers ~440px to its right. + title_lbl.set_halign(gtk4::Align::Center); + title_lbl.set_size_request(CONTENT_MAX_WIDTH, -1); outer.append(&title_lbl); let content = GBox::new(Orientation::Vertical, 4); // Explicit hexpand(false) overrides the auto-computed value GTK would // otherwise derive from the hexpand-ing labels inside every row, and - // Start keeps content pinned under the left-aligned title instead of - // stretching (Fill) or floating in the middle of a wide pane (Center). + // Center distributes the dead space evenly either side on a wide window + // instead of stranding it all on the right of a left-hugging column. content.set_hexpand(false); - // Center rather than left-align: on a wide/maximized window, a - // left-hugging column just moves the dead space from "right of every - // row" to "right of the whole column" — centering distributes it evenly - // either side instead, which reads as deliberate rather than stranded. content.set_halign(gtk4::Align::Center); content.set_size_request(CONTENT_MAX_WIDTH, -1); @@ -341,11 +350,29 @@ fn systemctl_active(unit: &str) -> bool { .unwrap_or(false) } +fn systemctl_enabled(unit: &str) -> bool { + std::process::Command::new("systemctl") + .args(["--user", "is-enabled", "--quiet", unit]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + /// Live systemd `--user` unit status plus Start/Stop/Restart/Logs controls — /// every bread-ecosystem panel whose app is actually a daemon (not just a /// config file) gets this, instead of only ever being able to edit the TOML /// and hope the running process picks it up. -pub fn service_control(unit: &'static str) -> GBox { +/// +/// `critical` guards Stop behind a confirm dialog — for a unit like `breadd` +/// (the whole desktop's event backbone), a bare click currently stops it as +/// casually as an optional sync helper; everything else can stay one-click. +/// +/// `has_config` adds a one-line note that Restart (not Save) is what applies +/// config changes below — previously only breadsearch's panel said this, +/// inconsistently, in its own hint text; bread/breadbox/breadcrumbs edit a +/// running daemon's TOML and never mentioned the running process won't +/// notice until restarted. +pub fn service_control(unit: &'static str, critical: bool, has_config: bool) -> GBox { let wrapper = GBox::new(Orientation::Vertical, 4); wrapper.append(§ion("Service")); @@ -354,6 +381,10 @@ pub fn service_control(unit: &'static str) -> GBox { status_lbl.set_selectable(true); wrapper.append(&row(unit, &status_lbl)); + let enabled_lbl = Label::new(None); + enabled_lbl.add_css_class("dim-label"); + wrapper.append(&row("Starts at login", &enabled_lbl)); + let btn_row = GBox::new(Orientation::Horizontal, 8); btn_row.set_margin_top(4); let toggle_btn = Button::new(); @@ -365,24 +396,53 @@ pub fn service_control(unit: &'static str) -> GBox { // plain closures aren't Clone. let refresh: Rc = { let status_lbl = status_lbl.clone(); + let enabled_lbl = enabled_lbl.clone(); let toggle_btn = toggle_btn.clone(); Rc::new(move || { let active = systemctl_active(unit); status_lbl.set_text(if active { "Running" } else { "Stopped" }); toggle_btn.set_label(if active { "Stop" } else { "Start" }); + enabled_lbl.set_text(if systemctl_enabled(unit) { "Yes" } else { "No" }); }) }; refresh(); - { + let do_toggle: Rc = { let refresh = refresh.clone(); - toggle_btn.connect_clicked(move |_| { + Rc::new(move || { let verb = if systemctl_active(unit) { "stop" } else { "start" }; let log_buf = gtk4::TextBuffer::new(None); let refresh = refresh.clone(); stream_command_then(&["systemctl", "--user", verb, unit], log_buf, move || { refresh(); }); + }) + }; + { + let do_toggle = do_toggle.clone(); + toggle_btn.connect_clicked(move |btn| { + if critical && systemctl_active(unit) { + let window = btn.root().and_then(|r| r.downcast::().ok()); + let dialog = AlertDialog::builder() + .message(format!("Stop {unit}?")) + .detail( + "This is a core part of the desktop's event handling — \ + stopping it may affect other bread apps until it's \ + restarted.", + ) + .buttons(["Cancel", "Stop"]) + .cancel_button(0) + .default_button(0) + .build(); + let do_toggle = do_toggle.clone(); + dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { + if result == Ok(1) { + do_toggle(); + } + }); + } else { + do_toggle(); + } }); } { @@ -406,6 +466,13 @@ pub fn service_control(unit: &'static str) -> GBox { btn_row.append(&logs_btn); wrapper.append(&btn_row); + if has_config { + wrapper.append(&hint( + "Save (below) only writes the config file — click Restart above \ + for the running service to pick up the change.", + )); + } + wrapper } From 5aaf71e80a1791311981959497eb4b18e0dd6999 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 4 Jul 2026 10:39:44 +0800 Subject: [PATCH 10/12] Ship yay, wire up LUKS disk encryption, self-signed Secure Boot, release signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yay (yay-bin, AUR-only like calamares/bibata) republished to [breadway] via the same PKGBUILD + Forgejo workflow pattern, so users can reach the wider AUR beyond bakery's bread ecosystem. Disk encryption: Calamares' partition module already has LUKS support enabled by default, but the checkbox led nowhere — no cryptsetup on the live/target image, no mkinitcpio encrypt hook, no GRUB cryptodisk wiring. An encrypted install would partition fine and then never boot. Added cryptsetup, pinned luksGeneration to luks1 (GRUB doesn't support LUKS2 + Argon2id), and post-install.sh now detects an encrypted root (lsblk TYPE == crypt) and conditionally adds the encrypt hook + GRUB_ENABLE_CRYPTODISK + --modules="cryptodisk luks luks2" on both grub-install passes. No effect on a normal unencrypted install. Secure Boot: self-signed via sbctl (shipped in packages.x86_64). BOS can't ship a Microsoft-signed shim without going through Microsoft's own paid UEFI CA process, so post-install.sh enrolls BOS's own keys automatically only when the firmware is already in Setup Mode (sbctl status --json), signs the kernel/bootloader, and leaves it alone otherwise — sbctl's own pacman hook re-signs on every future kernel/GRUB update, no further wiring needed. Release signing: generated a dedicated Ed25519 "BOS Release Signing" key (not reused from anything else), stored as the GPG_PRIVATE_KEY Forgejo Actions secret. release-iso.yml now generates SHA256SUMS and a detached SHA256SUMS.asc signature alongside every ISO upload; public key committed at KEYS.asc with verification instructions in the README. README updated: fixed a stale "greetd + tuigreet" line (breadgreet since round 3), documented yay/encryption/secure-boot/verification. --- .forgejo/workflows/release-iso.yml | 78 +++++++++++++------ .forgejo/workflows/yay-bin.yml | 36 +++++++++ KEYS.asc | 15 ++++ README.md | 53 ++++++++++--- .../etc/calamares/modules/partition.conf | 12 +++ iso/airootfs/etc/calamares/post-install.sh | 68 +++++++++++++++- iso/packages.x86_64 | 15 ++++ packaging/yay-bin/PKGBUILD | 40 ++++++++++ 8 files changed, 282 insertions(+), 35 deletions(-) create mode 100644 .forgejo/workflows/yay-bin.yml create mode 100644 KEYS.asc create mode 100644 packaging/yay-bin/PKGBUILD diff --git a/.forgejo/workflows/release-iso.yml b/.forgejo/workflows/release-iso.yml index aff0bef..ea755ea 100644 --- a/.forgejo/workflows/release-iso.yml +++ b/.forgejo/workflows/release-iso.yml @@ -7,8 +7,13 @@ name: Build and release ISO # (GitHub releases cannot host files larger than 2 GB). # # Required secrets: -# RELEASE_TOKEN — Forgejo API token with write:repository scope -# MIRROR_TOKEN — GitHub personal access token with repo scope (already used by mirror.yml) +# RELEASE_TOKEN — Forgejo API token with write:repository scope +# MIRROR_TOKEN — GitHub personal access token with repo scope (already used by mirror.yml) +# GPG_PRIVATE_KEY — armoured secret key for the dedicated "BOS Release Signing" +# identity (releases@breadway.dev); public half is committed +# at KEYS.asc for verification. No passphrase (CI-only key, +# access controlled via the Forgejo secret store, not a +# passphrase nobody could type non-interactively anyway). on: push: @@ -129,14 +134,35 @@ jobs: bash build-local.sh ls -lh /bos-out/*.iso - - name: Create Forgejo release and upload ISO + - name: Checksum and sign + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + run: | + set -euo pipefail + VERSION="${{ steps.vars.outputs.version }}" + ISO=$(ls /bos-out/*.iso | head -1) + ISO_NAME="bos-${VERSION}-x86_64.iso" + cd /bos-out + mv "$(basename "$ISO")" "$ISO_NAME" + + sha256sum "$ISO_NAME" > SHA256SUMS + cat SHA256SUMS + + pacman -Sy --noconfirm --needed gnupg + export GNUPGHOME=/tmp/gnupg-release + mkdir -m 700 -p "$GNUPGHOME" + echo "$GPG_PRIVATE_KEY" | gpg --batch --import + gpg --batch --yes --local-user releases@breadway.dev \ + --detach-sign --armor -o SHA256SUMS.asc SHA256SUMS + echo "Signed SHA256SUMS -> SHA256SUMS.asc" + + - name: Create Forgejo release and upload assets env: FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | set -euo pipefail TAG="${{ steps.vars.outputs.tag }}" VERSION="${{ steps.vars.outputs.version }}" - ISO=$(ls /bos-out/*.iso | head -1) ISO_NAME="bos-${VERSION}-x86_64.iso" # Use an existing release for this tag if one exists (e.g. created @@ -157,35 +183,41 @@ jobs: \"tag_name\": \"${TAG}\", \"name\": \"BOS ${TAG}\", \"prerelease\": false, - \"body\": \"ISO image attached below.\\n\\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.\" + \"body\": \"ISO image attached below. Verify with SHA256SUMS + SHA256SUMS.asc (signed by the BOS Release Signing key — see KEYS.asc in the repo).\\n\\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.\" }") RELEASE_ID=$(echo "${RELEASE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") fi echo "Using release ID: ${RELEASE_ID}" - # Remove any existing asset with the same name before uploading - ASSET_ID=$(curl -sf \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" \ - | python3 -c " + upload_asset() { + local file="$1" name + name="$(basename "$file")" + local asset_id + asset_id=$(curl -sf \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" \ + | python3 -c " import json,sys assets=json.load(sys.stdin) - match=[a['id'] for a in assets if a['name']=='${ISO_NAME}'] + match=[a['id'] for a in assets if a['name']=='${name}'] print(match[0] if match else '') " 2>/dev/null || true) - - if [ -n "${ASSET_ID}" ]; then - curl -fsS -X DELETE \ + if [ -n "${asset_id}" ]; then + curl -fsS -X DELETE \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets/${asset_id}" + echo "Removed existing ${name} asset" + fi + curl -fsS -X POST \ -H "Authorization: token ${FORGEJO_TOKEN}" \ - "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets/${ASSET_ID}" - echo "Removed existing ${ISO_NAME} asset" - fi + -F "attachment=@${file};filename=${name}" \ + "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" + echo "Uploaded: ${name}" + } - curl -fsS -X POST \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - -F "attachment=@${ISO};filename=${ISO_NAME}" \ - "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" - echo "Uploaded: ${ISO_NAME}" + upload_asset "/bos-out/${ISO_NAME}" + upload_asset "/bos-out/SHA256SUMS" + upload_asset "/bos-out/SHA256SUMS.asc" - name: Create GitHub release env: @@ -196,7 +228,7 @@ jobs: VERSION="${{ steps.vars.outputs.version }}" FORGEJO_URL="https://git.breadway.dev/${GITHUB_REPOSITORY}/releases/tag/${TAG}" - printf '**Download ISO:** %s\n\nGitHub releases cannot host files >2 GB; the `bos-%s-x86_64.iso` (~2.5 GB) is on Forgejo.\n\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.' \ + printf '**Download ISO:** %s\n\nGitHub releases cannot host files >2 GB; the `bos-%s-x86_64.iso` (~2.5 GB), SHA256SUMS, and SHA256SUMS.asc (signed by the BOS Release Signing key — public half at [KEYS.asc](https://github.com/Breadway/bos/blob/main/KEYS.asc)) are all on Forgejo.\n\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.' \ "${FORGEJO_URL}" "${VERSION}" > /tmp/gh-release-notes.md gh release create "${TAG}" \ diff --git a/.forgejo/workflows/yay-bin.yml b/.forgejo/workflows/yay-bin.yml new file mode 100644 index 0000000..c31ad58 --- /dev/null +++ b/.forgejo/workflows/yay-bin.yml @@ -0,0 +1,36 @@ +name: Build and publish yay-bin + +# yay (and every AUR helper) is AUR-only — not in Arch's official repos — so +# BOS maintains an in-house PKGBUILD and publishes the built package to the +# [breadway] repo, same as bibata-cursor-theme and calamares. Prebuilt +# release tarball, no build step. +on: + push: + paths: + - 'packaging/yay-bin/**' + workflow_dispatch: + +jobs: + yay-bin: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + steps: + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + pacman -Syu --noconfirm base-devel git + useradd -m builder + git config --global --add safe.directory '*' + git clone --depth 1 --branch "${GITHUB_REF_NAME}" \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src + chown -R builder:builder /home/builder/src + su builder -c "cd /home/builder/src/packaging/yay-bin && makepkg -f --noconfirm --nocheck" + PKG=$(find /home/builder/src/packaging/yay-bin -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/KEYS.asc b/KEYS.asc new file mode 100644 index 0000000..fe380fd --- /dev/null +++ b/KEYS.asc @@ -0,0 +1,15 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEakhwGhYJKwYBBAHaRw8BAQdA/sZ/GYec5M2MD+w20mVF5tMUhGji210Dg7zL +TAhNsg60WUJPUyBSZWxlYXNlIFNpZ25pbmcgKGdpdC5icmVhZHdheS5kZXYvQnJl +YWR3YXkvYm9zIHJlbGVhc2VzIG9ubHkpIDxyZWxlYXNlc0BicmVhZHdheS5kZXY+ +iJYEExYKAD4WIQRWIDuGoRBpWufzEJNK8zI9Z4614gUCakhwGgIbIwUJA8JnAAUL +CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBK8zI9Z4614ggYAQDP8FTZ14i9YPKD +ARvZuP5QaYOUFhQ8uyG0CowXKy9O0AEAqYfjnvyJI3N651pVFSNUXyP16w1kMPSs +K0g3CLsztQ+4OARqSHAaEgorBgEEAZdVAQUBAQdAuJFuy2GHz5m9wXTm/PdSpLE9 +gERwHOLyM1OFuttrJW4DAQgHiH4EGBYKACYWIQRWIDuGoRBpWufzEJNK8zI9Z461 +4gUCakhwGgIbDAUJA8JnAAAKCRBK8zI9Z4614nzLAP9grcIFsAAeCyVKhziHmpXq +E0Hm6FfIr4sdEf63HZkyfwD/XeKeWfb3EWvVsloJrZZ9tDmR67iK52Hwl82wfFAU +cAo= +=Mrh1 +-----END PGP PUBLIC KEY BLOCK----- diff --git a/README.md b/README.md index 930804c..39e1fd8 100644 --- a/README.md +++ b/README.md @@ -20,17 +20,23 @@ wiring up dotfiles, no per-tool bakery installs. - **bos-settings**: a GTK4 control panel that configures every bread\* app's config from a GUI (non-destructively), plus snapshot rollback and bakery updates. See below. -- **Login**: greetd + tuigreet → Hyprland session. +- **Login**: greetd + breadgreet (bread-ecosystem's own greeter, under `cage`) + → Hyprland session. - **Boot splash**: Plymouth `bos` theme (logo + spinner, black background). - **Theming**: global dark across GTK3 (Adwaita-dark), GTK4/libadwaita (`color-scheme: prefer-dark`), and Qt (qt5ct/qt6ct Fusion dark); Papirus-Dark icons; Bibata cursor. - **Apps**: kitty, nautilus (+ gvfs), Zen browser, VLC, loupe, gnome-text-editor, gnome-calculator, file-roller, with file associations wired in `mimeapps.list`. + `yay` ships for AUR access beyond bakery's bread ecosystem + `[breadway]`. - **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing with avahi mDNS discovery, TLP power management, fwupd firmware updates. - **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed). +- **Security**: full-disk encryption (LUKS, via Calamares' built-in support — + cryptsetup + the matching mkinitcpio/GRUB wiring ship so an encrypted + install actually boots) and self-signed Secure Boot (via `sbctl`, enrolled + automatically at install time when the firmware is in Setup Mode). ## Repo layout @@ -73,11 +79,30 @@ to the Tailscale-reachable Forgejo registry for the build. ### Why some packages are in-house -`calamares`, `zen-browser-bin`, and `bibata-cursor-theme` are AUR-only. BOS -keeps a PKGBUILD for each under `packaging/` and republishes the built package -to the `[breadway]` repo via a Forgejo Actions workflow (built on the hestia -self-hosted runner, published with a scoped registry token). `bos-settings` -itself publishes the same way on a `v*` tag. +`calamares`, `zen-browser-bin`, `bibata-cursor-theme`, and `yay-bin` are +AUR-only. BOS keeps a PKGBUILD for each under `packaging/` and republishes the +built package to the `[breadway]` repo via a Forgejo Actions workflow (built +on the hestia self-hosted runner, published with a scoped registry token). +`bos-settings` itself publishes the same way on a `v*` tag. + +### Verifying a release + +Every tagged release ISO on the [Forgejo releases +page](https://git.breadway.dev/Breadway/bos/releases) ships alongside a +`SHA256SUMS` file and a detached signature `SHA256SUMS.asc`, signed by a +dedicated release-signing key (not reused from anything else): + +``` +5620 3B86 A110 695A E7F3 1093 4AF3 323D 678E B5E2 +``` + +The public half is committed at [`KEYS.asc`](KEYS.asc). To verify a download: + +```sh +gpg --import KEYS.asc +gpg --verify SHA256SUMS.asc SHA256SUMS +sha256sum -c SHA256SUMS +``` ## Testing in a VM @@ -166,9 +191,19 @@ cheatsheet in-session; first boot shows a short welcome (once). `virtio-vga-gl` + `-display gtk,gl=on` (virgl); plain software rendering is noticeably laggy. - **Wayland-first**: X11-only apps run through XWayland; a few may misbehave. -- **Secure Boot**: not configured. Boot with Secure Boot disabled, or enroll - your own keys. The installer writes both an NVRAM entry and the removable - `EFI/BOOT/BOOTX64.EFI` fallback. +- **Secure Boot**: self-signed only, via `sbctl` — BOS can't ship a + Microsoft-signed shim (that needs going through Microsoft's own paid UEFI + CA process). Post-install enrolls BOS's own keys automatically, but only + when the firmware is already in Setup Mode (no vendor keys installed yet); + otherwise it's skipped and you can run + `sudo sbctl enroll-keys --microsoft && sudo sbctl sign-all -g` yourself + later (after clearing your firmware's existing keys, if any). The installer + writes both an NVRAM entry and the removable `EFI/BOOT/BOOTX64.EFI` fallback + either way. +- **Disk encryption**: full-disk LUKS is available on the installer's "Erase + disk" page (Calamares' own checkbox) and on manually-created partitions — + BOS ships the matching `cryptsetup`/mkinitcpio/GRUB wiring so an encrypted + install actually boots (LUKS1, since GRUB doesn't support LUKS2 + Argon2id). - **Snapshots assume btrfs**: the snapper/grub-btrfs tooling expects the default btrfs subvolume layout the installer creates. diff --git a/iso/airootfs/etc/calamares/modules/partition.conf b/iso/airootfs/etc/calamares/modules/partition.conf index 13e6e1e..1ea258b 100644 --- a/iso/airootfs/etc/calamares/modules/partition.conf +++ b/iso/airootfs/etc/calamares/modules/partition.conf @@ -19,3 +19,15 @@ userSwapChoices: - small - suspend - file + +# Full-disk encryption (LUKS) is enabled by default in Calamares' partition +# module (enableLuksAutomatedPartitioning defaults to true) — the checkbox +# already shows on the "Erase disk" page with no config needed here. Pin the +# LUKS generation explicitly rather than relying on Calamares' own implicit +# default: GRUB doesn't support LUKS2 + Argon2id, only PBKDF2, and using the +# wrong KDF produces an encrypted install GRUB can't unlock at boot. luks1 +# is unconditionally safe with BOS's plain grub-install setup (no separate +# unencrypted /boot — GRUB itself has to unlock the LUKS container to read +# the kernel). See post-install.sh for the matching cryptsetup/mkinitcpio/ +# GRUB wiring this actually needs to be bootable. +luksGeneration: luks1 diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index b7309db..9925dca 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -10,6 +10,19 @@ set -uo pipefail MAIN_USER="$(getent passwd 1000 | cut -d: -f1 || true)" +# Whether Calamares encrypted the root partition (LUKS) — checked once here, +# used below to conditionally wire mkinitcpio's encrypt hook and GRUB's +# cryptodisk support. `lsblk TYPE` reports "crypt" for a cryptsetup-opened +# mapper device regardless of what Calamares named it, so this works whether +# the user picked automated "Erase disk" encryption or hand-encrypted a +# partition in manual mode. +ROOT_SRC="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" +if [[ "$(lsblk -no TYPE "$ROOT_SRC" 2>/dev/null)" == "crypt" ]]; then + ROOT_ENCRYPTED=1 +else + ROOT_ENCRYPTED=0 +fi + # --------------------------------------------------------------------------- # Strip live-only bits that unpackfs copied verbatim from the live medium. # --------------------------------------------------------------------------- @@ -57,6 +70,15 @@ if [[ -f /etc/mkinitcpio.conf ]]; then sed -i 's/^\(HOOKS=.*\budev\b\)/\1 plymouth/' /etc/mkinitcpio.conf \ || echo "WARN: adding plymouth hook failed" fi + # encrypt — only when root is actually LUKS-encrypted (ROOT_ENCRYPTED, + # detected above). Must sit after `block` (provides the device nodes the + # encrypt hook opens) and before `filesystems` (mounts the now-unlocked + # root) — both already present in stock mkinitcpio.conf's default HOOKS. + if [[ "$ROOT_ENCRYPTED" == "1" ]] \ + && ! grep -qE '^HOOKS=.*\bencrypt\b' /etc/mkinitcpio.conf; then + sed -i 's/^\(HOOKS=.*\bblock\b\)/\1 encrypt/' /etc/mkinitcpio.conf \ + || echo "WARN: adding encrypt hook failed" + fi fi # --------------------------------------------------------------------------- @@ -74,6 +96,17 @@ if command -v plymouth-set-default-theme &>/dev/null; then plymouth-set-default-theme bos || echo "WARN: plymouth-set-default-theme failed" fi +# GRUB needs to unlock LUKS itself to reach the kernel — there's no separate +# unencrypted /boot partition (only /boot/efi is separate). GRUB_ENABLE_CRYPTODISK +# makes grub-mkconfig emit the cryptomount commands grub.cfg needs; the +# --modules flags below (on both grub-install calls) make sure the cryptodisk +# and luks decoders are actually compiled into core.img, not just referenced. +if [[ "$ROOT_ENCRYPTED" == "1" ]] && [[ -f /etc/default/grub ]] \ + && ! grep -q '^GRUB_ENABLE_CRYPTODISK=' /etc/default/grub; then + echo 'GRUB_ENABLE_CRYPTODISK=y' >> /etc/default/grub \ + || echo "WARN: adding GRUB_ENABLE_CRYPTODISK failed" +fi + # Rebuild every preset (default + fallback that bos-copy-kernel wrote) so the # microcode + plymouth HOOKS above are actually baked into the initramfs. mkinitcpio -P || echo "WARN: mkinitcpio -P failed" @@ -95,18 +128,20 @@ mkinitcpio -P || echo "WARN: mkinitcpio -P failed" # BIOS: MBR install onto the disk hosting /. # --------------------------------------------------------------------------- if command -v grub-install &>/dev/null; then + CRYPT_MODULES=() + [[ "$ROOT_ENCRYPTED" == "1" ]] && CRYPT_MODULES=(--modules="cryptodisk luks luks2") if [[ -d /sys/firmware/efi ]]; then grub-install --target=x86_64-efi --efi-directory=/boot/efi \ - --bootloader-id=BOS --recheck \ + --bootloader-id=BOS --recheck "${CRYPT_MODULES[@]}" \ || echo "WARN: grub-install (nvram) failed" grub-install --target=x86_64-efi --efi-directory=/boot/efi \ - --removable --recheck \ + --removable --recheck "${CRYPT_MODULES[@]}" \ || echo "WARN: grub-install (removable) failed" else ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" ROOT_DISK="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null)" if [[ -n "$ROOT_DISK" ]]; then - grub-install --target=i386-pc --recheck "/dev/$ROOT_DISK" \ + grub-install --target=i386-pc --recheck "${CRYPT_MODULES[@]}" "/dev/$ROOT_DISK" \ || echo "WARN: grub-install (BIOS) failed" else echo "WARN: could not determine the disk hosting / (root device: ${ROOT_DEV:-unknown}) — BIOS grub-install skipped" @@ -117,6 +152,33 @@ if command -v grub-mkconfig &>/dev/null; then grub-mkconfig -o /boot/grub/grub.cfg || echo "WARN: grub-mkconfig failed" fi +# --------------------------------------------------------------------------- +# Secure Boot: self-signed keys via sbctl, only when the firmware is already +# in Setup Mode (no vendor PK enrolled — the state a fresh/never-used +# machine boots in, or one where the user cleared their firmware's keys +# before installing). BOS can't ship a Microsoft-signed shim — that requires +# going through Microsoft's own paid UEFI CA signing process — so this is +# the realistic path for an Arch-based distro: generate our own keys, enroll +# them (plus Microsoft's, so a dual-booted Windows bootmgr and fwupd's +# signed capsule updates still verify), and sign the kernel + GRUB. sbctl's +# own package ships a pacman hook (zz-sbctl.hook) that re-signs everything +# automatically on every future kernel/GRUB update — nothing else to wire up. +# Best-effort and silent-skip (not a WARN) when out of Setup Mode — that's +# the expected state on most real hardware, not a failure. +# --------------------------------------------------------------------------- +if [[ -d /sys/firmware/efi ]] && command -v sbctl &>/dev/null; then + SETUP_MODE="$(sbctl status --json 2>/dev/null | python3 -c \ + 'import json,sys; print(json.load(sys.stdin).get("setup_mode", False))' 2>/dev/null)" + if [[ "$SETUP_MODE" == "True" ]]; then + sbctl create-keys || echo "WARN: sbctl create-keys failed" + sbctl enroll-keys --microsoft || echo "WARN: sbctl enroll-keys failed" + sbctl sign-all -g || echo "WARN: sbctl sign-all failed" + echo "Secure Boot: keys enrolled and boot files signed." + else + echo "Secure Boot: firmware not in Setup Mode — skipped (run 'sudo sbctl enroll-keys --microsoft && sudo sbctl sign-all -g' manually later if desired)." + fi +fi + # --------------------------------------------------------------------------- # Create @snapshots, @log, @cache as top-level btrfs subvolumes (peers of @, # not nested under it — so snapshots of @ don't recursively include diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index 058aac7..9b4ff5b 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -30,6 +30,15 @@ efibootmgr btrfs-progs dosfstools mtools +# LUKS full-disk encryption — Calamares' partition module has encryption +# support built in and enabled by default, but needs cryptsetup actually +# present (live, to create the container; installed, to unlock at boot via +# mkinitcpio's encrypt hook) or the checkbox leads to an unbootable system. +cryptsetup +# Secure Boot key enrollment/signing (self-signed — see post-install.sh). +# Ships its own pacman hook (zz-sbctl.hook) that re-signs the kernel/ +# bootloader automatically on every future update once enrolled. +sbctl # squashfs-tools: provides unsquashfs, which Calamares' unpackfs module uses # to extract airootfs.sfs onto the target during install. squashfs-tools @@ -161,6 +170,12 @@ mailcap # (calamares 3.4.x is already Qt6; there is no separate calamares-qt6 package) calamares +# AUR helper — yay-bin is AUR-only (no AUR helper ships in the official +# repos), so it's republished to [breadway] the same way (see +# packaging/yay-bin). Lets users reach the wider AUR beyond bakery's bread +# ecosystem + [breadway]'s own small set of republished packages. +yay-bin + # Bread ecosystem. # # The bread apps themselves (bakery, bread, breadbar, breadbox, breadcrumbs, diff --git a/packaging/yay-bin/PKGBUILD b/packaging/yay-bin/PKGBUILD new file mode 100644 index 0000000..0a3a45f --- /dev/null +++ b/packaging/yay-bin/PKGBUILD @@ -0,0 +1,40 @@ +# BOS in-house rebuild of yay-bin (AUR-only upstream — no AUR helper is in +# the official Arch repos, including yay itself). Republished to the +# [breadway] repo so the ISO build can pull it via pacman (same pattern as +# bibata-cursor-theme and calamares). Prebuilt release tarball — no build step. +# Upstream maintainer: Jguer +pkgname=yay-bin +pkgver=13.0.1 +pkgrel=1 +pkgdesc="Yet another yogurt. Pacman wrapper and AUR helper written in go. Pre-compiled." +arch=('x86_64') +url="https://github.com/Jguer/yay" +license=('GPL-3.0-or-later') +depends=( + 'pacman>6.1' + 'git' +) +optdepends=( + 'sudo: privilege elevation' + 'doas: privilege elevation' +) +provides=('yay') +conflicts=('yay') + +source=("https://github.com/Jguer/yay/releases/download/v${pkgver}/${pkgname/-bin/}_${pkgver}_x86_64.tar.gz") +sha256sums=('1fdfcb5f7f387bc858d3a5754bdf4e4575bfbddac9560535a716d0ed7189c057') + +package() { + _output="${srcdir}/${pkgname/-bin/}_${pkgver}_${CARCH}" + install -Dm755 "${_output}/${pkgname/-bin/}" "${pkgdir}/usr/bin/${pkgname/-bin/}" + install -Dm644 "${_output}/yay.8" "${pkgdir}/usr/share/man/man8/yay.8" + + install -Dm644 "${_output}/bash" "${pkgdir}/usr/share/bash-completion/completions/yay" + install -Dm644 "${_output}/zsh" "${pkgdir}/usr/share/zsh/site-functions/_yay" + install -Dm644 "${_output}/fish" "${pkgdir}/usr/share/fish/vendor_completions.d/yay.fish" + + LANGS="ca cs de en es eu fr_FR he id it_IT ja ko pl_PL pt_BR pt ru_RU ru sv tr uk zh_CN zh_TW" + for lang in ${LANGS}; do + install -Dm644 "${_output}/${lang}.mo" "${pkgdir}/usr/share/locale/${lang}/LC_MESSAGES/yay.mo" + done +} From ad4d71db34532ac83d849d5524537f601f2a53c1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 4 Jul 2026 11:04:16 +0800 Subject: [PATCH 11/12] bos-settings 0.6.0: Power, Firewall, Users, AUR, Firmware panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues reducing terminal-reliance for graphical system control. Five new panels, plus three GUI apps shipped for things better served by an existing dedicated tool than reimplemented in bos-settings (gnome-disk-utility, gufw, mission-center). - Power: battery status/health, brightness, charge-limit thresholds where the hardware exposes them, TLP profile shown read-only by design (no Balanced/Performance switcher — TLP auto-selects by power source, and power-profiles-daemon isn't installed because it conflicts with tlp). - Firewall: ufw enable/disable, add/remove rules, view active rules. ufw's own status check requires root (confirmed against the installed script — not just changes, reads too), so unlike every other panel this one does NOT query state in build(): every view is constructed eagerly at app launch, and an unconditional privileged read here would mean a polkit prompt on every single bos-settings open. Starts blank with a "Status not loaded" placeholder; state loads only on an explicit Refresh click, with a guard so refresh's own set_active() doesn't loop back into triggering ufw enable/disable. - Users: add/remove accounts, change passwords. All through pkexec on a background thread. Can't remove the account you're currently running as. - AUR: search via yay. Installing deliberately opens a terminal instead of a silent --noconfirm install — yay's interactive PKGBUILD diff review and sudo prompt are the actual safety mechanism against a malicious AUR package, not a formality worth automating away. - Firmware: fwupd device list + updates, same stream-output-then-refresh pattern as Packages. packages.x86_64: gnome-disk-utility, gufw, mission-center for disk/firewall/ task-manager GUIs that don't need reinventing inside bos-settings. --- Cargo.lock | 2 +- README.md | 47 +++-- bos-settings/Cargo.toml | 2 +- bos-settings/src/ui/sidebar.rs | 5 + bos-settings/src/ui/views/aur.rs | 169 +++++++++++++++ bos-settings/src/ui/views/firewall.rs | 246 +++++++++++++++++++++ bos-settings/src/ui/views/firmware.rs | 159 ++++++++++++++ bos-settings/src/ui/views/mod.rs | 5 + bos-settings/src/ui/views/power.rs | 193 +++++++++++++++++ bos-settings/src/ui/views/users.rs | 293 ++++++++++++++++++++++++++ bos-settings/src/ui/window.rs | 5 + iso/packages.x86_64 | 9 + 12 files changed, 1119 insertions(+), 16 deletions(-) create mode 100644 bos-settings/src/ui/views/aur.rs create mode 100644 bos-settings/src/ui/views/firewall.rs create mode 100644 bos-settings/src/ui/views/firmware.rs create mode 100644 bos-settings/src/ui/views/power.rs create mode 100644 bos-settings/src/ui/views/users.rs diff --git a/Cargo.lock b/Cargo.lock index b126d42..bc9c049 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bos-settings" -version = "0.5.0" +version = "0.6.0" dependencies = [ "async-channel", "bread-theme", diff --git a/README.md b/README.md index 39e1fd8..b736e5d 100644 --- a/README.md +++ b/README.md @@ -120,21 +120,40 @@ avoid memory pressure. ## bos-settings -`bos-settings` edits each bread\* app's TOML **non-destructively**: it parses -the file with `toml_edit`, changes only the keys a view exposes, and writes it -back — preserving comments and any keys the UI doesn't model (calendar -passwords, saved-network passwords, model paths). Views: +A GTK4 settings app aiming for GNOME-Settings-style parity: not just editing +config files, but live system state and control, so day-to-day machine +administration doesn't require a terminal. -| View | Config | -|------|--------| -| bread | `bread/breadd.toml` — daemon, lua, modules, all adapters, events, notifications | -| breadbar | `breadbar/style.css` override | -| breadbox | `breadbox/config.toml` — launcher contexts | -| breadcrumbs | `breadcrumbs/breadcrumbs.toml` — settings, saved networks, profiles | -| breadpad | `breadpad/breadpad.toml` — settings, model + ollama, reminders, calendar | -| Snapshots | `snapper` list / rollback / delete | -| Packages | `bakery` installed list + updates | -| Hyprland | open config in editor + monitor list | +Bread-ecosystem TOML configs are edited **non-destructively**: `toml_edit` +parses the file, changes only the keys a view exposes, and writes it back — +preserving comments and any keys the UI doesn't model (calendar passwords, +saved-network passwords, model paths). Panels with a daemon behind them +(bread, breadbox, breadcrumbs, breadsearch, breadclip) also get live +systemd status + Start/Stop/Restart/Logs via a shared `service_control` +widget, not just the config file. + +| Panel | What it does | +|-------|--------------| +| About | System info (OS/kernel/CPU/GPU/memory/disk/uptime) + hostname | +| Network | Wi-Fi scan/connect, Ethernet status, radio toggle | +| Wi-Fi Profiles (breadcrumbs) | `breadcrumbs.toml` — settings, saved networks, profiles | +| Firewall | ufw rules: enable/disable, add/remove, view active rules | +| Sound | PipeWire output/input device + volume via `pactl` | +| Power | Battery status/health, brightness, charge limits (hardware-dependent), TLP profile (read-only) | +| Date & Time | Timezone, NTP sync toggle | +| Display (Hyprland) | Connected monitors + open `hyprland.lua` in editor | +| Users | Add/remove accounts, change passwords | +| Wallpaper (breadpaper) | Set wallpaper, drives the pywal-derived accent palette | +| Bar (breadbar) | `breadbar/style.css` override, live-reloads on save | +| Launcher (breadbox) | `breadbox/config.toml` — launcher contexts | +| Clipboard (breadclip) | breadclipd service control + "open history" | +| Notes (breadpad) | `breadpad/breadpad.toml` — settings, model + ollama, reminders, calendar | +| File Search (breadsearch) | `breadsearch/config.toml` — index/search/model + breadmill service | +| Daemon (bread) | `breadd.toml` — daemon, lua, modules, adapters, events, notifications | +| Packages | `bakery` installed list + updates, pacman system update | +| AUR | Search via `yay`; installing opens a terminal (AUR build scripts need review) | +| Firmware | `fwupd` device list + updates | +| Snapshots | `snapper` list / boot-into (grub-btrfs) / delete | Build standalone: diff --git a/bos-settings/Cargo.toml b/bos-settings/Cargo.toml index eaf8db9..61bb665 100644 --- a/bos-settings/Cargo.toml +++ b/bos-settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bos-settings" -version = "0.5.0" +version = "0.6.0" edition = "2021" [dependencies] diff --git a/bos-settings/src/ui/sidebar.rs b/bos-settings/src/ui/sidebar.rs index bbc4fb1..5b2ef8e 100644 --- a/bos-settings/src/ui/sidebar.rs +++ b/bos-settings/src/ui/sidebar.rs @@ -33,9 +33,12 @@ const fn item_sub( pub const SYSTEM_ITEMS: &[SidebarItem] = &[ item("network", "Network", "network-wireless-symbolic"), item_sub("breadcrumbs", "Wi-Fi Profiles", "breadcrumbs", "network-workgroup-symbolic"), + item("firewall", "Firewall", "security-high-symbolic"), item("sound", "Sound", "audio-volume-high-symbolic"), + item("power", "Power", "battery-good-symbolic"), item("datetime", "Date & Time", "preferences-system-time-symbolic"), item_sub("hyprland", "Display", "hyprland.lua", "video-display-symbolic"), + item("users", "Users", "system-users-symbolic"), ]; pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[ @@ -50,6 +53,8 @@ pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[ pub const MAINTENANCE_ITEMS: &[SidebarItem] = &[ item("packages", "Packages", "package-x-generic-symbolic"), + item("aur", "AUR", "system-search-symbolic"), + item("firmware", "Firmware", "software-update-available-symbolic"), item("snapshots", "Snapshots", "document-open-recent-symbolic"), ]; diff --git a/bos-settings/src/ui/views/aur.rs b/bos-settings/src/ui/views/aur.rs new file mode 100644 index 0000000..b526224 --- /dev/null +++ b/bos-settings/src/ui/views/aur.rs @@ -0,0 +1,169 @@ +//! AUR search via yay — graphical discovery for the wider AUR beyond +//! bakery's bread ecosystem and [breadway]'s own republished packages. +//! +//! Search and browsing are fully graphical; actually installing a package +//! opens a terminal running `yay -S ` instead of a silent `--noconfirm` +//! install. That's deliberate, not a shortcut we didn't get around to: +//! AUR packages run arbitrary maintainer-supplied build scripts, and yay's +//! interactive PKGBUILD diff review (plus the sudo password prompt) is the +//! actual safety mechanism against a malicious/compromised AUR package — +//! automating it away in the name of "no terminal" would remove the one +//! step that exists to catch that. + +use gtk4::prelude::*; +use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow}; +use std::process::Command; + +use crate::ui::widgets as w; + +#[derive(Clone)] +struct AurResult { + name: String, + version: String, + description: String, +} + +fn search(query: &str) -> Vec { + let Ok(output) = Command::new("yay").args(["-Ss", "--aur", query]).output() else { + return Vec::new(); + }; + let text = String::from_utf8_lossy(&output.stdout); + let mut results = Vec::new(); + let mut lines = text.lines().peekable(); + while let Some(header) = lines.next() { + // "aur/name version (+votes score) [Orphaned]" — name/version are + // always the first two whitespace-separated fields after the repo/. + let Some(rest) = header.strip_prefix("aur/") else { continue }; + let mut parts = rest.split_whitespace(); + let Some(name) = parts.next() else { continue }; + let version = parts.next().unwrap_or("").to_string(); + let description = lines.next().unwrap_or("").trim().to_string(); + results.push(AurResult { name: name.to_string(), version, description }); + } + results +} + +fn install_in_terminal(pkg: &str) { + let _ = Command::new("kitty").args(["-e", "yay", "-S", pkg]).spawn(); +} + +pub fn build() -> GBox { + let (outer, content) = w::view_scaffold("AUR"); + content.append(&w::hint( + "Search the Arch User Repository via yay. Installing opens a \ + terminal — AUR packages run arbitrary build scripts, and reviewing \ + what yay is about to do (and entering your password) is a real \ + safety step, not just a formality.", + )); + + let search_row = GBox::new(Orientation::Horizontal, 8); + let search_entry = Entry::new(); + search_entry.set_hexpand(true); + search_entry.set_placeholder_text(Some("Search the AUR…")); + let search_btn = Button::with_label("Search"); + search_btn.add_css_class("suggested-action"); + search_row.append(&search_entry); + search_row.append(&search_btn); + content.append(&search_row); + + let status = w::hint("Search for a package to see results here."); + content.append(&status); + + let list = ListBox::new(); + list.set_selection_mode(gtk4::SelectionMode::None); + let scroll = ScrolledWindow::new(); + scroll.set_vexpand(true); + scroll.set_min_content_height(320); + scroll.set_child(Some(&list)); + content.append(&scroll); + + let run_search = { + let list = list.clone(); + let status = status.clone(); + let search_entry = search_entry.clone(); + move |btn: Option