bos-settings 0.6.0: Power, Firewall, Users, AUR, Firmware panels
Continues reducing terminal-reliance for graphical system control. Five new panels, plus three GUI apps shipped for things better served by an existing dedicated tool than reimplemented in bos-settings (gnome-disk-utility, gufw, mission-center). - Power: battery status/health, brightness, charge-limit thresholds where the hardware exposes them, TLP profile shown read-only by design (no Balanced/Performance switcher — TLP auto-selects by power source, and power-profiles-daemon isn't installed because it conflicts with tlp). - Firewall: ufw enable/disable, add/remove rules, view active rules. ufw's own status check requires root (confirmed against the installed script — not just changes, reads too), so unlike every other panel this one does NOT query state in build(): every view is constructed eagerly at app launch, and an unconditional privileged read here would mean a polkit prompt on every single bos-settings open. Starts blank with a "Status not loaded" placeholder; state loads only on an explicit Refresh click, with a guard so refresh's own set_active() doesn't loop back into triggering ufw enable/disable. - Users: add/remove accounts, change passwords. All through pkexec on a background thread. Can't remove the account you're currently running as. - AUR: search via yay. Installing deliberately opens a terminal instead of a silent --noconfirm install — yay's interactive PKGBUILD diff review and sudo prompt are the actual safety mechanism against a malicious AUR package, not a formality worth automating away. - Firmware: fwupd device list + updates, same stream-output-then-refresh pattern as Packages. packages.x86_64: gnome-disk-utility, gufw, mission-center for disk/firewall/ task-manager GUIs that don't need reinventing inside bos-settings.
This commit is contained in:
parent
5aaf71e80a
commit
ad4d71db34
12 changed files with 1119 additions and 16 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
|||
|
||||
[[package]]
|
||||
name = "bos-settings"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"bread-theme",
|
||||
|
|
|
|||
47
README.md
47
README.md
|
|
@ -120,21 +120,40 @@ avoid memory pressure.
|
|||
|
||||
## bos-settings
|
||||
|
||||
`bos-settings` edits each bread\* app's TOML **non-destructively**: it parses
|
||||
the file with `toml_edit`, changes only the keys a view exposes, and writes it
|
||||
back — preserving comments and any keys the UI doesn't model (calendar
|
||||
passwords, saved-network passwords, model paths). Views:
|
||||
A GTK4 settings app aiming for GNOME-Settings-style parity: not just editing
|
||||
config files, but live system state and control, so day-to-day machine
|
||||
administration doesn't require a terminal.
|
||||
|
||||
| View | Config |
|
||||
|------|--------|
|
||||
| bread | `bread/breadd.toml` — daemon, lua, modules, all adapters, events, notifications |
|
||||
| breadbar | `breadbar/style.css` override |
|
||||
| breadbox | `breadbox/config.toml` — launcher contexts |
|
||||
| breadcrumbs | `breadcrumbs/breadcrumbs.toml` — settings, saved networks, profiles |
|
||||
| breadpad | `breadpad/breadpad.toml` — settings, model + ollama, reminders, calendar |
|
||||
| Snapshots | `snapper` list / rollback / delete |
|
||||
| Packages | `bakery` installed list + updates |
|
||||
| Hyprland | open config in editor + monitor list |
|
||||
Bread-ecosystem TOML configs are edited **non-destructively**: `toml_edit`
|
||||
parses the file, changes only the keys a view exposes, and writes it back —
|
||||
preserving comments and any keys the UI doesn't model (calendar passwords,
|
||||
saved-network passwords, model paths). Panels with a daemon behind them
|
||||
(bread, breadbox, breadcrumbs, breadsearch, breadclip) also get live
|
||||
systemd status + Start/Stop/Restart/Logs via a shared `service_control`
|
||||
widget, not just the config file.
|
||||
|
||||
| Panel | What it does |
|
||||
|-------|--------------|
|
||||
| About | System info (OS/kernel/CPU/GPU/memory/disk/uptime) + hostname |
|
||||
| Network | Wi-Fi scan/connect, Ethernet status, radio toggle |
|
||||
| Wi-Fi Profiles (breadcrumbs) | `breadcrumbs.toml` — settings, saved networks, profiles |
|
||||
| Firewall | ufw rules: enable/disable, add/remove, view active rules |
|
||||
| Sound | PipeWire output/input device + volume via `pactl` |
|
||||
| Power | Battery status/health, brightness, charge limits (hardware-dependent), TLP profile (read-only) |
|
||||
| Date & Time | Timezone, NTP sync toggle |
|
||||
| Display (Hyprland) | Connected monitors + open `hyprland.lua` in editor |
|
||||
| Users | Add/remove accounts, change passwords |
|
||||
| Wallpaper (breadpaper) | Set wallpaper, drives the pywal-derived accent palette |
|
||||
| Bar (breadbar) | `breadbar/style.css` override, live-reloads on save |
|
||||
| Launcher (breadbox) | `breadbox/config.toml` — launcher contexts |
|
||||
| Clipboard (breadclip) | breadclipd service control + "open history" |
|
||||
| Notes (breadpad) | `breadpad/breadpad.toml` — settings, model + ollama, reminders, calendar |
|
||||
| File Search (breadsearch) | `breadsearch/config.toml` — index/search/model + breadmill service |
|
||||
| Daemon (bread) | `breadd.toml` — daemon, lua, modules, adapters, events, notifications |
|
||||
| Packages | `bakery` installed list + updates, pacman system update |
|
||||
| AUR | Search via `yay`; installing opens a terminal (AUR build scripts need review) |
|
||||
| Firmware | `fwupd` device list + updates |
|
||||
| Snapshots | `snapper` list / boot-into (grub-btrfs) / delete |
|
||||
|
||||
Build standalone:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "bos-settings"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
|
|
@ -33,9 +33,12 @@ const fn item_sub(
|
|||
pub const SYSTEM_ITEMS: &[SidebarItem] = &[
|
||||
item("network", "Network", "network-wireless-symbolic"),
|
||||
item_sub("breadcrumbs", "Wi-Fi Profiles", "breadcrumbs", "network-workgroup-symbolic"),
|
||||
item("firewall", "Firewall", "security-high-symbolic"),
|
||||
item("sound", "Sound", "audio-volume-high-symbolic"),
|
||||
item("power", "Power", "battery-good-symbolic"),
|
||||
item("datetime", "Date & Time", "preferences-system-time-symbolic"),
|
||||
item_sub("hyprland", "Display", "hyprland.lua", "video-display-symbolic"),
|
||||
item("users", "Users", "system-users-symbolic"),
|
||||
];
|
||||
|
||||
pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[
|
||||
|
|
@ -50,6 +53,8 @@ pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[
|
|||
|
||||
pub const MAINTENANCE_ITEMS: &[SidebarItem] = &[
|
||||
item("packages", "Packages", "package-x-generic-symbolic"),
|
||||
item("aur", "AUR", "system-search-symbolic"),
|
||||
item("firmware", "Firmware", "software-update-available-symbolic"),
|
||||
item("snapshots", "Snapshots", "document-open-recent-symbolic"),
|
||||
];
|
||||
|
||||
|
|
|
|||
169
bos-settings/src/ui/views/aur.rs
Normal file
169
bos-settings/src/ui/views/aur.rs
Normal 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
|
||||
}
|
||||
246
bos-settings/src/ui/views/firewall.rs
Normal file
246
bos-settings/src/ui/views/firewall.rs
Normal 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
|
||||
}
|
||||
159
bos-settings/src/ui/views/firmware.rs
Normal file
159
bos-settings/src/ui/views/firmware.rs
Normal 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
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod about;
|
||||
pub mod aur;
|
||||
pub mod bread;
|
||||
pub mod breadbar;
|
||||
pub mod breadbox;
|
||||
|
|
@ -8,8 +9,12 @@ pub mod breadpad;
|
|||
pub mod breadpaper;
|
||||
pub mod breadsearch;
|
||||
pub mod datetime;
|
||||
pub mod firewall;
|
||||
pub mod firmware;
|
||||
pub mod hyprland;
|
||||
pub mod network;
|
||||
pub mod packages;
|
||||
pub mod power;
|
||||
pub mod snapshots;
|
||||
pub mod sound;
|
||||
pub mod users;
|
||||
|
|
|
|||
193
bos-settings/src/ui/views/power.rs
Normal file
193
bos-settings/src/ui/views/power.rs
Normal 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
|
||||
}
|
||||
293
bos-settings/src/ui/views/users.rs
Normal file
293
bos-settings/src/ui/views/users.rs
Normal 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
|
||||
}
|
||||
|
|
@ -35,8 +35,13 @@ pub fn build_ui(app: &Application) {
|
|||
stack.add_named(&views::network::build(), Some("network"));
|
||||
stack.add_named(&views::sound::build(), Some("sound"));
|
||||
stack.add_named(&views::datetime::build(), Some("datetime"));
|
||||
stack.add_named(&views::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::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::breadbar::build(), Some("breadbar"));
|
||||
stack.add_named(&views::breadbox::build(), Some("breadbox"));
|
||||
|
|
|
|||
|
|
@ -287,6 +287,15 @@ system-config-printer
|
|||
# remote post-install (needs network); the runtime is shipped ready.
|
||||
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
|
||||
# printer discovery still works).
|
||||
ufw
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue