Migrate bos-settings from GTK4 to Tauri + Svelte 5

Ports every remaining view (Keybinds, the last one — dual Flat/MultiLayout
schema detection, per-action dedicated fields with a raw-JSON fallback for
anything not modeled) and removes the now-fully-dead GTK4 crate (root
Cargo.toml + src/), leaving src-tauri/ as the single Rust binary crate.

Also folds in the UX pass done alongside the migration: responsive
multi-column layout instead of a single centered column, chip/dropdown
pickers replacing free-typed fields (Wi-Fi networks, Bar style, keyboard
layout, tag lists), consistent pill-switch toggles and boxed-list card
styling across every page, and a sidebar scroll-chaining fix.
This commit is contained in:
Breadway 2026-07-22 19:23:02 +08:00
parent 62365ebf10
commit 93c3b88b10
147 changed files with 15804 additions and 7516 deletions

7
src-tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

5311
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

36
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,36 @@
[package]
name = "bos-settings"
version = "0.8.0"
description = "System settings app for BOS (Bread Operating System)"
authors = ["Breadway"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "bos_settings_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml_edit = "0.22"
tokio = { version = "1", features = ["process", "io-util", "time", "macros"] }
notify = "7"
regex = "1"
# TODO(owner): switch to a tag-pinned git dependency once bread-theme cuts a
# release including css_custom_properties/css_tokens (added 2026-07-21 for
# this migration, unreleased) — matches the same pending-tag pattern
# bos-settings' old GTK Cargo.toml used for bread-utils.
bread-theme = { path = "../../bread-ecosystem/bread-theme" }
bread-utils = { path = "../../bread-ecosystem/bread-utils", features = ["toml"] }

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"dialog:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,155 @@
//! Read-only system info, plus the one thing worth making writable: hostname.
//! BOS is a rolling release (no fixed version number to show — `os-release`
//! ships `BUILD_ID=rolling` on purpose), so there's no "BOS 1.2.3" readout
//! here the way a point-release distro's About panel would have one.
use serde::Serialize;
use std::fs;
use tokio::process::Command;
#[derive(Serialize)]
pub struct SystemInfo {
os: String,
kernel: String,
cpu: String,
gpu: String,
memory: String,
disk: String,
uptime: String,
hostname: String,
}
fn os_pretty_name() -> String {
fs::read_to_string("/etc/os-release")
.ok()
.and_then(|s| {
s.lines()
.find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string()))
})
.unwrap_or_else(|| "BOS".to_string())
}
fn hostname() -> String {
fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "unknown".to_string())
}
async fn kernel() -> String {
Command::new("uname")
.arg("-r")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
fn cpu() -> String {
let model = fs::read_to_string("/proc/cpuinfo")
.ok()
.and_then(|s| {
s.lines()
.find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string()))
})
.unwrap_or_else(|| "unknown".to_string());
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
if cores > 0 {
format!("{model} ({cores} threads)")
} else {
model
}
}
fn memory() -> String {
let kb = fs::read_to_string("/proc/meminfo")
.ok()
.and_then(|s| {
s.lines()
.find(|l| l.starts_with("MemTotal:"))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|v| v.parse::<u64>().ok())
});
match kb {
Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0),
None => "unknown".to_string(),
}
}
async fn gpu() -> String {
let Ok(output) = Command::new("lspci").output().await else {
return "unknown".to_string();
};
let text = String::from_utf8_lossy(&output.stdout);
text.lines()
// "Display controller" covers integrated GPUs some laptop chipsets
// (this dev laptop's AMD Radeon 860M included) report under instead
// of "VGA compatible controller" — without it those show "unknown".
.find(|l| {
l.contains("VGA compatible controller")
|| l.contains("3D controller")
|| l.contains("Display controller")
})
.and_then(|l| l.split(": ").nth(1))
.unwrap_or("unknown")
.to_string()
}
async fn disk_usage() -> String {
let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output().await else {
return "unknown".to_string();
};
let text = String::from_utf8_lossy(&output.stdout);
text.lines()
.nth(1)
.map(|l| {
let cols: Vec<&str> = l.split_whitespace().collect();
match cols.as_slice() {
[used, size, pcent] => format!("{used} of {size} used ({pcent})"),
_ => l.trim().to_string(),
}
})
.unwrap_or_else(|| "unknown".to_string())
}
async fn uptime() -> String {
Command::new("uptime")
.arg("-p")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
#[tauri::command]
pub async fn get_system_info() -> SystemInfo {
SystemInfo {
os: os_pretty_name(),
kernel: kernel().await,
cpu: cpu(),
gpu: gpu().await,
memory: memory(),
disk: disk_usage().await,
uptime: uptime().await,
hostname: hostname(),
}
}
#[tauri::command]
pub async fn set_hostname(name: String) -> Result<(), String> {
let name = name.trim();
if name.is_empty() {
return Err("Hostname can't be empty".into());
}
let output = Command::new("pkexec")
.args(["hostnamectl", "set-hostname", name])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}

View file

@ -0,0 +1,73 @@
//! hypr/settings.json — Hyprland gaps/borders/blur/shadow/input, read by
//! `scripts/ui/settings.lua` on the Hyprland side. No comments to preserve,
//! so it's a plain typed struct round-tripped whole.
//!
//! `Default` here must stay in sync with `scripts/ui/settings.lua`'s
//! `DEFAULTS` table on the Hyprland side — two different languages/
//! processes reading the same file, neither able to import the other's
//! defaults.
use serde::{Deserialize, Serialize};
use super::config;
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Appearance {
gaps_in: i64,
gaps_out: i64,
border_size: i64,
active_border: String,
inactive_border: String,
layout: String,
resize_on_border: bool,
rounding: i64,
blur_enabled: bool,
blur_size: i64,
blur_passes: i64,
shadow_enabled: bool,
shadow_range: i64,
shadow_render_power: i64,
kb_layout: String,
follow_mouse: i64,
natural_scroll: bool,
}
impl Default for Appearance {
fn default() -> Self {
Self {
gaps_in: 5,
gaps_out: 10,
border_size: 2,
active_border: "rgba(88c0d0ff)".to_string(),
inactive_border: "rgba(4c566aff)".to_string(),
layout: "dwindle".to_string(),
resize_on_border: true,
rounding: 8,
blur_enabled: true,
blur_size: 6,
blur_passes: 2,
shadow_enabled: true,
shadow_range: 12,
shadow_render_power: 3,
kb_layout: "us".to_string(),
follow_mouse: 1,
natural_scroll: true,
}
}
}
fn config_path() -> std::path::PathBuf {
config::config_dir().join("hypr/settings.json")
}
#[tauri::command]
pub fn get_appearance() -> Appearance {
std::fs::read_to_string(config_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
}
#[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())
}

View file

@ -0,0 +1,47 @@
//! AUR search via yay — graphical discovery beyond bakery's bread ecosystem
//! and [breadway]'s own republished packages.
//!
//! Installing opens a terminal running `yay -S <pkg>` instead of a silent
//! `--noconfirm` install — deliberate, not a shortcut skipped. AUR packages
//! run arbitrary maintainer-supplied build scripts, and yay's interactive
//! PKGBUILD diff review (plus the sudo prompt) is the actual safety
//! mechanism against a malicious/compromised package; automating it away
//! would remove the one step that exists to catch that.
use serde::Serialize;
#[derive(Serialize, Clone)]
pub struct AurResult {
name: String,
version: String,
description: String,
}
#[tauri::command]
pub async fn search_aur(query: String) -> Vec<AurResult> {
let Ok(output) = tokio::process::Command::new("yay").args(["-Ss", "--aur", &query]).output().await 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 "aur/".
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 });
if results.len() >= 50 {
break;
}
}
results
}
#[tauri::command]
pub fn install_aur_package(pkg: String) {
let _ = std::process::Command::new("kitty").args(["-e", "yay", "-S", &pkg]).spawn();
}

View file

@ -0,0 +1,59 @@
//! hypr/autostart.json — the *extra*, user-toggleable autostart apps. The
//! core bootstrap sequence stays hardcoded in hyprland.lua on purpose (it's
//! timing/order-sensitive infrastructure, not something this panel exposes).
use serde::{Deserialize, Serialize};
use super::config;
#[derive(Clone, Serialize, Deserialize)]
pub struct AutostartEntry {
command: String,
#[serde(default)]
label: String,
#[serde(default = "default_true")]
enabled: bool,
}
fn default_true() -> bool {
true
}
#[derive(Serialize, Deserialize)]
struct AutostartFile {
#[serde(default)]
extra: Vec<AutostartEntry>,
}
fn default_extra() -> Vec<AutostartEntry> {
vec![
AutostartEntry { command: "breadbar".into(), label: "Bar (breadbar)".into(), enabled: true },
AutostartEntry { command: "hypridle".into(), label: "Idle / lock daemon (hypridle)".into(), enabled: true },
AutostartEntry { command: "bos-netcheck".into(), label: "Network connectivity check".into(), enabled: true },
AutostartEntry { command: "breadhelp --autostart".into(), label: "BOS Help (first-run onboarding)".into(), enabled: true },
]
}
fn config_path() -> std::path::PathBuf {
config::config_dir().join("hypr/autostart.json")
}
#[tauri::command]
pub fn get_autostart_entries() -> Vec<AutostartEntry> {
std::fs::read_to_string(config_path())
.ok()
.and_then(|s| serde_json::from_str::<AutostartFile>(&s).ok())
.map(|f| f.extra)
.unwrap_or_else(default_extra)
}
/// Empty-command rows (still-being-typed "Add app" entries) are dropped on
/// save rather than written as a broken autostart.json entry the Lua loader
/// would otherwise have to reject.
#[tauri::command]
pub fn save_autostart_entries(entries: Vec<AutostartEntry>) -> Result<(), String> {
let entries: Vec<AutostartEntry> = entries.into_iter().filter(|e| !e.command.trim().is_empty()).collect();
let file = AutostartFile { extra: entries };
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())
}

View file

@ -0,0 +1,123 @@
//! Bluetooth over `bluetoothctl`'s non-interactive mode — no D-Bus
//! dependency needed, same "shell out to the standard CLI" choice as
//! Network (nmcli). None of this needs `pkexec` — BlueZ's D-Bus policy
//! already allows the active session user.
//!
//! Pairing only covers "Just Works" Simple Secure Pairing — bluetoothd's
//! own built-in default agent auto-accepts that for most audio/HID
//! devices. A device that requires PIN/passkey confirmation isn't
//! supported (would need this app to register its own bluetoothd agent);
//! pairing such a device just fails, surfaced as an error.
use serde::Serialize;
use std::collections::HashSet;
use tokio::process::Command;
#[derive(Serialize, Clone)]
pub struct BtDevice {
address: String,
name: String,
connected: bool,
}
#[tauri::command]
pub async fn get_adapter_powered() -> Option<bool> {
let output = Command::new("bluetoothctl").arg("show").output().await.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
if !text.trim_start().starts_with("Controller") {
return None;
}
Some(text.lines().any(|l| l.trim() == "Powered: yes"))
}
#[tauri::command]
pub async fn set_adapter_powered(on: bool) {
let val = if on { "on" } else { "off" };
let _ = Command::new("bluetoothctl").args(["power", val]).status().await;
}
fn parse_device_lines(text: &str) -> Vec<BtDevice> {
text.lines()
.filter_map(|l| {
let rest = l.strip_prefix("Device ")?;
let (addr, name) = rest.split_once(' ')?;
Some(BtDevice { address: addr.trim().to_string(), name: name.trim().to_string(), connected: false })
})
.collect()
}
async fn run_devices(filter: Option<&str>) -> Vec<BtDevice> {
let mut args = vec!["devices"];
if let Some(f) = filter {
args.push(f);
}
let Ok(out) = Command::new("bluetoothctl").args(&args).output().await else {
return Vec::new();
};
parse_device_lines(&String::from_utf8_lossy(&out.stdout))
}
#[tauri::command]
pub async fn get_paired_devices() -> Vec<BtDevice> {
let mut devices = run_devices(Some("Paired")).await;
let connected: HashSet<String> = run_devices(Some("Connected")).await.into_iter().map(|d| d.address).collect();
for d in &mut devices {
d.connected = connected.contains(&d.address);
}
devices
}
/// Scans for a few seconds and returns every device BlueZ has seen that
/// isn't already paired.
#[tauri::command]
pub async fn scan_bluetooth() -> Vec<BtDevice> {
let _ = Command::new("bluetoothctl").args(["--timeout", "5", "scan", "on"]).output().await;
let paired: HashSet<String> = run_devices(Some("Paired")).await.into_iter().map(|d| d.address).collect();
run_devices(None).await.into_iter().filter(|d| !paired.contains(&d.address)).collect()
}
#[tauri::command]
pub async fn bt_connect(address: String) -> Result<(), String> {
let out = Command::new("bluetoothctl").args(["connect", &address]).output().await.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
}
#[tauri::command]
pub async fn bt_disconnect(address: String) -> Result<(), String> {
let out = Command::new("bluetoothctl").args(["disconnect", &address]).output().await.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
}
#[tauri::command]
pub async fn bt_forget(address: String) -> Result<(), String> {
let out = Command::new("bluetoothctl").args(["remove", &address]).output().await.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
}
/// Pairs, then confirms it actually landed in the paired-device cache —
/// bluetoothctl's exit code alone isn't a reliable signal for pairing.
#[tauri::command]
pub async fn bt_pair(address: String) -> Result<(), String> {
let out = Command::new("bluetoothctl").args(["pair", &address]).output().await.map_err(|e| e.to_string())?;
let now_paired = run_devices(Some("Paired")).await.iter().any(|d| d.address == address);
if now_paired {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
}

View file

@ -0,0 +1,80 @@
//! breadd.toml — the bread daemon config. Schema mirrors
//! breadd/src/core/config.rs (daemon, lua, modules, adapters, events,
//! notifications). Edited non-destructively via `commands::config`.
use serde::{Deserialize, Serialize};
use super::config;
fn config_path() -> std::path::PathBuf {
config::config_dir().join("bread/breadd.toml")
}
#[derive(Serialize, Deserialize)]
pub struct BreadConfig {
log_level: String,
socket_path: String,
lua_entry_point: String,
lua_module_path: String,
modules_builtin: bool,
modules_disable: Vec<String>,
adapter_hyprland: bool,
adapter_udev: bool,
udev_subsystems: Vec<String>,
adapter_power: bool,
power_poll_interval_secs: i64,
adapter_network: bool,
adapter_bluetooth: bool,
dedup_window_ms: i64,
notif_default_timeout_ms: i64,
notif_default_urgency: String,
notif_notify_send_path: String,
}
#[tauri::command]
pub fn get_bread_config() -> BreadConfig {
let doc = config::load_doc(&config_path());
BreadConfig {
log_level: config::get_str(&doc, &["daemon", "log_level"]).unwrap_or_else(|| "info".into()),
socket_path: config::get_str(&doc, &["daemon", "socket_path"]).unwrap_or_default(),
lua_entry_point: config::get_str(&doc, &["lua", "entry_point"]).unwrap_or_default(),
lua_module_path: config::get_str(&doc, &["lua", "module_path"]).unwrap_or_default(),
modules_builtin: config::get_bool(&doc, &["modules", "builtin"]).unwrap_or(true),
modules_disable: config::get_str_list(&doc, &["modules", "disable"]),
adapter_hyprland: config::get_bool(&doc, &["adapters", "hyprland", "enabled"]).unwrap_or(true),
adapter_udev: config::get_bool(&doc, &["adapters", "udev", "enabled"]).unwrap_or(true),
udev_subsystems: config::get_str_list(&doc, &["adapters", "udev", "subsystems"]),
adapter_power: config::get_bool(&doc, &["adapters", "power", "enabled"]).unwrap_or(true),
power_poll_interval_secs: config::get_i64(&doc, &["adapters", "power", "poll_interval_secs"]).unwrap_or(30),
adapter_network: config::get_bool(&doc, &["adapters", "network", "enabled"]).unwrap_or(true),
adapter_bluetooth: config::get_bool(&doc, &["adapters", "bluetooth", "enabled"]).unwrap_or(true),
dedup_window_ms: config::get_i64(&doc, &["events", "dedup_window_ms"]).unwrap_or(250),
notif_default_timeout_ms: config::get_i64(&doc, &["notifications", "default_timeout_ms"]).unwrap_or(5000),
notif_default_urgency: config::get_str(&doc, &["notifications", "default_urgency"]).unwrap_or_else(|| "normal".into()),
notif_notify_send_path: config::get_str(&doc, &["notifications", "notify_send_path"]).unwrap_or_default(),
}
}
#[tauri::command]
pub fn save_bread_config(cfg: BreadConfig) -> Result<(), String> {
let path = config_path();
let mut doc = config::load_doc(&path);
config::set_str(&mut doc, &["daemon", "log_level"], &cfg.log_level);
config::set_str_or_remove(&mut doc, &["daemon", "socket_path"], &cfg.socket_path);
config::set_str_or_remove(&mut doc, &["lua", "entry_point"], &cfg.lua_entry_point);
config::set_str_or_remove(&mut doc, &["lua", "module_path"], &cfg.lua_module_path);
config::set_bool(&mut doc, &["modules", "builtin"], cfg.modules_builtin);
config::set_str_list(&mut doc, &["modules", "disable"], &cfg.modules_disable);
config::set_bool(&mut doc, &["adapters", "hyprland", "enabled"], cfg.adapter_hyprland);
config::set_bool(&mut doc, &["adapters", "udev", "enabled"], cfg.adapter_udev);
config::set_str_list(&mut doc, &["adapters", "udev", "subsystems"], &cfg.udev_subsystems);
config::set_bool(&mut doc, &["adapters", "power", "enabled"], cfg.adapter_power);
config::set_i64(&mut doc, &["adapters", "power", "poll_interval_secs"], cfg.power_poll_interval_secs);
config::set_bool(&mut doc, &["adapters", "network", "enabled"], cfg.adapter_network);
config::set_bool(&mut doc, &["adapters", "bluetooth", "enabled"], cfg.adapter_bluetooth);
config::set_i64(&mut doc, &["events", "dedup_window_ms"], cfg.dedup_window_ms);
config::set_i64(&mut doc, &["notifications", "default_timeout_ms"], cfg.notif_default_timeout_ms);
config::set_str(&mut doc, &["notifications", "default_urgency"], &cfg.notif_default_urgency);
config::set_str_or_remove(&mut doc, &["notifications", "notify_send_path"], &cfg.notif_notify_send_path);
config::save_doc(&path, &doc).map_err(|e| e.to_string())
}

View file

@ -0,0 +1,144 @@
//! breadbar/style.css — CSS overrides for the bar. No systemd unit (breadbar
//! is launched directly by hyprland.lua's exec-once); SIGHUP is its own
//! documented live-reload mechanism.
//!
//! The file has a fixed, hand-written structure (see the shipped template's
//! comments), so the common properties users actually tweak — font, bar
//! chrome, workspace indicator, spacing, tray/notification radii — are
//! exposed as a typed `BreadbarStyle` struct. `get`/`set_value` locate a
//! known `selector { ... }` block and rewrite just one declaration's value
//! inside it, leaving comments, ordering, and every unmodeled property
//! (font-weight, per-element opacities, notification padding, etc.)
//! untouched. Anything not modeled here stays reachable via the raw
//! `get`/`save_breadbar_css` pair, kept as an "Advanced" escape hatch.
use regex::{Captures, Regex};
use super::config;
fn css_path() -> std::path::PathBuf {
config::config_dir().join("breadbar/style.css")
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct BreadbarStyle {
pub font_family: String,
pub font_size: u32,
pub bar_border_radius: u32,
pub bar_padding: u32,
pub workspace_inactive_opacity: f64,
pub workspace_font_size: u32,
pub stat_gap: u32,
pub tray_icon_size: u32,
pub notification_border_radius: u32,
}
fn find_block<'a>(css: &'a str, selector: &str) -> Option<(usize, usize)> {
let pat = format!(r"(?m)^\s*{}\s*\{{", regex::escape(selector));
let re = Regex::new(&pat).ok()?;
let m = re.find(css)?;
let body_start = m.end();
let body_end = body_start + css[body_start..].find('}')?;
Some((body_start, body_end))
}
fn get_value(css: &str, selector: &str, property: &str) -> Option<String> {
let (start, end) = find_block(css, selector)?;
let body = &css[start..end];
let re = Regex::new(&format!(r"(?m)^\s*{}\s*:\s*([^;]+);", regex::escape(property))).ok()?;
Some(re.captures(body)?.get(1)?.as_str().trim().to_string())
}
fn set_value(css: &str, selector: &str, property: &str, new_value: &str) -> Option<String> {
let (start, end) = find_block(css, selector)?;
let body = &css[start..end];
let re = Regex::new(&format!(r"(?m)(^\s*{}\s*:\s*)([^;]+)(;)", regex::escape(property))).ok()?;
if !re.is_match(body) {
return None;
}
let new_body = re
.replace(body, |caps: &Captures| format!("{}{}{}", &caps[1], new_value, &caps[3]))
.into_owned();
Some(format!("{}{}{}", &css[..start], new_body, &css[end..]))
}
fn strip_px(v: &str) -> u32 {
v.trim().trim_end_matches("px").trim().parse().unwrap_or(0)
}
#[tauri::command]
pub fn get_breadbar_css() -> String {
std::fs::read_to_string(css_path()).unwrap_or_default()
}
/// Saves the CSS and sends breadbar SIGHUP to live-reload it. Returns
/// whether the reload signal was actually delivered (`false` just means
/// breadbar isn't running — the file is saved either way).
#[tauri::command]
pub fn save_breadbar_css(css: String) -> Result<bool, String> {
config::atomic_write(&css_path(), &css).map_err(|e| e.to_string())?;
reload_breadbar()
}
#[tauri::command]
pub fn get_breadbar_style() -> BreadbarStyle {
let css = std::fs::read_to_string(css_path()).unwrap_or_default();
BreadbarStyle {
font_family: get_value(&css, "*", "font-family")
.and_then(|v| v.split(',').next().map(|s| s.trim().trim_matches(['\'', '"']).to_string()))
.unwrap_or_else(|| "Varela Round".to_string()),
font_size: get_value(&css, "*", "font-size").map(|v| strip_px(&v)).unwrap_or(14),
bar_border_radius: get_value(&css, "window.breadbar", "border-radius")
.map(|v| strip_px(&v))
.unwrap_or(0),
bar_padding: get_value(&css, "window.breadbar", "padding").map(|v| strip_px(&v)).unwrap_or(0),
workspace_inactive_opacity: get_value(&css, ".workspace-btn", "opacity")
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0.45),
workspace_font_size: get_value(&css, ".workspace-btn", "font-size")
.map(|v| strip_px(&v))
.unwrap_or(20),
stat_gap: get_value(&css, ".stat-pair", "margin-right").map(|v| strip_px(&v)).unwrap_or(12),
tray_icon_size: get_value(&css, ".tray-btn image", "-gtk-icon-size")
.map(|v| strip_px(&v))
.unwrap_or(16),
notification_border_radius: get_value(&css, "window.breadbar-notification", "border-radius")
.map(|v| strip_px(&v))
.unwrap_or(6),
}
}
#[tauri::command]
pub fn save_breadbar_style(style: BreadbarStyle) -> Result<bool, String> {
let mut css = std::fs::read_to_string(css_path()).unwrap_or_default();
let px = |n: u32| format!("{n}px");
let edits: [(&str, &str, String); 9] = [
("*", "font-family", format!("'{}', sans-serif", style.font_family)),
("*", "font-size", px(style.font_size)),
("window.breadbar", "border-radius", px(style.bar_border_radius)),
("window.breadbar", "padding", px(style.bar_padding)),
(".workspace-btn", "opacity", style.workspace_inactive_opacity.to_string()),
(".workspace-btn", "font-size", px(style.workspace_font_size)),
(".stat-pair", "margin-right", px(style.stat_gap)),
(".tray-btn image", "-gtk-icon-size", px(style.tray_icon_size)),
("window.breadbar-notification", "border-radius", px(style.notification_border_radius)),
];
for (selector, property, value) in &edits {
if let Some(updated) = set_value(&css, selector, property, value) {
css = updated;
}
}
config::atomic_write(&css_path(), &css).map_err(|e| e.to_string())?;
reload_breadbar()
}
fn reload_breadbar() -> Result<bool, String> {
Ok(std::process::Command::new("pkill")
.args(["-HUP", "-x", "breadbar"])
.status()
.map(|s| s.success())
.unwrap_or(false))
}

View file

@ -0,0 +1,64 @@
//! breadbox config.toml — launcher contexts. Schema mirrors breadbox-shared
//! (`#[serde(rename = "context")]` — the TOML key is `[[context]]`,
//! singular, despite the Rust field being `contexts`), with `name` +
//! `priority`, an ordered list of app/category hints. The context array is
//! rewritten on save; any other top-level keys/comments are preserved.
use serde::{Deserialize, Serialize};
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
use super::config;
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadbox/config.toml")
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Context {
name: String,
priority: Vec<String>,
}
fn read_contexts(doc: &DocumentMut) -> Vec<Context> {
let Some(aot) = doc.get("context").and_then(Item::as_array_of_tables) else {
return Vec::new();
};
aot.iter()
.map(|t| Context {
name: t.get("name").and_then(Item::as_str).unwrap_or("").to_string(),
priority: t
.get("priority")
.and_then(Item::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default(),
})
.collect()
}
fn write_contexts(doc: &mut DocumentMut, ctxs: &[Context]) {
let mut aot = ArrayOfTables::new();
for ctx in ctxs {
let mut t = Table::new();
t.insert("name", value(&ctx.name));
let mut arr = Array::new();
for p in &ctx.priority {
arr.push(p.as_str());
}
t.insert("priority", value(arr));
aot.push(t);
}
doc.as_table_mut().insert("context", Item::ArrayOfTables(aot));
}
#[tauri::command]
pub fn get_breadbox_contexts() -> Vec<Context> {
read_contexts(&config::load_doc(&config_path()))
}
#[tauri::command]
pub fn save_breadbox_contexts(contexts: Vec<Context>) -> Result<(), String> {
let path = config_path();
let mut doc = config::load_doc(&path);
write_contexts(&mut doc, &contexts);
config::save_doc(&path, &doc).map_err(|e| e.to_string())
}

View file

@ -0,0 +1,8 @@
//! breadclip has no config file to edit — this panel exists purely to make
//! its background daemon (breadclipd, via `service.rs`) and its
//! on-demand popup visible/controllable from Settings.
#[tauri::command]
pub fn open_breadclip() {
let _ = std::process::Command::new("breadclip").spawn();
}

View file

@ -0,0 +1,176 @@
//! breadcrumbs.toml — Wi-Fi profile state machine. Schema mirrors
//! breadcrumbs/src/config.rs:
//! [settings] scalar tunables
//! [[networks]] saved networks (ssid / password / hidden)
//! [profiles.<name>] per-location profile (networks, tailscale, …)
//! `[settings]` is edited in place; `networks`/`profiles` are rewritten from
//! their editors on save. Other keys/comments are preserved.
use serde::{Deserialize, Serialize};
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
use super::config;
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadcrumbs/breadcrumbs.toml")
}
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Network {
ssid: String,
password: String,
hidden: bool,
}
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Profile {
name: String,
networks: Vec<String>,
detect_ssids: Vec<String>,
bootstrap: String,
exit_node: String,
tailscale: bool,
include_all_known: bool,
}
#[derive(Serialize, Deserialize)]
pub struct Settings {
default_profile: String,
dns: String,
exit_node: String,
ping_host: String,
connectivity_url: String,
nmcli_wait: i64,
watch_interval: i64,
}
#[derive(Serialize)]
pub struct BreadcrumbsConfig {
settings: Settings,
networks: Vec<Network>,
profiles: Vec<Profile>,
}
fn read_networks(doc: &DocumentMut) -> Vec<Network> {
let Some(aot) = doc.get("networks").and_then(Item::as_array_of_tables) else {
return Vec::new();
};
aot.iter()
.map(|t| Network {
ssid: t.get("ssid").and_then(Item::as_str).unwrap_or("").to_string(),
password: t.get("password").and_then(Item::as_str).unwrap_or("").to_string(),
hidden: t.get("hidden").and_then(Item::as_bool).unwrap_or(false),
})
.collect()
}
fn write_networks(doc: &mut DocumentMut, nets: &[Network]) {
let mut aot = ArrayOfTables::new();
for n in nets {
let mut t = Table::new();
t.insert("ssid", value(&n.ssid));
t.insert("password", value(&n.password));
t.insert("hidden", value(n.hidden));
aot.push(t);
}
doc.as_table_mut().insert("networks", Item::ArrayOfTables(aot));
}
fn read_profiles(doc: &DocumentMut) -> Vec<Profile> {
let Some(tbl) = doc.get("profiles").and_then(Item::as_table) else {
return Vec::new();
};
let str_list = |item: Option<&Item>| -> Vec<String> {
item.and_then(Item::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default()
};
tbl.iter()
.filter_map(|(name, item)| {
let p = item.as_table()?;
Some(Profile {
name: name.to_string(),
networks: str_list(p.get("networks")),
detect_ssids: str_list(p.get("detect_ssids")),
bootstrap: p.get("bootstrap").and_then(Item::as_str).unwrap_or("").to_string(),
exit_node: p.get("exit_node").and_then(Item::as_str).unwrap_or("").to_string(),
tailscale: p.get("tailscale").and_then(Item::as_bool).unwrap_or(false),
include_all_known: p.get("include_all_known").and_then(Item::as_bool).unwrap_or(false),
})
})
.collect()
}
fn write_profiles(doc: &mut DocumentMut, profiles: &[Profile]) {
let mut tbl = Table::new();
let to_arr = |items: &[String]| {
let mut a = Array::new();
for s in items {
a.push(s.as_str());
}
a
};
for p in profiles {
if p.name.is_empty() {
continue;
}
let mut t = Table::new();
t.insert("networks", value(to_arr(&p.networks)));
t.insert("tailscale", value(p.tailscale));
t.insert("include_all_known", value(p.include_all_known));
if !p.detect_ssids.is_empty() {
t.insert("detect_ssids", value(to_arr(&p.detect_ssids)));
}
if !p.bootstrap.is_empty() {
t.insert("bootstrap", value(&p.bootstrap));
}
if !p.exit_node.is_empty() {
t.insert("exit_node", value(&p.exit_node));
}
tbl.insert(&p.name, Item::Table(t));
}
doc.as_table_mut().insert("profiles", Item::Table(tbl));
}
#[tauri::command]
pub fn get_breadcrumbs_config() -> BreadcrumbsConfig {
let doc = config::load_doc(&config_path());
BreadcrumbsConfig {
settings: Settings {
// breadcrumbs' own default_profile_name() is "away", not "home".
default_profile: config::get_str(&doc, &["settings", "default_profile"]).unwrap_or_else(|| "away".into()),
dns: config::get_str(&doc, &["settings", "dns"]).unwrap_or_else(|| "1.1.1.1".into()),
exit_node: config::get_str(&doc, &["settings", "exit_node"]).unwrap_or_default(),
ping_host: config::get_str(&doc, &["settings", "ping_host"]).unwrap_or_else(|| "1.1.1.1".into()),
connectivity_url: config::get_str(&doc, &["settings", "connectivity_url"])
.unwrap_or_else(|| "http://connectivitycheck.gstatic.com/generate_204".into()),
nmcli_wait: config::get_i64(&doc, &["settings", "nmcli_wait"]).unwrap_or(8),
watch_interval: config::get_i64(&doc, &["settings", "watch_interval"]).unwrap_or(12),
},
networks: read_networks(&doc),
profiles: read_profiles(&doc),
}
}
#[derive(Deserialize)]
pub struct SaveBreadcrumbsInput {
settings: Settings,
networks: Vec<Network>,
profiles: Vec<Profile>,
}
#[tauri::command]
pub fn save_breadcrumbs_config(input: SaveBreadcrumbsInput) -> Result<(), String> {
let path = config_path();
let mut doc = config::load_doc(&path);
config::set_str(&mut doc, &["settings", "default_profile"], &input.settings.default_profile);
config::set_str(&mut doc, &["settings", "dns"], &input.settings.dns);
config::set_str_or_remove(&mut doc, &["settings", "exit_node"], &input.settings.exit_node);
config::set_str(&mut doc, &["settings", "ping_host"], &input.settings.ping_host);
config::set_str(&mut doc, &["settings", "connectivity_url"], &input.settings.connectivity_url);
config::set_i64(&mut doc, &["settings", "nmcli_wait"], input.settings.nmcli_wait);
config::set_i64(&mut doc, &["settings", "watch_interval"], input.settings.watch_interval);
write_networks(&mut doc, &input.networks);
write_profiles(&mut doc, &input.profiles);
config::save_doc(&path, &doc).map_err(|e| e.to_string())
}

View file

@ -0,0 +1,78 @@
//! breadpad.toml — the breadpad notes/reminders config. Schema mirrors
//! breadpad-shared/src/config.rs (settings, model + model.ollama, reminders,
//! calendar). Edited non-destructively (calendar password + model paths
//! are preserved across saves).
use serde::{Deserialize, Serialize};
use super::config;
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadpad/breadpad.toml")
}
#[derive(Serialize, Deserialize)]
pub struct BreadpadConfig {
default_type: String,
workspace_tag: bool,
snooze_options: Vec<String>,
archive_after_days: i64,
model_path: String,
tokenizer_path: String,
ollama_enabled: bool,
ollama_endpoint: String,
ollama_model: String,
ollama_confidence_threshold: f64,
reminders_default_morning: String,
reminders_missed_grace_minutes: i64,
calendar_enabled: bool,
calendar_url: String,
calendar_username: String,
calendar_password: String,
}
#[tauri::command]
pub fn get_breadpad_config() -> BreadpadConfig {
let doc = config::load_doc(&config_path());
BreadpadConfig {
default_type: config::get_str(&doc, &["settings", "default_type"]).unwrap_or_else(|| "note".into()),
workspace_tag: config::get_bool(&doc, &["settings", "workspace_tag"]).unwrap_or(true),
snooze_options: config::get_str_list(&doc, &["settings", "snooze_options"]),
archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"]).unwrap_or(30),
model_path: config::get_str(&doc, &["model", "path"]).unwrap_or_default(),
tokenizer_path: config::get_str(&doc, &["model", "tokenizer"]).unwrap_or_default(),
ollama_enabled: config::get_bool(&doc, &["model", "ollama", "enabled"]).unwrap_or(true),
ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"]).unwrap_or_default(),
ollama_model: config::get_str(&doc, &["model", "ollama", "model"]).unwrap_or_default(),
ollama_confidence_threshold: config::get_f64(&doc, &["model", "ollama", "confidence_threshold"]).unwrap_or(0.6),
reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"]).unwrap_or_else(|| "7:00".into()),
reminders_missed_grace_minutes: config::get_i64(&doc, &["reminders", "missed_grace_minutes"]).unwrap_or(60),
calendar_enabled: config::get_bool(&doc, &["calendar", "enabled"]).unwrap_or(false),
calendar_url: config::get_str(&doc, &["calendar", "url"]).unwrap_or_default(),
calendar_username: config::get_str(&doc, &["calendar", "username"]).unwrap_or_default(),
calendar_password: config::get_str(&doc, &["calendar", "password"]).unwrap_or_default(),
}
}
#[tauri::command]
pub fn save_breadpad_config(cfg: BreadpadConfig) -> Result<(), String> {
let path = config_path();
let mut doc = config::load_doc(&path);
config::set_str(&mut doc, &["settings", "default_type"], &cfg.default_type);
config::set_bool(&mut doc, &["settings", "workspace_tag"], cfg.workspace_tag);
config::set_str_list(&mut doc, &["settings", "snooze_options"], &cfg.snooze_options);
config::set_i64(&mut doc, &["settings", "archive_after_days"], cfg.archive_after_days);
config::set_str_or_remove(&mut doc, &["model", "path"], &cfg.model_path);
config::set_str_or_remove(&mut doc, &["model", "tokenizer"], &cfg.tokenizer_path);
config::set_bool(&mut doc, &["model", "ollama", "enabled"], cfg.ollama_enabled);
config::set_str_or_remove(&mut doc, &["model", "ollama", "endpoint"], &cfg.ollama_endpoint);
config::set_str_or_remove(&mut doc, &["model", "ollama", "model"], &cfg.ollama_model);
config::set_f64(&mut doc, &["model", "ollama", "confidence_threshold"], cfg.ollama_confidence_threshold);
config::set_str_or_remove(&mut doc, &["reminders", "default_morning"], &cfg.reminders_default_morning);
config::set_i64(&mut doc, &["reminders", "missed_grace_minutes"], cfg.reminders_missed_grace_minutes);
config::set_bool(&mut doc, &["calendar", "enabled"], cfg.calendar_enabled);
config::set_str_or_remove(&mut doc, &["calendar", "url"], &cfg.calendar_url);
config::set_str_or_remove(&mut doc, &["calendar", "username"], &cfg.calendar_username);
config::set_str_or_remove(&mut doc, &["calendar", "password"], &cfg.calendar_password);
config::save_doc(&path, &doc).map_err(|e| e.to_string())
}

View file

@ -0,0 +1,111 @@
//! breadpaper — wallpaper manager. No config file to edit here; breadpaper
//! takes no persistent settings, just an image path via its CLI
//! (`breadpaper set <path>` / `breadpaper get`). These commands are a thin
//! backend for that CLI so wallpaper (and the pywal-driven theme it
//! generates) has a discoverable home in Settings.
use serde::Serialize;
use std::path::{Path, PathBuf};
use tokio::process::Command;
/// Extensions breadpaper's own `validate()` accepts.
const WALLPAPER_EXTS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp"];
/// Caps how many thumbnails the library ever returns, and how deep the
/// recursive scan goes (the library is organized in subfolders, e.g. by
/// show/series, so a non-recursive scan would find nothing).
const MAX_LIBRARY_ITEMS: usize = 80;
const MAX_SCAN_DEPTH: usize = 4;
pub fn wallpaper_library_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
PathBuf::from(home).join("Pictures/Backgrounds")
}
fn is_wallpaper_file(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| WALLPAPER_EXTS.iter().any(|ext| ext.eq_ignore_ascii_case(e)))
.unwrap_or(false)
}
fn scan_wallpapers(dir: &Path) -> Vec<PathBuf> {
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth == 0 || out.len() >= MAX_LIBRARY_ITEMS {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut entries: Vec<_> = entries.flatten().collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
if out.len() >= MAX_LIBRARY_ITEMS {
return;
}
let path = entry.path();
if path.is_dir() {
walk(&path, depth - 1, out);
} else if is_wallpaper_file(&path) {
out.push(path);
}
}
}
let mut out = Vec::new();
walk(dir, MAX_SCAN_DEPTH, &mut out);
out
}
#[tauri::command]
pub async fn get_current_wallpaper() -> Option<String> {
let out = Command::new("breadpaper").arg("get").output().await.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
#[tauri::command]
pub async fn set_wallpaper(path: String) -> Result<(), String> {
let ok = Command::new("breadpaper")
.arg("set")
.arg(&path)
.status()
.await
.map(|s| s.success())
.unwrap_or(false);
if ok {
Ok(())
} else {
Err("breadpaper failed — see terminal/journal".into())
}
}
#[derive(Serialize)]
pub struct LibraryEntry {
path: String,
name: String,
}
/// Lists wallpapers under the library dir. Bounded/depth-limited (see the
/// constants above) — this is gated behind an explicit "Browse" click on
/// the frontend, not run at app launch, same "costs real time, gated
/// behind a button" posture as the network view's Wi-Fi scan.
#[tauri::command]
pub fn list_wallpaper_library() -> Vec<LibraryEntry> {
scan_wallpapers(&wallpaper_library_dir())
.into_iter()
.map(|p| LibraryEntry {
name: p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(),
path: p.to_string_lossy().to_string(),
})
.collect()
}
#[tauri::command]
pub fn wallpaper_library_dir_display() -> String {
wallpaper_library_dir().to_string_lossy().to_string()
}

View file

@ -0,0 +1,55 @@
//! breadsearch/config.toml — semantic search indexer (breadmill) + GUI.
//! Schema mirrors breadsearch-shared::Config ([index], [search], [model], [power]).
use serde::{Deserialize, Serialize};
use super::config;
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadsearch/config.toml")
}
#[derive(Serialize, Deserialize)]
pub struct BreadsearchConfig {
power_enabled: bool,
run_on_battery: bool,
backend: String,
index_roots: Vec<String>,
index_excludes: Vec<String>,
index_extensions: Vec<String>,
max_file_mb: f64,
search_limit: i64,
snippet_len: i64,
}
#[tauri::command]
pub fn get_breadsearch_config() -> BreadsearchConfig {
let doc = config::load_doc(&config_path());
BreadsearchConfig {
power_enabled: config::get_bool(&doc, &["power", "enabled"]).unwrap_or(true),
run_on_battery: config::get_bool(&doc, &["power", "run_on_battery"]).unwrap_or(false),
backend: config::get_str(&doc, &["model", "backend"]).unwrap_or_else(|| "cpu".into()),
index_roots: config::get_str_list(&doc, &["index", "roots"]),
index_excludes: config::get_str_list(&doc, &["index", "excludes"]),
index_extensions: config::get_str_list(&doc, &["index", "extensions"]),
max_file_mb: config::get_f64(&doc, &["index", "max_file_mb"]).unwrap_or(10.0),
search_limit: config::get_i64(&doc, &["search", "limit"]).unwrap_or(10),
snippet_len: config::get_i64(&doc, &["search", "snippet_len"]).unwrap_or(200),
}
}
#[tauri::command]
pub fn save_breadsearch_config(cfg: BreadsearchConfig) -> Result<(), String> {
let path = config_path();
let mut doc = config::load_doc(&path);
config::set_bool(&mut doc, &["power", "enabled"], cfg.power_enabled);
config::set_bool(&mut doc, &["power", "run_on_battery"], cfg.run_on_battery);
config::set_str(&mut doc, &["model", "backend"], &cfg.backend);
config::set_str_list(&mut doc, &["index", "roots"], &cfg.index_roots);
config::set_str_list(&mut doc, &["index", "excludes"], &cfg.index_excludes);
config::set_str_list(&mut doc, &["index", "extensions"], &cfg.index_extensions);
config::set_f64(&mut doc, &["index", "max_file_mb"], cfg.max_file_mb);
config::set_i64(&mut doc, &["search", "limit"], cfg.search_limit);
config::set_i64(&mut doc, &["search", "snippet_len"], cfg.snippet_len);
config::save_doc(&path, &doc).map_err(|e| e.to_string())
}

View file

@ -0,0 +1,226 @@
//! Non-destructive config editing.
//!
//! Every bread* app owns a TOML config that may contain keys, sections, and
//! comments this settings app does not model (e.g. breadpad's calendar
//! credentials, breadcrumbs' saved-network passwords). To edit safely we parse
//! the file into a `toml_edit::DocumentMut`, mutate only the specific keys the
//! UI exposes, and write the document back — preserving everything else,
//! formatting and comments included.
use std::error::Error;
use std::path::{Path, PathBuf};
use toml_edit::{value, Array, DocumentMut, Item, Table, Value};
/// Load a TOML file into an editable document. A missing file yields an
/// empty document so the UI still renders with defaults — normal for a fresh
/// install. A file that *exists* but fails to parse is far more dangerous:
/// falling back to an empty document there means the next Save (see
/// `save_doc`) overwrites it with only the UI-modelled keys, silently
/// destroying anything else in the file (breadpad's calendar credentials,
/// breadcrumbs' saved network passwords, ...). Back up the unparseable file
/// once before falling back, so a bad edit is always recoverable.
pub fn load_doc(path: &Path) -> DocumentMut {
bread_utils::tomlcfg::load_doc("bos-settings", path)
}
/// Write the document back to disk, creating parent dirs as needed.
pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<(), Box<dyn Error>> {
bread_utils::tomlcfg::save_doc(path, doc)?;
Ok(())
}
/// Write `contents` to `path` atomically, backing up whatever was there
/// before overwriting it.
///
/// Every config-writing view in this app (TOML via `save_doc` above, and the
/// plain-JSON views — keybinds, autostart, appearance/settings.json,
/// monitors.json, breadbar's CSS) goes through this instead of a bare
/// `std::fs::write` — see `bread_utils::atomic::write_atomic_backed_up`'s
/// doc comment for why (crash/power-loss safety via temp-then-rename, plus
/// a `.bak` of whatever was there before).
pub fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
bread_utils::atomic::write_atomic_backed_up(path, contents)
}
pub fn config_dir() -> PathBuf {
bread_utils::xdg::config_home()
}
// --- typed readers (walk a dotted path, return None if absent/wrong type) ---
fn get<'a>(doc: &'a DocumentMut, path: &[&str]) -> Option<&'a Item> {
let mut tbl = doc.as_table();
let (last, parents) = path.split_last()?;
for key in parents {
tbl = tbl.get(key)?.as_table()?;
}
tbl.get(last)
}
pub fn get_bool(doc: &DocumentMut, path: &[&str]) -> Option<bool> {
get(doc, path)?.as_bool()
}
pub fn get_str(doc: &DocumentMut, path: &[&str]) -> Option<String> {
get(doc, path)?.as_str().map(str::to_string)
}
pub fn get_i64(doc: &DocumentMut, path: &[&str]) -> Option<i64> {
get(doc, path)?.as_integer()
}
pub fn get_f64(doc: &DocumentMut, path: &[&str]) -> Option<f64> {
let item = get(doc, path)?;
item.as_float().or_else(|| item.as_integer().map(|i| i as f64))
}
/// Read an array of strings (e.g. modules.disable, contexts[].priority).
pub fn get_str_list(doc: &DocumentMut, path: &[&str]) -> Vec<String> {
match get(doc, path).and_then(Item::as_array) {
Some(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
None => Vec::new(),
}
}
// --- setters (auto-create intermediate tables, replace only the leaf) ---
fn table_at_mut<'a>(doc: &'a mut DocumentMut, parents: &[&str]) -> &'a mut Table {
let mut tbl = doc.as_table_mut();
for key in parents {
let entry = tbl.entry(key).or_insert_with(|| Item::Table(Table::new()));
if !entry.is_table() {
*entry = Item::Table(Table::new());
}
tbl = entry.as_table_mut().expect("just ensured table");
}
tbl
}
fn set_item(doc: &mut DocumentMut, path: &[&str], item: Item) {
let Some((last, parents)) = path.split_last() else {
return;
};
table_at_mut(doc, parents).insert(last, item);
}
pub fn set_bool(doc: &mut DocumentMut, path: &[&str], v: bool) {
set_item(doc, path, value(v));
}
pub fn set_str(doc: &mut DocumentMut, path: &[&str], v: &str) {
set_item(doc, path, value(v));
}
pub fn set_i64(doc: &mut DocumentMut, path: &[&str], v: i64) {
set_item(doc, path, value(v));
}
pub fn set_f64(doc: &mut DocumentMut, path: &[&str], v: f64) {
set_item(doc, path, value(v));
}
pub fn set_str_list(doc: &mut DocumentMut, path: &[&str], items: &[String]) {
let mut arr = Array::new();
for s in items {
arr.push(s.as_str());
}
set_item(doc, path, Item::Value(Value::Array(arr)));
}
/// Set a string key, or remove it entirely when the value is empty — keeps
/// optional fields out of the file rather than persisting `key = ""`.
pub fn set_str_or_remove(doc: &mut DocumentMut, path: &[&str], v: &str) {
if v.is_empty() {
remove(doc, path);
} else {
set_str(doc, path, v);
}
}
pub fn remove(doc: &mut DocumentMut, path: &[&str]) {
if let Some((last, parents)) = path.split_last() {
let mut tbl = doc.as_table_mut();
for key in parents {
match tbl.get_mut(key).and_then(Item::as_table_mut) {
Some(t) => tbl = t,
None => return,
}
}
tbl.remove(last);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edits_preserve_unmodelled_keys_and_comments() {
let src = "\
# a leading comment
[daemon]
log_level = \"info\"
[calendar]
password = \"secret\" # keep me
";
let mut doc: DocumentMut = src.parse().unwrap();
// Modify a single modelled key.
set_str(&mut doc, &["daemon", "log_level"], "debug");
// A key/section the UI never touches must survive untouched.
let out = doc.to_string();
assert!(out.contains("log_level = \"debug\""));
assert!(out.contains("password = \"secret\""));
assert!(out.contains("# keep me"));
assert!(out.contains("# a leading comment"));
}
#[test]
fn setters_create_missing_tables() {
let mut doc = DocumentMut::new();
set_bool(&mut doc, &["adapters", "power", "enabled"], false);
set_i64(&mut doc, &["adapters", "power", "poll_interval_secs"], 45);
assert_eq!(get_bool(&doc, &["adapters", "power", "enabled"]), Some(false));
assert_eq!(
get_i64(&doc, &["adapters", "power", "poll_interval_secs"]),
Some(45)
);
}
#[test]
fn empty_string_removes_key() {
let mut doc: DocumentMut = "[calendar]\nurl = \"x\"\n".parse().unwrap();
set_str_or_remove(&mut doc, &["calendar", "url"], "");
assert_eq!(get_str(&doc, &["calendar", "url"]), None);
}
#[test]
fn str_list_roundtrips() {
let mut doc = DocumentMut::new();
let items = vec!["a".to_string(), "b".to_string()];
set_str_list(&mut doc, &["modules", "disable"], &items);
assert_eq!(get_str_list(&doc, &["modules", "disable"]), items);
}
#[test]
fn atomic_write_backs_up_previous_contents_and_no_tmp_file_left_behind() {
let dir = std::env::temp_dir().join(format!("bos-settings-atomic-write-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
let backup = dir.join("config.toml.bak");
atomic_write(&path, "first").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first");
assert!(!backup.exists(), "no backup should be made when there's nothing to back up yet");
atomic_write(&path, "second").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "second");
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "first");
let leftover_tmp: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".tmp."))
.collect();
assert!(leftover_tmp.is_empty(), "temp file should be renamed away, not left behind: {leftover_tmp:?}");
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -0,0 +1,82 @@
//! Timezone + NTP, over `timedatectl`. `systemd-timesyncd` is enabled by
//! default, so NTP sync is on out of the box — this is mostly for picking a
//! timezone and confirming sync is healthy.
use serde::Serialize;
use tokio::process::Command;
async fn show_property(prop: &str) -> String {
Command::new("timedatectl")
.args(["show", &format!("--property={prop}")])
.output()
.await
.ok()
.and_then(|o| {
String::from_utf8_lossy(&o.stdout)
.trim()
.strip_prefix(&format!("{prop}="))
.map(str::to_string)
})
.unwrap_or_default()
}
async fn list_timezones() -> Vec<String> {
Command::new("timedatectl")
.arg("list-timezones")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect())
.unwrap_or_default()
}
async fn current_time_label() -> String {
Command::new("date")
.arg("+%A, %d %B %Y %H:%M")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
#[derive(Serialize)]
pub struct DateTimeInfo {
current_time: String,
timezones: Vec<String>,
current_tz: String,
ntp_enabled: bool,
ntp_synced: bool,
}
#[tauri::command]
pub async fn get_datetime_info() -> DateTimeInfo {
DateTimeInfo {
current_time: current_time_label().await,
timezones: list_timezones().await,
current_tz: show_property("Timezone").await,
ntp_enabled: show_property("NTP").await == "yes",
ntp_synced: show_property("NTPSynchronized").await == "yes",
}
}
#[tauri::command]
pub async fn set_timezone(tz: String) -> Result<(), String> {
let output = Command::new("pkexec")
.args(["timedatectl", "set-timezone", &tz])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err("Error — check the timezone name".into())
}
}
#[tauri::command]
pub async fn set_ntp_enabled(enabled: bool) -> Result<(), String> {
let val = if enabled { "true" } else { "false" };
Command::new("pkexec").args(["timedatectl", "set-ntp", val]).status().await.map_err(|e| e.to_string())?;
Ok(())
}

View file

@ -0,0 +1,89 @@
//! ufw firewall rules. `ufw status` itself requires root (confirmed against
//! the installed ufw script), so unlike every other read-only panel, this
//! doesn't query eagerly — the frontend only calls `get_firewall_status`
//! when the user clicks Refresh, deferring the one unavoidable polkit
//! prompt to an explicit action instead of forcing it on app open.
use serde::Serialize;
use tokio::process::Command;
#[derive(Serialize, Clone)]
pub struct FirewallRule {
number: String,
text: String,
}
#[derive(Serialize)]
pub struct FirewallStatus {
active: bool,
rules: Vec<FirewallRule>,
}
/// One `pkexec ufw status numbered` call, parsed for both the active/
/// inactive line and the numbered rules.
#[tauri::command]
pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
let output = Command::new("pkexec")
.args(["ufw", "status", "numbered"])
.output()
.await
.map_err(|e| format!("couldn't run pkexec: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if stderr.is_empty() {
match output.status.code() {
Some(127) => "no polkit authentication agent is available in this session".to_string(),
Some(code) => format!("pkexec exited with status {code}"),
None => "pkexec was terminated by a signal".to_string(),
}
} else {
stderr
});
}
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(FirewallRule { number, text: rest.trim().to_string() })
})
.collect();
Ok(FirewallStatus { active, rules })
}
#[tauri::command]
pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> {
let verb = if enabled { "enable" } else { "disable" };
let output = Command::new("pkexec").args(["ufw", "--force", verb]).output().await.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
#[tauri::command]
pub async fn add_firewall_rule(rule: String) -> Result<(), String> {
let output = Command::new("pkexec").args(["ufw", "allow", rule.trim()]).output().await.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
#[tauri::command]
pub async fn remove_firewall_rule(number: String) -> Result<(), String> {
let output = Command::new("pkexec").args(["ufw", "--force", "delete", &number]).output().await.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}

View file

@ -0,0 +1,32 @@
use serde::Serialize;
#[derive(Serialize, Clone)]
pub struct FwDevice {
name: String,
version: String,
}
#[tauri::command]
pub async fn get_updatable_firmware() -> Vec<FwDevice> {
let Ok(output) = tokio::process::Command::new("fwupdmgr").args(["get-devices", "--json"]).output().await 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()
}

View file

@ -0,0 +1,110 @@
//! Display: live-connected-monitor readout (from `hyprctl monitors -j`) plus
//! an editor for `hypr/monitors.json` — the monitor *layout* Hyprland itself
//! reads at login. Like appearance.rs/autostart.rs, a plain typed struct
//! round-tripped whole (JSON has no comments to preserve).
//!
//! `Default` here (the single wildcard rule) must stay in sync with
//! `scripts/display/monitors.lua`'s own `DEFAULT_MONITORS` fallback.
use serde::{Deserialize, Serialize};
use super::config;
#[derive(Clone, Serialize, Deserialize)]
pub struct MonitorRule {
output: String,
#[serde(default = "default_mode")]
mode: String,
#[serde(default = "default_position")]
position: String,
#[serde(default = "default_scale")]
scale: String,
}
fn default_mode() -> String {
"preferred".to_string()
}
fn default_position() -> String {
"auto".to_string()
}
fn default_scale() -> String {
"auto".to_string()
}
impl Default for MonitorRule {
fn default() -> Self {
Self { output: String::new(), mode: default_mode(), position: default_position(), scale: default_scale() }
}
}
#[derive(Serialize, Deserialize)]
struct MonitorsFile {
#[serde(default)]
monitors: Vec<MonitorRule>,
}
fn config_path() -> std::path::PathBuf {
config::config_dir().join("hypr/monitors.json")
}
fn hypr_path(name: &str) -> std::path::PathBuf {
config::config_dir().join("hypr").join(name)
}
#[derive(Serialize)]
pub struct LiveMonitor {
name: String,
mode: String,
}
#[tauri::command]
pub fn get_live_monitors() -> Vec<LiveMonitor> {
let Some(value) = bread_utils::proc::run_json("hyprctl", &["monitors", "-j"], std::time::Duration::from_secs(3))
else {
return Vec::new();
};
let Ok(monitors) = serde_json::from_value::<Vec<serde_json::Value>>(value) else {
return Vec::new();
};
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") })
})
.collect()
}
#[tauri::command]
pub fn get_monitor_rules() -> Vec<MonitorRule> {
std::fs::read_to_string(config_path())
.ok()
.and_then(|s| serde_json::from_str::<MonitorsFile>(&s).ok())
.filter(|f| !f.monitors.is_empty())
.map(|f| f.monitors)
.unwrap_or_else(|| vec![MonitorRule::default()])
}
#[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())
}
/// Opens `hyprland.lua` in `$EDITOR` (nano if unset) inside a terminal —
/// spawning a TUI editor with no terminal to attach to is a silent no-op.
#[tauri::command]
pub fn open_hyprland_conf() {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
let path = hypr_path("hyprland.lua");
let _ = std::process::Command::new("kitty").args(["-e", &editor]).arg(path).spawn();
}
#[tauri::command]
pub fn open_keybinds_viewer() {
let _ = std::process::Command::new("breadhelp").spawn();
}

View file

@ -0,0 +1,354 @@
//! hypr/binds.json — Hyprland keybind editor, read by
//! `scripts/ui/binds.lua` on the Hyprland side (see hyprland.lua).
//!
//! This file has TWO real on-disk shapes, and which one applies depends on
//! the machine:
//!
//! - **Flat** (`default_mods` + a single `bindings` array) — what BOS itself
//! ships (`iso/airootfs/etc/skel/.config/hypr/binds.json`, read by the
//! BOS-shipped `scripts/input/binds.lua`). No layouts. Each bind carries
//! `label`/`category`/`demo_cmd` fields breadhelp depends on for its
//! cheatsheet and guided tour.
//! - **MultiLayout** (`globals`/`common`/one `layouts` entry per keyboard
//! layout) — a personal, per-machine schema some dev setups use instead,
//! read by a different, personal `binds.lua`.
//!
//! `SchemaKind` detects which shape is actually on disk (from the top-level
//! key set) and `save()` always emits that SAME shape back — see
//! `SchemaKind::detect` and `save_to`. Loading a real BOS (Flat) file under
//! the wrong assumption and saving it back would silently drop the
//! `bindings` key entirely — still valid JSON, so the Lua `pcall`
//! failsafes on the reading side would never catch it.
//!
//! Each bind's shape also varies by `action` (`exec` needs `command`,
//! `move_dir` needs `direction`, workspace-focus needs `workspace`, mouse
//! binds need `options.mouse`, ...). Rather than modelling every action's
//! field set as its own row layout — which would mean a combinatorial
//! explosion of widgets and silently dropping any action shape this editor
//! doesn't already know about — `action`/`key`/`mods` get real fields (the
//! ones every bind has) and everything else round-trips through
//! `#[serde(flatten)]` into a small inline-JSON column, same trade-off the
//! other Hyprland JSON editors (appearance.rs, hyprland.rs, autostart.rs)
//! already make: no comments to preserve, so this is a whole-file round
//! trip, not the `toml_edit`/`Doc` path-based pattern.
use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use super::config;
/// Which on-disk shape `binds.json` was loaded as. Detected once at load
/// time from the top-level key set present in the JSON, then pinned for the
/// lifetime of the editor session (round-tripped to the frontend and back
/// on save) so `save()` always writes back the same shape it read,
/// regardless of what the in-memory model happens to have populated.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SchemaKind {
/// `{ "default_mods": [...], "bindings": [...] }` — BOS's real shipped
/// shape. No layout-switching UI applies; there's nothing to switch.
Flat,
/// `{ "active_layout", "default_mods", "globals", "common", "layouts" }`
/// — the personal, multi-keyboard-layout schema this editor was
/// originally built against.
MultiLayout,
/// Neither key set matched — an empty file, a totally different shape,
/// or unparsable JSON. Loading still renders (empty), but `save()`
/// refuses outright rather than guessing a shape and risking silently
/// destroying whatever the real file's actual schema was.
Unknown,
}
impl SchemaKind {
fn detect(top_level: &Map<String, Value>) -> Self {
if top_level.contains_key("bindings") {
SchemaKind::Flat
} else if top_level.contains_key("globals")
|| top_level.contains_key("common")
|| top_level.contains_key("layouts")
{
SchemaKind::MultiLayout
} else {
SchemaKind::Unknown
}
}
}
#[derive(Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Bind {
action: String,
#[serde(skip_serializing_if = "Option::is_none")]
key: Option<String>,
/// `None` (key omitted) means "fall back to `default_mods`"; `Some(_)`
/// — including `Some(vec![])` — means "use exactly this, even if that's
/// no modifiers at all." Real BOS binds rely on that distinction (e.g.
/// media keys pin `"mods": []` on purpose so they never inherit
/// `default_mods`), so this can't collapse both cases to "omit the
/// key" the way a bare `Vec<String>` with `skip_serializing_if` would —
/// that would silently turn an explicit "no mods" into "use the
/// default" the next time this editor saves the file.
#[serde(skip_serializing_if = "Option::is_none")]
mods: Option<Vec<String>>,
/// Everything else a bind can carry — `command`, `direction`,
/// `workspace`, `x`, `y`, `layout`, `options`, `label`, `category`,
/// `demo_cmd`, and any action shape not yet invented. Edited on the
/// frontend as compact inline JSON. This flatten is what keeps
/// breadhelp's `label`/`category`/`demo_cmd` fields — which this
/// editor's UI has no dedicated widgets for — alive across a full
/// load/save round trip instead of being silently dropped.
#[serde(flatten)]
extra: Map<String, Value>,
}
#[derive(Serialize, Deserialize, Default)]
#[serde(default)]
pub struct BindsFile {
#[serde(skip_serializing_if = "String::is_empty")]
active_layout: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
default_mods: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
globals: Vec<Bind>,
#[serde(skip_serializing_if = "Vec::is_empty")]
common: Vec<Bind>,
/// A `BTreeMap` (alphabetical), not the file's original insertion order
/// — same "whole-file round trip, formatting not preserved" trade-off as
/// the rest of this file's JSON-config siblings.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
layouts: BTreeMap<String, Vec<Bind>>,
/// Flat-schema bind list — BOS's real shipped shape. Only ever populated
/// when `SchemaKind::Flat` was detected at load time; stays empty (and
/// so omitted, see `to_json`) for a MultiLayout file.
#[serde(skip_serializing_if = "Vec::is_empty")]
bindings: Vec<Bind>,
}
/// What the frontend fetches once at load: which shape was detected, plus
/// the data itself. Sent back verbatim to `save_keybinds` so a stray code
/// path on either side can't accidentally save `file` without knowing which
/// shape it's supposed to come back out as.
#[derive(Serialize)]
pub struct BindsPayload {
kind: SchemaKind,
file: BindsFile,
}
fn config_path() -> std::path::PathBuf {
config::config_dir().join("hypr/binds.json")
}
fn load_from(path: &Path) -> (BindsFile, SchemaKind) {
let Ok(text) = std::fs::read_to_string(path) else {
// No file yet (fresh install/environment) — nothing on disk to
// misdetect or destroy. BOS itself ships the flat schema, so a new
// file defaults to Flat rather than the personal MultiLayout schema
// this editor originally assumed.
return (BindsFile::default(), SchemaKind::Flat);
};
let kind = match serde_json::from_str::<Value>(&text) {
Ok(Value::Object(top_level)) => SchemaKind::detect(&top_level),
// Unparsable JSON, or valid JSON that isn't even an object — treat
// as Unknown so save() refuses rather than silently overwriting
// whatever this file actually was with an empty default.
_ => SchemaKind::Unknown,
};
let file: BindsFile = serde_json::from_str(&text).unwrap_or_default();
(file, kind)
}
fn load() -> (BindsFile, SchemaKind) {
load_from(&config_path())
}
/// Serialize `f` in exactly the shape `kind` implies:
/// - `Flat` -> `{ "default_mods": [...], "bindings": [...] }`, nothing else
/// — no `active_layout`/`globals`/`common`/`layouts` keys, even if the
/// struct happens to carry empty values for them.
/// - `MultiLayout` -> today's existing shape (whatever fields are
/// non-empty), via `BindsFile`'s own `Serialize` impl.
fn to_json(f: &BindsFile, kind: SchemaKind) -> Value {
match kind {
SchemaKind::Flat => serde_json::json!({
"default_mods": f.default_mods,
"bindings": f.bindings,
}),
SchemaKind::MultiLayout => serde_json::to_value(f).unwrap_or(Value::Null),
SchemaKind::Unknown => Value::Null,
}
}
fn save_to(path: &Path, f: &BindsFile, kind: SchemaKind) -> std::io::Result<()> {
if kind == SchemaKind::Unknown {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"binds.json's schema wasn't recognized (expected a \"bindings\" key, or one of \
\"globals\"/\"common\"/\"layouts\") — refusing to save so nothing gets silently \
overwritten. Fix or remove the file, then reopen this panel.",
));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = serde_json::to_string_pretty(&to_json(f, kind)).unwrap_or_default();
config::atomic_write(path, &text)
}
fn save(f: &BindsFile, kind: SchemaKind) -> std::io::Result<()> {
save_to(&config_path(), f, kind)
}
#[tauri::command]
pub fn get_keybinds() -> BindsPayload {
let (file, kind) = load();
BindsPayload { kind, file }
}
#[tauri::command]
pub fn save_keybinds(file: BindsFile, kind: SchemaKind) -> Result<(), String> {
save(&file, kind).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
/// A representative slice of BOS's real shipped `binds.json`
/// (`iso/airootfs/etc/skel/.config/hypr/binds.json`, flat schema) —
/// chosen to exercise the extra-field variety breadhelp reads (`label`,
/// `category`, `demo_cmd`), an explicit `mods: []` override, a nested
/// `options` object, and both integer and string `workspace` values.
/// This is the fixture that would have caught the original GTK-editor
/// bug: mis-detecting this shape as MultiLayout and silently dropping
/// the whole `bindings` array on save.
const REAL_BOS_FLAT_FIXTURE: &str = r#"{
"default_mods": ["SUPER"],
"bindings": [
{ "action": "exec", "command": "kitty", "key": "RETURN", "label": "Open a terminal", "category": "apps" },
{ "action": "close", "key": "BACKSPACE", "label": "Close the focused window", "category": "windows" },
{ "action": "exec", "command": "breadbox", "key": "SPACE", "label": "Open the app launcher (breadbox)", "category": "apps", "demo_cmd": "breadbox" },
{ "action": "exec", "command": "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+", "key": "XF86AudioRaiseVolume", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Volume up", "category": "media" },
{ "action": "focus", "workspace": 1, "key": "1", "label": "Switch to workspace 1", "category": "workspaces" },
{ "action": "focus", "workspace": "e+1", "key": "bracketright", "label": "Next workspace", "category": "workspaces" },
{ "action": "resize_dir", "x": 30, "y": 0, "key": "right", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow right)", "category": "focus" },
{ "action": "drag", "key": "mouse:272", "options": { "mouse": true }, "label": "Move a window (drag)", "category": "mouse" }
]
}"#;
fn parse(text: &str) -> (BindsFile, SchemaKind) {
let kind = match serde_json::from_str::<Value>(text) {
Ok(Value::Object(top)) => SchemaKind::detect(&top),
_ => SchemaKind::Unknown,
};
let file: BindsFile = serde_json::from_str(text).unwrap_or_default();
(file, kind)
}
#[test]
fn detects_flat_schema_from_real_bos_binds_json() {
let (_, kind) = parse(REAL_BOS_FLAT_FIXTURE);
assert_eq!(kind, SchemaKind::Flat);
}
#[test]
fn round_trips_real_bos_flat_binds_json_through_load_and_save() {
let (file, kind) = parse(REAL_BOS_FLAT_FIXTURE);
assert_eq!(kind, SchemaKind::Flat);
let original: Value = serde_json::from_str(REAL_BOS_FLAT_FIXTURE).unwrap();
let saved = to_json(&file, kind);
// Flat save must emit EXACTLY {default_mods, bindings} — no
// active_layout/globals/common/layouts keys leaking in.
let saved_obj = saved.as_object().expect("flat save must be a JSON object");
assert_eq!(
saved_obj.keys().cloned().collect::<std::collections::BTreeSet<_>>(),
["default_mods", "bindings"].into_iter().map(String::from).collect(),
"Flat schema must round-trip as exactly {{default_mods, bindings}}"
);
// The `bindings` array — and every per-bind extra field (label,
// category, demo_cmd, mods, options, integer vs string workspace,
// ...) — must survive the round trip semantically untouched.
assert_eq!(saved["bindings"], original["bindings"]);
assert_eq!(saved["default_mods"], original["default_mods"]);
}
#[test]
fn round_trip_via_files_preserves_bindings_key_and_extras() {
let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("binds.json");
std::fs::write(&path, REAL_BOS_FLAT_FIXTURE).unwrap();
let (file, kind) = load_from(&path);
assert_eq!(kind, SchemaKind::Flat);
save_to(&path, &file, kind).unwrap();
let saved_text = std::fs::read_to_string(&path).unwrap();
let saved: Value = serde_json::from_str(&saved_text).unwrap();
let original: Value = serde_json::from_str(REAL_BOS_FLAT_FIXTURE).unwrap();
assert!(saved.get("bindings").is_some(), "bindings key must survive a load -> save round trip");
assert_eq!(saved["bindings"], original["bindings"]);
assert_eq!(saved["default_mods"], original["default_mods"]);
// Backup safety net: a second save must leave `.bak` holding the
// prior contents.
save_to(&path, &file, kind).unwrap();
let backup_path = dir.join("binds.json.bak");
assert!(backup_path.exists(), "save must back up the previous file");
let backup: Value = serde_json::from_str(&std::fs::read_to_string(&backup_path).unwrap()).unwrap();
assert_eq!(backup["bindings"], original["bindings"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn detects_and_round_trips_multi_layout_schema() {
let text = r#"{
"active_layout": "qwerty",
"default_mods": ["SUPER"],
"globals": [{ "action": "exec", "command": "kitty", "key": "RETURN" }],
"common": [],
"layouts": { "qwerty": [{ "action": "close", "key": "BACKSPACE" }] }
}"#;
let (file, kind) = parse(text);
assert_eq!(kind, SchemaKind::MultiLayout);
let saved = to_json(&file, kind);
assert!(saved.get("bindings").is_none(), "MultiLayout save must not emit a flat `bindings` key");
assert_eq!(saved["active_layout"], "qwerty");
assert_eq!(saved["layouts"]["qwerty"][0]["action"], "close");
assert_eq!(saved["globals"][0]["command"], "kitty");
}
#[test]
fn unknown_schema_is_detected_and_refuses_to_save() {
let text = r#"{ "some_other_shape": true }"#;
let (file, kind) = parse(text);
assert_eq!(kind, SchemaKind::Unknown);
let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-unknown-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("binds.json");
let result = save_to(&path, &file, kind);
assert!(result.is_err(), "save() must refuse when schema kind is Unknown");
assert!(!path.exists(), "refusing to save must not create/touch the target file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_file_defaults_to_flat_not_multi_layout() {
let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-missing-test-{}", std::process::id()));
// Don't create the file at all.
let path = dir.join("binds.json");
let (_, kind) = load_from(&path);
assert_eq!(kind, SchemaKind::Flat);
}
}

View file

@ -0,0 +1,28 @@
pub mod about;
pub mod appearance;
pub mod aur;
pub mod autostart;
pub mod bluetooth;
pub mod bread;
pub mod breadbar;
pub mod breadbox;
pub mod breadclip;
pub mod breadcrumbs;
pub mod breadpad;
pub mod breadpaper;
pub mod breadsearch;
pub mod config;
pub mod datetime;
pub mod firewall;
pub mod firmware;
pub mod hyprland;
pub mod keybinds;
pub mod network;
pub mod packages;
pub mod power;
pub mod service;
pub mod snapshots;
pub mod sound;
pub mod streaming;
pub mod theme;
pub mod users;

View file

@ -0,0 +1,117 @@
//! Wi-Fi + Ethernet over `nmcli`. NetworkManager lets the active session
//! user manage connections via polkit already, so the common paths (scan,
//! connect, toggle radio) need no `pkexec`. VPN import, 802.1x, and other
//! edge cases are punted to `nm-connection-editor` via the Advanced button.
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use tokio::process::Command;
#[derive(Serialize, Clone)]
pub struct WifiNetwork {
ssid: String,
signal: i32,
secured: bool,
active: bool,
known: bool,
}
async fn radio_enabled() -> bool {
Command::new("nmcli")
.args(["radio", "wifi"])
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "enabled")
.unwrap_or(false)
}
async fn ethernet_status() -> Option<String> {
let out = Command::new("nmcli").args(["-t", "-f", "DEVICE,TYPE,STATE"]).arg("dev").output().await.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines().find_map(|l| {
let mut cols = l.splitn(3, ':');
let (dev, ty, state) = (cols.next()?, cols.next()?, cols.next()?);
(ty == "ethernet").then(|| format!("{dev}: {state}"))
})
}
async fn known_connection_names() -> HashSet<String> {
let Ok(out) = Command::new("nmcli").args(["-t", "-f", "NAME"]).arg("con").arg("show").output().await else {
return HashSet::new();
};
String::from_utf8_lossy(&out.stdout).lines().map(str::to_string).collect()
}
#[derive(Serialize)]
pub struct NetworkInfo {
radio_enabled: bool,
ethernet: Option<String>,
}
#[tauri::command]
pub async fn get_network_info() -> NetworkInfo {
NetworkInfo { radio_enabled: radio_enabled().await, ethernet: ethernet_status().await }
}
#[tauri::command]
pub async fn set_wifi_radio(enabled: bool) -> Result<(), String> {
let val = if enabled { "on" } else { "off" };
Command::new("nmcli").args(["radio", "wifi", val]).status().await.map_err(|e| e.to_string())?;
Ok(())
}
/// Scan + list Wi-Fi networks, deduplicated by SSID (keeping the strongest
/// signal — the same AP shows once per band/BSSID otherwise).
#[tauri::command]
pub async fn scan_wifi() -> Vec<WifiNetwork> {
let _ = Command::new("nmcli").args(["dev", "wifi", "rescan"]).output().await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let Ok(out) = Command::new("nmcli").args(["-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi", "list"]).output().await
else {
return Vec::new();
};
let known = known_connection_names().await;
let text = String::from_utf8_lossy(&out.stdout);
let mut by_ssid: HashMap<String, WifiNetwork> = HashMap::new();
for line in text.lines() {
let mut cols = line.splitn(4, ':');
let (ssid, signal, security, in_use) = (cols.next(), cols.next(), cols.next(), cols.next());
let Some(ssid) = ssid.filter(|s| !s.is_empty()) else { continue };
let signal: i32 = signal.and_then(|s| s.parse().ok()).unwrap_or(0);
let net = WifiNetwork {
ssid: ssid.to_string(),
signal,
secured: security.map(|s| !s.is_empty()).unwrap_or(false),
active: in_use == Some("*"),
known: known.contains(ssid),
};
by_ssid.entry(ssid.to_string()).and_modify(|existing| if net.signal > existing.signal { *existing = net.clone() }).or_insert(net);
}
let mut list: Vec<_> = by_ssid.into_values().collect();
list.sort_by(|a, b| b.signal.cmp(&a.signal));
list
}
#[tauri::command]
pub async fn connect_wifi(ssid: String, password: Option<String>) -> Result<(), String> {
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
} else if known.contains(&ssid) {
Command::new("nmcli").args(["con", "up", &ssid]).output().await
} else {
Command::new("nmcli").args(["dev", "wifi", "connect", &ssid]).output().await
}
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
#[tauri::command]
pub fn open_connection_editor() {
let _ = std::process::Command::new("nm-connection-editor").spawn();
}

View file

@ -0,0 +1,39 @@
use serde::Serialize;
use std::collections::HashMap;
#[derive(Serialize, Clone)]
pub struct InstalledPackage {
name: String,
version: String,
}
#[tauri::command]
pub fn get_installed_packages() -> Vec<InstalledPackage> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let path = std::path::Path::new(&home).join(".local/state/bakery/installed.json");
let Ok(text) = std::fs::read_to_string(&path) else {
return Vec::new();
};
let Ok(mut parsed) = serde_json::from_str::<serde_json::Value>(&text) else {
return Vec::new();
};
// installed.json is {"packages": {name: {version, binaries, services}}},
// not a flat map of package name to metadata.
let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else {
return Vec::new();
};
let Ok(packages) = serde_json::from_value::<HashMap<String, serde_json::Value>>(packages) else {
return Vec::new();
};
let mut list: Vec<InstalledPackage> = packages
.into_iter()
.map(|(name, val)| {
let version = val.get("version").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
InstalledPackage { name, version }
})
.collect();
list.sort_by(|a, b| a.name.cmp(&b.name));
list
}

View file

@ -0,0 +1,145 @@
//! Battery/power status (upower), brightness (brightnessctl), and TLP's
//! current profile. Deliberately no AC/Battery/Performance *switcher* — TLP
//! automatically picks a profile by power source, so this only exposes
//! controls for things that are actually user choices: brightness, and
//! charge thresholds where the hardware supports them.
use serde::Serialize;
use tokio::process::Command;
async fn upower_device(kind: &str) -> Option<String> {
let out = Command::new("upower").arg("-e").output().await.ok()?;
String::from_utf8_lossy(&out.stdout).lines().find(|l| l.to_lowercase().contains(kind)).map(str::to_string)
}
async fn upower_field(device: &str, field: &str) -> Option<String> {
let out = Command::new("upower").args(["-i", device]).output().await.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())
}
async fn battery_summary() -> Vec<(String, String)> {
let Some(bat) = upower_device("bat").await else {
return vec![("Battery".to_string(), "No battery detected".to_string())];
};
let mut rows = Vec::new();
if let Some(state) = upower_field(&bat, "state").await {
rows.push(("Status".to_string(), state));
}
if let Some(pct) = upower_field(&bat, "percentage").await {
rows.push(("Charge".to_string(), pct));
}
let t = match upower_field(&bat, "time to empty").await {
Some(t) => Some(t),
None => upower_field(&bat, "time to full").await,
};
if let Some(t) = t {
rows.push(("Time remaining".to_string(), t));
}
let full: Option<f64> = upower_field(&bat, "energy-full").await.and_then(|v| v.split_whitespace().next()?.parse().ok());
let design: Option<f64> =
upower_field(&bat, "energy-full-design").await.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
}
async fn power_source() -> String {
match upower_device("ac").await {
Some(ac) => match upower_field(&ac, "online").await {
Some(v) if v == "yes" => "AC power".to_string(),
Some(_) => "Battery".to_string(),
None => "Unknown".to_string(),
},
None => "Unknown".to_string(),
}
}
async fn tlp_profile() -> Option<String> {
let out = Command::new("tlp-stat").arg("-s").output().await.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())
}
async fn brightness_device() -> Option<String> {
let out = Command::new("brightnessctl").output().await.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)
}
async fn brightness_pct() -> Option<u32> {
let out = Command::new("brightnessctl").output().await.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 — genuinely hardware-dependent, not every install
/// will have this.
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)
}
#[derive(Serialize)]
pub struct PowerInfo {
battery: Vec<(String, String)>,
power_source: String,
brightness_pct: Option<u32>,
charge_start: Option<i64>,
charge_end: Option<i64>,
tlp_profile: Option<String>,
}
#[tauri::command]
pub async fn get_power_info() -> PowerInfo {
let charge = charge_threshold_paths();
PowerInfo {
battery: battery_summary().await,
power_source: power_source().await,
brightness_pct: brightness_pct().await,
charge_start: charge.as_ref().map(|(s, _)| read_threshold(s)),
charge_end: charge.as_ref().map(|(_, e)| read_threshold(e)),
tlp_profile: tlp_profile().await,
}
}
#[tauri::command]
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}%");
Command::new("brightnessctl").args(["--device", &device, "set", &pct]).status().await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn set_charge_threshold(which: String, percent: i64) -> Result<(), String> {
let Some((start, end)) = charge_threshold_paths() else {
return Err("No charge threshold support on this hardware".into());
};
let path = if which == "start" { start } else { end };
Command::new("pkexec").args(["tee", &path.display().to_string()]).arg(percent.to_string()).output().await.map_err(|e| e.to_string())?;
Ok(())
}

View file

@ -0,0 +1,77 @@
//! Live systemd `--user` unit status plus start/stop/restart/logs — every
//! bread-ecosystem panel whose app is actually a daemon (not just a config
//! file) gets this. Ported from `src/ui/widgets.rs`'s `service_control`;
//! the `critical`-unit confirm-before-stop behavior moves to the frontend
//! (a confirm step gating the call to `service_action`), since that's UI
//! policy, not something the command itself needs to know.
use serde::{Deserialize, Serialize};
use tokio::process::Command;
#[derive(Serialize)]
pub struct ServiceStatus {
active: bool,
enabled: bool,
}
#[derive(Deserialize)]
pub enum ServiceAction {
Start,
Stop,
Restart,
}
async fn systemctl_active(unit: &str) -> bool {
Command::new("systemctl")
.args(["--user", "is-active", "--quiet", unit])
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}
async fn systemctl_enabled(unit: &str) -> bool {
Command::new("systemctl")
.args(["--user", "is-enabled", "--quiet", unit])
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}
#[tauri::command]
pub async fn get_service_status(unit: String) -> ServiceStatus {
ServiceStatus {
active: systemctl_active(&unit).await,
enabled: systemctl_enabled(&unit).await,
}
}
#[tauri::command]
pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), String> {
let verb = match action {
ServiceAction::Start => "start",
ServiceAction::Stop => "stop",
ServiceAction::Restart => "restart",
};
let output = Command::new("systemctl")
.args(["--user", verb, &unit])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
/// Opens a terminal following the unit's journal — same as today's GTK
/// panel, no reason to pull an open-ended `journalctl -f` tail into the
/// webview.
#[tauri::command]
pub fn open_logs(unit: String) {
let _ = std::process::Command::new("kitty")
.args(["-e", "journalctl", "--user", "-u", &unit, "-f"])
.spawn();
}

View file

@ -0,0 +1,62 @@
use serde::Serialize;
use tokio::process::Command;
#[derive(Serialize, Clone)]
pub struct SnapshotRow {
number: String,
date: String,
description: String,
}
/// `Err` carries snapper's trimmed stderr — distinct from `Ok(vec![])`
/// (snapper works fine, there just aren't any snapshots yet).
#[tauri::command]
pub async fn get_snapshots() -> Result<Vec<SnapshotRow>, String> {
// NOTE: the real flag is --columns, not --output-cols (snapper rejects
// that outright) — confirmed against snapper 0.13's own --help.
let output = Command::new("snapper")
.args(["list", "--columns", "number,date,description"])
.output()
.await
.map_err(|e| e.to_string())?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
eprintln!("bos-settings: snapper list failed: {stderr}");
return Err(stderr);
}
let text = String::from_utf8_lossy(&output.stdout);
Ok(text
.lines()
.skip(2) // header + separator
.filter_map(|line| {
let mut cols = line.splitn(3, '|');
let number = cols.next()?.trim().to_string();
// Snapshot 0 ("current") always exists, can't be rolled back to
// or deleted, and isn't a real snapshot.
if number == "0" {
return None;
}
Some(SnapshotRow { number, date: cols.next()?.trim().to_string(), description: cols.next()?.trim().to_string() })
})
.collect())
}
#[tauri::command]
pub async fn delete_snapshot(number: String) -> Result<(), String> {
let output = Command::new("snapper").args(["delete", &number]).status().await.map_err(|e| e.to_string())?;
if output.success() {
Ok(())
} else {
Err("snapper delete exited with an error — the snapshot wasn't removed.".into())
}
}
/// BOS boots with root pinned to a named subvolume (grub emits
/// rootflags=subvol=@), so `snapper rollback`'s usual mechanism has no
/// effect here. The real way back is grub-btrfs, which generates a GRUB
/// submenu entry per snapshot — this just reboots so the user can pick it.
#[tauri::command]
pub fn reboot_system() {
let _ = std::process::Command::new("systemctl").arg("reboot").spawn();
}

View file

@ -0,0 +1,102 @@
//! Output/input volume and device selection over PipeWire's pulse
//! compatibility layer (`pactl`) — the same surface hyprland.lua's media
//! keys already use via `wpctl`. `pactl` is used here instead because it
//! can enumerate devices with human-readable descriptions and switch the
//! default in one command; `wpctl` cannot easily do either.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio::process::Command;
#[derive(Deserialize, Serialize, Clone)]
pub struct SoundDevice {
name: String,
description: String,
mute: bool,
percent: f64,
}
#[derive(Deserialize)]
struct RawDevice {
name: String,
description: String,
mute: bool,
volume: HashMap<String, RawVolumeChannel>,
}
#[derive(Deserialize)]
struct RawVolumeChannel {
value_percent: String,
}
impl RawDevice {
fn percent(&self) -> f64 {
self.volume
.values()
.next()
.and_then(|v| v.value_percent.trim_end_matches('%').trim().parse::<f64>().ok())
.unwrap_or(0.0)
}
}
async fn list_devices(kind: &str) -> Vec<SoundDevice> {
let Ok(output) = Command::new("pactl").args(["-f", "json", "list", kind]).output().await else {
return Vec::new();
};
let raw: Vec<RawDevice> = serde_json::from_slice(&output.stdout).unwrap_or_default();
raw.into_iter()
.map(|d| SoundDevice { name: d.name.clone(), description: d.description.clone(), mute: d.mute, percent: d.percent() })
.collect()
}
async fn default_device_name(kind: &str) -> Option<String> {
let flag = if kind == "sinks" { "get-default-sink" } else { "get-default-source" };
Command::new("pactl")
.arg(flag)
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
}
#[derive(Serialize)]
pub struct SoundSection {
devices: Vec<SoundDevice>,
default_name: Option<String>,
}
#[tauri::command]
pub async fn get_sound_section(kind: String) -> SoundSection {
let devices = list_devices(&kind).await;
let default_name = default_device_name(&kind).await;
SoundSection { devices, default_name }
}
#[tauri::command]
pub async fn set_default_sound_device(kind: String, name: String) -> Result<(), String> {
let flag = if kind == "sinks" { "set-default-sink" } else { "set-default-source" };
Command::new("pactl").args([flag, &name]).status().await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn set_sound_volume(kind: String, name: String, percent: i64) -> Result<(), String> {
let flag = if kind == "sinks" { "set-sink-volume" } else { "set-source-volume" };
let pct = format!("{percent}%");
Command::new("pactl").args([flag, &name, &pct]).status().await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn set_sound_mute(kind: String, name: String, mute: bool) -> Result<(), String> {
let flag = if kind == "sinks" { "set-sink-mute" } else { "set-source-mute" };
let val = if mute { "1" } else { "0" };
Command::new("pactl").args([flag, &name, val]).status().await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn open_mixer() {
let _ = std::process::Command::new("pavucontrol").spawn();
}

View file

@ -0,0 +1,57 @@
//! Shared event-streaming command runner for the genuinely long-running
//! operations (package/firmware updates) where the GTK app treated output
//! as "watch the log scroll" — the Tauri-side analog of
//! `stream_command_then`'s async_channel → glib::spawn_future_local
//! pipeline, using Tauri's event bus instead of a GLib main-loop channel.
//! Most other commands are simple request/response (see the other modules)
//! since the operations they wrap finish in well under a second.
use serde::Serialize;
use std::process::Stdio;
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
#[derive(Clone, Serialize)]
struct CmdOutputEvent {
session_id: String,
line: String,
}
/// Runs `program args...`, emitting one `cmd-output` event per line of
/// stdout/stderr (tagged with `session_id` so the frontend can route
/// concurrent streams), and resolves to whether it exited successfully —
/// the frontend awaits this call directly rather than needing a second
/// "done" event.
#[tauri::command]
pub async fn run_streaming_command(app: AppHandle, session_id: String, program: String, args: Vec<String>) -> bool {
let child = Command::new(&program).args(&args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn();
let mut child = match child {
Ok(c) => c,
Err(e) => {
let _ = app.emit("cmd-output", CmdOutputEvent { session_id, line: format!("Error: {e}") });
return false;
}
};
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let read_stdout = async {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = app.emit("cmd-output", CmdOutputEvent { session_id: session_id.clone(), line });
}
};
let stderr_app = app.clone();
let stderr_session = session_id.clone();
let read_stderr = async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = stderr_app.emit("cmd-output", CmdOutputEvent { session_id: stderr_session.clone(), line });
}
};
tokio::join!(read_stdout, read_stderr);
child.wait().await.map(|s| s.success()).unwrap_or(false)
}

View file

@ -0,0 +1,74 @@
//! Bridges `bread-theme`'s pywal-derived palette into the webview as CSS
//! custom properties, and keeps it live: `bread-theme`'s generator rewrites
//! its shared stylesheet with a temp-then-rename (atomic replace), which
//! kills a direct file watch (inotify reports DELETE_SELF and never
//! re-arms) — so this watches the *parent directory* and filters by
//! filename instead, the same strategy `bread_theme::gtk::watch_theme_file`
//! uses for the GTK apps.
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use tauri::{AppHandle, Emitter, Manager};
/// Initial theme fetch — called once by the frontend at startup.
#[tauri::command]
pub fn get_theme_css() -> String {
render_theme_css()
}
fn render_theme_css() -> String {
let palette = bread_theme::load_palette();
format!(
"{}\n{}",
bread_theme::css_custom_properties(&palette),
bread_theme::css_tokens(),
)
}
/// Start watching the shared theme file and emit `theme-changed` with the
/// freshly rendered CSS whenever it's rewritten (palette change from a new
/// wallpaper, or a manual `bread-theme reload`). Call once from `setup`.
pub fn watch_and_emit(app: &AppHandle) {
let target = bread_theme::shared_css_path();
let Some(dir) = target.parent() else { return };
let _ = std::fs::create_dir_all(dir);
let app_for_watcher = app.clone();
let target_for_watcher = target.clone();
let mut watcher = match RecommendedWatcher::new(
move |res: notify::Result<Event>| {
let Ok(event) = res else { return };
// Rewrites land as CREATE/MODIFY/RENAME events touching the
// stylesheet's path specifically — the directory watch also
// sees unrelated siblings, so filter to the target file.
let touches_target = matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
) && event.paths.iter().any(|p| p == &target_for_watcher);
if touches_target {
let _ = app_for_watcher.emit("theme-changed", render_theme_css());
}
},
notify::Config::default(),
) {
Ok(w) => w,
Err(e) => {
tracing_or_eprintln(&format!("theme watcher: failed to create: {e}"));
return;
}
};
if let Err(e) = watcher.watch(dir, RecursiveMode::NonRecursive) {
tracing_or_eprintln(&format!("theme watcher: failed to watch {}: {e}", dir.display()));
return;
}
// Leaked to stay alive for the process lifetime — this app has exactly
// one theme watcher, created once at startup, never torn down.
app.manage(WatcherHandle(watcher));
}
struct WatcherHandle(RecommendedWatcher);
fn tracing_or_eprintln(msg: &str) {
eprintln!("{msg}");
}

View file

@ -0,0 +1,104 @@
//! User account management — add/remove users, change passwords. Everything
//! here needs root (useradd/userdel/chpasswd), so every action goes through
//! `pkexec`.
use serde::Serialize;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
#[derive(Serialize, Clone)]
pub 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
// (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()
}
#[derive(Serialize)]
pub struct UsersInfo {
accounts: Vec<Account>,
current_user: String,
}
#[tauri::command]
pub fn get_users_info() -> UsersInfo {
UsersInfo { accounts: list_accounts(), current_user: std::env::var("USER").unwrap_or_default() }
}
/// Runs a root command that needs a line of input on stdin (chpasswd's own
/// "user:password" format). `pkexec` inherits the spawning process's stdin
/// only when explicitly piped, so this pipes it through.
async fn run_with_stdin(args: &[&str], input: String) -> bool {
let Ok(mut child) = Command::new(args[0]).args(&args[1..]).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
else {
return false;
};
if let Some(mut stdin) = child.stdin.take() {
if stdin.write_all(input.as_bytes()).await.is_err() {
return false;
}
}
child.wait().await.map(|s| s.success()).unwrap_or(false)
}
#[tauri::command]
pub async fn change_password(username: String, password: String) -> Result<(), String> {
let input = format!("{username}:{password}\n");
if run_with_stdin(&["pkexec", "chpasswd"], input).await {
Ok(())
} else {
Err("Failed to change password".into())
}
}
#[tauri::command]
pub async fn remove_user(username: String) -> Result<(), String> {
let output = Command::new("pkexec").args(["userdel", "-r", &username]).output().await.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
#[tauri::command]
pub async fn add_user(username: String, full_name: String, password: String) -> Result<(), String> {
let username = username.trim().to_string();
let mut useradd_args = vec!["pkexec".to_string(), "useradd".to_string(), "-m".to_string(), "-s".to_string(), "/bin/bash".to_string()];
if !full_name.trim().is_empty() {
useradd_args.push("-c".to_string());
useradd_args.push(full_name.trim().to_string());
}
useradd_args.push(username.clone());
let args_ref: Vec<&str> = useradd_args.iter().map(String::as_str).collect();
let output = Command::new(args_ref[0]).args(&args_ref[1..]).output().await.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let input = format!("{username}:{password}\n");
if run_with_stdin(&["pkexec", "chpasswd"], input).await {
Ok(())
} else {
Err("User created, but setting the password failed.".into())
}
}

92
src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,92 @@
mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
commands::theme::watch_and_emit(app.handle());
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::theme::get_theme_css,
commands::about::get_system_info,
commands::about::set_hostname,
commands::service::get_service_status,
commands::service::service_action,
commands::service::open_logs,
commands::breadclip::open_breadclip,
commands::bread::get_bread_config,
commands::bread::save_bread_config,
commands::breadpad::get_breadpad_config,
commands::breadpad::save_breadpad_config,
commands::breadsearch::get_breadsearch_config,
commands::breadsearch::save_breadsearch_config,
commands::breadbar::get_breadbar_css,
commands::breadbar::save_breadbar_css,
commands::breadbar::get_breadbar_style,
commands::breadbar::save_breadbar_style,
commands::breadbox::get_breadbox_contexts,
commands::breadbox::save_breadbox_contexts,
commands::breadcrumbs::get_breadcrumbs_config,
commands::breadcrumbs::save_breadcrumbs_config,
commands::breadpaper::get_current_wallpaper,
commands::breadpaper::set_wallpaper,
commands::breadpaper::list_wallpaper_library,
commands::breadpaper::wallpaper_library_dir_display,
commands::appearance::get_appearance,
commands::appearance::save_appearance,
commands::autostart::get_autostart_entries,
commands::autostart::save_autostart_entries,
commands::hyprland::get_live_monitors,
commands::hyprland::get_monitor_rules,
commands::hyprland::save_monitor_rules,
commands::hyprland::open_hyprland_conf,
commands::hyprland::open_keybinds_viewer,
commands::keybinds::get_keybinds,
commands::keybinds::save_keybinds,
commands::sound::get_sound_section,
commands::sound::set_default_sound_device,
commands::sound::set_sound_volume,
commands::sound::set_sound_mute,
commands::sound::open_mixer,
commands::datetime::get_datetime_info,
commands::datetime::set_timezone,
commands::datetime::set_ntp_enabled,
commands::power::get_power_info,
commands::power::set_brightness,
commands::power::set_charge_threshold,
commands::network::get_network_info,
commands::network::set_wifi_radio,
commands::network::scan_wifi,
commands::network::connect_wifi,
commands::network::open_connection_editor,
commands::bluetooth::get_adapter_powered,
commands::bluetooth::set_adapter_powered,
commands::bluetooth::get_paired_devices,
commands::bluetooth::scan_bluetooth,
commands::bluetooth::bt_connect,
commands::bluetooth::bt_disconnect,
commands::bluetooth::bt_forget,
commands::bluetooth::bt_pair,
commands::firewall::get_firewall_status,
commands::firewall::set_firewall_enabled,
commands::firewall::add_firewall_rule,
commands::firewall::remove_firewall_rule,
commands::users::get_users_info,
commands::users::change_password,
commands::users::remove_user,
commands::users::add_user,
commands::streaming::run_streaming_command,
commands::packages::get_installed_packages,
commands::aur::search_aur,
commands::aur::install_aur_package,
commands::firmware::get_updatable_firmware,
commands::snapshots::get_snapshots,
commands::snapshots::delete_snapshot,
commands::snapshots::reboot_system,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
bos_settings_lib::run()
}

41
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,41 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "BOS Settings",
"version": "0.8.0",
"identifier": "dev.breadway.bos-settings",
"build": {
"beforeDevCommand": "npm --prefix frontend run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm --prefix frontend run build",
"frontendDist": "../frontend/build"
},
"app": {
"windows": [
{
"title": "BOS Settings",
"width": 960,
"height": 640,
"decorations": false,
"backgroundColor": "#0c0c0c"
}
],
"security": {
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["$HOME/Pictures/Backgrounds/**"]
}
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}