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.
7
src-tauri/.gitignore
vendored
Normal 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
36
src-tauri/Cargo.toml
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
src-tauri/capabilities/default.json
Normal 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
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
155
src-tauri/src/commands/about.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
73
src-tauri/src/commands/appearance.rs
Normal 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())
|
||||
}
|
||||
47
src-tauri/src/commands/aur.rs
Normal 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();
|
||||
}
|
||||
59
src-tauri/src/commands/autostart.rs
Normal 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())
|
||||
}
|
||||
123
src-tauri/src/commands/bluetooth.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
80
src-tauri/src/commands/bread.rs
Normal 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())
|
||||
}
|
||||
144
src-tauri/src/commands/breadbar.rs
Normal 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))
|
||||
}
|
||||
64
src-tauri/src/commands/breadbox.rs
Normal 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())
|
||||
}
|
||||
8
src-tauri/src/commands/breadclip.rs
Normal 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();
|
||||
}
|
||||
176
src-tauri/src/commands/breadcrumbs.rs
Normal 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())
|
||||
}
|
||||
78
src-tauri/src/commands/breadpad.rs
Normal 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())
|
||||
}
|
||||
111
src-tauri/src/commands/breadpaper.rs
Normal 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()
|
||||
}
|
||||
55
src-tauri/src/commands/breadsearch.rs
Normal 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())
|
||||
}
|
||||
226
src-tauri/src/commands/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
82
src-tauri/src/commands/datetime.rs
Normal 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(())
|
||||
}
|
||||
89
src-tauri/src/commands/firewall.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
32
src-tauri/src/commands/firmware.rs
Normal 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()
|
||||
}
|
||||
110
src-tauri/src/commands/hyprland.rs
Normal 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();
|
||||
}
|
||||
354
src-tauri/src/commands/keybinds.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
28
src-tauri/src/commands/mod.rs
Normal 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;
|
||||
117
src-tauri/src/commands/network.rs
Normal 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();
|
||||
}
|
||||
39
src-tauri/src/commands/packages.rs
Normal 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
|
||||
}
|
||||
145
src-tauri/src/commands/power.rs
Normal 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(())
|
||||
}
|
||||
77
src-tauri/src/commands/service.rs
Normal 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();
|
||||
}
|
||||
62
src-tauri/src/commands/snapshots.rs
Normal 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();
|
||||
}
|
||||
102
src-tauri/src/commands/sound.rs
Normal 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();
|
||||
}
|
||||
57
src-tauri/src/commands/streaming.rs
Normal 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)
|
||||
}
|
||||
74
src-tauri/src/commands/theme.rs
Normal 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}");
|
||||
}
|
||||
104
src-tauri/src/commands/users.rs
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||