dev #1

Merged
Breadway merged 12 commits from dev into main 2026-07-04 12:39:58 +08:00
12 changed files with 1119 additions and 16 deletions
Showing only changes of commit ad4d71db34 - Show all commits

2
Cargo.lock generated
View file

@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]] [[package]]
name = "bos-settings" name = "bos-settings"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"bread-theme", "bread-theme",

View file

@ -120,21 +120,40 @@ avoid memory pressure.
## bos-settings ## bos-settings
`bos-settings` edits each bread\* app's TOML **non-destructively**: it parses A GTK4 settings app aiming for GNOME-Settings-style parity: not just editing
the file with `toml_edit`, changes only the keys a view exposes, and writes it config files, but live system state and control, so day-to-day machine
back — preserving comments and any keys the UI doesn't model (calendar administration doesn't require a terminal.
passwords, saved-network passwords, model paths). Views:
| View | Config | Bread-ecosystem TOML configs are edited **non-destructively**: `toml_edit`
|------|--------| parses the file, changes only the keys a view exposes, and writes it back —
| bread | `bread/breadd.toml` — daemon, lua, modules, all adapters, events, notifications | preserving comments and any keys the UI doesn't model (calendar passwords,
| breadbar | `breadbar/style.css` override | saved-network passwords, model paths). Panels with a daemon behind them
| breadbox | `breadbox/config.toml` — launcher contexts | (bread, breadbox, breadcrumbs, breadsearch, breadclip) also get live
| breadcrumbs | `breadcrumbs/breadcrumbs.toml` — settings, saved networks, profiles | systemd status + Start/Stop/Restart/Logs via a shared `service_control`
| breadpad | `breadpad/breadpad.toml` — settings, model + ollama, reminders, calendar | widget, not just the config file.
| Snapshots | `snapper` list / rollback / delete |
| Packages | `bakery` installed list + updates | | Panel | What it does |
| Hyprland | open config in editor + monitor list | |-------|--------------|
| 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: Build standalone:

View file

@ -1,6 +1,6 @@
[package] [package]
name = "bos-settings" name = "bos-settings"
version = "0.5.0" version = "0.6.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View file

@ -33,9 +33,12 @@ const fn item_sub(
pub const SYSTEM_ITEMS: &[SidebarItem] = &[ pub const SYSTEM_ITEMS: &[SidebarItem] = &[
item("network", "Network", "network-wireless-symbolic"), item("network", "Network", "network-wireless-symbolic"),
item_sub("breadcrumbs", "Wi-Fi Profiles", "breadcrumbs", "network-workgroup-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("sound", "Sound", "audio-volume-high-symbolic"),
item("power", "Power", "battery-good-symbolic"),
item("datetime", "Date & Time", "preferences-system-time-symbolic"), item("datetime", "Date & Time", "preferences-system-time-symbolic"),
item_sub("hyprland", "Display", "hyprland.lua", "video-display-symbolic"), item_sub("hyprland", "Display", "hyprland.lua", "video-display-symbolic"),
item("users", "Users", "system-users-symbolic"),
]; ];
pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[ pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[
@ -50,6 +53,8 @@ pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[
pub const MAINTENANCE_ITEMS: &[SidebarItem] = &[ pub const MAINTENANCE_ITEMS: &[SidebarItem] = &[
item("packages", "Packages", "package-x-generic-symbolic"), 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"), item("snapshots", "Snapshots", "document-open-recent-symbolic"),
]; ];

View file

@ -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 <pkg>` 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<AurResult> {
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<Button>| {
let query = search_entry.text().to_string();
if query.trim().is_empty() {
return;
}
if let Some(b) = &btn {
b.set_sensitive(false);
}
status.set_text("Searching…");
let (tx, rx) = async_channel::bounded::<Vec<AurResult>>(1);
std::thread::spawn(move || {
let _ = tx.send_blocking(search(&query));
});
let list = list.clone();
let status = status.clone();
let btn = btn.clone();
glib::spawn_future_local(async move {
if let Ok(results) = rx.recv().await {
while let Some(child) = list.first_child() {
list.remove(&child);
}
if results.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"system-search-symbolic",
"No results",
"Try a different search term.",
)));
list.append(&row);
status.set_text("No results.");
} else {
status.set_text(&format!("{} result(s)", results.len()));
for r in results.into_iter().take(50) {
let row = ListBoxRow::new();
row.set_selectable(false);
let vbox = GBox::new(Orientation::Vertical, 2);
vbox.add_css_class("card");
vbox.set_margin_top(3);
vbox.set_margin_bottom(3);
let top = GBox::new(Orientation::Horizontal, 12);
let name_lbl = Label::new(Some(&format!("{} {}", r.name, r.version)));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
let install_btn = Button::with_label("Install");
{
let pkg = r.name.clone();
install_btn.connect_clicked(move |_| install_in_terminal(&pkg));
}
top.append(&name_lbl);
top.append(&install_btn);
vbox.append(&top);
let desc_lbl = Label::new(Some(&r.description));
desc_lbl.add_css_class("dim-label");
desc_lbl.set_xalign(0.0);
desc_lbl.set_wrap(true);
desc_lbl.set_max_width_chars(64);
vbox.append(&desc_lbl);
row.set_child(Some(&vbox));
list.append(&row);
}
}
}
if let Some(b) = &btn {
b.set_sensitive(true);
}
});
}
};
{
let run_search = run_search.clone();
search_btn.connect_clicked(move |btn| run_search(Some(btn.clone())));
}
{
let run_search = run_search.clone();
search_entry.connect_activate(move |_| run_search(None));
}
outer
}

View file

@ -0,0 +1,246 @@
//! ufw firewall rules — BOS enables ufw by default (deny incoming, allow
//! outgoing, mDNS allowed for printer discovery; see post-install.sh) but
//! previously offered no graphical way to add/remove a rule for something
//! like a local dev server or a LAN game, only a terminal.
//!
//! `ufw status` itself requires root — confirmed against the installed
//! ufw script, which exits with "ERROR: You need to be root to run this
//! script" for a plain status check, not just for changes. Every other
//! panel's read-only state is free to query eagerly in `build()`, but doing
//! that here would mean a polkit password prompt on every single
//! bos-settings launch (every view is constructed immediately at startup —
//! see window.rs). So this panel starts blank and loads state only when the
//! user clicks Refresh, deferring the one unavoidable prompt to an explicit
//! action instead of forcing it on app open.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch};
use std::cell::Cell;
use std::rc::Rc;
use crate::ui::widgets as w;
#[derive(Clone)]
struct Rule {
number: String,
text: String,
}
struct Status {
active: bool,
rules: Vec<Rule>,
}
/// One `pkexec ufw status numbered` call, parsed for both the active/inactive
/// line and the numbered rules — a single privileged read instead of two.
fn fetch_status() -> Option<Status> {
let output = std::process::Command::new("pkexec")
.args(["ufw", "status", "numbered"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active");
let rules = text
.lines()
.filter_map(|l| {
let l = l.trim_start();
if !l.starts_with('[') {
return None;
}
let (num, rest) = l.split_once(']')?;
let number = num.trim_start_matches('[').trim().to_string();
Some(Rule { number, text: rest.trim().to_string() })
})
.collect();
Some(Status { active, rules })
}
fn render_rules(list: &ListBox, rules: &[Rule], programmatic: &Rc<Cell<bool>>) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
if rules.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"security-high-symbolic",
"No rules",
"Only the default policy (deny incoming, allow outgoing) applies.",
)));
list.append(&row);
return;
}
for rule in rules {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 16);
hbox.add_css_class("card");
hbox.set_margin_top(3);
hbox.set_margin_bottom(3);
let text_lbl = Label::new(Some(&rule.text));
text_lbl.set_hexpand(true);
text_lbl.set_xalign(0.0);
text_lbl.set_wrap(true);
let remove_btn = Button::with_label("Remove");
remove_btn.add_css_class("destructive-action");
{
let list = list.clone();
let number = rule.number.clone();
let programmatic = programmatic.clone();
remove_btn.connect_clicked(move |_| {
let log_buf = gtk4::TextBuffer::new(None);
let list2 = list.clone();
let programmatic2 = programmatic.clone();
w::stream_command_then(
&["pkexec", "ufw", "--force", "delete", &number],
log_buf,
move || refresh(&list2, None, &programmatic2),
);
});
}
hbox.append(&text_lbl);
hbox.append(&remove_btn);
row.set_child(Some(&hbox));
list.append(&row);
}
}
/// Re-fetch status on a background thread and update the list (+ switch, if
/// given) on completion. `enabled_sw` is `None` when called from a row
/// action (delete/add) where the enabled state can't have changed.
///
/// `programmatic` guards against `set_active` below re-triggering the
/// switch's own `connect_active_notify` handler (which calls `ufw enable`/
/// `disable`) — without it, the very first Refresh after ufw turns out to
/// already be active would immediately fire an unwanted `ufw enable` the
/// moment `set_active(true)` flips a switch that just became sensitive.
fn refresh(list: &ListBox, enabled_sw: Option<&Switch>, programmatic: &Rc<Cell<bool>>) {
let (tx, rx) = async_channel::bounded::<Option<Status>>(1);
std::thread::spawn(move || {
let _ = tx.send_blocking(fetch_status());
});
let list = list.clone();
let enabled_sw = enabled_sw.cloned();
let programmatic = programmatic.clone();
glib::spawn_future_local(async move {
if let Ok(Some(status)) = rx.recv().await {
render_rules(&list, &status.rules, &programmatic);
if let Some(sw) = &enabled_sw {
programmatic.set(true);
sw.set_sensitive(true);
sw.set_active(status.active);
programmatic.set(false);
}
}
});
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Firewall");
content.append(&w::hint(
"Reading and changing firewall state needs your password (polkit) \
ufw requires root even just to check status. Click Refresh to \
load the current state.",
));
let programmatic = Rc::new(Cell::new(false));
let enabled_sw = Switch::new();
enabled_sw.set_sensitive(false);
content.append(&w::row("Firewall enabled", &enabled_sw));
content.append(&w::hint(
"Default policy: deny incoming, allow outgoing. mDNS (5353/udp) is \
allowed by default so printer/network discovery keeps working.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
{
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"security-high-symbolic",
"Status not loaded",
"Click Refresh below to check the firewall's current state.",
)));
list.append(&row);
}
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_min_content_height(260);
scroll.set_child(Some(&list));
content.append(&scroll);
let refresh_btn = Button::with_label("Refresh status");
{
let list = list.clone();
let enabled_sw = enabled_sw.clone();
let programmatic = programmatic.clone();
refresh_btn.connect_clicked(move |_| refresh(&list, Some(&enabled_sw), &programmatic));
}
content.append(&refresh_btn);
{
let list = list.clone();
let programmatic = programmatic.clone();
enabled_sw.connect_active_notify(move |s| {
// Skip both the pre-refresh insensitive state and any
// programmatic set_active() from refresh() itself — only a
// real user click should call out to ufw enable/disable.
if !s.is_sensitive() || programmatic.get() {
return;
}
let verb = if s.is_active() { "enable" } else { "disable" };
let log_buf = gtk4::TextBuffer::new(None);
let list2 = list.clone();
let programmatic2 = programmatic.clone();
w::stream_command_then(&["pkexec", "ufw", "--force", verb], log_buf, move || {
refresh(&list2, None, &programmatic2);
});
});
}
content.append(&w::section("Add rule"));
content.append(&w::hint(
"e.g. \"8080/tcp\", \"22/tcp\", or a service name like \"OpenSSH\".",
));
let add_row = GBox::new(Orientation::Horizontal, 8);
let add_entry = Entry::new();
add_entry.set_hexpand(true);
add_entry.set_placeholder_text(Some("port/proto or service name"));
let add_btn = Button::with_label("Allow");
{
let list = list.clone();
let add_entry = add_entry.clone();
let programmatic = programmatic.clone();
add_btn.connect_clicked(move |_| {
let rule = add_entry.text().to_string();
if rule.trim().is_empty() {
return;
}
let log_buf = gtk4::TextBuffer::new(None);
let list2 = list.clone();
let add_entry2 = add_entry.clone();
let programmatic2 = programmatic.clone();
w::stream_command_then(
&["pkexec", "ufw", "allow", rule.trim()],
log_buf,
move || {
add_entry2.set_text("");
refresh(&list2, None, &programmatic2);
},
);
});
}
add_row.append(&add_entry);
add_row.append(&add_btn);
content.append(&add_row);
outer
}

View file

@ -0,0 +1,159 @@
//! fwupd firmware updates — thin GUI over `fwupdmgr`, following the same
//! "stream command output, refresh the list on completion" pattern as
//! Packages. fwupd itself (the daemon + CLI) is always installed;
//! `fwupd-refresh.timer` already keeps the metadata current in the
//! background, this panel is just for actually seeing/applying updates.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView};
use crate::ui::widgets as w;
use crate::ui::widgets::stream_command_then;
#[derive(Clone)]
struct FwDevice {
name: String,
version: String,
}
fn list_updatable() -> Vec<FwDevice> {
let Ok(output) = std::process::Command::new("fwupdmgr")
.args(["get-devices", "--json"])
.output()
else {
return Vec::new();
};
let Ok(root) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
return Vec::new();
};
let Some(devices) = root.get("Devices").and_then(|d| d.as_array()) else {
return Vec::new();
};
devices
.iter()
.filter(|d| {
d.get("Flags")
.and_then(|f| f.as_array())
.is_some_and(|flags| flags.iter().any(|f| f.as_str() == Some("updatable")))
})
.filter_map(|d| {
Some(FwDevice {
name: d.get("Name")?.as_str()?.to_string(),
version: d.get("Version").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(),
})
})
.collect()
}
fn populate(list: &ListBox) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
let devices = list_updatable();
if devices.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"software-update-available-symbolic",
"No updatable firmware devices found",
"Not every device supports firmware updates through fwupd.",
)));
list.append(&row);
return;
}
for dev in devices {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 16);
hbox.add_css_class("card");
hbox.set_margin_top(3);
hbox.set_margin_bottom(3);
let name_lbl = Label::new(Some(&dev.name));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
let ver_lbl = Label::new(Some(&dev.version));
ver_lbl.add_css_class("dim-label");
ver_lbl.set_xalign(1.0);
hbox.append(&name_lbl);
hbox.append(&ver_lbl);
row.set_child(Some(&hbox));
list.append(&row);
}
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Firmware");
content.append(&w::hint(
"Firmware updates for hardware that supports them (UEFI, some \
peripherals). fwupd-refresh.timer already keeps update metadata \
current in the background.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
populate(&list);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
content.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);
log_view.set_height_request(140);
log_view.set_margin_top(8);
log_view.set_visible(false);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(12);
let check_btn = Button::with_label("Check for updates");
{
let log_buf = log_buf.clone();
let log_view = log_view.clone();
let list = list.clone();
check_btn.connect_clicked(move |_| {
log_buf.set_text("");
log_view.set_visible(true);
let list2 = list.clone();
stream_command_then(&["fwupdmgr", "refresh"], log_buf.clone(), move || {
populate(&list2);
});
});
}
let update_btn = Button::with_label("Update all");
update_btn.add_css_class("suggested-action");
{
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();
stream_command_then(&["fwupdmgr", "update", "-y"], log_buf.clone(), move || {
populate(&list2);
});
});
}
let refresh_btn = Button::with_label("Refresh list");
{
let list = list.clone();
refresh_btn.connect_clicked(move |_| {
populate(&list);
});
}
btn_row.append(&check_btn);
btn_row.append(&update_btn);
btn_row.append(&refresh_btn);
content.append(&btn_row);
content.append(&log_view);
outer
}

View file

@ -1,4 +1,5 @@
pub mod about; pub mod about;
pub mod aur;
pub mod bread; pub mod bread;
pub mod breadbar; pub mod breadbar;
pub mod breadbox; pub mod breadbox;
@ -8,8 +9,12 @@ pub mod breadpad;
pub mod breadpaper; pub mod breadpaper;
pub mod breadsearch; pub mod breadsearch;
pub mod datetime; pub mod datetime;
pub mod firewall;
pub mod firmware;
pub mod hyprland; pub mod hyprland;
pub mod network; pub mod network;
pub mod packages; pub mod packages;
pub mod power;
pub mod snapshots; pub mod snapshots;
pub mod sound; pub mod sound;
pub mod users;

View file

@ -0,0 +1,193 @@
//! Battery/power status (upower), brightness (brightnessctl), and TLP's
//! current profile. Deliberately no AC/Battery/Performance *switcher* —
//! TLP's whole model on BOS is automatic profile selection by power source
//! (see packages.x86_64: power-profiles-daemon is intentionally not
//! installed, it conflicts with tlp), so this panel is read-only for TLP
//! and only exposes controls for things that are actually user choices:
//! brightness, and charge thresholds where the hardware supports them.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Scale};
use std::process::Command;
use crate::ui::widgets as w;
fn upower_device(kind: &str) -> Option<String> {
let out = Command::new("upower").arg("-e").output().ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.find(|l| l.to_lowercase().contains(kind))
.map(str::to_string)
}
fn upower_field(device: &str, field: &str) -> Option<String> {
let out = Command::new("upower").args(["-i", device]).output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.find(|l| l.trim_start().starts_with(field))
.and_then(|l| l.split(':').nth(1))
.map(|v| v.trim().to_string())
}
fn battery_summary() -> Vec<(String, String)> {
let Some(bat) = upower_device("bat") else {
return vec![("Battery".to_string(), "No battery detected".to_string())];
};
let mut rows = Vec::new();
if let Some(state) = upower_field(&bat, "state") {
rows.push(("Status".to_string(), state));
}
if let Some(pct) = upower_field(&bat, "percentage") {
rows.push(("Charge".to_string(), pct));
}
if let Some(t) = upower_field(&bat, "time to empty").or_else(|| upower_field(&bat, "time to full")) {
rows.push(("Time remaining".to_string(), t));
}
let full: Option<f64> = upower_field(&bat, "energy-full").and_then(|v| {
v.split_whitespace().next()?.parse().ok()
});
let design: Option<f64> = upower_field(&bat, "energy-full-design").and_then(|v| {
v.split_whitespace().next()?.parse().ok()
});
if let (Some(full), Some(design)) = (full, design) {
if design > 0.0 {
rows.push(("Battery health".to_string(), format!("{:.0}% of design capacity", full / design * 100.0)));
}
}
rows
}
fn power_source() -> String {
match upower_device("ac").and_then(|ac| upower_field(&ac, "online")) {
Some(v) if v == "yes" => "AC power".to_string(),
Some(_) => "Battery".to_string(),
None => "Unknown".to_string(),
}
}
fn tlp_profile() -> Option<String> {
let out = Command::new("tlp-stat").arg("-s").output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.find(|l| l.trim_start().starts_with("TLP profile"))
.and_then(|l| l.split('=').nth(1))
.map(|v| v.trim().to_string())
}
fn brightness_device() -> Option<String> {
let out = Command::new("brightnessctl").output().ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.find(|l| l.starts_with("Device"))
.and_then(|l| l.split('\'').nth(1))
.map(str::to_string)
}
fn brightness_pct() -> Option<u32> {
let out = Command::new("brightnessctl").output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.find(|l| l.contains("Current brightness"))
.and_then(|l| l.split('(').nth(1))
.and_then(|v| v.trim_end_matches("%)").parse().ok())
}
/// Charge-threshold sysfs paths, only Some when the running kernel driver
/// actually exposes them (ideapad_laptop/thinkpad_acpi on some models;
/// nothing on this dev laptop's Yoga Slim 7 — this is genuinely
/// hardware-dependent, not something every install will have).
fn charge_threshold_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
let base = std::path::Path::new("/sys/class/power_supply");
let entries = std::fs::read_dir(base).ok()?;
for entry in entries.flatten() {
let start = entry.path().join("charge_control_start_threshold");
let end = entry.path().join("charge_control_end_threshold");
if start.exists() && end.exists() {
return Some((start, end));
}
}
None
}
fn read_threshold(path: &std::path::Path) -> i64 {
std::fs::read_to_string(path)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(100)
}
pub fn build() -> GBox {
let (outer, c) = w::view_scaffold("Power");
c.append(&w::section("Battery"));
for (label, value) in battery_summary() {
c.append(&w::info_row(&label, &value));
}
c.append(&w::info_row("Power source", &power_source()));
c.append(&w::section("Brightness"));
if let Some(pct) = brightness_pct() {
let scale = Scale::with_range(gtk4::Orientation::Horizontal, 1.0, 100.0, 1.0);
scale.set_value(pct as f64);
scale.set_hexpand(true);
scale.set_draw_value(true);
if let Some(device) = brightness_device() {
scale.connect_value_changed(move |s| {
let pct = format!("{}%", s.value() as i64);
let _ = Command::new("brightnessctl")
.args(["--device", &device, "set", &pct])
.spawn();
});
}
c.append(&w::row("Screen brightness", &scale));
} else {
c.append(&w::hint("No controllable backlight found."));
}
if let Some((start_path, end_path)) = charge_threshold_paths() {
c.append(&w::section("Charge limits"));
c.append(&w::hint(
"Some laptops let you cap charging below 100% to slow battery \
wear on a machine that's mostly plugged in. Not every model \
supports this shown here because yours reported it does.",
));
let start_adj = gtk4::Adjustment::new(read_threshold(&start_path) as f64, 0.0, 100.0, 1.0, 5.0, 0.0);
let start_spin = gtk4::SpinButton::new(Some(&start_adj), 1.0, 0);
{
let path = start_path.clone();
start_spin.connect_value_changed(move |s| {
let val = (s.value() as i64).to_string();
let _ = Command::new("pkexec")
.args(["tee", &path.display().to_string()])
.arg(&val)
.output();
});
}
c.append(&w::row("Start charging below (%)", &start_spin));
let end_adj = gtk4::Adjustment::new(read_threshold(&end_path) as f64, 1.0, 100.0, 1.0, 5.0, 0.0);
let end_spin = gtk4::SpinButton::new(Some(&end_adj), 1.0, 0);
{
let path = end_path.clone();
end_spin.connect_value_changed(move |s| {
let val = (s.value() as i64).to_string();
let _ = Command::new("pkexec")
.args(["tee", &path.display().to_string()])
.arg(&val)
.output();
});
}
c.append(&w::row("Stop charging at (%)", &end_spin));
}
c.append(&w::section("TLP"));
c.append(&w::hint(
"TLP automatically applies a power-saving profile on battery and a \
performance profile on AC there's no manual profile switch here \
by design (power-profiles-daemon isn't installed; it conflicts \
with tlp).",
));
c.append(&w::info_row("Current profile", tlp_profile().as_deref().unwrap_or("unknown")));
outer
}

View file

@ -0,0 +1,293 @@
//! User account management — add/remove users, change passwords. Everything
//! here needs root (useradd/userdel/chpasswd), so every action goes through
//! `pkexec` on a background thread (GTK widgets aren't Send), matching the
//! async_channel + glib::spawn_future_local handoff used elsewhere for
//! destructive/root actions (see snapshots.rs).
use gtk4::prelude::*;
use gtk4::{AlertDialog, Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
use std::io::Write;
use std::process::{Command, Stdio};
use crate::ui::widgets as w;
#[derive(Clone)]
struct Account {
username: String,
full_name: String,
}
fn list_accounts() -> Vec<Account> {
let Ok(text) = std::fs::read_to_string("/etc/passwd") else {
return Vec::new();
};
text.lines()
.filter_map(|line| {
let f: Vec<&str> = line.split(':').collect();
if f.len() < 7 {
return None;
}
let uid: u32 = f[2].parse().ok()?;
let shell = f[6];
// Real human accounts: normal UID range, a real login shell (not
// nologin/false — excludes system/service accounts like
// greeter, avahi, etc).
if !(1000..60000).contains(&uid) || shell.ends_with("nologin") || shell.ends_with("/false") {
return None;
}
Some(Account {
username: f[0].to_string(),
full_name: f[4].split(',').next().unwrap_or("").to_string(),
})
})
.collect()
}
fn current_user() -> String {
std::env::var("USER").unwrap_or_default()
}
/// Run a root command that needs a line of input on stdin (chpasswd's own
/// "user:password" format) on a background thread, reporting success back
/// via a channel. `pkexec` inherits the spawning process's stdin only when
/// explicitly piped, so this pipes it through rather than relying on that.
fn run_with_stdin(args: &[&str], input: String, on_done: impl FnOnce(bool) + 'static) {
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let (tx, rx) = async_channel::bounded::<bool>(1);
std::thread::spawn(move || {
let ok = (|| -> std::io::Result<bool> {
let mut child = Command::new(&args[0])
.args(&args[1..])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input.as_bytes())?;
}
Ok(child.wait()?.success())
})()
.unwrap_or(false);
let _ = tx.send_blocking(ok);
});
glib::spawn_future_local(async move {
let ok = rx.recv().await.unwrap_or(false);
on_done(ok);
});
}
fn populate(list: &ListBox) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
let me = current_user();
for acc in list_accounts() {
let row = ListBoxRow::new();
row.set_selectable(false);
let card = GBox::new(Orientation::Vertical, 6);
card.add_css_class("card");
card.set_margin_top(3);
card.set_margin_bottom(3);
let top = GBox::new(Orientation::Horizontal, 12);
let label_text = if acc.full_name.is_empty() {
acc.username.clone()
} else {
format!("{} ({})", acc.username, acc.full_name)
};
let name_lbl = Label::new(Some(&label_text));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
let change_pw_btn = Button::with_label("Change password");
let remove_btn = Button::with_label("Remove");
remove_btn.add_css_class("destructive-action");
// Don't let the panel remove the account it's currently running as
// — that's a self-lockout, not a normal account-management action.
remove_btn.set_sensitive(acc.username != me);
top.append(&name_lbl);
top.append(&change_pw_btn);
top.append(&remove_btn);
card.append(&top);
// Inline password row, hidden until "Change password" is clicked —
// same pattern as Network's secured-connection password prompt.
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("New password"));
let pw_apply_btn = Button::with_label("Apply");
pw_row.append(&pw_entry);
pw_row.append(&pw_apply_btn);
pw_row.set_visible(false);
card.append(&pw_row);
let status = Label::new(None);
status.add_css_class("dim-label");
status.set_xalign(0.0);
card.append(&status);
{
let pw_row = pw_row.clone();
change_pw_btn.connect_clicked(move |_| {
pw_row.set_visible(!pw_row.is_visible());
});
}
{
let username = acc.username.clone();
let pw_entry = pw_entry.clone();
let pw_row = pw_row.clone();
let status = status.clone();
pw_apply_btn.connect_clicked(move |_| {
let password = pw_entry.text().to_string();
if password.is_empty() {
return;
}
let input = format!("{username}:{password}\n");
let status2 = status.clone();
let pw_entry2 = pw_entry.clone();
let pw_row2 = pw_row.clone();
status.set_text("Applying…");
run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
if ok {
status2.set_text("Password changed");
pw_entry2.set_text("");
pw_row2.set_visible(false);
} else {
status2.set_text("Failed to change password");
}
});
});
}
{
let list = list.clone();
let username = acc.username.clone();
remove_btn.connect_clicked(move |btn| {
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
let dialog = AlertDialog::builder()
.message(format!("Remove user {username}?"))
.detail("Deletes the account and its home directory. This cannot be undone.")
.buttons(["Cancel", "Remove"])
.cancel_button(0)
.default_button(0)
.build();
let list2 = list.clone();
let username2 = username.clone();
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result != Ok(1) {
return;
}
let log_buf = gtk4::TextBuffer::new(None);
let list3 = list2.clone();
w::stream_command_then(
&["pkexec", "userdel", "-r", &username2],
log_buf,
move || populate(&list3),
);
});
});
}
row.set_child(Some(&card));
list.append(&row);
}
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Users");
content.append(&w::hint(
"Real login accounts on this machine (system/service accounts \
aren't shown). Your own account can't be removed from here.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
populate(&list);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_min_content_height(260);
scroll.set_child(Some(&list));
content.append(&scroll);
content.append(&w::section("Add user"));
let username_entry = Entry::new();
username_entry.set_placeholder_text(Some("username"));
content.append(&w::row("Username", &username_entry));
let fullname_entry = Entry::new();
fullname_entry.set_placeholder_text(Some("Full name (optional)"));
content.append(&w::row("Full name", &fullname_entry));
let password_entry = Entry::new();
password_entry.set_visibility(false);
password_entry.set_placeholder_text(Some("password"));
content.append(&w::row("Password", &password_entry));
let add_status = Label::new(None);
add_status.add_css_class("dim-label");
add_status.set_xalign(0.0);
let add_btn = Button::with_label("Add user");
add_btn.add_css_class("suggested-action");
add_btn.set_halign(gtk4::Align::Start);
add_btn.set_margin_top(8);
{
let list = list.clone();
let username_entry = username_entry.clone();
let fullname_entry = fullname_entry.clone();
let password_entry = password_entry.clone();
let add_status = add_status.clone();
add_btn.connect_clicked(move |_| {
let username = username_entry.text().to_string();
let full_name = fullname_entry.text().to_string();
let password = password_entry.text().to_string();
if username.trim().is_empty() || password.is_empty() {
add_status.set_text("Username and password are required.");
return;
}
add_status.set_text("Adding…");
let mut useradd_args = vec!["pkexec", "useradd", "-m", "-s", "/bin/bash"];
if !full_name.trim().is_empty() {
useradd_args.push("-c");
useradd_args.push(full_name.trim());
}
useradd_args.push(username.trim());
let list2 = list.clone();
let username2 = username.trim().to_string();
let password2 = password.clone();
let add_status2 = add_status.clone();
let username_entry2 = username_entry.clone();
let fullname_entry2 = fullname_entry.clone();
let password_entry2 = password_entry.clone();
let log_buf = gtk4::TextBuffer::new(None);
w::stream_command_then(&useradd_args, log_buf, move || {
let input = format!("{username2}:{password2}\n");
let list3 = list2.clone();
let add_status3 = add_status2.clone();
let username_entry3 = username_entry2.clone();
let fullname_entry3 = fullname_entry2.clone();
let password_entry3 = password_entry2.clone();
run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
if ok {
add_status3.set_text("User added.");
username_entry3.set_text("");
fullname_entry3.set_text("");
password_entry3.set_text("");
populate(&list3);
} else {
add_status3.set_text("User created, but setting the password failed.");
populate(&list3);
}
});
});
});
}
content.append(&add_btn);
content.append(&add_status);
outer
}

View file

@ -35,8 +35,13 @@ pub fn build_ui(app: &Application) {
stack.add_named(&views::network::build(), Some("network")); stack.add_named(&views::network::build(), Some("network"));
stack.add_named(&views::sound::build(), Some("sound")); stack.add_named(&views::sound::build(), Some("sound"));
stack.add_named(&views::datetime::build(), Some("datetime")); stack.add_named(&views::datetime::build(), Some("datetime"));
stack.add_named(&views::power::build(), Some("power"));
stack.add_named(&views::firewall::build(), Some("firewall"));
stack.add_named(&views::users::build(), Some("users"));
stack.add_named(&views::snapshots::build(), Some("snapshots")); stack.add_named(&views::snapshots::build(), Some("snapshots"));
stack.add_named(&views::packages::build(), Some("packages")); stack.add_named(&views::packages::build(), Some("packages"));
stack.add_named(&views::firmware::build(), Some("firmware"));
stack.add_named(&views::aur::build(), Some("aur"));
stack.add_named(&views::bread::build(), Some("bread")); stack.add_named(&views::bread::build(), Some("bread"));
stack.add_named(&views::breadbar::build(), Some("breadbar")); stack.add_named(&views::breadbar::build(), Some("breadbar"));
stack.add_named(&views::breadbox::build(), Some("breadbox")); stack.add_named(&views::breadbox::build(), Some("breadbox"));

View file

@ -287,6 +287,15 @@ system-config-printer
# remote post-install (needs network); the runtime is shipped ready. # remote post-install (needs network); the runtime is shipped ready.
flatpak flatpak
# Graphical alternatives to terminal-only tools, so users who want more
# graphical control aren't funneled to a shell for everyday things.
# gnome-disk-utility: partition/format/SMART-health GUI for gnome-disks.
# gufw: GUI front-end for the ufw firewall bos already enables by default.
# mission-center: graphical task manager (CPU/mem/disk/net + process list).
gnome-disk-utility
gufw
mission-center
# Firewall — ufw, enabled deny-incoming in post-install.sh (mDNS allowed so # Firewall — ufw, enabled deny-incoming in post-install.sh (mDNS allowed so
# printer discovery still works). # printer discovery still works).
ufw ufw