Fix boot-critical, session, and UX bugs found in round-3 UX audit
Snapshots panel was non-functional (wrong snapper flag); breadgreet picked the wrong session .desktop and skipped bos-session's PATH fixup; Calamares aborted offline installs over an unnecessary packages module; snapshot rollback silently no-op'd on BOS's pinned-subvolume layout (now points at grub-btrfs instead); breadcrumbs was configurable but had no daemon to run it. Also: SUPER+I double-bind, breadpaper/packages panels blocking the GTK main thread, dead polkit rule, and a sweep of smaller drift (default sidebar view, stale wording, missing .desktop launchers, wallpaper daemon not recording its own default).
This commit is contained in:
parent
a8f1592e75
commit
d78a2343f4
32 changed files with 553 additions and 209 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
153
bos-settings/src/ui/views/breadpaper.rs
Normal file
153
bos-settings/src/ui/views/breadpaper.rs
Normal file
|
|
@ -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 <path>` / `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<PathBuf> {
|
||||
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::<gtk4::Window>().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::<bool>(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
|
||||
}
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<String, String> {
|
||||
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<String, String> {
|
|||
}
|
||||
|
||||
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::<String>(256);
|
||||
let args: Vec<String> = 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
|
||||
|
|
|
|||
|
|
@ -12,20 +12,37 @@ struct SnapshotRow {
|
|||
}
|
||||
|
||||
fn list_snapshots() -> Vec<SnapshotRow> {
|
||||
// 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::<gtk4::Window>().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::<gtk4::Window>().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::<bool>(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, |_| {});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue