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).
256 lines
9.5 KiB
Rust
256 lines
9.5 KiB
Rust
use gtk4::prelude::*;
|
|
use gtk4::{
|
|
AlertDialog, Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow,
|
|
};
|
|
use std::process::Command;
|
|
|
|
use crate::ui::widgets as w;
|
|
|
|
#[derive(Clone)]
|
|
struct SnapshotRow {
|
|
number: String,
|
|
date: String,
|
|
description: String,
|
|
}
|
|
|
|
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", "--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,
|
|
date: cols.next()?.trim().to_string(),
|
|
description: cols.next()?.trim().to_string(),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
let snapshots = list_snapshots();
|
|
if snapshots.is_empty() {
|
|
let row = ListBoxRow::new();
|
|
row.set_selectable(false);
|
|
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 true;
|
|
}
|
|
for snap in &snapshots {
|
|
let row = ListBoxRow::new();
|
|
row.set_widget_name(&snap.number);
|
|
|
|
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 num_lbl = Label::new(Some(&snap.number));
|
|
num_lbl.set_width_chars(4);
|
|
num_lbl.set_xalign(0.0);
|
|
|
|
let date_lbl = Label::new(Some(&snap.date));
|
|
date_lbl.set_width_chars(22);
|
|
date_lbl.set_xalign(0.0);
|
|
|
|
let desc_lbl = Label::new(Some(&snap.description));
|
|
desc_lbl.set_hexpand(true);
|
|
desc_lbl.set_xalign(0.0);
|
|
|
|
hbox.append(&num_lbl);
|
|
hbox.append(&date_lbl);
|
|
hbox.append(&desc_lbl);
|
|
row.set_child(Some(&hbox));
|
|
list.append(&row);
|
|
}
|
|
false
|
|
}
|
|
|
|
pub fn build() -> GBox {
|
|
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.",
|
|
));
|
|
|
|
let list = ListBox::new();
|
|
list.set_selection_mode(gtk4::SelectionMode::Single);
|
|
let empty = populate_list(&list);
|
|
|
|
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);
|
|
|
|
let refresh_btn = Button::with_label("Refresh");
|
|
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 |_| {
|
|
let empty = populate_list(&list);
|
|
rollback_btn.set_sensitive(!empty);
|
|
delete_btn.set_sensitive(!empty);
|
|
});
|
|
}
|
|
|
|
{
|
|
let list = list.clone();
|
|
rollback_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 window = btn
|
|
.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!("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) {
|
|
let _ = Command::new("systemctl").args(["reboot"]).spawn();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
{
|
|
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::<gtk4::Window>().ok());
|
|
|
|
let dialog = AlertDialog::builder()
|
|
.message(&format!("Delete snapshot #{number}?"))
|
|
.detail("This cannot be undone.")
|
|
.buttons(["Cancel", "Delete"])
|
|
.cancel_button(0)
|
|
.default_button(0)
|
|
.build();
|
|
|
|
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 }
|
|
|
|
// 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();
|
|
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 {
|
|
let empty = populate_list(&list);
|
|
rollback_btn.set_sensitive(!empty);
|
|
delete_btn.set_sensitive(!empty);
|
|
} 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, |_| {});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
btn_row.append(&refresh_btn);
|
|
btn_row.append(&rollback_btn);
|
|
btn_row.append(&delete_btn);
|
|
outer.append(&btn_row);
|
|
|
|
outer
|
|
}
|