diff --git a/bos-settings/src/ui/sidebar.rs b/bos-settings/src/ui/sidebar.rs index d4185f2..0ae4e3d 100644 --- a/bos-settings/src/ui/sidebar.rs +++ b/bos-settings/src/ui/sidebar.rs @@ -12,6 +12,7 @@ pub const APPS_ITEMS: &[SidebarItem] = &[ SidebarItem { id: "breadbox", label: "breadbox" }, SidebarItem { id: "breadcrumbs", label: "breadcrumbs" }, SidebarItem { id: "breadpad", label: "breadpad" }, + SidebarItem { id: "breadpaper", label: "breadpaper" }, SidebarItem { id: "breadsearch", label: "breadsearch" }, ]; @@ -33,12 +34,12 @@ pub fn build() -> (GBox, ListBox) { append_section(&list, "Apps", APPS_ITEMS); append_section(&list, "System", SYSTEM_ITEMS); - // Select the snapshots row so it matches the default stack page + // Select the bread row so it matches the default stack page let mut i = 0; loop { match list.row_at_index(i) { None => break, - Some(row) if row.widget_name() == "snapshots" => { + Some(row) if row.widget_name() == "bread" => { list.select_row(Some(&row)); break; } diff --git a/bos-settings/src/ui/views/breadcrumbs.rs b/bos-settings/src/ui/views/breadcrumbs.rs index f479a13..41761ab 100644 --- a/bos-settings/src/ui/views/breadcrumbs.rs +++ b/bos-settings/src/ui/views/breadcrumbs.rs @@ -356,7 +356,9 @@ pub fn build() -> GBox { &doc, &["settings", "default_profile"], &["home", "away"], - "home", + // breadcrumbs' own default_profile_name() is "away", not "home" — + // this was showing the wrong value for an unset key. + "away", )); content.append(&w::entry_row("DNS", &doc, &["settings", "dns"], "1.1.1.1", "")); content.append(&w::entry_row( diff --git a/bos-settings/src/ui/views/breadpaper.rs b/bos-settings/src/ui/views/breadpaper.rs new file mode 100644 index 0000000..e1d7c18 --- /dev/null +++ b/bos-settings/src/ui/views/breadpaper.rs @@ -0,0 +1,153 @@ +//! breadpaper — wallpaper manager. No config file to edit here; breadpaper +//! takes no persistent settings, just an image path via its CLI (`breadpaper +//! set ` / `breadpaper get`). This panel is a thin GUI front-end for +//! that CLI so wallpaper (and the pywal-driven theme it generates) has a +//! discoverable home in BOS Settings instead of only being reachable from a +//! terminal. + +use std::path::PathBuf; +use std::process::Command; + +use gtk4::prelude::*; +use gtk4::{ + Box as GBox, Button, FileChooserAction, FileChooserDialog, Image, Label, Orientation, + ResponseType, +}; + +use crate::ui::widgets as w; + +fn current_wallpaper() -> Option { + let out = Command::new("breadpaper").arg("get").output().ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { + None + } else { + Some(PathBuf::from(s)) + } +} + +fn refresh_preview(preview: &Image, path_lbl: &Label) { + match current_wallpaper() { + Some(path) => { + path_lbl.set_text(&path.display().to_string()); + preview.set_from_file(Some(&path)); + } + None => { + path_lbl.set_text("No wallpaper set"); + preview.set_icon_name(Some("image-missing")); + } + } +} + +pub fn build() -> GBox { + let (outer, c) = w::view_scaffold("breadpaper"); + + c.append(&w::hint( + "Sets the desktop wallpaper, generates a matching pywal palette, and \ + reloads the shared bread-theme stylesheet — the wallpaper drives \ + the whole desktop's accent colors.", + )); + + let preview = Image::new(); + preview.set_pixel_size(320); + preview.set_margin_top(8); + preview.set_margin_bottom(8); + c.append(&preview); + + let path_lbl = Label::new(None); + path_lbl.set_wrap(true); + path_lbl.add_css_class("dim-label"); + c.append(&path_lbl); + + refresh_preview(&preview, &path_lbl); + + let btn_row = GBox::new(Orientation::Horizontal, 8); + btn_row.set_margin_top(8); + + let choose_btn = Button::with_label("Choose image..."); + let status = Label::new(None); + status.add_css_class("dim-label"); + + { + let preview = preview.clone(); + let path_lbl = path_lbl.clone(); + let status = status.clone(); + choose_btn.connect_clicked(move |btn| { + let window = btn.root().and_then(|r| r.downcast::().ok()); + let dialog = FileChooserDialog::new( + Some("Choose a wallpaper"), + window.as_ref(), + FileChooserAction::Open, + &[("Cancel", ResponseType::Cancel), ("Set", ResponseType::Accept)], + ); + // Restricted to what breadpaper's own validate() actually + // accepts (png/jpg/jpeg/webp/gif/bmp) — add_pixbuf_formats() + // also offers svg/tiff/etc. that breadpaper rejects outright. + let filter = gtk4::FileFilter::new(); + for ext in ["png", "jpg", "jpeg", "webp", "gif", "bmp"] { + filter.add_suffix(ext); + } + filter.set_name(Some("Images")); + dialog.add_filter(&filter); + + let preview = preview.clone(); + let path_lbl = path_lbl.clone(); + let status = status.clone(); + dialog.connect_response(move |dialog, response| { + if response == ResponseType::Accept { + if let Some(file) = dialog.file() { + if let Some(path) = file.path() { + // breadpaper set runs `awww img` + pywal palette + // generation, which is routinely 1-3s (pywal + // spawns Python + an ImageMagick backend) — not + // the "sub-second" call this used to assume. + // GTK widgets aren't Send, so run it in a thread + // and hand the result back over a channel + // (same pattern as snapshots.rs). + status.set_text("Setting..."); + let (tx, rx) = async_channel::bounded::(1); + std::thread::spawn(move || { + let ok = Command::new("breadpaper") + .arg("set") + .arg(&path) + .status() + .map(|s| s.success()) + .unwrap_or(false); + let _ = tx.send_blocking(ok); + }); + + let preview = preview.clone(); + let path_lbl = path_lbl.clone(); + let status = status.clone(); + glib::spawn_future_local(async move { + let ok = rx.recv().await.unwrap_or(false); + if ok { + refresh_preview(&preview, &path_lbl); + status.set_text("Wallpaper set"); + } else { + status.set_text("breadpaper failed — see terminal/journal"); + } + let lbl = status.clone(); + glib::timeout_add_seconds_local(3, move || { + lbl.set_text(""); + glib::ControlFlow::Break + }); + }); + } + } + } + dialog.close(); + }); + dialog.show(); + }); + } + + btn_row.append(&choose_btn); + btn_row.append(&status); + c.append(&btn_row); + + outer +} diff --git a/bos-settings/src/ui/views/breadsearch.rs b/bos-settings/src/ui/views/breadsearch.rs index e12a09d..4c292c4 100644 --- a/bos-settings/src/ui/views/breadsearch.rs +++ b/bos-settings/src/ui/views/breadsearch.rs @@ -34,16 +34,22 @@ pub fn build() -> GBox { )); c.append(&w::section("Model")); + // breadmill supports npu/rocm backends in its own codebase, but the + // prebuilt binary bakery actually ships is CPU-only (no --features + // npu/rocm in its release build) — offering them here would just be a + // dropdown option that silently falls back to cpu. Re-add once a build + // with those features is published. c.append(&w::dropdown_row( "Compute backend", &doc, &["model", "backend"], - &["cpu", "npu", "rocm"], + &["cpu"], "cpu", )); c.append(&w::hint( - "npu needs breadmill built with --features npu (AMD Ryzen AI SDK); \ - rocm needs --features rocm. Falls back to cpu if not compiled in.", + "The bakery-published breadmill is CPU-only for now. NPU (AMD Ryzen \ + AI) and ROCm backends exist in breadmill's own code but need a \ + separately-built binary with those features enabled.", )); c.append(&w::section("Index")); diff --git a/bos-settings/src/ui/views/mod.rs b/bos-settings/src/ui/views/mod.rs index d29a324..ef9936c 100644 --- a/bos-settings/src/ui/views/mod.rs +++ b/bos-settings/src/ui/views/mod.rs @@ -3,6 +3,7 @@ pub mod breadbar; pub mod breadbox; pub mod breadcrumbs; pub mod breadpad; +pub mod breadpaper; pub mod breadsearch; pub mod hyprland; pub mod packages; diff --git a/bos-settings/src/ui/views/packages.rs b/bos-settings/src/ui/views/packages.rs index 79d01d0..e860b80 100644 --- a/bos-settings/src/ui/views/packages.rs +++ b/bos-settings/src/ui/views/packages.rs @@ -7,6 +7,8 @@ use std::collections::HashMap; use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; +use crate::ui::widgets as w; + fn read_installed() -> HashMap { let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); let path = std::path::Path::new(&home) @@ -42,6 +44,14 @@ fn read_installed() -> HashMap { } fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { + stream_command_then(args, log_buf, || {}); +} + +/// Same as stream_command, but runs `on_done` once the process's output +/// stream ends (i.e. the child has exited) — the channel closes when both +/// the stdout- and stderr-forwarding threads drop their sender, which only +/// happens after `child.wait()` returns. +fn stream_command_then(args: &[&str], log_buf: gtk4::TextBuffer, on_done: impl FnOnce() + 'static) { let (sender, receiver) = async_channel::bounded::(256); let args: Vec = args.iter().map(|s| s.to_string()).collect(); @@ -65,7 +75,7 @@ fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { let stderr = child.stderr.take().expect("stderr piped"); let tx2 = sender.clone(); - std::thread::spawn(move || { + let stderr_thread = std::thread::spawn(move || { for line in BufReader::new(stderr).lines().flatten() { let _ = tx2.send_blocking(line); } @@ -75,6 +85,7 @@ fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { let _ = sender.send_blocking(line); } let _ = child.wait(); + let _ = stderr_thread.join(); }); glib::spawn_future_local(async move { @@ -82,25 +93,14 @@ fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { let mut end = log_buf.end_iter(); log_buf.insert(&mut end, &format!("{line}\n")); } + on_done(); }); } -pub fn build() -> GBox { - let vbox = GBox::new(Orientation::Vertical, 0); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("Packages")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some("Bread ecosystem packages installed via bakery.")); - subtitle.set_xalign(0.0); - subtitle.set_margin_bottom(16); - vbox.append(&subtitle); - - let list = ListBox::new(); - list.set_selection_mode(gtk4::SelectionMode::None); +fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { + while let Some(child) = list.first_child() { + list.remove(&child); + } let packages = read_installed(); if packages.is_empty() { @@ -114,52 +114,82 @@ pub fn build() -> GBox { lbl.set_margin_start(8); row.set_child(Some(&lbl)); list.append(&row); - } else { - let mut names: Vec<_> = packages.iter().collect(); - names.sort_by_key(|(k, _)| k.as_str()); - - for (name, version) in names { - let row = ListBoxRow::new(); - row.set_selectable(false); - let hbox = GBox::new(Orientation::Horizontal, 16); - hbox.set_margin_top(6); - hbox.set_margin_bottom(6); - hbox.set_margin_start(8); - hbox.set_margin_end(8); - - let name_lbl = Label::new(Some(name)); - name_lbl.set_hexpand(true); - name_lbl.set_xalign(0.0); - - let ver_lbl = Label::new(Some(version)); - ver_lbl.set_xalign(1.0); - - // Spawn a thread to reap the child process — no zombies - let pkg_name = name.clone(); - let update_btn = Button::with_label("Update"); - update_btn.connect_clicked(move |_| { - match Command::new("bakery").args(["update", &pkg_name]).spawn() { - Ok(mut child) => { - std::thread::spawn(move || { let _ = child.wait(); }); - } - Err(_) => {} // bakery not found; button is a no-op - } - }); - - hbox.append(&name_lbl); - hbox.append(&ver_lbl); - hbox.append(&update_btn); - row.set_child(Some(&hbox)); - list.append(&row); - } + return; } + let mut names: Vec<_> = packages.iter().collect(); + names.sort_by_key(|(k, _)| k.as_str()); + + for (name, version) in names { + let row = ListBoxRow::new(); + row.set_selectable(false); + let hbox = GBox::new(Orientation::Horizontal, 16); + hbox.set_margin_top(6); + hbox.set_margin_bottom(6); + hbox.set_margin_start(8); + hbox.set_margin_end(8); + + let name_lbl = Label::new(Some(name)); + name_lbl.set_hexpand(true); + name_lbl.set_xalign(0.0); + + let ver_lbl = Label::new(Some(version)); + ver_lbl.set_xalign(1.0); + + let pkg_name = name.clone(); + let update_btn = Button::with_label("Update"); + { + let log_buf = log_buf.clone(); + let list = list.clone(); + update_btn.connect_clicked(move |_| { + log_buf.set_text(""); + let list2 = list.clone(); + let log_buf2 = log_buf.clone(); + // Route through stream_command (like the other buttons) so + // output is visible and the row refreshes with the new + // version once the update actually finishes — previously + // this was fire-and-forget with the Err case silently + // swallowed, so a missing `bakery` binary made the button + // look broken with zero feedback either way. + stream_command_then(&["bakery", "update", &pkg_name], log_buf.clone(), move || { + populate_packages(&list2, &log_buf2); + }); + }); + } + + hbox.append(&name_lbl); + hbox.append(&ver_lbl); + hbox.append(&update_btn); + row.set_child(Some(&hbox)); + list.append(&row); + } +} + +pub fn build() -> GBox { + let vbox = GBox::new(Orientation::Vertical, 0); + vbox.add_css_class("view-content"); + + let title = Label::new(Some("Packages")); + title.add_css_class("title"); + title.set_xalign(0.0); + vbox.append(&title); + + let subtitle = Label::new(Some("Bread ecosystem packages installed via bakery, and system packages via pacman below.")); + subtitle.set_xalign(0.0); + subtitle.set_margin_bottom(16); + vbox.append(&subtitle); + + let list = ListBox::new(); + list.set_selection_mode(gtk4::SelectionMode::None); + + let log_buf = gtk4::TextBuffer::new(None); + populate_packages(&list, &log_buf); + let scroll = ScrolledWindow::new(); scroll.set_vexpand(true); scroll.set_child(Some(&list)); vbox.append(&scroll); - let log_buf = gtk4::TextBuffer::new(None); let log_view = TextView::with_buffer(&log_buf); log_view.set_editable(false); log_view.set_monospace(true); @@ -169,7 +199,9 @@ pub fn build() -> GBox { let btn_row = GBox::new(Orientation::Horizontal, 8); btn_row.set_margin_top(12); - let check_btn = Button::with_label("Check for updates"); + // Labeled "List installed", not "Check for updates" — bakery list is a + // listing of installed packages, it doesn't check for available updates. + let check_btn = Button::with_label("List installed"); let update_all_btn = Button::with_label("Update all"); { @@ -191,6 +223,35 @@ pub fn build() -> GBox { btn_row.append(&check_btn); btn_row.append(&update_all_btn); vbox.append(&btn_row); + + // --------------------------------------------------------------------- + // System packages (pacman) — the other update channel. bakery only + // covers the userspace bread apps; base system/kernel/bos-settings/AUR + // republished packages come from pacman + the [breadway] repo, and + // bos-update (the CLI) already updates both — this panel previously + // only exposed the bakery half, so a user relying on it alone would + // never get base-system updates through the GUI. + // --------------------------------------------------------------------- + vbox.append(&w::section("System packages (pacman)")); + vbox.append(&w::hint( + "Base system, kernel, bos-settings, and republished AUR packages — \ + the other half of what `bos-update` covers. Needs your password \ + (polkit) since pacman requires root.", + )); + + let pacman_btn_row = GBox::new(Orientation::Horizontal, 8); + pacman_btn_row.set_margin_top(8); + let pacman_update_btn = Button::with_label("Update system (pacman -Syu)"); + { + let log_buf = log_buf.clone(); + pacman_update_btn.connect_clicked(move |_| { + log_buf.set_text(""); + stream_command(&["pkexec", "pacman", "-Syu", "--noconfirm"], log_buf.clone()); + }); + } + pacman_btn_row.append(&pacman_update_btn); + vbox.append(&pacman_btn_row); + vbox.append(&log_view); vbox diff --git a/bos-settings/src/ui/views/snapshots.rs b/bos-settings/src/ui/views/snapshots.rs index d3ac8b7..dd7bbf7 100644 --- a/bos-settings/src/ui/views/snapshots.rs +++ b/bos-settings/src/ui/views/snapshots.rs @@ -12,20 +12,37 @@ struct SnapshotRow { } fn list_snapshots() -> Vec { + // NOTE: the real flag is --columns, not --output-cols (which snapper + // rejects outright with "Unknown option") — confirmed against snapper + // 0.13's own --help. With the wrong flag this always failed and the + // panel silently showed "No snapshots found" on every install. let Ok(output) = Command::new("snapper") - .args(["list", "--output-cols", "number,date,description"]) + .args(["list", "--columns", "number,date,description"]) .output() else { return Vec::new(); }; + if !output.status.success() { + eprintln!( + "bos-settings: snapper list failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + return Vec::new(); + } let text = String::from_utf8_lossy(&output.stdout); text.lines() .skip(2) // header + separator .filter_map(|line| { let mut cols = line.splitn(3, '|'); + let number = cols.next()?.trim().to_string(); + // Snapshot 0 ("current") always exists, can't be rolled back to + // or deleted, and isn't a real snapshot — filter it out. + if number == "0" { + return None; + } Some(SnapshotRow { - number: cols.next()?.trim().to_string(), + number, date: cols.next()?.trim().to_string(), description: cols.next()?.trim().to_string(), }) @@ -89,7 +106,8 @@ pub fn build() -> GBox { vbox.append(&title); let subtitle = Label::new(Some( - "System snapshots created by snap-pac on each pacman transaction.", + "System snapshots created by snap-pac on each pacman transaction. \ + Boot into one from the GRUB menu to recover; delete old ones here.", )); subtitle.set_xalign(0.0); subtitle.set_margin_bottom(16); @@ -108,7 +126,7 @@ pub fn build() -> GBox { btn_row.set_margin_top(12); let refresh_btn = Button::with_label("Refresh"); - let rollback_btn = Button::with_label("Rollback to selected"); + let rollback_btn = Button::with_label("Boot into selected..."); let delete_btn = Button::with_label("Delete selected"); delete_btn.add_css_class("destructive-action"); @@ -130,23 +148,29 @@ pub fn build() -> GBox { .root() .and_then(|r| r.downcast::().ok()); + // BOS boots with root pinned to a named subvolume (grub emits + // rootflags=subvol=@), so `snapper rollback`'s usual mechanism — + // switching the btrfs *default* subvolume — has no effect here; + // grub never consults it. The real, working way to get back to a + // snapshot on this layout is grub-btrfs (already installed + + // running via grub-btrfsd.service): it generates a GRUB submenu + // entry per snapshot, bootable directly. So this button doesn't + // touch the filesystem at all — it just points you at that menu. let dialog = AlertDialog::builder() - .message(&format!("Roll back to snapshot #{number}?")) - .detail("The current system state will be replaced on next boot. \ - A polkit prompt will ask for your password.") - .buttons(["Cancel", "Roll back"]) + .message(&format!("Boot into snapshot #{number}?")) + .detail("Snapshots on BOS are booted directly from the GRUB \ + menu (under \"BOS snapshots\"), not rolled back in \ + place. Reboot now and pick this snapshot there, or \ + later if you'd rather keep working — the menu entry \ + will still be there.") + .buttons(["Later", "Reboot now"]) .cancel_button(0) .default_button(0) .build(); dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { if result == Ok(1) { - // pkexec so polkit handles the privilege escalation - std::thread::spawn(move || { - let _ = Command::new("pkexec") - .args(["snapper", "rollback", &number]) - .status(); - }); + let _ = Command::new("systemctl").args(["reboot"]).spawn(); } }); }); @@ -163,7 +187,6 @@ pub fn build() -> GBox { .root() .and_then(|r| r.downcast::().ok()); - let list = list.clone(); let dialog = AlertDialog::builder() .message(&format!("Delete snapshot #{number}?")) .detail("This cannot be undone.") @@ -172,11 +195,43 @@ pub fn build() -> GBox { .default_button(0) .build(); + let window2 = window.clone(); + let list2 = list.clone(); dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { - if result == Ok(1) { - let _ = Command::new("snapper").args(["delete", &number]).status(); - populate_list(&list); - } + if result != Ok(1) { return } + + // snapper's DBus path authorizes via ALLOW_USERS, not pkexec — + // this only works because post-install.sh seeds that config + // key, but if it's ever missing this fails silently unless we + // check the exit status. GTK widgets aren't Send, so hand the + // outcome back over a channel rather than touching them from + // the thread (same pattern as the rollback flow used to). + let (tx, rx) = async_channel::bounded::(1); + std::thread::spawn(move || { + let ok = Command::new("snapper") + .args(["delete", &number]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + let _ = tx.send_blocking(ok); + }); + + let list = list2.clone(); + let window = window2.clone(); + glib::spawn_future_local(async move { + let ok = rx.recv().await.unwrap_or(false); + if ok { + populate_list(&list); + } else { + let err = AlertDialog::builder() + .message("Delete failed") + .detail("snapper delete exited with an error — the \ + snapshot wasn't removed.") + .buttons(["OK"]) + .build(); + err.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, |_| {}); + } + }); }); }); } diff --git a/bos-settings/src/ui/window.rs b/bos-settings/src/ui/window.rs index cc395cd..bdcf89e 100644 --- a/bos-settings/src/ui/window.rs +++ b/bos-settings/src/ui/window.rs @@ -32,11 +32,13 @@ pub fn build_ui(app: &Application) { stack.add_named(&views::breadbox::build(), Some("breadbox")); stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs")); stack.add_named(&views::breadpad::build(), Some("breadpad")); + stack.add_named(&views::breadpaper::build(), Some("breadpaper")); stack.add_named(&views::breadsearch::build(), Some("breadsearch")); stack.add_named(&views::hyprland::build(), Some("hyprland")); - // Default to snapshots view - stack.set_visible_child_name("snapshots"); + // Default to the bread panel — Snapshots was previously first, an odd + // first impression for a settings app named after the bread ecosystem. + stack.set_visible_child_name("bread"); { let stack = stack.clone(); diff --git a/dotfiles/bread/init.lua b/dotfiles/bread/init.lua index 270ee7e..4ad682a 100644 --- a/dotfiles/bread/init.lua +++ b/dotfiles/bread/init.lua @@ -1 +1,5 @@ -bread.activate_profile("default") +bread.once("bread.system.startup", function() + bread.profile.activate("default") +end) + +return bread diff --git a/iso/airootfs/etc/calamares/settings.conf b/iso/airootfs/etc/calamares/settings.conf index 8846a56..8872182 100644 --- a/iso/airootfs/etc/calamares/settings.conf +++ b/iso/airootfs/etc/calamares/settings.conf @@ -35,7 +35,12 @@ sequence: - users - networkcfg - hwclock - - packages + # packages module removed: it set update_db:true with no + # skip_if_no_internet/ignore_update_db_error, so an offline install (the + # exact case bos-welcome's nmtui step exists for) aborted here with a + # fatal pacman -Sy failure. Its only try_install packages (pipewire-pulse, + # pipewire-alsa) are already in packages.x86_64 and installed by + # unpackfs, so the step did nothing useful even when it succeeded. # archiso strips the kernel from the squashfs; stage it, drop the archiso # initramfs config, and write a stock mkinitcpio preset before initcpio runs. - shellprocess@kernel diff --git a/iso/airootfs/etc/greetd/breadgreet.example.toml b/iso/airootfs/etc/greetd/breadgreet.example.toml new file mode 100644 index 0000000..c5f2093 --- /dev/null +++ b/iso/airootfs/etc/greetd/breadgreet.example.toml @@ -0,0 +1,26 @@ +# breadgreet commonly runs as the dedicated `greeter` system user (per +# greetd's own convention), so it checks /etc/greetd/breadgreet.toml first; +# copy this there for a real install. For local dev/testing under a normal +# user session it falls back to ~/.config/breadgreet/breadgreet.toml. +# +# Every field is optional and defaults to the value shown here. + +[background] +mode = "color" +path = "" +blur = false + +[clock] +format = "%H:%M" + +[font] +family = "Varela Round" + +[sessions] +# Directories scanned for .desktop session entries, in order. +wayland_dirs = ["/usr/share/wayland-sessions"] +xsessions_dirs = ["/usr/share/xsessions"] +# .desktop file stem (without extension) to auto-select. Falls back to the +# first entry found if this isn't present. v1 has no session picker UI — +# BOS only ships one session (Hyprland) today. +default = "hyprland" diff --git a/iso/airootfs/etc/greetd/breadgreet.toml b/iso/airootfs/etc/greetd/breadgreet.toml new file mode 100644 index 0000000..311dfad --- /dev/null +++ b/iso/airootfs/etc/greetd/breadgreet.toml @@ -0,0 +1,15 @@ +# BOS's real breadgreet config — see breadgreet.example.toml in this same +# directory for the full set of options with explanations. +# +# The one setting that actually matters here: `sessions.default` MUST be +# "bos", not breadgreet's compiled-in default of "hyprland". The `hyprland` +# pacman package installs its own /usr/share/wayland-sessions/hyprland.desktop +# alongside BOS's own bos.desktop, and breadgreet's session picker matches by +# .desktop file stem — with no override it picks "hyprland.desktop" over +# "bos.desktop", which skips bos-session's PATH fixup (adds ~/.local/bin for +# the bakery bread apps; greetd starts no login shell, so /etc/profile.d is +# never sourced any other way). Confirmed via breadgreet's own test suite +# (sessions.rs: discover_prefers_configured_default_over_first_entry). + +[sessions] +default = "bos" diff --git a/iso/airootfs/etc/greetd/config.toml b/iso/airootfs/etc/greetd/config.toml index 4488779..6d514c2 100644 --- a/iso/airootfs/etc/greetd/config.toml +++ b/iso/airootfs/etc/greetd/config.toml @@ -2,11 +2,15 @@ # squashfs and enabled by post-install.sh; the live ISO instead autologins # liveuser on tty1 (see getty@tty1 drop-in) so the installer comes straight up. # -# tuigreet shows a minimal greeter, then launches the BOS Hyprland session via -# bos-session (which fixes up PATH for the bakery bread apps). +# breadgreet (bread-ecosystem's own greeter) is hosted under cage — a single- +# client kiosk Wayland compositor, since greetd itself has no display of its +# own — and speaks greetd's IPC directly. It resolves the session to launch +# from /usr/share/wayland-sessions/bos.desktop, which points at bos-session +# (fixes up PATH for the bakery bread apps; greetd starts no login shell, so +# /etc/profile.d is never sourced otherwise). [terminal] vt = 1 [default_session] -command = "tuigreet --remember --time --cmd /usr/local/bin/bos-session" +command = "cage -s -- breadgreet" user = "greeter" diff --git a/iso/airootfs/etc/os-release b/iso/airootfs/etc/os-release index e8cffa1..4f28e07 100644 --- a/iso/airootfs/etc/os-release +++ b/iso/airootfs/etc/os-release @@ -9,4 +9,3 @@ DOCUMENTATION_URL="https://wiki.archlinux.org/" SUPPORT_URL="https://bbs.archlinux.org/" BUG_REPORT_URL="https://git.breadway.dev/Breadway/bos/issues" PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/" -LOGO=archlinux-logo diff --git a/iso/airootfs/etc/polkit-1/rules.d/10-snapper.rules b/iso/airootfs/etc/polkit-1/rules.d/10-snapper.rules deleted file mode 100644 index dc2b538..0000000 --- a/iso/airootfs/etc/polkit-1/rules.d/10-snapper.rules +++ /dev/null @@ -1,11 +0,0 @@ -// Allow members of the wheel group to perform snapper rollback via pkexec -// without a password prompt. Other snapper operations (list/create/delete) -// are controlled by ALLOW_USERS in /etc/snapper/configs/root. -polkit.addRule(function(action, subject) { - if (action.id == "io.opensuse.Snapper.Rollback" && - subject.local && - subject.active && - subject.isInGroup("wheel")) { - return polkit.Result.YES; - } -}); diff --git a/iso/airootfs/etc/skel/.config/bread/init.lua b/iso/airootfs/etc/skel/.config/bread/init.lua index 270ee7e..4ad682a 100644 --- a/iso/airootfs/etc/skel/.config/bread/init.lua +++ b/iso/airootfs/etc/skel/.config/bread/init.lua @@ -1 +1,5 @@ -bread.activate_profile("default") +bread.once("bread.system.startup", function() + bread.profile.activate("default") +end) + +return bread diff --git a/iso/airootfs/etc/skel/.config/breadcrumbs/breadcrumbs.toml b/iso/airootfs/etc/skel/.config/breadcrumbs/breadcrumbs.toml deleted file mode 100644 index 46e26f3..0000000 --- a/iso/airootfs/etc/skel/.config/breadcrumbs/breadcrumbs.toml +++ /dev/null @@ -1,3 +0,0 @@ -[[profile]] -name = "home" -ssids = [] diff --git a/iso/airootfs/etc/skel/.config/breadlock/breadlock.example.toml b/iso/airootfs/etc/skel/.config/breadlock/breadlock.example.toml new file mode 100644 index 0000000..223c563 --- /dev/null +++ b/iso/airootfs/etc/skel/.config/breadlock/breadlock.example.toml @@ -0,0 +1,22 @@ +# Copy to ~/.config/breadlock/breadlock.toml — every field is optional and +# defaults to the value shown here if omitted or the file doesn't exist. + +[background] +# "color" (bread-theme palette background) or "image" (a PNG, cover-fit) +mode = "color" +path = "" +# v2 feature — accepted but currently just logs a warning and shows the +# background unblurred (needs a wlr-screencopy capture, not implemented yet). +blur = false + +[clock] +# strftime format +format = "%H:%M" + +[font] +family = "Varela Round" + +[input] +# How long the "wrong password" state (red pill) shows before input +# re-enables, in milliseconds. +fail_timeout_ms = 800 diff --git a/iso/airootfs/etc/skel/.config/hypr/hypridle.conf b/iso/airootfs/etc/skel/.config/hypr/hypridle.conf index aacd512..021545c 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hypridle.conf +++ b/iso/airootfs/etc/skel/.config/hypr/hypridle.conf @@ -3,7 +3,7 @@ # playback, etc. won't dim or suspend the machine). Vendor-neutral: nothing here # is Intel/AMD specific. general { - lock_cmd = pidof hyprlock || hyprlock + lock_cmd = pidof breadlock || breadlock before_sleep_cmd = loginctl lock-session after_sleep_cmd = hyprctl dispatch dpms on ignore_dbus_inhibit = false diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index e9b785d..9928148 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -124,10 +124,15 @@ hl.bind(mod .. " + comma", hl.dsp.exec_cmd("bos-settings")) hl.bind(mod .. " + slash", hl.dsp.exec_cmd("bos-keybinds")) hl.bind(mod .. " + L", hl.dsp.exec_cmd("loginctl lock-session")) hl.bind(mod .. " + F", hl.dsp.window.fullscreen({ action = "toggle" })) -hl.bind(mod .. " + V", hl.dsp.window.float({ action = "toggle" })) +hl.bind(mod .. " + I", hl.dsp.window.float({ action = "toggle" })) +hl.bind(mod .. " + P", hl.dsp.window.pseudo({ action = "toggle" })) +hl.bind(mod .. " + R", hl.dsp.window.resize()) -- breadclip (its own gtk4-layer-shell popup — not a TUI, so no terminal -- needed). Previously piped cliphist through fzf directly from the -- compositor with no terminal attached, which was a silent no-op. +-- Bound on both V (breadclip's own suggested default, freed up now that +-- float toggle moved to I) and SHIFT+V (kept for muscle memory). +hl.bind(mod .. " + V", hl.dsp.exec_cmd("breadclip")) hl.bind(mod .. " + SHIFT + V", hl.dsp.exec_cmd("breadclip")) hl.bind(mod .. " + T", hl.dsp.layout("togglesplit")) hl.bind(mod .. " + Tab", hl.dsp.focus({ urgent_or_last = true })) @@ -185,6 +190,7 @@ hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-" hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true }) hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true }) hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) +hl.bind("XF86Calculator", hl.dsp.exec_cmd("gnome-calculator")) -- --------------------------------------------------------------------------- -- Autostart. polkit agent + the bread ecosystem + idle daemon + wallpaper. @@ -206,8 +212,12 @@ hl.on("hyprland.start", function() -- rather than an exec-once here. "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1", "awww-daemon", - -- set the default wallpaper once the daemon is up (retry until ready) - [[bash -c 'until awww img /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']], + -- Set the default wallpaper once the daemon is up (retry until + -- ready) via `breadpaper set`, not raw `awww img` — breadpaper also + -- generates the pywal palette and reloads bread-theme, and records + -- the path so `breadpaper get` (and its bos-settings panel) show + -- the real default instead of "No wallpaper set" on a fresh install. + [[bash -c 'until breadpaper set /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']], -- breadd runs as a systemd user service (~/.config/systemd/user/breadd.service, -- enabled in skel). It autostarts at login but before Hyprland exists, so -- push the compositor's Wayland env into the user manager and restart breadd diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprlock.conf b/iso/airootfs/etc/skel/.config/hypr/hyprlock.conf deleted file mode 100644 index 30903df..0000000 --- a/iso/airootfs/etc/skel/.config/hypr/hyprlock.conf +++ /dev/null @@ -1,28 +0,0 @@ -# Lock screen (hyprlock). Solid dark background (no runtime-generated wallpaper -# dependency), accent matched to the Hyprland border colour. -general { - hide_cursor = true -} - -background { - monitor = - color = rgba(46, 52, 64, 1.0) - blur_passes = 0 -} - -input-field { - monitor = - size = 20%, 5% - outline_thickness = 3 - inner_color = rgba(0, 0, 0, 0.2) - outer_color = rgba(136, 192, 208, 0.8) - check_color = rgba(120, 220, 140, 0.95) - fail_color = rgba(255, 90, 90, 0.95) - font_color = rgba(255, 255, 255, 0.95) - fade_on_empty = false - rounding = 12 - placeholder_text = Password… - position = 0, -20 - halign = center - valign = center -} diff --git a/iso/airootfs/etc/skel/.local/share/applications/breadclip.desktop b/iso/airootfs/etc/skel/.local/share/applications/breadclip.desktop new file mode 100644 index 0000000..f13f8b3 --- /dev/null +++ b/iso/airootfs/etc/skel/.local/share/applications/breadclip.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=breadclip +Comment=Clipboard history +Exec=breadclip +Icon=edit-paste +Terminal=false +Type=Application +Categories=Utility; +StartupWMClass=breadclip diff --git a/iso/airootfs/etc/skel/.local/share/applications/breadman.desktop b/iso/airootfs/etc/skel/.local/share/applications/breadman.desktop new file mode 100644 index 0000000..2c4434a --- /dev/null +++ b/iso/airootfs/etc/skel/.local/share/applications/breadman.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=breadman +Comment=Notes and reminders manager +Exec=breadman +Icon=accessories-text-editor +Terminal=false +Type=Application +Categories=Utility;Office; +StartupWMClass=breadman diff --git a/iso/airootfs/etc/skel/.local/share/applications/breadmon.desktop b/iso/airootfs/etc/skel/.local/share/applications/breadmon.desktop new file mode 100644 index 0000000..a7d143d --- /dev/null +++ b/iso/airootfs/etc/skel/.local/share/applications/breadmon.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=breadmon +Comment=Monitor layout manager (TUI) +Exec=breadmon +Icon=video-display +Terminal=true +Type=Application +Categories=Settings;System; +StartupWMClass=breadmon diff --git a/iso/airootfs/etc/skel/.local/share/applications/breadsearch.desktop b/iso/airootfs/etc/skel/.local/share/applications/breadsearch.desktop new file mode 100644 index 0000000..d6e59fb --- /dev/null +++ b/iso/airootfs/etc/skel/.local/share/applications/breadsearch.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=breadsearch +Comment=Semantic system-wide search +Exec=breadsearch +Icon=system-search +Terminal=false +Type=Application +Categories=Utility; +StartupWMClass=breadsearch diff --git a/iso/airootfs/root/.bash_profile b/iso/airootfs/root/.bash_profile deleted file mode 100644 index 390a22d..0000000 --- a/iso/airootfs/root/.bash_profile +++ /dev/null @@ -1,19 +0,0 @@ -# Auto-start Hyprland on tty1 in the live session -if [[ "$(tty)" == "/dev/tty1" ]] && [[ -z "$WAYLAND_DISPLAY" ]]; then - # Allow a software-rendering fallback so the live session comes up even - # without a GPU (VMs, headless, exotic hardware). On real hardware wlroots - # still selects the hardware renderer; this only permits llvmpipe when no - # GPU renderer is available. Must be exported before Hyprland starts — - # wlroots reads it at renderer init, earlier than any Hyprland `env=` line. - export WLR_RENDERER_ALLOW_SOFTWARE=1 - # Software cursors: hardware-cursor planes are often unusable in VMs and - # show as invisible/garbled; this is the reliable choice for a live medium. - export WLR_NO_HARDWARE_CURSORS=1 - # Run the compositor, capturing its output so a failed live boot is - # diagnosable (Hyprland also keeps its own log under $XDG_RUNTIME_DIR/hypr/). - # On exit, drop to an interactive shell with the error in view instead of - # letting the getty autologin respawn-loop hide it behind a blank cursor. - Hyprland &>/var/log/hyprland-live.log - echo "Hyprland exited (rc=$?). Log: /var/log/hyprland-live.log" - exec bash -i -fi diff --git a/iso/airootfs/root/.config/hypr/hyprland.conf b/iso/airootfs/root/.config/hypr/hyprland.conf deleted file mode 100644 index 8adb80a..0000000 --- a/iso/airootfs/root/.config/hypr/hyprland.conf +++ /dev/null @@ -1,28 +0,0 @@ -# Live-session Hyprland config — launches Calamares on start. -# This is NOT the installed system config; that lives in dotfiles/hypr/. - -monitor=,preferred,auto,1 - -exec-once = calamares - -general { - border_size = 2 - col.active_border = rgba(88c0d0ff) - col.inactive_border = rgba(4c566aff) -} - -decoration { - rounding = 4 -} - -input { - kb_layout = us - follow_mouse = 1 -} - -misc { - disable_hyprland_logo = true - disable_splash_rendering = true - # Keep compositor running if calamares exits (user can relaunch) - exit_window_request_force = false -} diff --git a/iso/airootfs/usr/local/bin/bos-live-setup b/iso/airootfs/usr/local/bin/bos-live-setup index ad54075..f7b1d26 100644 --- a/iso/airootfs/usr/local/bin/bos-live-setup +++ b/iso/airootfs/usr/local/bin/bos-live-setup @@ -18,16 +18,18 @@ if ! id liveuser &>/dev/null; then passwd -d liveuser >/dev/null fi -# Layer the installer onto the live desktop: auto-launch it, and bind Super+I to -# relaunch it after it's been closed. Appended (in Lua) to the skel hyprland.lua -# native config so the full desktop stays intact. +# Layer the installer onto the live desktop: auto-launch it, and bind +# Super+Shift+I to relaunch it after it's been closed. Appended (in Lua) to +# the skel hyprland.lua native config so the full desktop stays intact. +# NOTE: plain SUPER+I is float-toggle in the skel config — don't reuse it +# here, it was tried once and silently double-bound both actions. HYPR=/home/liveuser/.config/hypr/hyprland.lua install -d -m 0755 -o liveuser -g liveuser /home/liveuser/.config/hypr if ! grep -q bos-launch-calamares "$HYPR" 2>/dev/null; then cat >>"$HYPR" <<'EOF' -- --- live-media installer (added by bos-live-setup; absent on installed system) --- -hl.bind("SUPER + I", hl.dsp.exec_cmd("bos-launch-calamares")) +hl.bind("SUPER + SHIFT + I", hl.dsp.exec_cmd("bos-launch-calamares")) hl.on("hyprland.start", function() hl.dispatch(hl.dsp.exec_cmd("bos-launch-calamares")) end) EOF fi diff --git a/iso/airootfs/usr/local/bin/bos-welcome b/iso/airootfs/usr/local/bin/bos-welcome index ef0c2ed..92d2c2c 100644 --- a/iso/airootfs/usr/local/bin/bos-welcome +++ b/iso/airootfs/usr/local/bin/bos-welcome @@ -1,24 +1,36 @@ #!/bin/bash -# First-run welcome. Shows a short getting-started message once, then drops a -# marker so it never shows again. Launched from the Hyprland autostart; the -# bos-welcome window class is floated/centred by a Hyprland window rule. +# First-run welcome + first-run/every-login network check. Launched from the +# Hyprland autostart; the bos-welcome window class is floated/centred by a +# Hyprland window rule. set -u # Never run in the live/installer session — only on an installed system. [[ "$(id -un)" == "liveuser" ]] && exit 0 -marker="${XDG_CONFIG_HOME:-$HOME/.config}/bos/.welcomed" -[[ -f "$marker" ]] && exit 0 -mkdir -p "$(dirname "$marker")" +welcomed_marker="${XDG_CONFIG_HOME:-$HOME/.config}/bos/.welcomed" +mkdir -p "$(dirname "$welcomed_marker")" -# First-run network check. A fresh install usually boots with no connection -# (Wi-Fi isn't configured during install), and the first `bos-update`/pacman run +# Network check. A fresh install usually boots with no connection (Wi-Fi +# isn't configured during install), and the first `bos-update`/pacman run # then fails with confusing DNS/"could not resolve host" errors. If -# NetworkManager reports we're not fully online, open nmtui so the user can join -# a network before anything else. Best-effort: missing nmcli/nmtui/kitty, or the -# user quitting nmtui, must never block the welcome below. +# NetworkManager reports we're not fully online, open nmtui so the user can +# join a network before anything else. This runs on EVERY login, not just +# the first, and isn't gated by any marker — it keeps re-prompting until the +# machine is actually online, then naturally stops (the "full" check below +# short-circuits). Best-effort: missing nmcli/nmtui/kitty, or the user +# quitting nmtui, must never block the welcome text below. if command -v nmcli &>/dev/null; then conn="$(nmcli networking connectivity check 2>/dev/null)" + # NetworkManager may still be associating right at compositor start — + # give it a few short retries before concluding we're actually offline, + # so a machine with working Wi-Fi doesn't get a spurious nmtui popup. + tries=0 + while [[ "$conn" != "full" && "$tries" -lt 3 ]]; do + sleep 1 + conn="$(nmcli networking connectivity check 2>/dev/null)" + tries=$((tries + 1)) + done + if [[ "$conn" != "full" ]]; then notify-send -u normal "BOS" "No internet yet — opening network setup so updates work." 2>/dev/null || true if command -v nmtui &>/dev/null; then @@ -27,8 +39,10 @@ if command -v nmcli &>/dev/null; then fi fi -# Mark welcomed only now, so an interrupted/aborted network step still re-prompts -# next login rather than being suppressed forever. -touch "$marker" +# Welcome text: shown once ever, independent of network status above (an +# offline machine still gets useful onboarding text — it just also keeps +# getting the network prompt on future logins until it connects). +[[ -f "$welcomed_marker" ]] && exit 0 +touch "$welcomed_marker" exec kitty --class bos-welcome --title "Welcome to BOS" -- less -R /usr/share/bos/welcome.txt diff --git a/iso/airootfs/usr/share/bos/keybinds.txt b/iso/airootfs/usr/share/bos/keybinds.txt index 4ecf0b7..1c1649a 100644 --- a/iso/airootfs/usr/share/bos/keybinds.txt +++ b/iso/airootfs/usr/share/bos/keybinds.txt @@ -10,14 +10,16 @@ SUPER + E files (nautilus) SUPER + B browser (zen) SUPER + U notes / reminders (breadpad) - SUPER + M package manager (breadman) + SUPER + M notes / task manager (breadman) SUPER + , BOS Settings SUPER + / this keybind cheatsheet SUPER + L lock screen SUPER + Backspace close window SUPER + F fullscreen - SUPER + V toggle floating - SUPER + Shift + V clipboard history + SUPER + I toggle floating + SUPER + P toggle pseudotile + SUPER + R resize mode + SUPER + V / Shift + V clipboard history (breadclip) SUPER + T toggle split direction SUPER + Tab last window SUPER + N exit Hyprland (log out) @@ -45,6 +47,7 @@ MEDIA & HARDWARE KEYS volume / brightness / play-pause / next / prev (work on lock screen) + calculator key opens gnome-calculator ────────────────────────────────────────────────────────── Press q to close. Configure everything in BOS Settings (SUPER + ,). diff --git a/iso/airootfs/usr/share/wayland-sessions/bos.desktop b/iso/airootfs/usr/share/wayland-sessions/bos.desktop new file mode 100644 index 0000000..0bbecee --- /dev/null +++ b/iso/airootfs/usr/share/wayland-sessions/bos.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Name=BOS +Comment=Bread OS (Hyprland) +Exec=/usr/local/bin/bos-session +Type=Application diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index 41902e1..058aac7 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -11,14 +11,18 @@ amd-ucode intel-ucode # Power management (vendor-neutral; works on Intel and AMD). tlp auto-tunes power -# by AC/battery and CPU driver; hypridle/hyprlock handle idle dim/off/lock/suspend +# by AC/battery and CPU driver; hypridle/breadlock handle idle dim/off/lock/suspend # and the lock screen; upower exposes battery state. NOT power-profiles-daemon — # it conflicts with tlp. tlp tlp-rdw upower hypridle -hyprlock +# breadlock replaces hyprlock (locker) and tuigreet (greetd's greeter) — see +# /etc/greetd/config.toml and skel's hypridle.conf lock_cmd. cage hosts +# breadgreet as a single-client kiosk compositor under greetd. +breadlock +cage # Bootloader + filesystem grub @@ -56,9 +60,8 @@ xdg-desktop-portal-hyprland # and Firefox-based apps (Zen). Without it those apps get no file dialog. xdg-desktop-portal-gtk # Login manager for the installed system (Wayland-native; enabled by -# post-install.sh, launches the Hyprland session via tuigreet → bos-session). +# post-install.sh, launches the Hyprland session via breadgreet → bos-session). greetd -greetd-tuigreet xdg-utils xdg-user-dirs polkit