From 60a473fff01d1f5738203e414709b7093368368e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:07:04 +0800 Subject: [PATCH 1/8] Add OS settings panels for updates, printing, VPN, and related surfaces Typed commands only: pacman/bakery/fwupd compose on Updates, CUPS/nmcli, hyprsunset, fcitx5, MIME defaults, bakery track, restic (backup.toml 0600), curated optional software, and an NVIDIA offer card gated on the probe file. --- frontend/src/lib/sidebar.ts | 20 + frontend/src/lib/views/Accessibility.svelte | 197 +++++++++ frontend/src/lib/views/Backup.svelte | 205 ++++++++++ frontend/src/lib/views/Channel.svelte | 127 ++++++ frontend/src/lib/views/Defaults.svelte | 104 +++++ frontend/src/lib/views/InputMethod.svelte | 166 ++++++++ frontend/src/lib/views/NightLight.svelte | 145 +++++++ frontend/src/lib/views/Optional.svelte | 149 +++++++ frontend/src/lib/views/Printing.svelte | 147 +++++++ frontend/src/lib/views/Updates.svelte | 243 +++++++++++ frontend/src/lib/views/Vpn.svelte | 148 +++++++ frontend/src/lib/views/registry.ts | 20 + src/src/commands/a11y.rs | 116 ++++++ src/src/commands/backup.rs | 358 ++++++++++++++++ src/src/commands/channel.rs | 91 +++++ src/src/commands/defaults.rs | 431 ++++++++++++++++++++ src/src/commands/ime.rs | 177 ++++++++ src/src/commands/mod.rs | 12 + src/src/commands/nightlight.rs | 216 ++++++++++ src/src/commands/nvidia.rs | 106 +++++ src/src/commands/optional.rs | 112 +++++ src/src/commands/packages.rs | 11 +- src/src/commands/printing.rs | 204 +++++++++ src/src/commands/streaming.rs | 68 ++- src/src/commands/updates.rs | 184 +++++++++ src/src/commands/util.rs | 258 ++++++++++++ src/src/commands/vpn.rs | 179 ++++++++ src/src/lib.rs | 33 ++ src/src/screenshot.rs | 10 + 29 files changed, 4229 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/views/Accessibility.svelte create mode 100644 frontend/src/lib/views/Backup.svelte create mode 100644 frontend/src/lib/views/Channel.svelte create mode 100644 frontend/src/lib/views/Defaults.svelte create mode 100644 frontend/src/lib/views/InputMethod.svelte create mode 100644 frontend/src/lib/views/NightLight.svelte create mode 100644 frontend/src/lib/views/Optional.svelte create mode 100644 frontend/src/lib/views/Printing.svelte create mode 100644 frontend/src/lib/views/Updates.svelte create mode 100644 frontend/src/lib/views/Vpn.svelte create mode 100644 src/src/commands/a11y.rs create mode 100644 src/src/commands/backup.rs create mode 100644 src/src/commands/channel.rs create mode 100644 src/src/commands/defaults.rs create mode 100644 src/src/commands/ime.rs create mode 100644 src/src/commands/nightlight.rs create mode 100644 src/src/commands/nvidia.rs create mode 100644 src/src/commands/optional.rs create mode 100644 src/src/commands/printing.rs create mode 100644 src/src/commands/updates.rs create mode 100644 src/src/commands/util.rs create mode 100644 src/src/commands/vpn.rs diff --git a/frontend/src/lib/sidebar.ts b/frontend/src/lib/sidebar.ts index 7d98ce6..c9f6740 100644 --- a/frontend/src/lib/sidebar.ts +++ b/frontend/src/lib/sidebar.ts @@ -31,6 +31,16 @@ import Lock from "@lucide/svelte/icons/lock"; import Camera from "@lucide/svelte/icons/camera"; import AppWindow from "@lucide/svelte/icons/app-window"; import CircleHelp from "@lucide/svelte/icons/circle-help"; +import Download from "@lucide/svelte/icons/download"; +import Printer from "@lucide/svelte/icons/printer"; +import ShieldEllipsis from "@lucide/svelte/icons/shield-ellipsis"; +import Moon from "@lucide/svelte/icons/moon"; +import Languages from "@lucide/svelte/icons/languages"; +import Accessibility from "@lucide/svelte/icons/accessibility"; +import AppWindowMac from "@lucide/svelte/icons/app-window-mac"; +import GitBranch from "@lucide/svelte/icons/git-branch"; +import Archive from "@lucide/svelte/icons/archive"; +import Boxes from "@lucide/svelte/icons/boxes"; export interface SidebarItem { /** Must match a key in the view component map (see routing in +page.svelte). */ @@ -45,15 +55,20 @@ export interface SidebarItem { export const SYSTEM_ITEMS: SidebarItem[] = [ { id: "network", label: "Network", icon: Wifi }, { id: "breadcrumbs", label: "Wi-Fi Profiles", sublabel: "breadcrumbs", icon: Network }, + { id: "vpn", label: "VPN / WireGuard", sublabel: "NetworkManager", icon: ShieldEllipsis }, { id: "bluetooth", label: "Bluetooth", icon: Bluetooth }, + { id: "printing", label: "Printing", sublabel: "CUPS", icon: Printer }, { id: "firewall", label: "Firewall", icon: Shield }, { id: "sound", label: "Sound", icon: Volume2 }, { id: "power", label: "Power", icon: BatteryFull }, { id: "datetime", label: "Date & Time", icon: Clock }, { id: "hyprland", label: "Display", sublabel: "monitors.json", icon: Monitor }, + { id: "nightlight", label: "Night light", sublabel: "hyprsunset", icon: Moon }, { id: "breadmon", label: "Monitors", sublabel: "breadmon", icon: AppWindow }, { id: "breadlock", label: "Lock & greet", sublabel: "breadlock", icon: Lock }, { id: "keybinds", label: "Keybinds", sublabel: "binds.json", icon: Keyboard }, + { id: "ime", label: "Input method", sublabel: "fcitx5", icon: Languages }, + { id: "accessibility", label: "Accessibility", icon: Accessibility }, { id: "breadshot", label: "Screenshots", sublabel: "breadshot", icon: Camera }, { id: "autostart", label: "Startup Apps", sublabel: "autostart.json", icon: Rocket }, { id: "users", label: "Users", icon: Users }, @@ -67,14 +82,19 @@ export const PERSONALIZATION_ITEMS: SidebarItem[] = [ { id: "breadclip", label: "Clipboard", sublabel: "breadclipd", icon: Clipboard }, { id: "breadpad", label: "Notes", sublabel: "breadpad", icon: NotebookPen }, { id: "breadsearch", label: "File Search", sublabel: "breadsearch", icon: Search }, + { id: "defaults", label: "Default apps", sublabel: "mimeapps.list", icon: AppWindowMac }, { id: "bread", label: "Daemon", sublabel: "breadd", icon: Cog }, ]; export const MAINTENANCE_ITEMS: SidebarItem[] = [ + { id: "updates", label: "Updates", icon: Download }, { id: "packages", label: "Packages", icon: Package }, { id: "aur", label: "AUR", icon: Search }, { id: "firmware", label: "Firmware", icon: RefreshCw }, { id: "snapshots", label: "Snapshots", icon: History }, + { id: "channel", label: "Bakery channel", sublabel: "track", icon: GitBranch }, + { id: "backup", label: "Backup", sublabel: "restic", icon: Archive }, + { id: "optional", label: "Optional software", icon: Boxes }, ]; export const ABOUT_ITEMS: SidebarItem[] = [ diff --git a/frontend/src/lib/views/Accessibility.svelte b/frontend/src/lib/views/Accessibility.svelte new file mode 100644 index 0000000..02bb427 --- /dev/null +++ b/frontend/src/lib/views/Accessibility.svelte @@ -0,0 +1,197 @@ + + + + + {#if !st} + + {:else if !st.orca_installed} + + + {:else} +
+ Orca + +
+ {/if} +
+ + + {#if st} + + + + {#if !st.kmag_installed} + + + {:else} + + {/if} + {/if} + + + + {#if st} +
+ Sticky keys + +
+
+ Slow keys + +
+ + {/if} +
+ {#if message}{/if} + +
+ + diff --git a/frontend/src/lib/views/Backup.svelte b/frontend/src/lib/views/Backup.svelte new file mode 100644 index 0000000..45da508 --- /dev/null +++ b/frontend/src/lib/views/Backup.svelte @@ -0,0 +1,205 @@ + + + + + {#if !st} + + {:else if !st.restic_installed} + + + {:else} + + + + + {/if} + + + +
+ + + + +
+ {#if message}{/if} +
+ + + {#if snapshots.length === 0} + + {:else} +
+ {#each snapshots as s (s.id)} + + {/each} +
+ {/if} +
+ +
+ + diff --git a/frontend/src/lib/views/Channel.svelte b/frontend/src/lib/views/Channel.svelte new file mode 100644 index 0000000..a49c439 --- /dev/null +++ b/frontend/src/lib/views/Channel.svelte @@ -0,0 +1,127 @@ + + + + + {#if !track} + + {:else} +
+ {#each track.tracks as name (name)} + + {/each} +
+ + {/if} + + {#if message}{/if} +
+ +
+ + diff --git a/frontend/src/lib/views/Defaults.svelte b/frontend/src/lib/views/Defaults.svelte new file mode 100644 index 0000000..0fd2927 --- /dev/null +++ b/frontend/src/lib/views/Defaults.svelte @@ -0,0 +1,104 @@ + + + + + {#if st} + {#each CATEGORIES as cat (cat.id)} +
+ {cat.label} + +
+ {/each} + + {:else} + + {/if} +
+
+ + diff --git a/frontend/src/lib/views/InputMethod.svelte b/frontend/src/lib/views/InputMethod.svelte new file mode 100644 index 0000000..ef99f54 --- /dev/null +++ b/frontend/src/lib/views/InputMethod.svelte @@ -0,0 +1,166 @@ + + + + + {#if !st} + + {:else} +
+ Enable fcitx5 for this session + +
+ + + {/if} + {#if message}{/if} +
+ + + {#if st} +
    + {#each st.packages as p (p.name)} +
  • {p.name}{p.installed ? "" : " — not installed"}
  • + {/each} +
+ {/if} + {#if missing.length} + + {/if} +
+ +
+ + diff --git a/frontend/src/lib/views/NightLight.svelte b/frontend/src/lib/views/NightLight.svelte new file mode 100644 index 0000000..13ec93f --- /dev/null +++ b/frontend/src/lib/views/NightLight.svelte @@ -0,0 +1,145 @@ + + + + + {#if !st} + + {:else if !st.installed} + + + {:else} +
+ Night light + +
+ + + + {/if} + {#if message}{/if} +
+ +
+ + diff --git a/frontend/src/lib/views/Optional.svelte b/frontend/src/lib/views/Optional.svelte new file mode 100644 index 0000000..3fbfcf0 --- /dev/null +++ b/frontend/src/lib/views/Optional.svelte @@ -0,0 +1,149 @@ + + + + + {#if !st} + + {:else} + {#each st.items as item (item.id)} +
+
+ {item.title} +

{item.detail}

+ {item.via}{item.installed ? " · installed" : ""} +
+ {#if item.installed} + Installed + {:else} + + {/if} +
+ {/each} + {/if} + {#if message}{/if} +
+ +
+ + diff --git a/frontend/src/lib/views/Printing.svelte b/frontend/src/lib/views/Printing.svelte new file mode 100644 index 0000000..2fc3ac5 --- /dev/null +++ b/frontend/src/lib/views/Printing.svelte @@ -0,0 +1,147 @@ + + + + + {#if !status} + + {:else if status.error} + + {:else if status.printers.length === 0} + + {:else} +
+ {#each status.printers as p (p.name)} +
+
+ {p.name}{p.is_default ? " (default)" : ""} + {p.enabled ? p.status : "disabled"} +
+ +
+ {/each} +
+ {/if} +
+ + +
+ {#if message}{/if} +
+ + + + + + +
+ + diff --git a/frontend/src/lib/views/Updates.svelte b/frontend/src/lib/views/Updates.svelte new file mode 100644 index 0000000..4b48203 --- /dev/null +++ b/frontend/src/lib/views/Updates.svelte @@ -0,0 +1,243 @@ + + + + {#if status?.nvidia} + +
+ +
+ {status.nvidia.gpu} +

{status.nvidia.reason}

+ +
+ +
+
+ {/if} + + + {#if !status} + + {:else if status.pacman_error} + + {:else if status.pacman.length === 0} + + {:else} +
+ {#each status.pacman as pkg (pkg.name)} +
+ {pkg.name} + {pkg.current} → {pkg.latest} +
+ {/each} +
+ {/if} +
+ + +
+
+ + + {#if !status} + + {:else if status.bakery_error} + + {:else if status.bakery.length === 0} + + {:else} +
+ {#each status.bakery as pkg (pkg.name)} +
+ {pkg.name} + {pkg.current ? `${pkg.current} → ` : ""}{pkg.latest} + +
+ {/each} +
+ {/if} +
+ +
+
+ + + {#if !status} + + {:else if status.firmware.length === 0} + + {:else} +
+ {#each status.firmware as dev (dev.name)} +
+ {dev.name} + {dev.version} +
+ {/each} +
+ {/if} +
+ + +
+
+ + + + + + +
+ + diff --git a/frontend/src/lib/views/Vpn.svelte b/frontend/src/lib/views/Vpn.svelte new file mode 100644 index 0000000..7bf4102 --- /dev/null +++ b/frontend/src/lib/views/Vpn.svelte @@ -0,0 +1,148 @@ + + + + + {#if !status} + + {:else if status.error} + + {:else if status.connections.length === 0} + + {:else} +
+ {#each status.connections as c (c.name)} +
+
+ {c.name} + {c.kind}{c.active ? " · connected" : ""}{c.autoconnect ? " · autoconnect" : ""} +
+ {#if c.active} + + {:else} + + {/if} +
+ {/each} +
+ {/if} + + {#if message}{/if} +
+ + + + + +
+ + diff --git a/frontend/src/lib/views/registry.ts b/frontend/src/lib/views/registry.ts index ee0e5e4..6a87bbf 100644 --- a/frontend/src/lib/views/registry.ts +++ b/frontend/src/lib/views/registry.ts @@ -30,6 +30,16 @@ import Breadlock from "./Breadlock.svelte"; import Breadshot from "./Breadshot.svelte"; import Breadmon from "./Breadmon.svelte"; import Breadhelp from "./Breadhelp.svelte"; +import Updates from "./Updates.svelte"; +import Printing from "./Printing.svelte"; +import Vpn from "./Vpn.svelte"; +import NightLight from "./NightLight.svelte"; +import InputMethod from "./InputMethod.svelte"; +import Accessibility from "./Accessibility.svelte"; +import Defaults from "./Defaults.svelte"; +import Channel from "./Channel.svelte"; +import Backup from "./Backup.svelte"; +import Optional from "./Optional.svelte"; export const VIEWS: Record = { about: About, @@ -60,4 +70,14 @@ export const VIEWS: Record = { breadshot: Breadshot, breadmon: Breadmon, breadhelp: Breadhelp, + updates: Updates, + printing: Printing, + vpn: Vpn, + nightlight: NightLight, + ime: InputMethod, + accessibility: Accessibility, + defaults: Defaults, + channel: Channel, + backup: Backup, + optional: Optional, }; diff --git a/src/src/commands/a11y.rs b/src/src/commands/a11y.rs new file mode 100644 index 0000000..807bc1d --- /dev/null +++ b/src/src/commands/a11y.rs @@ -0,0 +1,116 @@ +//! Accessibility toggles that actually do something on Hyprland. +//! Orca launches. Magnifier is Hyprland `cursor:zoom_factor`. Sticky/slow +//! keys are not exposed by Hyprland or xkeyboard-config rules — the UI +//! must show that honestly rather than a dead switch. + +use serde::Serialize; +use tokio::process::Command; + +use super::util::{command_exists, fail_output, pacman_installed}; + +#[derive(Serialize)] +pub struct A11yStatus { + orca_installed: bool, + orca_running: bool, + zoom_factor: f64, + sticky_keys_supported: bool, + slow_keys_supported: bool, + kmag_installed: bool, + note: String, +} + +async fn orca_running() -> bool { + Command::new("pgrep") + .args(["-x", "orca"]) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) +} + +async fn read_zoom() -> f64 { + let output = Command::new("hyprctl") + .args(["getoption", "cursor:zoom_factor", "-j"]) + .output() + .await; + let Ok(output) = output else { + return 1.0; + }; + let Ok(v) = serde_json::from_slice::(&output.stdout) else { + return 1.0; + }; + v.get("float") + .and_then(|x| x.as_f64()) + .or_else(|| v.get("int").and_then(|x| x.as_i64()).map(|i| i as f64)) + .unwrap_or(1.0) +} + +#[tauri::command] +pub async fn get_a11y_status() -> A11yStatus { + A11yStatus { + orca_installed: command_exists("orca") || pacman_installed("orca"), + orca_running: orca_running().await, + zoom_factor: read_zoom().await, + sticky_keys_supported: false, + slow_keys_supported: false, + kmag_installed: command_exists("kmag") || pacman_installed("kmag"), + note: "Hyprland does not expose XKB AccessX (sticky keys / slow keys). Those toggles stay off because they would not do anything.".into(), + } +} + +#[tauri::command] +pub async fn set_cursor_zoom(factor: f64) -> Result { + let factor = factor.clamp(1.0, 8.0); + let value = format!("{factor:.2}"); + let output = Command::new("hyprctl") + .args(["keyword", "cursor:zoom_factor", &value]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(factor) + } else { + Err(fail_output(&output, "hyprctl keyword cursor:zoom_factor")) + } +} + +#[tauri::command] +pub async fn set_orca_running(running: bool) -> Result<(), String> { + if running { + if !command_exists("orca") { + return Err("orca is not installed".into()); + } + std::process::Command::new("orca") + .arg("--replace") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| format!("couldn't start orca: {e}"))?; + Ok(()) + } else { + let _ = Command::new("pkill").args(["-x", "orca"]).status().await; + Ok(()) + } +} + +#[tauri::command] +pub fn open_kmag() -> Result<(), String> { + if !command_exists("kmag") { + return Err("kmag is not installed".into()); + } + std::process::Command::new("kmag") + .spawn() + .map_err(|e| format!("couldn't start kmag: {e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + #[test] + fn zoom_clamp_bounds() { + let f = 0.2_f64.clamp(1.0, 8.0); + assert_eq!(f, 1.0); + assert_eq!(12.0_f64.clamp(1.0, 8.0), 8.0); + } +} diff --git a/src/src/commands/backup.rs b/src/src/commands/backup.rs new file mode 100644 index 0000000..19a6a13 --- /dev/null +++ b/src/src/commands/backup.rs @@ -0,0 +1,358 @@ +//! restic backups of `$HOME`. Repo path + password live in +//! `~/.config/bos-settings/backup.toml` (0600). The password is write-only +//! to the webview — empty on save keeps the stored secret. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use tauri::AppHandle; +use tokio::process::Command; + +use super::config; +use super::streaming; +use super::util::{self, command_exists, fail_output}; + +fn backup_toml() -> PathBuf { + util::bos_settings_dir().join("backup.toml") +} + +#[derive(Clone)] +pub struct BackupSecrets { + pub repo: String, + pub password: Option, +} + +impl BackupSecrets { + fn empty() -> Self { + Self { + repo: String::new(), + password: None, + } + } +} + +pub fn load_secrets() -> BackupSecrets { + load_secrets_from(&backup_toml()) +} + +fn load_secrets_from(path: &Path) -> BackupSecrets { + let Ok(text) = std::fs::read_to_string(path) else { + return BackupSecrets::empty(); + }; + let doc = text.parse::().unwrap_or_default(); + BackupSecrets { + repo: config::get_str(&doc, &["repo"]).unwrap_or_default(), + password: config::get_str(&doc, &["password"]).filter(|s| !s.is_empty()), + } +} + +fn save_secrets_to(path: &Path, repo: &str, password: Option<&str>) -> Result<(), String> { + let existing = load_secrets_from(path); + let password = match password.map(str::trim).filter(|s| !s.is_empty()) { + Some(p) => Some(p.to_string()), + None => existing.password, + }; + let mut doc = toml_edit::DocumentMut::new(); + config::set_str(&mut doc, &["repo"], repo.trim()); + if let Some(p) = password.as_deref() { + config::set_str(&mut doc, &["password"], p); + } + util::write_secure(path, &doc.to_string()) +} + +#[derive(Serialize)] +pub struct BackupStatus { + restic_installed: bool, + repo: String, + has_password: bool, + snapshots: Vec, + error: Option, +} + +#[derive(Serialize, Clone)] +pub struct ResticSnapshot { + id: String, + time: String, + paths: Vec, +} + +#[tauri::command] +pub fn get_backup_config() -> BackupStatus { + let s = load_secrets(); + BackupStatus { + restic_installed: command_exists("restic"), + repo: s.repo, + has_password: s.password.is_some(), + snapshots: Vec::new(), + error: None, + } +} + +#[derive(Deserialize)] +pub struct SaveBackupInput { + repo: String, + #[serde(default)] + password: Option, +} + +#[tauri::command] +pub fn save_backup_config(input: SaveBackupInput) -> Result<(), String> { + if !valid_repo(&input.repo) { + return Err("repo must be an absolute path or sftp:user@host:path".into()); + } + save_secrets_to(&backup_toml(), &input.repo, input.password.as_deref()) +} + +pub fn valid_repo(repo: &str) -> bool { + let repo = repo.trim(); + if repo.is_empty() || repo.len() > 512 || repo.contains('\n') || repo.contains('\0') { + return false; + } + if let Some(rest) = repo.strip_prefix("sftp:") { + return !rest.is_empty() && rest.contains('@') && rest.contains(':') && !rest.contains(' '); + } + std::path::Path::new(repo).is_absolute() +} + +fn require_ready() -> Result { + if !command_exists("restic") { + return Err("restic is not installed".into()); + } + let s = load_secrets(); + if !valid_repo(&s.repo) { + return Err("set a repository path first".into()); + } + if s.password.is_none() { + return Err("set a repository password first".into()); + } + Ok(s) +} + +fn restic_args<'a>(repo: &'a str, extra: &'a [&'a str]) -> Vec<&'a str> { + let mut args = vec!["--repo", repo]; + args.extend_from_slice(extra); + args +} + +#[tauri::command] +pub async fn restic_init(app: AppHandle, session_id: String) -> bool { + let Ok(s) = require_ready() else { + streaming::emit_line( + &app, + &session_id, + "Error: configure repo and password first", + ); + return false; + }; + let password = s.password.clone().unwrap_or_default(); + let extra = ["init"]; + let args = restic_args(&s.repo, &extra); + streaming::run_hardcoded_env( + app, + session_id, + "restic", + &args, + &[("RESTIC_PASSWORD", password)], + ) + .await +} + +fn exclude_args(home: &str) -> Vec { + let extras = [ + ".cache", + ".local/share/Trash", + ".local/share/Steam", + ".npm", + ".cargo/registry", + ".cargo/git", + ".rustup", + ".var/app", + ]; + let mut args = vec![ + "--exclude-caches".into(), + "--exclude".into(), + "node_modules".into(), + "--exclude".into(), + "target".into(), + "--exclude".into(), + ".git".into(), + ]; + for rel in extras { + args.push("--exclude".into()); + args.push(format!("{home}/{rel}")); + } + args +} + +#[tauri::command] +pub async fn restic_backup(app: AppHandle, session_id: String) -> bool { + let s = match require_ready() { + Ok(s) => s, + Err(e) => { + streaming::emit_line(&app, &session_id, &format!("Error: {e}")); + return false; + } + }; + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + let password = s.password.clone().unwrap_or_default(); + let excludes = exclude_args(&home); + let mut args = vec!["--repo".to_string(), s.repo.clone()]; + args.extend(excludes); + args.push("backup".into()); + args.push(home); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + streaming::run_hardcoded_env( + app, + session_id, + "restic", + &arg_refs, + &[("RESTIC_PASSWORD", password)], + ) + .await +} + +#[tauri::command] +pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot: String) -> bool { + let s = match require_ready() { + Ok(s) => s, + Err(e) => { + streaming::emit_line(&app, &session_id, &format!("Error: {e}")); + return false; + } + }; + let snap = snapshot.trim(); + if !valid_snapshot_id(snap) { + streaming::emit_line(&app, &session_id, "Error: invalid snapshot id"); + return false; + } + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + let password = s.password.clone().unwrap_or_default(); + let extra = ["restore", snap, "--target", home.as_str(), "--dry-run"]; + let args = restic_args(&s.repo, &extra); + streaming::run_hardcoded_env( + app, + session_id, + "restic", + &args, + &[("RESTIC_PASSWORD", password)], + ) + .await +} + +fn valid_snapshot_id(id: &str) -> bool { + if id == "latest" { + return true; + } + let bytes = id.as_bytes(); + !bytes.is_empty() && bytes.len() <= 64 && bytes.iter().all(|b| b.is_ascii_hexdigit()) +} + +#[tauri::command] +pub async fn list_restic_snapshots() -> Result, String> { + let s = require_ready()?; + let password = s.password.clone().unwrap_or_default(); + let output = Command::new("restic") + .args(["--repo", &s.repo, "snapshots", "--json"]) + .env("RESTIC_PASSWORD", password) + .output() + .await + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(fail_output(&output, "restic snapshots")); + } + parse_snapshots(&output.stdout) +} + +fn parse_snapshots(bytes: &[u8]) -> Result, String> { + let v: serde_json::Value = + serde_json::from_slice(bytes).map_err(|e| format!("restic json: {e}"))?; + let Some(arr) = v.as_array() else { + return Ok(Vec::new()); + }; + Ok(arr + .iter() + .filter_map(|s| { + let id = s + .get("short_id") + .or_else(|| s.get("id")) + .and_then(|x| x.as_str())? + .to_string(); + let time = s + .get("time") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let paths = s + .get("paths") + .and_then(|x| x.as_array()) + .map(|a| { + a.iter() + .filter_map(|p| p.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + Some(ResticSnapshot { id, time, paths }) + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repo_accepts_abs_and_sftp() { + assert!(valid_repo("/mnt/backup/bos")); + assert!(valid_repo("sftp:user@host:/backups/bos")); + assert!(!valid_repo("relative/path")); + assert!(!valid_repo("sftp:nocolon")); + assert!(!valid_repo("sftp:user host:/x")); + assert!(!valid_repo("")); + } + + #[test] + fn snapshot_id_hex_or_latest() { + assert!(valid_snapshot_id("latest")); + assert!(valid_snapshot_id("a1b2c3d4")); + assert!(!valid_snapshot_id("../x")); + assert!(!valid_snapshot_id("latest;rm")); + } + + #[test] + fn write_secure_is_0600_and_keeps_password() { + let dir = std::env::temp_dir().join(format!( + "bos-settings-backup-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("backup.toml"); + save_secrets_to(&path, "/tmp/repo", Some("hunter2")).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("hunter2")); + assert!(text.contains("/tmp/repo")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "backup.toml must be 0600, got {mode:o}"); + } + save_secrets_to(&path, "/tmp/repo2", Some("")).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("hunter2"), "empty password keeps secret"); + assert!(text.contains("/tmp/repo2")); + let loaded = load_secrets_from(&path); + assert_eq!(loaded.password.as_deref(), Some("hunter2")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn parse_restic_json() { + let json = br#"[{"short_id":"abc123","time":"2026-08-15T01:00:00Z","paths":["/home/a"]}]"#; + let v = parse_snapshots(json).unwrap(); + assert_eq!(v[0].id, "abc123"); + assert_eq!(v[0].paths[0], "/home/a"); + } +} diff --git a/src/src/commands/channel.rs b/src/src/commands/channel.rs new file mode 100644 index 0000000..bc33a17 --- /dev/null +++ b/src/src/commands/channel.rs @@ -0,0 +1,91 @@ +//! Bakery track (stable / beta / dev). Preference only — `bakery update +//! --all` afterwards actually installs the new track's builds. + +use serde::Serialize; +use tokio::process::Command; + +use super::util::{fail_output, strip_ansi}; + +const TRACKS: &[&str] = &["stable", "beta", "dev"]; + +#[derive(Serialize)] +pub struct BakeryTrack { + current: String, + tracks: Vec, +} + +fn parse_track_show(text: &str) -> String { + let text = strip_ansi(text); + for line in text.lines() { + let line = line.trim(); + let lower = line.to_ascii_lowercase(); + if let Some(rest) = lower.strip_prefix("current track:") { + let raw = line[line.len() - rest.len()..].trim(); + return raw.to_ascii_lowercase(); + } + if TRACKS.contains(&line) { + return line.to_string(); + } + } + let lower = text.to_ascii_lowercase(); + for track in TRACKS { + if lower.contains(track) { + return (*track).to_string(); + } + } + "stable".into() +} + +#[tauri::command] +pub async fn get_bakery_track() -> Result { + let output = Command::new("bakery") + .args(["track", "show"]) + .output() + .await + .map_err(|e| format!("couldn't run bakery: {e}"))?; + if !output.status.success() { + return Err(fail_output(&output, "bakery track show")); + } + let text = String::from_utf8_lossy(&output.stdout); + Ok(BakeryTrack { + current: parse_track_show(&text), + tracks: TRACKS.iter().map(|s| (*s).to_string()).collect(), + }) +} + +#[tauri::command] +pub async fn set_bakery_track(track: String) -> Result { + let track = track.trim().to_ascii_lowercase(); + if !TRACKS.contains(&track.as_str()) { + return Err(format!( + "unknown track '{track}' — expected stable, beta, or dev" + )); + } + let output = Command::new("bakery") + .args(["track", "set", &track]) + .output() + .await + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(fail_output(&output, "bakery track set")); + } + get_bakery_track().await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_current_track_line() { + assert_eq!(parse_track_show("current track: dev\n"), "dev"); + assert_eq!(parse_track_show("current track: stable"), "stable"); + assert_eq!(parse_track_show("beta"), "beta"); + } + + #[test] + fn rejects_unknown_in_set_guard() { + assert!(!TRACKS.contains(&"nightly")); + assert!(TRACKS.contains(&"stable")); + } +} diff --git a/src/src/commands/defaults.rs b/src/src/commands/defaults.rs new file mode 100644 index 0000000..b6386a4 --- /dev/null +++ b/src/src/commands/defaults.rs @@ -0,0 +1,431 @@ +//! Default applications via `~/.config/mimeapps.list`. Categories cover +//! the associations BOS already ships in skel (browser, files, images, +//! PDF, editor) plus a terminal entry. + +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; + +use serde::Serialize; + +use super::config; + +const CATEGORIES: &[(&str, &[&str])] = &[ + ( + "browser", + &[ + "x-scheme-handler/http", + "x-scheme-handler/https", + "text/html", + ], + ), + ("files", &["inode/directory"]), + ("terminal", &["x-scheme-handler/terminal"]), + ( + "image", + &[ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "image/svg+xml", + ], + ), + ("pdf", &["application/pdf"]), + ("editor", &["text/plain", "text/markdown"]), +]; + +#[derive(Serialize, Clone)] +pub struct DesktopApp { + id: String, + name: String, +} + +#[derive(Serialize)] +pub struct DefaultsStatus { + path: String, + current: HashMap, + options: HashMap>, +} + +fn mimeapps_path() -> PathBuf { + config::config_dir().join("mimeapps.list") +} + +fn xdg_terminals_path() -> PathBuf { + config::config_dir().join("xdg-terminals.list") +} + +fn applications_dirs() -> Vec { + let mut dirs = vec![ + PathBuf::from("/usr/share/applications"), + PathBuf::from("/usr/local/share/applications"), + ]; + if let Ok(home) = std::env::var("HOME") { + dirs.push(PathBuf::from(home).join(".local/share/applications")); + } + dirs +} + +#[derive(Clone)] +struct DesktopMeta { + id: String, + name: String, + mimes: Vec, + terminal: bool, +} + +fn parse_desktop(id: &str, text: &str) -> Option { + let mut in_entry = false; + let mut name = String::new(); + let mut mimes = Vec::new(); + let mut terminal = false; + let mut hidden = false; + for line in text.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_entry = line.eq_ignore_ascii_case("[Desktop Entry]"); + continue; + } + if !in_entry { + continue; + } + if let Some(v) = line.strip_prefix("Name=") { + if name.is_empty() { + name = v.to_string(); + } + } else if let Some(v) = line.strip_prefix("MimeType=") { + mimes = v + .split(';') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + } else if let Some(v) = line.strip_prefix("Categories=") { + terminal |= v.split(';').any(|c| c.trim() == "TerminalEmulator"); + } else if line == "Hidden=true" || line == "NoDisplay=true" { + hidden = true; + } + } + if hidden || name.is_empty() { + return None; + } + Some(DesktopMeta { + id: id.to_string(), + name, + mimes, + terminal, + }) +} + +fn scan_desktops() -> Vec { + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for dir in applications_dirs() { + let Ok(entries) = std::fs::read_dir(dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("desktop") { + continue; + } + let Some(id) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !seen.insert(id.to_string()) { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + if let Some(meta) = parse_desktop(id, &text) { + out.push(meta); + } + } + } + out.sort_by_key(|a| a.name.to_lowercase()); + out +} + +fn parse_default_applications(text: &str) -> BTreeMap { + let mut map = BTreeMap::new(); + let mut in_defaults = false; + for line in text.lines() { + let t = line.trim(); + if t.starts_with('[') { + in_defaults = t.eq_ignore_ascii_case("[Default Applications]"); + continue; + } + if !in_defaults || t.is_empty() || t.starts_with('#') { + continue; + } + if let Some((k, v)) = t.split_once('=') { + let desktop = v.split(';').next().unwrap_or("").trim(); + if !desktop.is_empty() { + map.insert(k.trim().to_string(), desktop.to_string()); + } + } + } + map +} + +fn current_for_category(defaults: &BTreeMap, mimes: &[&str]) -> String { + for mime in mimes { + if let Some(v) = defaults.get(*mime) { + return v.clone(); + } + } + String::new() +} + +fn options_for( + apps: &[DesktopMeta], + category: &str, + mimes: &[&str], + current: &str, +) -> Vec { + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for app in apps { + let matches = if category == "terminal" { + app.terminal || app.mimes.iter().any(|m| mimes.contains(&m.as_str())) + } else { + app.mimes.iter().any(|m| mimes.contains(&m.as_str())) + }; + if matches && seen.insert(app.id.clone()) { + out.push(DesktopApp { + id: app.id.clone(), + name: app.name.clone(), + }); + } + } + if !current.is_empty() && !seen.contains(current) { + out.insert( + 0, + DesktopApp { + id: current.to_string(), + name: current.trim_end_matches(".desktop").to_string(), + }, + ); + } + out +} + +#[tauri::command] +pub fn get_default_apps() -> DefaultsStatus { + let path = mimeapps_path(); + let text = std::fs::read_to_string(&path).unwrap_or_default(); + let defaults = parse_default_applications(&text); + let apps = scan_desktops(); + let mut current = HashMap::new(); + let mut options = HashMap::new(); + for (cat, mimes) in CATEGORIES { + let cur = if *cat == "terminal" { + read_terminal_default(&defaults) + } else { + current_for_category(&defaults, mimes) + }; + options.insert((*cat).to_string(), options_for(&apps, cat, mimes, &cur)); + current.insert((*cat).to_string(), cur); + } + DefaultsStatus { + path: path.display().to_string(), + current, + options, + } +} + +fn read_terminal_default(defaults: &BTreeMap) -> String { + if let Ok(text) = std::fs::read_to_string(xdg_terminals_path()) { + if let Some(id) = text + .lines() + .map(str::trim) + .find(|l| !l.is_empty() && !l.starts_with('#')) + { + return id.to_string(); + } + } + current_for_category(defaults, &["x-scheme-handler/terminal"]) +} + +#[derive(serde::Deserialize)] +pub struct SaveDefaultsInput { + current: HashMap, +} + +#[tauri::command] +pub fn save_default_apps(input: SaveDefaultsInput) -> Result<(), String> { + let path = mimeapps_path(); + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + let mut replacements = BTreeMap::new(); + for (cat, mimes) in CATEGORIES { + let Some(desktop) = input.current.get(*cat).map(|s| s.trim()) else { + continue; + }; + if desktop.is_empty() { + continue; + } + if !valid_desktop_id(desktop) { + return Err(format!("invalid desktop id '{desktop}'")); + } + for mime in *mimes { + replacements.insert((*mime).to_string(), desktop.to_string()); + } + if *cat == "terminal" { + write_terminal_list(desktop)?; + } + } + let text = upsert_defaults(&existing, &replacements); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + config::atomic_write(&path, &text).map_err(|e| e.to_string()) +} + +fn write_terminal_list(desktop: &str) -> Result<(), String> { + let path = xdg_terminals_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + config::atomic_write(&path, &format!("{desktop}\n")).map_err(|e| e.to_string()) +} + +fn valid_desktop_id(id: &str) -> bool { + let bytes = id.as_bytes(); + bytes.ends_with(b".desktop") + && bytes.len() > ".desktop".len() + && bytes.len() <= 128 + && bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.' | b'+')) +} + +fn upsert_defaults(existing: &str, replacements: &BTreeMap) -> String { + if existing.trim().is_empty() { + let mut out = String::from("[Default Applications]\n"); + for (mime, desktop) in replacements { + out.push_str(&format!("{mime}={desktop}\n")); + } + return out; + } + let mut out = String::new(); + let mut in_defaults = false; + let mut seen = std::collections::HashSet::new(); + let mut wrote_header = false; + for line in existing.lines() { + let t = line.trim(); + if t.starts_with('[') { + if in_defaults { + for (mime, desktop) in replacements { + if seen.insert(mime.clone()) { + out.push_str(&format!("{mime}={desktop}\n")); + } + } + } + in_defaults = t.eq_ignore_ascii_case("[Default Applications]"); + if in_defaults { + wrote_header = true; + } + out.push_str(line); + out.push('\n'); + continue; + } + if in_defaults { + if let Some((k, _)) = t.split_once('=') { + let key = k.trim(); + if let Some(desktop) = replacements.get(key) { + out.push_str(&format!("{key}={desktop}\n")); + seen.insert(key.to_string()); + continue; + } + } + } + out.push_str(line); + out.push('\n'); + } + if in_defaults { + for (mime, desktop) in replacements { + if seen.insert(mime.clone()) { + out.push_str(&format!("{mime}={desktop}\n")); + } + } + } else if !wrote_header { + if !out.ends_with('\n') && !out.is_empty() { + out.push('\n'); + } + out.push_str("\n[Default Applications]\n"); + for (mime, desktop) in replacements { + out.push_str(&format!("{mime}={desktop}\n")); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_skel_defaults() { + let text = "\ +[Default Applications] +text/html=zen.desktop +x-scheme-handler/http=zen.desktop +inode/directory=org.gnome.Nautilus.desktop +"; + let map = parse_default_applications(text); + assert_eq!(map.get("text/html").unwrap(), "zen.desktop"); + assert_eq!( + current_for_category(&map, &["x-scheme-handler/http", "text/html"]), + "zen.desktop" + ); + } + + #[test] + fn upsert_replaces_only_named_keys() { + let existing = "\ +# keep +[Default Applications] +text/html=old.desktop +image/png=org.gnome.Loupe.desktop + +[Added Associations] +text/html=extra.desktop; +"; + let mut rep = BTreeMap::new(); + rep.insert("text/html".into(), "zen.desktop".into()); + rep.insert("x-scheme-handler/http".into(), "zen.desktop".into()); + let out = upsert_defaults(existing, &rep); + assert!(out.contains("# keep")); + assert!(out.contains("text/html=zen.desktop")); + assert!(out.contains("x-scheme-handler/http=zen.desktop")); + assert!(out.contains("image/png=org.gnome.Loupe.desktop")); + assert!(out.contains("[Added Associations]")); + assert!(out.contains("text/html=extra.desktop;")); + assert_eq!(out.matches("text/html=zen.desktop").count(), 1); + } + + #[test] + fn desktop_id_check() { + assert!(valid_desktop_id("zen.desktop")); + assert!(valid_desktop_id("org.gnome.Nautilus.desktop")); + assert!(!valid_desktop_id("zen")); + assert!(!valid_desktop_id("../evil.desktop")); + } + + #[test] + fn parse_desktop_skips_hidden() { + let hidden = parse_desktop( + "x.desktop", + "[Desktop Entry]\nName=X\nNoDisplay=true\nMimeType=text/plain;\n", + ); + assert!(hidden.is_none()); + let ok = parse_desktop( + "ed.desktop", + "[Desktop Entry]\nName=Editor\nMimeType=text/plain;\nCategories=Utility;\n", + ) + .unwrap(); + assert_eq!(ok.name, "Editor"); + assert!(ok.mimes.contains(&"text/plain".into())); + } +} diff --git a/src/src/commands/ime.rs b/src/src/commands/ime.rs new file mode 100644 index 0000000..d0c40e5 --- /dev/null +++ b/src/src/commands/ime.rs @@ -0,0 +1,177 @@ +//! fcitx5 input method for this session: environment.d + Hyprland env + +//! systemd --user / `fcitx5 -d`. Missing packages are offered via the +//! allowlisted pacman installer, not installed on page load. + +use serde::Serialize; +use tokio::process::Command; + +use super::config; +use super::util::{self, command_exists, pacman_installed}; + +const FRAGMENT: &str = "fcitx5.conf"; +const ENV_FILE: &str = "90-fcitx5.conf"; + +const ENV_LINES_SYSTEMD: &str = "\ +GTK_IM_MODULE=fcitx +QT_IM_MODULE=fcitx +XMODIFIERS=@im=fcitx +SDL_IM_MODULE=fcitx +"; + +const ENV_LINES_HYPR: &str = "\ +env = GTK_IM_MODULE,fcitx +env = QT_IM_MODULE,fcitx +env = XMODIFIERS,@im=fcitx +env = SDL_IM_MODULE,fcitx +exec-once = fcitx5 -d +"; + +#[derive(Serialize, Clone)] +pub struct ImePackage { + name: String, + installed: bool, +} + +#[derive(Serialize)] +pub struct ImeStatus { + enabled: bool, + running: bool, + packages: Vec, + error: Option, +} + +fn env_path() -> std::path::PathBuf { + config::config_dir().join("environment.d").join(ENV_FILE) +} + +fn wanted_packages() -> &'static [&'static str] { + &[ + "fcitx5", + "fcitx5-gtk", + "fcitx5-qt", + "fcitx5-configtool", + "fcitx5-chinese-addons", + ] +} + +fn packages_status() -> Vec { + wanted_packages() + .iter() + .map(|name| ImePackage { + name: (*name).to_string(), + installed: pacman_installed(name), + }) + .collect() +} + +fn env_file_present() -> bool { + env_path().is_file() +} + +async fn fcitx_running() -> bool { + Command::new("pgrep") + .args(["-x", "fcitx5"]) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) +} + +#[tauri::command] +pub async fn get_ime_status() -> ImeStatus { + ImeStatus { + enabled: env_file_present(), + running: fcitx_running().await, + packages: packages_status(), + error: None, + } +} + +#[tauri::command] +pub async fn set_ime_enabled(enabled: bool) -> Result { + if enabled { + enable_ime().await?; + } else { + disable_ime().await?; + } + Ok(ImeStatus { + enabled: env_file_present(), + running: fcitx_running().await, + packages: packages_status(), + error: None, + }) +} + +async fn enable_ime() -> Result<(), String> { + if !command_exists("fcitx5") { + return Err("fcitx5 is not installed".into()); + } + let env = env_path(); + if let Some(parent) = env.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + config::atomic_write(&env, ENV_LINES_SYSTEMD).map_err(|e| e.to_string())?; + + let hypr = util::hypr_dir().join(FRAGMENT); + std::fs::create_dir_all(util::hypr_dir()).map_err(|e| e.to_string())?; + config::atomic_write(&hypr, ENV_LINES_HYPR).map_err(|e| e.to_string())?; + util::ensure_hypr_source(FRAGMENT)?; + + let _ = Command::new("systemctl") + .args([ + "--user", + "import-environment", + "GTK_IM_MODULE", + "QT_IM_MODULE", + "XMODIFIERS", + "SDL_IM_MODULE", + ]) + .status() + .await; + + let enabled_unit = Command::new("systemctl") + .args(["--user", "enable", "--now", "fcitx5.service"]) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false); + if !enabled_unit && !fcitx_running().await { + std::process::Command::new("fcitx5") + .arg("-d") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| format!("couldn't start fcitx5: {e}"))?; + } + Ok(()) +} + +async fn disable_ime() -> Result<(), String> { + let _ = std::fs::remove_file(env_path()); + let _ = std::fs::remove_file(util::hypr_dir().join(FRAGMENT)); + util::remove_hypr_source(FRAGMENT)?; + let _ = Command::new("systemctl") + .args(["--user", "disable", "--now", "fcitx5.service"]) + .status() + .await; + let _ = Command::new("pkill").args(["-x", "fcitx5"]).status().await; + Ok(()) +} + +#[tauri::command] +pub fn open_fcitx_config() { + let _ = std::process::Command::new("fcitx5-configtool").spawn(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn env_files_use_fcitx_module_name() { + assert!(ENV_LINES_SYSTEMD.contains("GTK_IM_MODULE=fcitx")); + assert!(ENV_LINES_HYPR.contains("XMODIFIERS,@im=fcitx")); + assert!(ENV_LINES_HYPR.contains("exec-once = fcitx5 -d")); + } +} diff --git a/src/src/commands/mod.rs b/src/src/commands/mod.rs index ccc0e2e..e1c5a29 100644 --- a/src/src/commands/mod.rs +++ b/src/src/commands/mod.rs @@ -1,7 +1,9 @@ +pub mod a11y; pub mod about; pub mod appearance; pub mod aur; pub mod autostart; +pub mod backup; pub mod bluetooth; pub mod bread; pub mod breadbar; @@ -15,18 +17,28 @@ pub mod breadpad; pub mod breadpaper; pub mod breadsearch; pub mod breadshot; +pub mod channel; pub mod config; pub mod datetime; +pub mod defaults; pub mod firewall; pub mod firmware; pub mod hyprland; +pub mod ime; pub mod keybinds; pub mod network; +pub mod nightlight; +pub mod nvidia; +pub mod optional; pub mod packages; pub mod power; +pub mod printing; pub mod service; pub mod snapshots; pub mod sound; pub mod streaming; pub mod theme; +pub mod updates; pub mod users; +pub mod util; +pub mod vpn; diff --git a/src/src/commands/nightlight.rs b/src/src/commands/nightlight.rs new file mode 100644 index 0000000..dc36bf5 --- /dev/null +++ b/src/src/commands/nightlight.rs @@ -0,0 +1,216 @@ +//! Night light via hyprsunset (Hyprland twilight IPC). The compositor +//! talks to a hyprsunset daemon socket; if the binary is missing we offer +//! a pacman install rather than pretending the toggle works. + +use serde::{Deserialize, Serialize}; +use tokio::process::Command; + +use super::config; +use super::util::{self, command_exists, fail_output}; + +const FRAGMENT: &str = "nightlight.conf"; +const DEFAULT_TEMP: u32 = 3500; +const MIN_TEMP: u32 = 2000; +const MAX_TEMP: u32 = 6500; + +#[derive(Serialize, Deserialize, Clone)] +pub struct NightlightConfig { + enabled: bool, + temperature: u32, +} + +impl Default for NightlightConfig { + fn default() -> Self { + Self { + enabled: false, + temperature: DEFAULT_TEMP, + } + } +} + +#[derive(Serialize)] +pub struct NightlightStatus { + installed: bool, + running: bool, + enabled: bool, + temperature: u32, + error: Option, +} + +fn persist_path() -> std::path::PathBuf { + util::bos_settings_dir().join("nightlight.toml") +} + +fn load_persist() -> NightlightConfig { + let Ok(text) = std::fs::read_to_string(persist_path()) else { + return NightlightConfig::default(); + }; + let doc = text.parse::().unwrap_or_default(); + NightlightConfig { + enabled: config::get_bool(&doc, &["enabled"]).unwrap_or(false), + temperature: config::get_i64(&doc, &["temperature"]) + .unwrap_or(DEFAULT_TEMP as i64) + .clamp(MIN_TEMP as i64, MAX_TEMP as i64) as u32, + } +} + +fn save_persist(cfg: &NightlightConfig) -> Result<(), String> { + let mut doc = toml_edit::DocumentMut::new(); + config::set_bool(&mut doc, &["enabled"], cfg.enabled); + config::set_i64(&mut doc, &["temperature"], cfg.temperature as i64); + let path = persist_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + config::atomic_write(&path, &doc.to_string()).map_err(|e| e.to_string()) +} + +fn clamp_temp(t: u32) -> u32 { + t.clamp(MIN_TEMP, MAX_TEMP) +} + +async fn hyprsunset_running() -> bool { + Command::new("hyprctl") + .args(["hyprsunset", "gamma", "1.0"]) + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +async fn start_daemon() -> Result<(), String> { + if hyprsunset_running().await { + return Ok(()); + } + if !command_exists("hyprsunset") { + return Err("hyprsunset is not installed".into()); + } + std::process::Command::new("hyprsunset") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| format!("couldn't start hyprsunset: {e}"))?; + for _ in 0..15 { + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + if hyprsunset_running().await { + return Ok(()); + } + } + Err("hyprsunset started but Hyprland twilight socket never came up".into()) +} + +async fn apply_temperature(temp: u32) -> Result<(), String> { + start_daemon().await?; + let t = clamp_temp(temp).to_string(); + let output = Command::new("hyprctl") + .args(["hyprsunset", "temperature", &t]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(fail_output(&output, "hyprctl hyprsunset")) + } +} + +async fn apply_identity() -> Result<(), String> { + if !hyprsunset_running().await { + return Ok(()); + } + let output = Command::new("hyprctl") + .args(["hyprsunset", "identity"]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(fail_output(&output, "hyprctl hyprsunset")) + } +} + +fn write_autostart() -> Result<(), String> { + let path = util::hypr_dir().join(FRAGMENT); + std::fs::create_dir_all(util::hypr_dir()).map_err(|e| e.to_string())?; + config::atomic_write(&path, "exec-once = hyprsunset\n").map_err(|e| e.to_string())?; + util::ensure_hypr_source(FRAGMENT) +} + +fn clear_autostart() -> Result<(), String> { + let path = util::hypr_dir().join(FRAGMENT); + let _ = std::fs::remove_file(path); + util::remove_hypr_source(FRAGMENT) +} + +#[tauri::command] +pub async fn get_nightlight() -> NightlightStatus { + let persist = load_persist(); + let installed = command_exists("hyprsunset"); + let running = if installed { + hyprsunset_running().await + } else { + false + }; + NightlightStatus { + installed, + running, + enabled: persist.enabled && running, + temperature: persist.temperature, + error: None, + } +} + +#[tauri::command] +pub async fn set_nightlight(enabled: bool, temperature: u32) -> Result { + if !command_exists("hyprsunset") { + return Ok(NightlightStatus { + installed: false, + running: false, + enabled: false, + temperature: clamp_temp(temperature), + error: Some("hyprsunset is not installed".into()), + }); + } + let mut cfg = NightlightConfig { + enabled, + temperature: clamp_temp(temperature), + }; + let mut error = None; + if enabled { + if let Err(e) = apply_temperature(cfg.temperature).await { + error = Some(e); + cfg.enabled = false; + } else if let Err(e) = write_autostart() { + error = Some(e); + } + } else { + if let Err(e) = apply_identity().await { + error = Some(e); + } + if let Err(e) = clear_autostart() { + error = Some(error.unwrap_or(e)); + } + } + save_persist(&cfg)?; + Ok(NightlightStatus { + installed: true, + running: hyprsunset_running().await, + enabled: cfg.enabled, + temperature: cfg.temperature, + error, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn temp_clamps() { + assert_eq!(clamp_temp(100), MIN_TEMP); + assert_eq!(clamp_temp(9000), MAX_TEMP); + assert_eq!(clamp_temp(3500), 3500); + } +} diff --git a/src/src/commands/nvidia.rs b/src/src/commands/nvidia.rs new file mode 100644 index 0000000..cf59830 --- /dev/null +++ b/src/src/commands/nvidia.rs @@ -0,0 +1,106 @@ +//! NVIDIA driver offer. BOS writes a probe file when it sees a discrete +//! NVIDIA GPU; Settings only shows the card if that file exists and does +//! not install anything until the user clicks. + +use serde::Serialize; + +#[derive(Serialize, Clone)] +pub struct NvidiaOffer { + gpu: String, + reason: String, + packages: Vec, +} + +fn offer_paths() -> Vec { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + let state = std::path::Path::new(&home).join(".local/state/bos"); + vec![ + state.join("nvidia-offer.json"), + state.join("nvidia-probe.json"), + ] +} + +pub fn read_nvidia_offer() -> Option { + for path in offer_paths() { + if !path.is_file() { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + return Some(generic_offer()); + }; + if let Ok(v) = serde_json::from_str::(&text) { + if v.get("offer").and_then(|x| x.as_bool()) == Some(false) + || v.get("dismissed").and_then(|x| x.as_bool()) == Some(true) + { + return None; + } + let gpu = v + .get("gpu") + .or_else(|| v.get("name")) + .or_else(|| v.get("device")) + .and_then(|x| x.as_str()) + .unwrap_or("NVIDIA GPU") + .to_string(); + let reason = v + .get("reason") + .or_else(|| v.get("message")) + .and_then(|x| x.as_str()) + .unwrap_or("A discrete NVIDIA GPU was detected. The proprietary driver is not installed until you choose it.") + .to_string(); + let packages = v + .get("packages") + .and_then(|x| x.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| vec!["nvidia".into(), "nvidia-utils".into()]); + return Some(NvidiaOffer { + gpu, + reason, + packages, + }); + } + return Some(generic_offer()); + } + None +} + +fn generic_offer() -> NvidiaOffer { + NvidiaOffer { + gpu: "NVIDIA GPU".into(), + reason: "BOS found an NVIDIA device. Install the proprietary driver only if you want it — nouveau stays otherwise.".into(), + packages: vec!["nvidia".into(), "nvidia-utils".into()], + } +} + +#[tauri::command] +pub fn get_nvidia_offer() -> Option { + read_nvidia_offer() +} + +#[cfg(test)] +mod tests { + #[test] + fn missing_file_is_none() { + // This machine's real probe path is not something the unit test + // should depend on; the helper is covered via parse cases below. + let parsed = serde_json::from_str::("{\"offer\":false}").unwrap(); + assert_eq!(parsed["offer"], false); + } + + #[test] + fn dismissed_or_offer_false_hides() { + // Inlined copies of the hide conditions so a schema change is obvious. + let hide = |v: &str| { + let v: serde_json::Value = serde_json::from_str(v).unwrap(); + v.get("offer").and_then(|x| x.as_bool()) == Some(false) + || v.get("dismissed").and_then(|x| x.as_bool()) == Some(true) + }; + assert!(hide(r#"{"offer":false}"#)); + assert!(hide(r#"{"dismissed":true}"#)); + assert!(!hide(r#"{"gpu":"RTX 4060"}"#)); + } +} diff --git a/src/src/commands/optional.rs b/src/src/commands/optional.rs new file mode 100644 index 0000000..835ec8e --- /dev/null +++ b/src/src/commands/optional.rs @@ -0,0 +1,112 @@ +//! Curated optional software. Not an AUR dump — four explicit offers, +//! each installed through a typed command (bakery or allowlisted pacman). + +use serde::Serialize; +use tokio::process::Command; + +use super::packages::get_installed_packages; +use super::util::{command_exists, fail_output, pacman_installed}; + +#[derive(Serialize, Clone)] +pub struct OptionalItem { + id: String, + title: String, + detail: String, + installed: bool, + via: String, +} + +#[derive(Serialize)] +pub struct OptionalStatus { + items: Vec, + flathub: bool, +} + +fn bakery_has(name: &str) -> bool { + get_installed_packages().iter().any(|p| p.name == name) +} + +fn flathub_enabled() -> bool { + if !command_exists("flatpak") { + return false; + } + std::process::Command::new("flatpak") + .args(["remotes"]) + .output() + .ok() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .to_ascii_lowercase() + .contains("flathub") + }) + .unwrap_or(false) +} + +#[tauri::command] +pub fn get_optional_software() -> OptionalStatus { + let breadcast = bakery_has("breadcast") || command_exists("breadcast"); + let flatpak = pacman_installed("flatpak") || command_exists("flatpak"); + let office = pacman_installed("libreoffice-fresh") + && (pacman_installed("papers") || pacman_installed("evince")); + let steam = pacman_installed("steam") || command_exists("steam"); + OptionalStatus { + items: vec![ + OptionalItem { + id: "breadcast".into(), + title: "breadcast".into(), + detail: + "Optional bread-ecosystem app. Installed through bakery — it is not on the ISO." + .into(), + installed: breadcast, + via: "bakery".into(), + }, + OptionalItem { + id: "flatpak".into(), + title: "Flatpak + Flathub".into(), + detail: "Enables the Flatpak runtime and the Flathub user remote.".into(), + installed: flatpak && flathub_enabled(), + via: "pacman".into(), + }, + OptionalItem { + id: "office".into(), + title: "LibreOffice + PDF".into(), + detail: "libreoffice-fresh and papers (GNOME document viewer).".into(), + installed: office, + via: "pacman".into(), + }, + OptionalItem { + id: "steam".into(), + title: "Steam".into(), + detail: "Valve Steam from the multilib repo.".into(), + installed: steam, + via: "pacman".into(), + }, + ], + flathub: flathub_enabled(), + } +} + +/// User Flathub remote — no root. Flatpak itself is installed separately +/// via the allowlisted pacman command when missing. +#[tauri::command] +pub async fn enable_flathub() -> Result<(), String> { + if !command_exists("flatpak") { + return Err("flatpak is not installed".into()); + } + let output = Command::new("flatpak") + .args([ + "remote-add", + "--if-not-exists", + "--user", + "flathub", + "https://dl.flathub.org/repo/flathub.flatpakrepo", + ]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(fail_output(&output, "flatpak remote-add")) + } +} diff --git a/src/src/commands/packages.rs b/src/src/commands/packages.rs index 29e522b..53556fb 100644 --- a/src/src/commands/packages.rs +++ b/src/src/commands/packages.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; #[derive(Serialize, Clone)] pub struct InstalledPackage { - name: String, + pub name: String, version: String, } @@ -23,14 +23,19 @@ pub fn get_installed_packages() -> Vec { let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else { return Vec::new(); }; - let Ok(packages) = serde_json::from_value::>(packages) else { + let Ok(packages) = serde_json::from_value::>(packages) + else { return Vec::new(); }; let mut list: Vec = packages .into_iter() .map(|(name, val)| { - let version = val.get("version").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); + let version = val + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); InstalledPackage { name, version } }) .collect(); diff --git a/src/src/commands/printing.rs b/src/src/commands/printing.rs new file mode 100644 index 0000000..0f2918b --- /dev/null +++ b/src/src/commands/printing.rs @@ -0,0 +1,204 @@ +//! CUPS printers via lpstat / lpadmin. Adding a printer through the full +//! device wizard is `system-config-printer`; a simple IPP Everywhere queue +//! can be created here when the user has a URI. + +use serde::Serialize; +use tokio::process::Command; + +use super::util::{fail_output, valid_printer_name}; + +#[derive(Serialize, Clone)] +pub struct Printer { + name: String, + status: String, + enabled: bool, + is_default: bool, +} + +#[derive(Serialize)] +pub struct PrintingStatus { + printers: Vec, + default: Option, + cups_ok: bool, + error: Option, +} + +#[tauri::command] +pub async fn get_printers() -> PrintingStatus { + let output = match Command::new("lpstat").args(["-p", "-d"]).output().await { + Ok(o) => o, + Err(e) => { + return PrintingStatus { + printers: Vec::new(), + default: None, + cups_ok: false, + error: Some(format!("couldn't run lpstat: {e}")), + }; + } + }; + if !output.status.success() { + return PrintingStatus { + printers: Vec::new(), + default: None, + cups_ok: false, + error: Some(fail_output(&output, "lpstat")), + }; + } + let text = String::from_utf8_lossy(&output.stdout); + parse_lpstat(&text) +} + +fn parse_lpstat(text: &str) -> PrintingStatus { + let mut printers = Vec::new(); + let mut default = None; + for line in text.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("printer ") { + let mut parts = rest.splitn(2, ' '); + let name = parts.next().unwrap_or("").to_string(); + let rest = parts.next().unwrap_or(""); + if name.is_empty() { + continue; + } + let enabled = !rest.contains("disabled"); + let status = rest + .strip_prefix("is ") + .unwrap_or(rest) + .split(". ") + .next() + .unwrap_or(rest) + .trim() + .to_string(); + printers.push(Printer { + name, + status, + enabled, + is_default: false, + }); + } else if let Some(name) = line.strip_prefix("system default destination: ") { + default = Some(name.trim().to_string()); + } else if line == "no system default destination" { + default = None; + } + } + if let Some(def) = default.as_deref() { + for p in &mut printers { + p.is_default = p.name == def; + } + } + PrintingStatus { + printers, + default, + cups_ok: true, + error: None, + } +} + +#[tauri::command] +pub async fn set_default_printer(name: String) -> Result<(), String> { + if !valid_printer_name(&name) { + return Err(format!("invalid printer name '{name}'")); + } + let output = Command::new("lpadmin") + .args(["-d", &name]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + return Ok(()); + } + let output = Command::new("pkexec") + .args(["lpadmin", "-d", &name]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(fail_output(&output, "lpadmin")) + } +} + +#[tauri::command] +pub async fn add_ipp_printer(name: String, uri: String) -> Result<(), String> { + if !valid_printer_name(&name) { + return Err(format!("invalid printer name '{name}'")); + } + if !valid_printer_uri(&uri) { + return Err("URI must be ipp://, ipps://, socket://, usb://, or dnssd://".into()); + } + let args_owned = [ + "-p".into(), + name.clone(), + "-E".into(), + "-v".into(), + uri, + "-m".into(), + "everywhere".into(), + ]; + let output = Command::new("lpadmin") + .args(&args_owned) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + return Ok(()); + } + let mut pk = vec!["lpadmin".to_string()]; + pk.extend(args_owned); + let output = Command::new("pkexec") + .args(&pk) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(fail_output(&output, "lpadmin")) + } +} + +fn valid_printer_uri(uri: &str) -> bool { + let u = uri.trim(); + !u.is_empty() + && u.len() <= 512 + && !u.contains(char::is_whitespace) + && (u.starts_with("ipp://") + || u.starts_with("ipps://") + || u.starts_with("socket://") + || u.starts_with("usb://") + || u.starts_with("dnssd://")) +} + +#[tauri::command] +pub fn open_printer_settings() { + let _ = std::process::Command::new("system-config-printer").spawn(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lpstat_parses_idle_and_default() { + let text = "\ +printer Canon-TS6360a is idle. enabled since Mon 10 Aug 2026 +printer Hall is disabled since yesterday +system default destination: Canon-TS6360a +"; + let st = parse_lpstat(text); + assert_eq!(st.printers.len(), 2); + assert!(st.printers[0].is_default); + assert!(st.printers[0].enabled); + assert!(!st.printers[1].enabled); + assert_eq!(st.default.as_deref(), Some("Canon-TS6360a")); + } + + #[test] + fn uri_schemes() { + assert!(valid_printer_uri("ipp://192.168.1.5/ipp/print")); + assert!(valid_printer_uri("ipps://printer.local/ipp")); + assert!(!valid_printer_uri("http://evil")); + assert!(!valid_printer_uri("ipp://x y")); + } +} diff --git a/src/src/commands/streaming.rs b/src/src/commands/streaming.rs index 5b87c2b..3c683a3 100644 --- a/src/src/commands/streaming.rs +++ b/src/src/commands/streaming.rs @@ -15,20 +15,49 @@ use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; #[derive(Clone, Serialize)] -struct CmdOutputEvent { +pub(crate) struct CmdOutputEvent { session_id: String, line: String, } +pub(crate) fn emit_line(app: &AppHandle, session_id: &str, line: &str) { + let _ = app.emit( + "cmd-output", + CmdOutputEvent { + session_id: session_id.to_string(), + line: line.to_string(), + }, + ); +} + /// Runs a hardcoded `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. -async fn run_hardcoded(app: AppHandle, session_id: String, program: &str, args: &[&str]) -> bool { - let child = Command::new(program) - .args(args) +pub(crate) async fn run_hardcoded( + app: AppHandle, + session_id: String, + program: &str, + args: &[&str], +) -> bool { + run_hardcoded_env(app, session_id, program, args, &[]).await +} + +pub(crate) async fn run_hardcoded_env( + app: AppHandle, + session_id: String, + program: &str, + args: &[&str], + envs: &[(&str, String)], +) -> bool { + let mut cmd = Command::new(program); + cmd.args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .spawn(); + .kill_on_drop(true); + for (k, v) in envs { + cmd.env(k, v); + } + let child = cmd.spawn(); let mut child = match child { Ok(c) => c, Err(e) => { @@ -135,6 +164,35 @@ pub async fn fwupd_update(app: AppHandle, session_id: String) -> bool { run_hardcoded(app, session_id, "fwupdmgr", &["update", "-y"]).await } +#[tauri::command] +pub async fn bakery_install(app: AppHandle, session_id: String, name: String) -> bool { + if let Err(e) = super::util::allowed_bakery_install(&name) { + emit_line(&app, &session_id, &format!("Error: {e}")); + return false; + } + run_hardcoded(app, session_id, "bakery", &["-y", "install", &name]).await +} + +#[tauri::command] +pub async fn pacman_install(app: AppHandle, session_id: String, packages: Vec) -> bool { + let names = match super::util::allowed_pacman_packages(&packages) { + Ok(n) => n, + Err(e) => { + emit_line(&app, &session_id, &format!("Error: {e}")); + return false; + } + }; + let mut args: Vec = vec![ + "pacman".into(), + "-S".into(), + "--noconfirm".into(), + "--".into(), + ]; + args.extend(names); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + run_hardcoded(app, session_id, "pkexec", &refs).await +} + #[cfg(test)] mod tests { use super::valid_bakery_pkg; diff --git a/src/src/commands/updates.rs b/src/src/commands/updates.rs new file mode 100644 index 0000000..0b4da74 --- /dev/null +++ b/src/src/commands/updates.rs @@ -0,0 +1,184 @@ +//! Aggregated Updates page: pacman -Qu, bakery dry-run, fwupd devices. +//! Rollback is Snapshots / grub-btrfs — not `snapper rollback`. + +use serde::Serialize; +use tokio::process::Command; + +use super::firmware::{get_updatable_firmware, FwDevice}; +use super::nvidia::{read_nvidia_offer, NvidiaOffer}; +use super::util::strip_ansi; + +#[derive(Serialize, Clone)] +pub struct PendingUpdate { + name: String, + current: String, + latest: String, +} + +#[derive(Serialize)] +pub struct UpdatesStatus { + pacman: Vec, + pacman_error: Option, + bakery: Vec, + bakery_error: Option, + firmware: Vec, + nvidia: Option, +} + +#[tauri::command] +pub async fn get_updates_status() -> UpdatesStatus { + let (pacman, bakery, firmware) = tokio::join!( + list_pacman_upgrades(), + list_bakery_outdated(), + get_updatable_firmware() + ); + let (pacman, pacman_error) = match pacman { + Ok(v) => (v, None), + Err(e) => (Vec::new(), Some(e)), + }; + let (bakery, bakery_error) = match bakery { + Ok(v) => (v, None), + Err(e) => (Vec::new(), Some(e)), + }; + UpdatesStatus { + pacman, + pacman_error, + bakery, + bakery_error, + firmware, + nvidia: read_nvidia_offer(), + } +} + +async fn list_pacman_upgrades() -> Result, String> { + let output = Command::new("pacman") + .args(["-Qu"]) + .output() + .await + .map_err(|e| format!("couldn't run pacman: {e}"))?; + // pacman -Qu exits 1 when there is nothing to upgrade. + let text = String::from_utf8_lossy(&output.stdout); + Ok(parse_pacman_qu(&text)) +} + +fn parse_pacman_qu(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + // "name old -> new" — extra fields after new are ignored. + let mut parts = line.split_whitespace(); + let name = parts.next()?.to_string(); + let current = parts.next()?.to_string(); + let arrow = parts.next()?; + if arrow != "->" { + return None; + } + let latest = parts.next()?.to_string(); + Some(PendingUpdate { + name, + current, + latest, + }) + }) + .collect() +} + +async fn list_bakery_outdated() -> Result, String> { + let output = Command::new("bakery") + .args(["--dry-run", "update", "--all"]) + .output() + .await + .map_err(|e| format!("couldn't run bakery: {e}"))?; + let text = strip_ansi(&String::from_utf8_lossy(&output.stdout)); + let err = strip_ansi(&String::from_utf8_lossy(&output.stderr)); + let combined = format!("{text}\n{err}"); + Ok(parse_bakery_outdated(&combined)) +} + +/// bakery has no `outdated` subcommand. `--dry-run update --all` is the +/// CLI's own preview of what a track-aware update would change. +fn parse_bakery_outdated(text: &str) -> Vec { + let mut out = Vec::new(); + for raw in text.lines() { + let line = raw.trim(); + if let Some(pkg) = parse_would_update(line).or_else(|| parse_updating_arrow(line)) { + if !out.iter().any(|p: &PendingUpdate| p.name == pkg.name) { + out.push(pkg); + } + } + } + out +} + +fn parse_would_update(line: &str) -> Option { + // "dry-run: would update bakery to 0.7.3-dev.…" + // "Would update bakery 0.7.3-dev.…" + let lower = line.to_ascii_lowercase(); + let i = lower.find("would update")?; + let rest = line[i + "would update".len()..].trim(); + let rest = rest.strip_prefix(':').unwrap_or(rest).trim(); + let rest = rest.strip_prefix("to ").unwrap_or(rest); + let mut parts = rest.split_whitespace(); + let name = parts.next()?.to_string(); + let mut latest = parts.next().unwrap_or("").to_string(); + if latest.eq_ignore_ascii_case("to") { + latest = parts.next().unwrap_or("").to_string(); + } + if name.is_empty() { + return None; + } + Some(PendingUpdate { + name, + current: String::new(), + latest, + }) +} + +fn parse_updating_arrow(line: &str) -> Option { + // "updating bakery 0.7.2 → 0.7.3" + let line = line.strip_prefix("updating ")?; + let (name, rest) = line.split_once(' ')?; + let (current, latest) = rest.split_once('→')?; + Some(PendingUpdate { + name: name.trim().to_string(), + current: current.trim().to_string(), + latest: latest.trim().to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pacman_qu_parses_arrow_lines() { + let text = + "linux 6.15.1-1 -> 6.15.2-1\nextra-note\nbos-settings 0.8.0-1 -> 0.8.1-1 [ignored]\n"; + let v = parse_pacman_qu(text); + assert_eq!(v.len(), 2); + assert_eq!(v[0].name, "linux"); + assert_eq!(v[0].current, "6.15.1-1"); + assert_eq!(v[0].latest, "6.15.2-1"); + assert_eq!(v[1].name, "bos-settings"); + } + + #[test] + fn bakery_dry_run_parses_would_update_and_arrow() { + let text = "\ + · breadbar is already at 0.3.2 +updating bakery 0.7.2-dev.1 → 0.7.3-dev.2 + dry-run: would update bakery to 0.7.3-dev.2 +Would update breadcast 1.2.3 +1 updated, 14 already up to date +"; + let v = parse_bakery_outdated(text); + assert_eq!(v.len(), 2); + assert_eq!(v[0].name, "bakery"); + assert_eq!(v[0].latest, "0.7.3-dev.2"); + assert_eq!(v[1].name, "breadcast"); + assert_eq!(v[1].latest, "1.2.3"); + } +} diff --git a/src/src/commands/util.rs b/src/src/commands/util.rs new file mode 100644 index 0000000..1acb283 --- /dev/null +++ b/src/src/commands/util.rs @@ -0,0 +1,258 @@ +//! Shared helpers for the OS-panel commands: PATH lookups, tight name +//! checks, 0600 writes, and the Hyprland `source =` fragment convention. + +use std::path::{Path, PathBuf}; + +use super::config; + +/// Pacman packages these panels may install. A generic `pacman -S` runner +/// is an arbitrary-package primitive; every name must be on this list. +pub const PACMAN_ALLOWLIST: &[&str] = &[ + "hyprsunset", + "fcitx5", + "fcitx5-configtool", + "fcitx5-gtk", + "fcitx5-qt", + "fcitx5-im", + "fcitx5-chinese-addons", + "fcitx5-table-extra", + "orca", + "kmag", + "restic", + "flatpak", + "libreoffice-fresh", + "papers", + "evince", + "steam", + "nvidia", + "nvidia-utils", +]; + +/// Bakery packages these panels may `bakery install`. breadcast is optional +/// software and is not on the ISO; do not add breadarr. +pub const BAKERY_INSTALL_ALLOWLIST: &[&str] = &["breadcast"]; + +pub fn command_exists(name: &str) -> bool { + let Some(paths) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&paths).any(|dir| { + let candidate = dir.join(name); + candidate.is_file() + }) +} + +pub fn pacman_installed(pkg: &str) -> bool { + std::process::Command::new("pacman") + .args(["-Q", pkg]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Arch package / bakery name: starts alphanumeric, then `[A-Za-z0-9+._-]`. +pub fn valid_pkg_name(name: &str) -> bool { + let bytes = name.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 128 + && bytes[0].is_ascii_alphanumeric() + && bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'+' | b'.')) +} + +pub fn allowed_pacman_packages(names: &[String]) -> Result, String> { + if names.is_empty() { + return Err("no packages given".into()); + } + let mut out = Vec::with_capacity(names.len()); + for name in names { + if !valid_pkg_name(name) || !PACMAN_ALLOWLIST.contains(&name.as_str()) { + return Err(format!("refusing to install '{name}'")); + } + if !out.iter().any(|e| e == name) { + out.push(name.clone()); + } + } + Ok(out) +} + +pub fn allowed_bakery_install(name: &str) -> Result<(), String> { + if !valid_pkg_name(name) || !BAKERY_INSTALL_ALLOWLIST.contains(&name) { + return Err(format!("refusing to bakery-install '{name}'")); + } + Ok(()) +} + +pub fn bos_settings_dir() -> PathBuf { + config::config_dir().join("bos-settings") +} + +/// Atomic write with mode 0600 set on the new inode before/after replace, +/// matching breadcrumbs' `networks.toml` care. +pub fn write_secure(path: &Path, contents: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("creating {}: {e}", parent.display()))?; + } + bread_utils::atomic::write_atomic(path, contents, Some(0o600)) + .map_err(|e| format!("writing {}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +pub fn strip_ansi(s: &str) -> String { + let re = regex::Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").expect("ansi regex"); + re.replace_all(s, "").into_owned() +} + +pub fn fail_output(output: &std::process::Output, what: &str) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let msg = stderr.trim(); + if !msg.is_empty() { + return msg.to_string(); + } + let msg = stdout.trim(); + if !msg.is_empty() { + return msg.to_string(); + } + format!("{what} failed") +} + +pub fn hypr_dir() -> PathBuf { + config::config_dir().join("hypr") +} + +pub fn hyprland_conf() -> PathBuf { + hypr_dir().join("hyprland.conf") +} + +/// Ensure `hyprland.conf` sources `~/.config/hypr/{fragment}`. Appends a +/// single source line when missing; does not rewrite the rest of the file. +pub fn ensure_hypr_source(fragment: &str) -> Result<(), String> { + if !valid_fragment(fragment) { + return Err(format!("invalid hypr fragment '{fragment}'")); + } + let dir = hypr_dir(); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let path = hyprland_conf(); + let marker = format!("hypr/{fragment}"); + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + if existing.lines().any(|l| l.contains(&marker)) { + return Ok(()); + } + let mut text = existing; + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + text.push_str(&format!("source = ~/.config/hypr/{fragment}\n")); + config::atomic_write(&path, &text).map_err(|e| e.to_string()) +} + +pub fn remove_hypr_source(fragment: &str) -> Result<(), String> { + if !valid_fragment(fragment) { + return Err(format!("invalid hypr fragment '{fragment}'")); + } + let path = hyprland_conf(); + let Ok(existing) = std::fs::read_to_string(&path) else { + return Ok(()); + }; + let marker = format!("hypr/{fragment}"); + let filtered: String = + existing + .lines() + .filter(|l| !l.contains(&marker)) + .fold(String::new(), |mut acc, l| { + acc.push_str(l); + acc.push('\n'); + acc + }); + if filtered != existing { + config::atomic_write(&path, &filtered).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn valid_fragment(name: &str) -> bool { + let bytes = name.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 64 + && bytes[0].is_ascii_alphanumeric() + && bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.')) +} + +/// Connection / printer names: no flags, no newlines. Spaces are allowed +/// (NetworkManager connection ids often have them). +pub fn valid_nm_id(name: &str) -> bool { + let t = name.trim(); + !t.is_empty() + && t.len() <= 256 + && !t.starts_with('-') + && !t.contains('\n') + && !t.contains('\0') + && !t.contains(';') +} + +pub fn valid_printer_name(name: &str) -> bool { + let bytes = name.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 127 + && bytes[0].is_ascii_alphanumeric() + && bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkg_name_accepts_arch_names() { + assert!(valid_pkg_name("hyprsunset")); + assert!(valid_pkg_name("fcitx5-chinese-addons")); + assert!(valid_pkg_name("libreoffice-fresh")); + assert!(valid_pkg_name("nvidia-utils")); + } + + #[test] + fn pkg_name_rejects_flags() { + assert!(!valid_pkg_name("")); + assert!(!valid_pkg_name("-S")); + assert!(!valid_pkg_name("--noconfirm")); + assert!(!valid_pkg_name("foo;rm")); + assert!(!valid_pkg_name("foo bar")); + } + + #[test] + fn allowlist_rejects_unknown() { + assert!(allowed_pacman_packages(&["steam".into()]).is_ok()); + assert!(allowed_pacman_packages(&["evil".into()]).is_err()); + assert!(allowed_bakery_install("breadcast").is_ok()); + assert!(allowed_bakery_install("breadarr").is_err()); + } + + #[test] + fn nm_id_allows_spaces_not_flags() { + assert!(valid_nm_id("Home VPN")); + assert!(!valid_nm_id("-evil")); + assert!(!valid_nm_id("a\nb")); + assert!(!valid_nm_id("")); + } + + #[test] + fn printer_name_is_tight() { + assert!(valid_printer_name("Canon-TS6360a")); + assert!(!valid_printer_name("foo bar")); + assert!(!valid_printer_name("-d")); + } +} diff --git a/src/src/commands/vpn.rs b/src/src/commands/vpn.rs new file mode 100644 index 0000000..c09d039 --- /dev/null +++ b/src/src/commands/vpn.rs @@ -0,0 +1,179 @@ +//! NetworkManager VPN / WireGuard connections. breadcrumbs stays Wi-Fi +//! profiles; this panel only lists `vpn` and `wireguard` connection types. + +use serde::Serialize; +use tokio::process::Command; + +use super::util::{fail_output, valid_nm_id}; + +#[derive(Serialize, Clone)] +pub struct VpnConnection { + name: String, + kind: String, + active: bool, + autoconnect: bool, +} + +#[derive(Serialize)] +pub struct VpnStatus { + connections: Vec, + error: Option, +} + +#[tauri::command] +pub async fn get_vpn_connections() -> VpnStatus { + let output = match Command::new("nmcli") + .args([ + "-t", + "-f", + "NAME,TYPE,STATE,AUTOCONNECT", + "connection", + "show", + ]) + .output() + .await + { + Ok(o) => o, + Err(e) => { + return VpnStatus { + connections: Vec::new(), + error: Some(format!("couldn't run nmcli: {e}")), + }; + } + }; + if !output.status.success() { + return VpnStatus { + connections: Vec::new(), + error: Some(fail_output(&output, "nmcli")), + }; + } + let text = String::from_utf8_lossy(&output.stdout); + VpnStatus { + connections: parse_nm_connections(&text), + error: None, + } +} + +fn parse_nm_connections(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + // nmcli -t escapes ":" in names as "\:". + let cols = split_nmcli(line); + if cols.len() < 3 { + return None; + } + let kind = cols[1].as_str(); + if kind != "vpn" && kind != "wireguard" { + return None; + } + let state = cols[2].as_str(); + let autoconnect = cols.get(3).map(|s| s == "yes").unwrap_or(false); + Some(VpnConnection { + name: cols[0].clone(), + kind: kind.to_string(), + active: state == "activated" || state == "activating", + autoconnect, + }) + }) + .collect() +} + +fn split_nmcli(line: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(n) = chars.next() { + cur.push(n); + } + } else if c == ':' { + out.push(std::mem::take(&mut cur)); + } else { + cur.push(c); + } + } + out.push(cur); + out +} + +#[tauri::command] +pub async fn vpn_connect(name: String) -> Result<(), String> { + nmcli_con(&["connection", "up", "id", &checked_id(&name)?]).await +} + +#[tauri::command] +pub async fn vpn_disconnect(name: String) -> Result<(), String> { + nmcli_con(&["connection", "down", "id", &checked_id(&name)?]).await +} + +fn checked_id(name: &str) -> Result { + if !valid_nm_id(name) { + return Err(format!("invalid connection name '{name}'")); + } + Ok(name.trim().to_string()) +} + +async fn nmcli_con(args: &[&str]) -> Result<(), String> { + let output = Command::new("nmcli") + .args(args) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(fail_output(&output, "nmcli")) + } +} + +#[tauri::command] +pub async fn vpn_import(path: String) -> Result<(), String> { + let path = path.trim(); + if path.is_empty() || path.contains('\0') || path.contains('\n') { + return Err("invalid path".into()); + } + let p = std::path::Path::new(path); + if !p.is_absolute() || !p.is_file() { + return Err("pick an existing .conf or .ovpn file".into()); + } + let kind = match p + .extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_ascii_lowercase()) + .as_deref() + { + Some("ovpn") => "openvpn", + Some("conf") => "wireguard", + _ => return Err("import a WireGuard .conf or OpenVPN .ovpn file".into()), + }; + nmcli_con(&["connection", "import", "type", kind, "file", path]).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_wireguard_and_skips_wifi() { + let text = "\ +Home WG:wireguard:activated:yes +Office:vpn: +NetComm:802-11-wireless:activated +tailscale0:tun:activated:yes +"; + let v = parse_nm_connections(text); + assert_eq!(v.len(), 2); + assert_eq!(v[0].name, "Home WG"); + assert!(v[0].active); + assert_eq!(v[1].kind, "vpn"); + assert!(!v[1].active); + } + + #[test] + fn unescapes_colon_in_name() { + let text = r"Work\:VPN:vpn:activated:no"; + let v = parse_nm_connections(text); + assert_eq!(v[0].name, "Work:VPN"); + } +} diff --git a/src/src/lib.rs b/src/src/lib.rs index d022889..3fe5646 100644 --- a/src/src/lib.rs +++ b/src/src/lib.rs @@ -89,7 +89,9 @@ pub fn run() { commands::streaming::bakery_update, commands::streaming::bakery_list, commands::streaming::bakery_update_all, + commands::streaming::bakery_install, commands::streaming::pacman_system_update, + commands::streaming::pacman_install, commands::streaming::fwupd_refresh, commands::streaming::fwupd_update, commands::packages::get_installed_packages, @@ -111,6 +113,37 @@ pub fn run() { commands::breadshot::breadshot_region_clipboard, commands::breadmon::open_breadmon, commands::breadhelp::open_breadhelp, + commands::updates::get_updates_status, + commands::nvidia::get_nvidia_offer, + commands::printing::get_printers, + commands::printing::set_default_printer, + commands::printing::add_ipp_printer, + commands::printing::open_printer_settings, + commands::vpn::get_vpn_connections, + commands::vpn::vpn_connect, + commands::vpn::vpn_disconnect, + commands::vpn::vpn_import, + commands::nightlight::get_nightlight, + commands::nightlight::set_nightlight, + commands::ime::get_ime_status, + commands::ime::set_ime_enabled, + commands::ime::open_fcitx_config, + commands::a11y::get_a11y_status, + commands::a11y::set_cursor_zoom, + commands::a11y::set_orca_running, + commands::a11y::open_kmag, + commands::defaults::get_default_apps, + commands::defaults::save_default_apps, + commands::channel::get_bakery_track, + commands::channel::set_bakery_track, + commands::backup::get_backup_config, + commands::backup::save_backup_config, + commands::backup::restic_init, + commands::backup::restic_backup, + commands::backup::restic_restore_dry_run, + commands::backup::list_restic_snapshots, + commands::optional::get_optional_software, + commands::optional::enable_flathub, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/src/screenshot.rs b/src/src/screenshot.rs index c24b6dc..0e7388e 100644 --- a/src/src/screenshot.rs +++ b/src/src/screenshot.rs @@ -59,6 +59,16 @@ const KNOWN_VIEWS: &[&str] = &[ "aur", "firmware", "snapshots", + "updates", + "printing", + "vpn", + "nightlight", + "ime", + "accessibility", + "defaults", + "channel", + "backup", + "optional", "breadlock", "breadshot", "breadmon", From 0798fad697141f9c670ecccdf9d6ddd03b66a176 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:26:06 +0800 Subject: [PATCH 2/8] Adopt bread_utils::screenshot_cli for --screenshot flags Use the crate for pair validation and canvas defaults. Webview settle delays stay local; Tauri still does not have a GTK map signal. --- src/src/screenshot.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/src/screenshot.rs b/src/src/screenshot.rs index 0e7388e..6c8b963 100644 --- a/src/src/screenshot.rs +++ b/src/src/screenshot.rs @@ -23,7 +23,8 @@ //! `KNOWN_VIEWS`) — every one of them has a real registered component (see //! `frontend/src/lib/views/registry.ts`), no Placeholder fallbacks to skip. -use std::path::PathBuf; +use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH}; +use std::path::{Path, PathBuf}; use std::time::Duration; use tauri::Emitter; @@ -84,13 +85,13 @@ pub struct ScreenshotRequest { } /// `None` for a normal run. Exits the process with an error for an unknown -/// view, or if `--screenshot` was given without `--output` — before any -/// Tauri setup happens. +/// view, or if the `--screenshot` / `--output` pair is incomplete — before +/// any Tauri setup happens. pub fn parse(args: &[String]) -> Option { let mut view = None; let mut output = None; - let mut width = 1920u32; - let mut height = 1080u32; + let mut width = DEFAULT_WIDTH; + let mut height = DEFAULT_HEIGHT; let mut it = args.iter().skip(1); while let Some(arg) = it.next() { match arg.as_str() { @@ -109,6 +110,10 @@ pub fn parse(args: &[String]) -> Option { _ => {} } } + if let Err(e) = validate_pair(view.as_deref(), output.as_deref().map(Path::new)) { + eprintln!("bos-settings: {e}"); + std::process::exit(1); + } let view = view?; if !KNOWN_VIEWS.contains(&view.as_str()) { eprintln!( @@ -117,13 +122,9 @@ pub fn parse(args: &[String]) -> Option { ); std::process::exit(1); } - let Some(output) = output else { - eprintln!("bos-settings: --screenshot requires --output"); - std::process::exit(1); - }; Some(ScreenshotRequest { view, - output: output.into(), + output: output?.into(), width, height, }) From 3aad09c9efdffa3a206fe8519f3e0e5e00cae1d4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:48:25 +0800 Subject: [PATCH 3/8] Make rollback a GRUB snapshot reboot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates leads with “Boot a snapshot from GRUB (BOS snapshots)” and links to Snapshots. Snapshots is the list to pick in GRUB, not snapper rollback. --- frontend/src/lib/nav.ts | 3 +++ frontend/src/lib/views/Snapshots.svelte | 24 +++++++++++++++++++----- frontend/src/lib/views/Updates.svelte | 11 +++++++++-- frontend/src/routes/+page.svelte | 6 +++++- 4 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/nav.ts diff --git a/frontend/src/lib/nav.ts b/frontend/src/lib/nav.ts new file mode 100644 index 0000000..bced4f0 --- /dev/null +++ b/frontend/src/lib/nav.ts @@ -0,0 +1,3 @@ +/** Sidebar page switch. Set from +page.svelte; views call it to jump. */ +export type Navigate = (page: string) => void; +export const NAVIGATE_KEY = "bos-settings-navigate"; diff --git a/frontend/src/lib/views/Snapshots.svelte b/frontend/src/lib/views/Snapshots.svelte index 8687a00..6834326 100644 --- a/frontend/src/lib/views/Snapshots.svelte +++ b/frontend/src/lib/views/Snapshots.svelte @@ -42,7 +42,7 @@ if (!selected) return; if ( confirm( - `Boot into snapshot #${selected}? Snapshots on BOS are booted directly from the GRUB menu (under "BOS snapshots"), not rolled back in place. Reboot now and pick this snapshot there.`, + `Reboot to pick snapshot #${selected} in GRUB? BOS boots snapshots from the GRUB “BOS snapshots” submenu. This does not run snapper rollback.`, ) ) { invoke("reboot_system"); @@ -62,9 +62,10 @@ +
@@ -75,6 +76,11 @@ {:else if snapshots.length === 0} {:else} + {#each snapshots as snap (snap.number)} - +
@@ -121,7 +127,15 @@ min-width: 0; } - .row:hover { + .row.header { + background: transparent; + cursor: default; + opacity: 0.55; + font-size: var(--font-size-secondary, 12px); + padding-top: 0; + } + + .row:hover:not(.header) { background-color: color-mix(in srgb, var(--surface), var(--on-surface) 8%); } diff --git a/frontend/src/lib/views/Updates.svelte b/frontend/src/lib/views/Updates.svelte index 4b48203..ec4c865 100644 --- a/frontend/src/lib/views/Updates.svelte +++ b/frontend/src/lib/views/Updates.svelte @@ -1,5 +1,5 @@ @@ -100,18 +138,29 @@ {/if} - +
-
{#if message}{/if}
+ + +
+ + +
+
+ {#if snapshots.length === 0} diff --git a/src/src/commands/backup.rs b/src/src/commands/backup.rs index 19a6a13..ed4bf12 100644 --- a/src/src/commands/backup.rs +++ b/src/src/commands/backup.rs @@ -11,6 +11,12 @@ use super::config; use super::streaming; use super::util::{self, command_exists, fail_output}; +fn home_dir() -> PathBuf { + std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/root")) +} + fn backup_toml() -> PathBuf { util::bos_settings_dir().join("backup.toml") } @@ -66,6 +72,7 @@ pub struct BackupStatus { has_password: bool, snapshots: Vec, error: Option, + home: String, } #[derive(Serialize, Clone)] @@ -84,6 +91,7 @@ pub fn get_backup_config() -> BackupStatus { has_password: s.password.is_some(), snapshots: Vec::new(), error: None, + home: home_dir().to_string_lossy().into_owned(), } } @@ -161,6 +169,7 @@ fn exclude_args(home: &str) -> Vec { ".cache", ".local/share/Trash", ".local/share/Steam", + ".local/share/containers", ".npm", ".cargo/registry", ".cargo/git", @@ -210,8 +219,56 @@ pub async fn restic_backup(app: AppHandle, session_id: String) -> bool { .await } -#[tauri::command] -pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot: String) -> bool { +/// `~/bos-restore-`. Never `$HOME` itself — restore writes into a new +/// directory so a bad snapshot cannot clobber the live home. +pub fn default_restore_dir(snapshot: &str) -> PathBuf { + home_dir().join(format!("bos-restore-{snapshot}")) +} + +fn normalize_abs(path: &Path) -> PathBuf { + path.components().collect() +} + +/// Absolute path, not `$HOME` and not `/`. Empty target means the default. +pub fn valid_restore_target(path: &Path) -> bool { + if !path.is_absolute() { + return false; + } + let s = path.to_string_lossy(); + if s.is_empty() || s.len() > 512 || s.contains('\n') || s.contains('\0') { + return false; + } + let normalized = normalize_abs(path); + if normalized == PathBuf::from("/") { + return false; + } + normalized != normalize_abs(&home_dir()) +} + +fn resolve_restore_target(snapshot: &str, target: Option<&str>) -> Result { + if !valid_snapshot_id(snapshot) { + return Err("invalid snapshot id".into()); + } + let dest = match target.map(str::trim).filter(|s| !s.is_empty()) { + Some(t) => PathBuf::from(t), + None => default_restore_dir(snapshot), + }; + if !valid_restore_target(&dest) { + return Err( + "restore target must be an absolute path that is not $HOME (default is ~/bos-restore-)" + .into(), + ); + } + Ok(dest) +} + +async fn run_restic_restore( + app: AppHandle, + session_id: String, + snapshot: String, + target: Option, + dry_run: bool, +) -> bool { let s = match require_ready() { Ok(s) => s, Err(e) => { @@ -220,14 +277,38 @@ pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot } }; let snap = snapshot.trim(); - if !valid_snapshot_id(snap) { - streaming::emit_line(&app, &session_id, "Error: invalid snapshot id"); - return false; - } - let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + let dest = match resolve_restore_target(snap, target.as_deref()) { + Ok(p) => p, + Err(e) => { + streaming::emit_line(&app, &session_id, &format!("Error: {e}")); + return false; + } + }; + let dest_s = dest.to_string_lossy().into_owned(); let password = s.password.clone().unwrap_or_default(); - let extra = ["restore", snap, "--target", home.as_str(), "--dry-run"]; - let args = restic_args(&s.repo, &extra); + let mut extra = vec![ + "restore".to_string(), + snap.to_string(), + "--target".into(), + dest_s.clone(), + ]; + if dry_run { + extra.push("--dry-run".into()); + } + streaming::emit_line( + &app, + &session_id, + &format!( + "{} {snap} → {dest_s}", + if dry_run { + "Dry-run restore" + } else { + "Restoring" + } + ), + ); + let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect(); + let args = restic_args(&s.repo, &extra_refs); streaming::run_hardcoded_env( app, session_id, @@ -238,6 +319,26 @@ pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot .await } +#[tauri::command] +pub async fn restic_restore_dry_run( + app: AppHandle, + session_id: String, + snapshot: String, + target: Option, +) -> bool { + run_restic_restore(app, session_id, snapshot, target, true).await +} + +#[tauri::command] +pub async fn restic_restore( + app: AppHandle, + session_id: String, + snapshot: String, + target: Option, +) -> bool { + run_restic_restore(app, session_id, snapshot, target, false).await +} + fn valid_snapshot_id(id: &str) -> bool { if id == "latest" { return true; @@ -355,4 +456,33 @@ mod tests { assert_eq!(v[0].id, "abc123"); assert_eq!(v[0].paths[0], "/home/a"); } + + #[test] + fn restore_defaults_to_bos_restore_id_not_home() { + let dest = default_restore_dir("a1b2c3d4"); + let home = home_dir(); + assert_eq!(dest, home.join("bos-restore-a1b2c3d4")); + assert_ne!(dest, home); + assert!(valid_restore_target(&dest)); + assert!(!valid_restore_target(&home)); + assert!(!valid_restore_target(Path::new("/"))); + assert!(!valid_restore_target(Path::new("relative/path"))); + assert!(valid_restore_target(Path::new("/tmp/bos-restore-custom"))); + let resolved = resolve_restore_target("latest", None).unwrap(); + assert_eq!(resolved, home.join("bos-restore-latest")); + assert!(resolve_restore_target("latest", Some(home.to_str().unwrap())).is_err()); + } + + #[test] + fn exclude_covers_caches_and_containers() { + let args = exclude_args("/home/a"); + let joined = args.join(" "); + assert!(joined.contains("/home/a/.cache")); + assert!(joined.contains("/home/a/.local/share/Trash")); + assert!(joined.contains("/home/a/.local/share/Steam")); + assert!(joined.contains("/home/a/.local/share/containers")); + assert!(joined.contains("node_modules")); + assert!(joined.contains("target")); + assert!(joined.contains(".git")); + } } diff --git a/src/src/lib.rs b/src/src/lib.rs index da39441..a21d6a0 100644 --- a/src/src/lib.rs +++ b/src/src/lib.rs @@ -142,6 +142,7 @@ pub fn run() { commands::backup::restic_init, commands::backup::restic_backup, commands::backup::restic_restore_dry_run, + commands::backup::restic_restore, commands::backup::list_restic_snapshots, commands::optional::get_optional_software, commands::optional::enable_flathub, From a6cb245ae4cee37548b5afee2ed9cd09f1f681a1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:23:21 +0800 Subject: [PATCH 6/8] Load bread-theme CSS from the window's monitor palette Pin bread-theme to v0.7.4. --- src/Cargo.lock | 454 +++++++++++++++++++++++--------------- src/Cargo.toml | 2 +- src/src/commands/theme.rs | 25 ++- 3 files changed, 302 insertions(+), 179 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 020c388..1dfe71d 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -34,9 +34,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -169,13 +169,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -307,8 +307,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" dependencies = [ "dirs 5.0.1", "serde", @@ -410,9 +410,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -437,7 +437,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -452,9 +452,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.3.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "shlex", @@ -526,9 +526,9 @@ dependencies = [ [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "time", "version_check", @@ -701,6 +701,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "deranged" version = "0.5.8" @@ -797,13 +828,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -904,7 +935,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "vswhom", "winreg", ] @@ -971,11 +1002,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1027,9 +1057,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flate2" @@ -1065,13 +1095,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1100,24 +1130,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1126,9 +1156,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -1145,32 +1175,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -1406,9 +1436,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "gobject-sys" @@ -1521,9 +1551,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1541,9 +1571,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1643,9 +1673,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1657,9 +1687,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1670,9 +1700,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1684,16 +1714,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1704,15 +1735,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -1813,9 +1844,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is-docker" @@ -1865,6 +1896,59 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -1911,9 +1995,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -1955,9 +2039,9 @@ dependencies = [ [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -1999,9 +2083,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.188" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libdbus-sys" @@ -2024,9 +2108,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -2039,9 +2123,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -2129,7 +2213,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -2432,9 +2516,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" dependencies = [ "dunce", "is-wsl", @@ -2589,9 +2673,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plist" @@ -2647,10 +2731,25 @@ dependencies = [ ] [[package]] -name = "potential_utf" -version = "0.1.5" +name = "portable-atomic" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2792,7 +2891,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2812,7 +2911,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -2829,9 +2928,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2974,9 +3073,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -3070,7 +3169,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -3105,7 +3204,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -3128,9 +3227,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", "bs58", @@ -3138,8 +3237,9 @@ dependencies = [ "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -3148,9 +3248,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling", "proc-macro2", @@ -3368,9 +3468,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -3452,9 +3552,9 @@ dependencies = [ [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", @@ -3509,7 +3609,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tray-icon", "url", @@ -3560,7 +3660,7 @@ dependencies = [ "sha2", "syn 2.0.119", "tauri-utils", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "url", "uuid", @@ -3611,7 +3711,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", ] @@ -3634,8 +3734,8 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.19", - "toml 1.1.3+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "url", ] @@ -3655,7 +3755,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", "windows", "zbus", @@ -3679,7 +3779,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", "webkit2gtk", "webview2-com", @@ -3742,8 +3842,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.19", - "toml 1.1.3+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "url", "urlpattern", "uuid", @@ -3758,7 +3858,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] @@ -3794,11 +3894,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -3814,20 +3914,20 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -3855,9 +3955,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -3896,13 +3996,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -3947,9 +4047,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -4037,9 +4137,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -4134,9 +4234,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs 6.0.0", @@ -4150,7 +4250,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -4269,9 +4369,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4347,9 +4447,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -4360,9 +4460,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -4370,9 +4470,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4380,9 +4480,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -4393,9 +4493,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -4415,9 +4515,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -4425,9 +4525,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" dependencies = [ "phf", "phf_codegen", @@ -4510,7 +4610,7 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", "windows", "windows-core 0.61.2", ] @@ -5060,9 +5160,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" @@ -5097,7 +5197,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", "webkit2gtk", "webkit2gtk-sys", @@ -5154,9 +5254,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -5189,14 +5289,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zbus_names", "zvariant", "zvariant_utils", @@ -5213,6 +5313,15 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5236,9 +5345,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -5247,9 +5356,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -5258,13 +5367,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -5275,40 +5384,41 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" dependencies = [ "endi", "enumflags2", "serde", "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.5.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.119", + "syn 3.0.3", "winnow 1.0.4", ] diff --git a/src/Cargo.toml b/src/Cargo.toml index bbd9ae4..bb8b2d3 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -36,7 +36,7 @@ toml_edit = "0.22" tokio = { version = "1", features = ["process", "io-util", "time", "macros"] } notify = "7" regex = "1" -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml"] } anyhow = "1" diff --git a/src/src/commands/theme.rs b/src/src/commands/theme.rs index d9dcc91..1e5e4b0 100644 --- a/src/src/commands/theme.rs +++ b/src/src/commands/theme.rs @@ -11,17 +11,26 @@ 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() +pub fn get_theme_css(window: tauri::WebviewWindow) -> String { + render_theme_css(&palette_for_window(&window)) } -fn render_theme_css() -> String { - let palette = bread_theme::load_palette(); +fn palette_for_window(window: &tauri::WebviewWindow) -> bread_theme::Palette { + window + .current_monitor() + .ok() + .flatten() + .and_then(|m| m.name().map(|s| s.to_string())) + .map(|name| bread_theme::load_palette_for(&name)) + .unwrap_or_else(bread_theme::load_palette) +} + +fn render_theme_css(palette: &bread_theme::Palette) -> String { // bread-theme v0.7.1 exposes Palette + ink_on + tokens, but not the // later css_custom_properties / css_tokens helpers (those landed after // the tag). Emit the same :root custom-property names the Svelte app // already uses so a tag pin doesn't require a web-side rename. - format!("{}\n{}", css_custom_properties(&palette), css_tokens()) + format!("{}\n{}", css_custom_properties(palette), css_tokens()) } fn css_custom_properties(p: &bread_theme::Palette) -> String { @@ -103,7 +112,11 @@ pub fn watch_and_emit(app: &AppHandle) { 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()); + let css = app_for_watcher + .get_webview_window("main") + .map(|w| render_theme_css(&palette_for_window(&w))) + .unwrap_or_else(|| render_theme_css(&bread_theme::load_palette())); + let _ = app_for_watcher.emit("theme-changed", css); } }, notify::Config::default(), From 77b5a223d8b22cc9daeeb70f51302d4ca2ab59ab Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 14:09:46 +0800 Subject: [PATCH 7/8] Bump version to v0.8.2 --- src/Cargo.lock | 2 +- src/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 1dfe71d..bf1adc3 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -288,7 +288,7 @@ dependencies = [ [[package]] name = "bos-settings" -version = "0.8.0" +version = "0.8.2" dependencies = [ "anyhow", "bread-theme", diff --git a/src/Cargo.toml b/src/Cargo.toml index bb8b2d3..c74d506 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bos-settings" -version = "0.8.0" +version = "0.8.2" description = "System settings app for BOS (Bread Operating System)" authors = ["Breadway"] edition = "2021" From f7b114f77847b49078fc309bc8f2d9a06fbdb503 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:40:03 +0800 Subject: [PATCH 8/8] Harden privileged command operands against injection Validate chpasswd user:password fields, refuse deleting root or the current user, and pipe charge-threshold writes through tee stdin. Allowlist firewall rules, systemd units, AUR names, hostname, and timezone. Keep CalDAV passwords write-only like restic. --- src/capabilities/default.json | 1 - src/src/commands/about.rs | 85 +++++++++++++++---- src/src/commands/aur.rs | 29 +++++-- src/src/commands/breadpad.rs | 100 +++++++++++++++++++---- src/src/commands/datetime.rs | 55 ++++++++++++- src/src/commands/firewall.rs | 107 ++++++++++++++++++++++-- src/src/commands/power.rs | 90 +++++++++++++++++--- src/src/commands/service.rs | 54 ++++++++++-- src/src/commands/users.rs | 149 ++++++++++++++++++++++++++++------ src/src/commands/util.rs | 24 ++++++ 10 files changed, 605 insertions(+), 89 deletions(-) diff --git a/src/capabilities/default.json b/src/capabilities/default.json index 778bfb5..e895c6b 100644 --- a/src/capabilities/default.json +++ b/src/capabilities/default.json @@ -5,7 +5,6 @@ "windows": ["main"], "permissions": [ "core:default", - "opener:default", "dialog:default" ] } diff --git a/src/src/commands/about.rs b/src/src/commands/about.rs index 7ec9f56..b847a85 100644 --- a/src/src/commands/about.rs +++ b/src/src/commands/about.rs @@ -23,8 +23,10 @@ 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())) + s.lines().find_map(|l| { + l.strip_prefix("PRETTY_NAME=") + .map(|v| v.trim_matches('"').to_string()) + }) }) .unwrap_or_else(|| "BOS".to_string()) } @@ -49,11 +51,15 @@ 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())) + 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); + let cores = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(0); if cores > 0 { format!("{model} ({cores} threads)") } else { @@ -62,14 +68,12 @@ fn cpu() -> String { } 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::().ok()) - }); + 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::().ok()) + }); match kb { Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0), None => "unknown".to_string(), @@ -96,7 +100,11 @@ async fn gpu() -> String { } async fn disk_usage() -> String { - let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output().await else { + 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); @@ -136,11 +144,35 @@ pub async fn get_system_info() -> SystemInfo { } } +/// RFC 1123 labels (digit start allowed), no leading `-`. Linux static +/// hostnames are also capped at `HOST_NAME_MAX` (64). +fn valid_hostname(name: &str) -> bool { + let name = name.trim(); + if name.is_empty() || name.len() > 64 || name.starts_with('-') { + return false; + } + if name.contains('\n') || name.contains('\r') || name.contains('\0') { + return false; + } + name.split('.').all(valid_dns_label) +} + +fn valid_dns_label(label: &str) -> bool { + let b = label.as_bytes(); + if b.is_empty() || b.len() > 63 { + return false; + } + if !b[0].is_ascii_alphanumeric() || !b[b.len() - 1].is_ascii_alphanumeric() { + return false; + } + b.iter().all(|c| c.is_ascii_alphanumeric() || *c == b'-') +} + #[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()); + if !valid_hostname(name) { + return Err("invalid hostname".into()); } let output = Command::new("pkexec") .args(["hostnamectl", "set-hostname", name]) @@ -153,3 +185,24 @@ pub async fn set_hostname(name: String) -> Result<(), String> { Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hostname_rfc1123() { + assert!(valid_hostname("bos")); + assert!(valid_hostname("bos.local")); + assert!(valid_hostname("a1-b")); + assert!(valid_hostname("1host")); + assert!(!valid_hostname("")); + assert!(!valid_hostname("-bos")); + assert!(!valid_hostname("bos-")); + assert!(!valid_hostname("-foo.bar")); + assert!(!valid_hostname("foo_bar")); + assert!(!valid_hostname("bos\n-set-hostname evil")); + assert!(!valid_hostname("--help")); + assert!(!valid_hostname(&"a".repeat(65))); + } +} diff --git a/src/src/commands/aur.rs b/src/src/commands/aur.rs index 226af63..6c72b64 100644 --- a/src/src/commands/aur.rs +++ b/src/src/commands/aur.rs @@ -10,6 +10,8 @@ use serde::Serialize; +use super::util; + #[derive(Serialize, Clone)] pub struct AurResult { name: String, @@ -19,7 +21,11 @@ pub struct AurResult { #[tauri::command] pub async fn search_aur(query: String) -> Vec { - let Ok(output) = tokio::process::Command::new("yay").args(["-Ss", "--aur", &query]).output().await else { + 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); @@ -28,12 +34,18 @@ pub async fn search_aur(query: String) -> Vec { 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 Some(rest) = header.strip_prefix("aur/") else { + continue; + }; let mut parts = rest.split_whitespace(); let Some(name) = parts.next() else { continue }; let version = parts.next().unwrap_or("").to_string(); let description = lines.next().unwrap_or("").trim().to_string(); - results.push(AurResult { name: name.to_string(), version, description }); + results.push(AurResult { + name: name.to_string(), + version, + description, + }); if results.len() >= 50 { break; } @@ -42,6 +54,13 @@ pub async fn search_aur(query: String) -> Vec { } #[tauri::command] -pub fn install_aur_package(pkg: String) { - let _ = std::process::Command::new("kitty").args(["-e", "yay", "-S", &pkg]).spawn(); +pub fn install_aur_package(pkg: String) -> Result<(), String> { + if !util::valid_pkg_name(&pkg) { + return Err(format!("refusing to install '{pkg}'")); + } + std::process::Command::new("kitty") + .args(["-e", "yay", "-S", &pkg]) + .spawn() + .map_err(|e| e.to_string())?; + Ok(()) } diff --git a/src/src/commands/breadpad.rs b/src/src/commands/breadpad.rs index 5f0fb5d..19db123 100644 --- a/src/src/commands/breadpad.rs +++ b/src/src/commands/breadpad.rs @@ -35,22 +35,35 @@ pub struct BreadpadConfig { 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()), + 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), + 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_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), + 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(), + // Write-only to the webview, same as restic — never round-trip the secret. + calendar_password: String::new(), } } @@ -60,19 +73,76 @@ pub fn save_breadpad_config(cfg: BreadpadConfig) -> Result<(), String> { 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_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_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_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); + apply_calendar_password(&mut doc, &cfg.calendar_password); config::save_doc(&path, &doc).map_err(|e| e.to_string()) } + +/// Empty incoming password keeps the existing secret (PasswordField is write-only). +fn apply_calendar_password(doc: &mut toml_edit::DocumentMut, incoming: &str) { + if incoming.is_empty() { + return; + } + config::set_str(doc, &["calendar", "password"], incoming); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_password_keeps_existing_secret() { + let mut doc: toml_edit::DocumentMut = + "[calendar]\npassword = \"secret\"\n".parse().unwrap(); + apply_calendar_password(&mut doc, ""); + assert_eq!( + config::get_str(&doc, &["calendar", "password"]).as_deref(), + Some("secret") + ); + apply_calendar_password(&mut doc, "newpass"); + assert_eq!( + config::get_str(&doc, &["calendar", "password"]).as_deref(), + Some("newpass") + ); + } +} diff --git a/src/src/commands/datetime.rs b/src/src/commands/datetime.rs index 2e8660b..5413cc2 100644 --- a/src/src/commands/datetime.rs +++ b/src/src/commands/datetime.rs @@ -26,7 +26,12 @@ async fn list_timezones() -> Vec { .output() .await .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect()) + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .map(str::to_string) + .collect() + }) .unwrap_or_default() } @@ -60,10 +65,32 @@ pub async fn get_datetime_info() -> DateTimeInfo { } } +/// Reject flags, path traversal, and newlines before we ever exec. Charset +/// matches IANA names (`Area/City`, `UTC`, `Etc/GMT+6`). +fn timezone_looks_safe(tz: &str) -> bool { + let tz = tz.trim(); + if tz.is_empty() || tz.len() > 64 || tz.starts_with('-') { + return false; + } + if tz.contains('\n') || tz.contains('\r') || tz.contains('\0') || tz.contains("..") { + return false; + } + tz.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-')) +} + #[tauri::command] pub async fn set_timezone(tz: String) -> Result<(), String> { + let tz = tz.trim(); + if !timezone_looks_safe(tz) { + return Err("invalid timezone".into()); + } + let listed = list_timezones().await; + if !listed.is_empty() && !listed.iter().any(|t| t == tz) { + return Err("unknown timezone".into()); + } let output = Command::new("pkexec") - .args(["timedatectl", "set-timezone", &tz]) + .args(["timedatectl", "set-timezone", tz]) .output() .await .map_err(|e| e.to_string())?; @@ -77,6 +104,28 @@ pub async fn set_timezone(tz: String) -> Result<(), String> { #[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())?; + Command::new("pkexec") + .args(["timedatectl", "set-ntp", val]) + .status() + .await + .map_err(|e| e.to_string())?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn timezone_rejects_flags_and_traversal() { + assert!(timezone_looks_safe("UTC")); + assert!(timezone_looks_safe("America/New_York")); + assert!(timezone_looks_safe("Etc/GMT+6")); + assert!(!timezone_looks_safe("")); + assert!(!timezone_looks_safe("-UTC")); + assert!(!timezone_looks_safe("--help")); + assert!(!timezone_looks_safe("America/../UTC")); + assert!(!timezone_looks_safe("UTC\n--adjust")); + assert!(!timezone_looks_safe("UTC;reboot")); + } +} diff --git a/src/src/commands/firewall.rs b/src/src/commands/firewall.rs index 0841d7b..bfc6471 100644 --- a/src/src/commands/firewall.rs +++ b/src/src/commands/firewall.rs @@ -32,7 +32,9 @@ pub async fn get_firewall_status() -> Result { 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(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(), } @@ -41,7 +43,10 @@ pub async fn get_firewall_status() -> Result { }); } let text = String::from_utf8_lossy(&output.stdout); - let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active"); + let active = text + .lines() + .next() + .is_some_and(|l| l.trim() == "Status: active"); let rules = text .lines() .filter_map(|l| { @@ -51,7 +56,10 @@ pub async fn get_firewall_status() -> Result { } let (num, rest) = l.split_once(']')?; let number = num.trim_start_matches('[').trim().to_string(); - Some(FirewallRule { number, text: rest.trim().to_string() }) + Some(FirewallRule { + number, + text: rest.trim().to_string(), + }) }) .collect(); Ok(FirewallStatus { active, rules }) @@ -60,7 +68,11 @@ pub async fn get_firewall_status() -> Result { #[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())?; + let output = Command::new("pkexec") + .args(["ufw", "--force", verb]) + .output() + .await + .map_err(|e| e.to_string())?; if output.status.success() { Ok(()) } else { @@ -68,9 +80,54 @@ pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> { } } +/// Port, optional `/tcp`/`/udp`, or optional space-separated proto. No +/// service names, IPs, or flags — those become extra `ufw allow` operands. +fn valid_firewall_rule(rule: &str) -> bool { + let rule = rule.trim(); + if rule.is_empty() || rule.len() > 16 || rule.starts_with('-') { + return false; + } + if rule.contains('\n') || rule.contains('\r') || rule.contains('\0') { + return false; + } + let (port, proto) = if let Some((p, rest)) = rule.split_once('/') { + (p, Some(rest)) + } else if let Some((p, rest)) = rule.split_once(' ') { + (p, Some(rest.trim())) + } else { + (rule, None) + }; + let Ok(n) = port.parse::() else { + return false; + }; + if n == 0 { + return false; + } + match proto { + None => true, + Some(p) => p == "tcp" || p == "udp", + } +} + +fn valid_rule_number(number: &str) -> bool { + let t = number.trim(); + !t.is_empty() + && t.len() <= 8 + && t.bytes().all(|b| b.is_ascii_digit()) + && t.parse::().is_ok_and(|n| n > 0) +} + #[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())?; + let rule = rule.trim(); + if !valid_firewall_rule(rule) { + return Err("invalid firewall rule".into()); + } + let output = Command::new("pkexec") + .args(["ufw", "allow", rule]) + .output() + .await + .map_err(|e| e.to_string())?; if output.status.success() { Ok(()) } else { @@ -80,10 +137,48 @@ pub async fn add_firewall_rule(rule: String) -> Result<(), 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 !valid_rule_number(&number) { + return Err("invalid rule number".into()); + } + let output = Command::new("pkexec") + .args(["ufw", "--force", "delete", number.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()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn firewall_rule_is_port_and_optional_proto() { + assert!(valid_firewall_rule("22")); + assert!(valid_firewall_rule("8080/tcp")); + assert!(valid_firewall_rule("53/udp")); + assert!(valid_firewall_rule("80 tcp")); + assert!(!valid_firewall_rule("OpenSSH")); + assert!(!valid_firewall_rule("-f")); + assert!(!valid_firewall_rule("22;id")); + assert!(!valid_firewall_rule("22/tcp\nallow 23")); + assert!(!valid_firewall_rule("0")); + assert!(!valid_firewall_rule("65536")); + assert!(!valid_firewall_rule("22/all")); + } + + #[test] + fn firewall_delete_is_positive_int() { + assert!(valid_rule_number("1")); + assert!(valid_rule_number("12")); + assert!(!valid_rule_number("0")); + assert!(!valid_rule_number("-1")); + assert!(!valid_rule_number("1;2")); + assert!(!valid_rule_number("1\n2")); + assert!(!valid_rule_number("")); + } +} diff --git a/src/src/commands/power.rs b/src/src/commands/power.rs index 3d82def..d1d6820 100644 --- a/src/src/commands/power.rs +++ b/src/src/commands/power.rs @@ -7,13 +7,22 @@ use serde::Serialize; use tokio::process::Command; +use super::util; + async fn upower_device(kind: &str) -> Option { 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) + 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 { - let out = Command::new("upower").args(["-i", device]).output().await.ok()?; + 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)) @@ -39,12 +48,18 @@ async fn battery_summary() -> Vec<(String, String)> { if let Some(t) = t { rows.push(("Time remaining".to_string(), t)); } - let full: Option = upower_field(&bat, "energy-full").await.and_then(|v| v.split_whitespace().next()?.parse().ok()); - let design: Option = - upower_field(&bat, "energy-full-design").await.and_then(|v| v.split_whitespace().next()?.parse().ok()); + let full: Option = upower_field(&bat, "energy-full") + .await + .and_then(|v| v.split_whitespace().next()?.parse().ok()); + let design: Option = 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.push(( + "Battery health".to_string(), + format!("{:.0}% of design capacity", full / design * 100.0), + )); } } rows @@ -64,12 +79,19 @@ async fn power_source() -> String { async fn tlp_profile() -> Option { 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()) + 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 { 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) + 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 { @@ -98,7 +120,10 @@ fn charge_threshold_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)> } 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) + std::fs::read_to_string(path) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(100) } #[derive(Serialize)] @@ -130,16 +155,57 @@ pub async fn set_brightness(percent: i64) -> Result<(), String> { 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())?; + Command::new("brightnessctl") + .args(["--device", &device, "set", &pct]) + .status() + .await + .map_err(|e| e.to_string())?; Ok(()) } +fn charge_threshold_write(which: &str, percent: i64) -> Result<(String, i64), String> { + if which != "start" && which != "end" { + return Err("threshold must be start or end".into()); + } + Ok((which.to_string(), percent.clamp(0, 100))) +} + #[tauri::command] pub async fn set_charge_threshold(which: String, percent: i64) -> Result<(), String> { + let (which, percent) = charge_threshold_write(&which, percent)?; 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(()) + // GNU tee writes stdin to its path operands — the percent must be piped, + // not passed as a second path argument. + let input = format!("{percent}\n"); + if util::run_with_stdin(&["pkexec", "tee", &path.display().to_string()], &input).await { + Ok(()) + } else { + Err("Failed to set charge threshold".into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn charge_threshold_clamps_and_restricts_which() { + assert_eq!( + charge_threshold_write("start", 80).unwrap(), + ("start".into(), 80) + ); + assert_eq!( + charge_threshold_write("end", 150).unwrap(), + ("end".into(), 100) + ); + assert_eq!( + charge_threshold_write("start", -5).unwrap(), + ("start".into(), 0) + ); + assert!(charge_threshold_write("both", 50).is_err()); + assert!(charge_threshold_write("-start", 50).is_err()); + } } diff --git a/src/src/commands/service.rs b/src/src/commands/service.rs index c99fee4..17a1540 100644 --- a/src/src/commands/service.rs +++ b/src/src/commands/service.rs @@ -21,6 +21,19 @@ pub enum ServiceAction { Restart, } +/// Units the frontend already hardcodes in ServiceControl call sites. +const ALLOWED_UNITS: &[&str] = &[ + "breadd.service", + "breadclipd.service", + "breadcrumbs.service", + "breadmill.service", + "breadbox-sync.service", +]; + +fn allowed_unit(unit: &str) -> bool { + ALLOWED_UNITS.contains(&unit) +} + async fn systemctl_active(unit: &str) -> bool { Command::new("systemctl") .args(["--user", "is-active", "--quiet", unit]) @@ -40,15 +53,21 @@ async fn systemctl_enabled(unit: &str) -> bool { } #[tauri::command] -pub async fn get_service_status(unit: String) -> ServiceStatus { - ServiceStatus { +pub async fn get_service_status(unit: String) -> Result { + if !allowed_unit(&unit) { + return Err("unknown service".into()); + } + Ok(ServiceStatus { active: systemctl_active(&unit).await, enabled: systemctl_enabled(&unit).await, - } + }) } #[tauri::command] pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), String> { + if !allowed_unit(&unit) { + return Err("unknown service".into()); + } let verb = match action { ServiceAction::Start => "start", ServiceAction::Stop => "stop", @@ -70,8 +89,31 @@ pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), S /// 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") +pub fn open_logs(unit: String) -> Result<(), String> { + if !allowed_unit(&unit) { + return Err("unknown service".into()); + } + std::process::Command::new("kitty") .args(["-e", "journalctl", "--user", "-u", &unit, "-f"]) - .spawn(); + .spawn() + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn units_match_frontend_hardcoded_list() { + assert!(allowed_unit("breadd.service")); + assert!(allowed_unit("breadclipd.service")); + assert!(allowed_unit("breadcrumbs.service")); + assert!(allowed_unit("breadmill.service")); + assert!(allowed_unit("breadbox-sync.service")); + assert!(!allowed_unit("sshd.service")); + assert!(!allowed_unit("breadd.service;reboot")); + assert!(!allowed_unit("../sshd.service")); + assert!(!allowed_unit("-u sshd")); + } } diff --git a/src/src/commands/users.rs b/src/src/commands/users.rs index d26ff59..8b0db82 100644 --- a/src/src/commands/users.rs +++ b/src/src/commands/users.rs @@ -3,9 +3,10 @@ //! `pkexec`. use serde::Serialize; -use tokio::io::AsyncWriteExt; use tokio::process::Command; +use super::util; + #[derive(Serialize, Clone)] pub struct Account { username: String, @@ -26,10 +27,16 @@ fn list_accounts() -> Vec { 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") { + 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() }) + Some(Account { + username: f[0].to_string(), + full_name: f[4].split(',').next().unwrap_or("").to_string(), + }) }) .collect() } @@ -42,29 +49,69 @@ pub struct UsersInfo { #[tauri::command] pub fn get_users_info() -> UsersInfo { - UsersInfo { accounts: list_accounts(), current_user: std::env::var("USER").unwrap_or_default() } + 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 { +/// shadow-utils `USER_NAME_MAX` is 32; keep chpasswd/useradd operands inside it. +const USERNAME_MAX: usize = 32; + +/// `[a-z_][a-z0-9_-]*`, length-capped, no leading `-`. Also rejects `:`, +/// newlines, and other chpasswd field/line separators. +fn valid_username(name: &str) -> bool { + let bytes = name.as_bytes(); + if bytes.is_empty() || bytes.len() > USERNAME_MAX { 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) + let first = bytes[0]; + if first != b'_' && !first.is_ascii_lowercase() { + return false; + } + bytes[1..] + .iter() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(*b, b'_' | b'-')) +} + +/// chpasswd reads `user:password` lines — a `:`, `\n`, or `\r` in either +/// field injects extra passwd entries or shifts columns. +fn valid_chpasswd_password(password: &str) -> bool { + !password.is_empty() + && password.len() <= 512 + && !password.contains('\n') + && !password.contains('\r') + && !password.contains(':') + && !password.contains('\0') +} + +fn chpasswd_input(username: &str, password: &str) -> Result { + if !valid_username(username) { + return Err("invalid username".into()); + } + if !valid_chpasswd_password(password) { + return Err("invalid password".into()); + } + Ok(format!("{username}:{password}\n")) +} + +fn may_delete_user(username: &str, current: &str) -> Result<(), String> { + if !valid_username(username) { + return Err("invalid username".into()); + } + if username == "root" { + return Err("refusing to remove root".into()); + } + if !current.is_empty() && username == current { + return Err("refusing to remove the current user".into()); + } + Ok(()) } #[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 { + let input = chpasswd_input(&username, &password)?; + if util::run_with_stdin(&["pkexec", "chpasswd"], &input).await { Ok(()) } else { Err("Failed to change password".into()) @@ -73,7 +120,13 @@ pub async fn change_password(username: String, password: String) -> Result<(), S #[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())?; + let current = std::env::var("USER").unwrap_or_default(); + may_delete_user(&username, ¤t)?; + let output = Command::new("pkexec") + .args(["userdel", "-r", &username]) + .output() + .await + .map_err(|e| e.to_string())?; if output.status.success() { Ok(()) } else { @@ -83,22 +136,68 @@ pub async fn remove_user(username: String) -> Result<(), String> { #[tauri::command] pub async fn add_user(username: String, full_name: String, password: String) -> Result<(), String> { - let username = username.trim().to_string(); - let mut useradd_args = vec!["pkexec".to_string(), "useradd".to_string(), "-m".to_string(), "-s".to_string(), "/bin/bash".to_string()]; + let username = username.trim(); + let input = chpasswd_input(username, &password)?; + 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()); + useradd_args.push(username.to_string()); let args_ref: Vec<&str> = useradd_args.iter().map(String::as_str).collect(); - let output = Command::new(args_ref[0]).args(&args_ref[1..]).output().await.map_err(|e| e.to_string())?; + 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 { + if util::run_with_stdin(&["pkexec", "chpasswd"], &input).await { Ok(()) } else { Err("User created, but setting the password failed.".into()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chpasswd_rejects_newline_injection() { + assert!(chpasswd_input("alice", "pw\nroot:evil").is_err()); + assert!(chpasswd_input("alice\nroot", "pw").is_err()); + assert!(chpasswd_input("alice\rroot", "pw").is_err()); + assert!(chpasswd_input("alice", "pw\rroot:x").is_err()); + assert!(chpasswd_input("al:ice", "pw").is_err()); + assert!(chpasswd_input("alice", "p:w").is_err()); + assert_eq!(chpasswd_input("alice", "secret").unwrap(), "alice:secret\n"); + } + + #[test] + fn username_grammar() { + assert!(valid_username("alice")); + assert!(valid_username("_svc")); + assert!(valid_username("a1-b_c")); + assert!(!valid_username("")); + assert!(!valid_username("-alice")); + assert!(!valid_username("Alice")); + assert!(!valid_username("root user")); + assert!(!valid_username(&"a".repeat(USERNAME_MAX + 1))); + } + + #[test] + fn remove_user_refuses_root_and_self() { + assert!(may_delete_user("root", "alice").is_err()); + assert!(may_delete_user("alice", "alice").is_err()); + assert!(may_delete_user("root\n", "alice").is_err()); + assert!(may_delete_user("bob", "alice").is_ok()); + } +} diff --git a/src/src/commands/util.rs b/src/src/commands/util.rs index 1acb283..d7a40d6 100644 --- a/src/src/commands/util.rs +++ b/src/src/commands/util.rs @@ -90,6 +90,30 @@ pub fn bos_settings_dir() -> PathBuf { config::config_dir().join("bos-settings") } +/// Pipe `input` to a command's stdin (`pkexec` does not inherit a piped +/// stdin unless we set it). Used by chpasswd and `pkexec tee`. +pub async fn run_with_stdin(args: &[&str], input: &str) -> bool { + if args.is_empty() { + return false; + } + let Ok(mut child) = tokio::process::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() { + use tokio::io::AsyncWriteExt; + if stdin.write_all(input.as_bytes()).await.is_err() { + return false; + } + } + child.wait().await.map(|s| s.success()).unwrap_or(false) +} + /// Atomic write with mode 0600 set on the new inode before/after replace, /// matching breadcrumbs' `networks.toml` care. pub fn write_secure(path: &Path, contents: &str) -> Result<(), String> {