From 9119d99ba90b5f0380624ef611e80c2a1d439a68 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 22:35:50 +0800 Subject: [PATCH 01/48] 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 02/48] 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 03/48] 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 04/48] 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 05/48] 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 06/48] 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 07/48] 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 08/48] 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