Settings redesign: hub-based navigation, plus hardening and bug fixes

Rework the settings app around hub pages (home, network, displays, input,
apps, privacy, system) with a redesigned sidebar, shared nav state, and new
hub view components. Alongside the redesign: validate webview inputs into
root commands (users, firewall, snapshots, wifi), fix set_charge_threshold
writing the percentage via tee stdin, prevent streaming installs from
hanging on inherited stdin, add frontend type fixes, sync versions to
0.8.2, and clean up clippy/svelte-check warnings.
This commit is contained in:
Breadway 2026-08-30 18:37:21 +08:00
parent f7b114f778
commit dce2031743
97 changed files with 2723 additions and 1142 deletions

View file

@ -5,6 +5,10 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-start-dragging",
"dialog:default"
]
}

View file

@ -69,5 +69,7 @@ pub fn get_appearance() -> Appearance {
#[tauri::command]
pub fn save_appearance(appearance: Appearance) -> Result<(), String> {
let json = serde_json::to_string_pretty(&appearance).map_err(|e| e.to_string())?;
config::atomic_write(&config_path(), &json).map_err(|e| e.to_string())
config::atomic_write(&config_path(), &json).map_err(|e| e.to_string())?;
super::util::hypr_reload();
Ok(())
}

View file

@ -239,7 +239,7 @@ pub fn valid_restore_target(path: &Path) -> bool {
return false;
}
let normalized = normalize_abs(path);
if normalized == PathBuf::from("/") {
if normalized == *"/" {
return false;
}
normalized != normalize_abs(&home_dir())

View file

@ -7,6 +7,8 @@
use serde::Serialize;
use tokio::process::Command;
use super::util;
#[derive(Serialize, Clone)]
pub struct FirewallRule {
number: String,

View file

@ -51,10 +51,30 @@ fn hypr_path(name: &str) -> std::path::PathBuf {
config::config_dir().join("hypr").join(name)
}
#[derive(Serialize)]
#[derive(Serialize, Deserialize, Clone)]
pub struct LiveMonitor {
name: String,
mode: String,
pub name: String,
pub mode: String,
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
pub refresh: f64,
pub scale: f64,
pub transform: u32,
pub available_modes: Vec<String>,
}
fn json_i32(v: &serde_json::Value, key: &str) -> Option<i32> {
v.get(key)?.as_i64().map(|n| n as i32).or_else(|| v.get(key)?.as_f64().map(|n| n.round() as i32))
}
fn json_u32(v: &serde_json::Value, key: &str) -> Option<u32> {
v.get(key)?.as_u64().map(|n| n as u32).or_else(|| v.get(key)?.as_f64().map(|n| n.round() as u32))
}
fn json_f64(v: &serde_json::Value, key: &str) -> Option<f64> {
v.get(key)?.as_f64().or_else(|| v.get(key)?.as_u64().map(|n| n as f64))
}
#[tauri::command]
@ -69,15 +89,103 @@ pub fn get_live_monitors() -> Vec<LiveMonitor> {
monitors
.iter()
.filter_map(|m| {
let name = m.get("name")?.as_str()?;
let w = m.get("width")?.as_u64()?;
let h = m.get("height")?.as_u64()?;
let refresh = m.get("refreshRate")?.as_f64()?;
Some(LiveMonitor { name: name.to_string(), mode: format!("{w}x{h} @ {refresh:.0}Hz") })
let name = m.get("name")?.as_str()?.to_string();
let width = json_u32(m, "width")?;
let height = json_u32(m, "height")?;
let refresh = json_f64(m, "refreshRate").unwrap_or(60.0);
let x = json_i32(m, "x").unwrap_or(0);
let y = json_i32(m, "y").unwrap_or(0);
let scale = json_f64(m, "scale").unwrap_or(1.0).max(0.1);
let transform = json_u32(m, "transform").unwrap_or(0);
let available_modes = m
.get("availableModes")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
Some(LiveMonitor {
name,
mode: format!("{width}x{height} @ {refresh:.0}Hz"),
x,
y,
width,
height,
refresh,
scale,
transform,
available_modes,
})
})
.collect()
}
fn write_monitor_rules(rules: &[MonitorRule]) -> Result<(), String> {
let file = MonitorsFile { monitors: rules.to_vec() };
let json = serde_json::to_string_pretty(&file).map_err(|e| e.to_string())?;
config::atomic_write(&config_path(), &json).map_err(|e| e.to_string())
}
fn lua_ident(name: &str) -> Result<(), String> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(format!("bad monitor name '{name}'"));
}
Ok(())
}
/// Live-apply a layout via BOS Hyprland `hl.monitor()`, then persist monitors.json.
#[tauri::command]
pub fn apply_monitor_layout(monitors: Vec<LiveMonitor>) -> Result<(), String> {
if monitors.is_empty() {
return Err("no monitors".into());
}
let mut stmts = Vec::new();
let mut rules = Vec::new();
for m in &monitors {
lua_ident(&m.name)?;
let refresh = if m.refresh > 1.0 { m.refresh.round() as u32 } else { 60 };
let mode = format!("{}x{}@{refresh}", m.width.max(1), m.height.max(1));
let position = format!("{}x{}", m.x, m.y);
let scale = if (m.scale - 1.0).abs() < 0.001 {
"1".to_string()
} else {
format!("{:.2}", m.scale)
};
let transform = m.transform.min(7);
stmts.push(format!(
"hl.monitor({{ output = \"{}\", mode = \"{mode}\", position = \"{position}\", scale = \"{scale}\", transform = {transform}, vrr = false }})",
m.name
));
rules.push(MonitorRule {
output: m.name.clone(),
mode,
position,
scale,
});
}
let lua = stmts.join("; ");
let output = std::process::Command::new("hyprctl")
.args(["eval", &lua])
.output()
.map_err(|e| e.to_string())?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout != "ok" {
let low = stdout.to_lowercase();
if low.contains("unknown request") || low.contains("unknown command") || stdout.is_empty() {
return Err("Live layout needs BOS Hyprland (hyprctl eval / hl.monitor).".into());
}
return Err(format!("hyprctl: {stdout}"));
}
write_monitor_rules(&rules)?;
Ok(())
}
#[tauri::command]
pub fn get_monitor_rules() -> Vec<MonitorRule> {
std::fs::read_to_string(config_path())
@ -90,9 +198,9 @@ pub fn get_monitor_rules() -> Vec<MonitorRule> {
#[tauri::command]
pub fn save_monitor_rules(rules: Vec<MonitorRule>) -> Result<(), String> {
let file = MonitorsFile { monitors: rules };
let json = serde_json::to_string_pretty(&file).map_err(|e| e.to_string())?;
config::atomic_write(&config_path(), &json).map_err(|e| e.to_string())
write_monitor_rules(&rules)?;
super::util::hypr_reload();
Ok(())
}
/// Opens `hyprland.lua` in `$EDITOR` (nano if unset) inside a terminal —

View file

@ -254,7 +254,9 @@ pub fn get_keybinds() -> BindsPayload {
#[tauri::command]
pub fn save_keybinds(file: BindsFile, kind: SchemaKind) -> Result<(), String> {
save(&file, kind).map_err(|e| e.to_string())
save(&file, kind).map_err(|e| e.to_string())?;
super::util::hypr_reload();
Ok(())
}
#[cfg(test)]

View file

@ -7,6 +7,8 @@ use serde::Serialize;
use std::collections::{HashMap, HashSet};
use tokio::process::Command;
use super::util;
#[derive(Serialize, Clone)]
pub struct WifiNetwork {
ssid: String,
@ -95,6 +97,9 @@ pub async fn scan_wifi() -> Vec<WifiNetwork> {
#[tauri::command]
pub async fn connect_wifi(ssid: String, password: Option<String>) -> Result<(), String> {
if !util::valid_nm_id(&ssid) {
return Err(format!("invalid SSID '{ssid}'"));
}
let known = known_connection_names().await;
let output = if let Some(password) = password {
Command::new("nmcli").args(["dev", "wifi", "connect", &ssid, "password", &password]).output().await

View file

@ -97,7 +97,7 @@ pub fn read_nvidia_offer() -> Option<NvidiaOffer> {
.collect::<Vec<_>>()
})
.filter(|p| !p.is_empty())
.unwrap_or_else(|| default_packages());
.unwrap_or_else(default_packages);
return Some(NvidiaOffer {
gpu,
reason,

View file

@ -154,7 +154,8 @@ pub async fn set_brightness(percent: i64) -> Result<(), String> {
let Some(device) = brightness_device().await else {
return Err("No controllable backlight found".into());
};
let pct = format!("{percent}%");
// Clamp before handing the value to brightnessctl.
let pct = format!("{}%", percent.clamp(0, 100));
Command::new("brightnessctl")
.args(["--device", &device, "set", &pct])
.status()

View file

@ -1,6 +1,8 @@
use serde::Serialize;
use tokio::process::Command;
use super::util;
#[derive(Serialize, Clone)]
pub struct SnapshotRow {
number: String,
@ -44,6 +46,9 @@ pub async fn get_snapshots() -> Result<Vec<SnapshotRow>, String> {
#[tauri::command]
pub async fn delete_snapshot(number: String) -> Result<(), String> {
if !util::valid_number_id(&number) {
return Err("invalid snapshot number".into());
}
let output = Command::new("snapper").args(["delete", &number]).status().await.map_err(|e| e.to_string())?;
if output.success() {
Ok(())

View file

@ -51,6 +51,12 @@ pub(crate) async fn run_hardcoded_env(
) -> bool {
let mut cmd = Command::new(program);
cmd.args(args)
// Null stdin, never inherit: a child reading the app's inherited
// stdin (pkexec falling back to a tty password prompt, pacman's
// interactive confirm) would block forever and hang the install
// command. These commands are all non-interactive (--noconfirm/-y);
// authenticating goes through the polkit agent as a GUI dialog.
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);

View file

@ -39,7 +39,7 @@ fn css_custom_properties(p: &bread_theme::Palette) -> String {
("fg", p.foreground.as_str()),
("surface", p.color0.as_str()),
("overlay", p.color7.as_str()),
("accent", p.color4.as_str()),
("accent", p.color1.as_str()),
("red", p.color1.as_str()),
("green", p.color2.as_str()),
("yellow", p.color3.as_str()),
@ -48,7 +48,7 @@ fn css_custom_properties(p: &bread_theme::Palette) -> String {
("teal", p.color6.as_str()),
("on-bg", bread_theme::ink_on(&p.background)),
("on-surface", bread_theme::ink_on(&p.color0)),
("on-accent", bread_theme::ink_on(&p.color4)),
("on-accent", bread_theme::ink_on(&p.color1)),
("on-red", bread_theme::ink_on(&p.color1)),
("on-overlay", bread_theme::ink_on(&p.color7)),
];

View file

@ -110,6 +110,8 @@ fn may_delete_user(username: &str, current: &str) -> Result<(), String> {
#[tauri::command]
pub async fn change_password(username: String, password: String) -> Result<(), String> {
// chpasswd_input validates the username *and* the password (rejects the
// newline / `:` that would let one entry smuggle another).
let input = chpasswd_input(&username, &password)?;
if util::run_with_stdin(&["pkexec", "chpasswd"], &input).await {
Ok(())
@ -121,9 +123,11 @@ pub async fn change_password(username: String, password: String) -> Result<(), S
#[tauri::command]
pub async fn remove_user(username: String) -> Result<(), String> {
let current = std::env::var("USER").unwrap_or_default();
// Validates the username and refuses `root` / the current user.
may_delete_user(&username, &current)?;
// `--` so a username can never be read as a userdel option.
let output = Command::new("pkexec")
.args(["userdel", "-r", &username])
.args(["userdel", "-r", "--", &username])
.output()
.await
.map_err(|e| e.to_string())?;
@ -137,7 +141,14 @@ pub async fn remove_user(username: String) -> Result<(), String> {
#[tauri::command]
pub async fn add_user(username: String, full_name: String, password: String) -> Result<(), String> {
let username = username.trim();
// Validates the username and the password (newline / `:` injection).
let input = chpasswd_input(username, &password)?;
// GECOS field is otherwise free text; strip control chars and the field
// separators so `-c` can't smuggle extra passwd fields or arguments.
let gecos: String = full_name
.chars()
.filter(|c| !matches!(c, '\n' | '\r' | '\0' | ',' | ':'))
.collect();
let mut useradd_args = vec![
"pkexec".to_string(),
"useradd".to_string(),
@ -145,10 +156,12 @@ pub async fn add_user(username: String, full_name: String, password: String) ->
"-s".to_string(),
"/bin/bash".to_string(),
];
if !full_name.trim().is_empty() {
if !gecos.trim().is_empty() {
useradd_args.push("-c".to_string());
useradd_args.push(full_name.trim().to_string());
useradd_args.push(gecos.trim().to_string());
}
// `--` so the username can never be read as a useradd option.
useradd_args.push("--".to_string());
useradd_args.push(username.to_string());
let args_ref: Vec<&str> = useradd_args.iter().map(String::as_str).collect();
let output = Command::new(args_ref[0])

View file

@ -226,6 +226,11 @@ pub fn valid_nm_id(name: &str) -> bool {
&& !t.contains(';')
}
/// Reload Hyprland so settings.json / binds.json / monitors.json take effect now.
pub fn hypr_reload() {
let _ = std::process::Command::new("hyprctl").arg("reload").status();
}
pub fn valid_printer_name(name: &str) -> bool {
let bytes = name.as_bytes();
!bytes.is_empty()
@ -236,6 +241,43 @@ pub fn valid_printer_name(name: &str) -> bool {
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.'))
}
/// Linux account usernames: lowercase letters, digits, `_`, `-`, `.`;
/// must start with a lowercase letter (rejects a leading `-`, which would
/// be a flag injection into useradd/userdel); capped at useradd's 32-char MAX.
///
/// We deliberately do *not* accept a leading `@`/domain or spaces: account
/// creation here is a plain local user.
pub fn valid_username(name: &str) -> bool {
let bytes = name.as_bytes();
!bytes.is_empty()
&& bytes.len() <= 32
&& bytes[0].is_ascii_lowercase()
&& bytes
.iter()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(*b, b'_' | b'-' | b'.'))
}
/// Free-form text thrown at a CLI tool as a single argv element (firewall
/// rule string, etc). Blocks the two genuinely dangerous shapes — a leading
/// flag `-` and any control/whitespace injection — while still allowing
/// spaces, slashes, dots, colons etc that ufw rules legitimately use.
pub fn valid_cli_value(name: &str) -> bool {
let t = name.trim();
!t.is_empty()
&& t.len() <= 256
&& !t.starts_with('-')
&& !t.contains('\n')
&& !t.contains('\r')
&& !t.contains('\0')
&& !t.contains(';')
&& t.bytes().all(|b| !(0..=31).contains(&b))
}
/// ufw / snapper numeric id — digits only.
pub fn valid_number_id(num: &str) -> bool {
!num.is_empty() && num.len() <= 12 && num.bytes().all(|b| b.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
@ -279,4 +321,43 @@ mod tests {
assert!(!valid_printer_name("foo bar"));
assert!(!valid_printer_name("-d"));
}
#[test]
fn username_accepts_normal_accounts() {
assert!(valid_username("alice"));
assert!(valid_username("bob_2"));
assert!(valid_username("john.doe"));
assert!(valid_username("a"));
}
#[test]
fn username_rejects_flags_and_injection() {
assert!(!valid_username(""));
assert!(!valid_username("-r")); // system-account flag into useradd
assert!(!valid_username("--system"));
assert!(!valid_username("a\nb"));
assert!(!valid_username("foo bar"));
assert!(!valid_username("UPPER")); // must start lowercase
assert!(!valid_username(&"a".repeat(33)));
}
#[test]
fn cli_value_rejects_flags_and_controls() {
assert!(valid_cli_value("80/tcp"));
assert!(valid_cli_value("from 192.168.1.0/24 to any port 53"));
assert!(!valid_cli_value("--all"));
assert!(!valid_cli_value("-n"));
assert!(!valid_cli_value("a\nb"));
assert!(!valid_cli_value("a;rm"));
assert!(!valid_cli_value(""));
}
#[test]
fn number_id_is_digits_only() {
assert!(valid_number_id("42"));
assert!(!valid_number_id(""));
assert!(!valid_number_id("-1"));
assert!(!valid_number_id("12a"));
assert!(!valid_number_id("1 2"));
}
}

View file

@ -50,6 +50,7 @@ pub fn run() {
commands::hyprland::get_live_monitors,
commands::hyprland::get_monitor_rules,
commands::hyprland::save_monitor_rules,
commands::hyprland::apply_monitor_layout,
commands::hyprland::open_hyprland_conf,
commands::hyprland::open_keybinds_viewer,
commands::keybinds::get_keybinds,

View file

@ -37,6 +37,7 @@ const INITIAL_SETTLE_DELAY: Duration = Duration::from_millis(2000);
const VIEW_SETTLE_DELAY: Duration = Duration::from_millis(2000);
const KNOWN_VIEWS: &[&str] = &[
"home",
"network",
"breadcrumbs",
"bluetooth",
@ -45,7 +46,9 @@ const KNOWN_VIEWS: &[&str] = &[
"power",
"datetime",
"hyprland",
"displays",
"keybinds",
"input",
"autostart",
"users",
"appearance",
@ -56,17 +59,20 @@ const KNOWN_VIEWS: &[&str] = &[
"breadpad",
"breadsearch",
"bread",
"desktop",
"packages",
"aur",
"firmware",
"snapshots",
"updates",
"system",
"printing",
"vpn",
"nightlight",
"ime",
"accessibility",
"defaults",
"apps",
"channel",
"backup",
"optional",
@ -74,6 +80,7 @@ const KNOWN_VIEWS: &[&str] = &[
"breadshot",
"breadmon",
"breadhelp",
"privacy",
"about",
];

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "BOS Settings",
"version": "0.8.0",
"version": "0.8.2",
"identifier": "dev.breadway.bos-settings",
"build": {
"beforeDevCommand": "npm --prefix frontend run dev",
@ -13,10 +13,12 @@
"windows": [
{
"title": "BOS Settings",
"width": 960,
"height": 640,
"width": 1280,
"height": 840,
"minWidth": 960,
"minHeight": 640,
"decorations": false,
"backgroundColor": "#0c0c0c"
"backgroundColor": "#12161c"
}
],
"security": {