From cbac50683ea327398d474111389a0999b49b45a2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 21:47:00 +0800 Subject: [PATCH] Harden settings: split breadcrumbs secrets, typed exec, new panels Load and save Wi-Fi networks in networks.toml (0600) instead of writing PSKs back into breadcrumbs.toml. The password field is write-only. Replace the generic argv runner with typed bakery/pacman/fwupd commands and set a real Tauri CSP. Add Lock, Screenshots, Monitors, and Help panels. Pin bread-theme and bread-utils to bread-ecosystem v0.7.1. bread-screenshots is not on that tag, so --screenshot calls grim locally. bakery.toml lists webkitgtk-4.1 deps; README/CLAUDE.md match Tauri 2 + Svelte 5 and single-trunk main. --- .gitignore | 3 - CLAUDE.md | 33 ++ README.md | 17 +- bakery.toml | 2 +- .../src/lib/components/Placeholder.svelte | 2 +- frontend/src/lib/sidebar.ts | 12 +- frontend/src/lib/streaming.ts | 14 +- frontend/src/lib/views/Breadcrumbs.svelte | 37 +- frontend/src/lib/views/Breadhelp.svelte | 76 +++ frontend/src/lib/views/Breadlock.svelte | 94 ++++ frontend/src/lib/views/Breadmon.svelte | 34 ++ frontend/src/lib/views/Breadshot.svelte | 78 +++ frontend/src/lib/views/Firmware.svelte | 6 +- frontend/src/lib/views/Packages.svelte | 10 +- frontend/src/lib/views/registry.ts | 13 +- src/Cargo.lock | 27 +- src/Cargo.toml | 12 +- src/src/commands/breadcrumbs.rs | 469 ++++++++++++++++-- src/src/commands/breadhelp.rs | 10 + src/src/commands/breadlock.rs | 132 +++++ src/src/commands/breadmon.rs | 9 + src/src/commands/breadshot.rs | 139 ++++++ src/src/commands/config.rs | 28 +- src/src/commands/keybinds.rs | 91 +++- src/src/commands/mod.rs | 4 + src/src/commands/streaming.rs | 137 ++++- src/src/commands/theme.rs | 69 ++- src/src/lib.rs | 19 +- src/src/screenshot.rs | 37 +- src/tauri.conf.json | 2 +- 30 files changed, 1469 insertions(+), 147 deletions(-) create mode 100644 CLAUDE.md create mode 100644 frontend/src/lib/views/Breadhelp.svelte create mode 100644 frontend/src/lib/views/Breadlock.svelte create mode 100644 frontend/src/lib/views/Breadmon.svelte create mode 100644 frontend/src/lib/views/Breadshot.svelte create mode 100644 src/src/commands/breadhelp.rs create mode 100644 src/src/commands/breadlock.rs create mode 100644 src/src/commands/breadmon.rs create mode 100644 src/src/commands/breadshot.rs diff --git a/.gitignore b/.gitignore index 19b9179..45cd856 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,3 @@ logs/ # Claude Code local agent state .claude/ - -# Local hygiene notes (not for commit) -CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2c92bd6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,33 @@ +# CLAUDE.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. + +This repo is a **Tauri 2 + Svelte 5** settings app, bakery-distributed. It follows the branch/release workflow in `CONTRIBUTING.md` — read and follow it for any git, branch, or release work here (the single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, etc). Don't improvise a different workflow. The short version: there is one long-lived branch, `main` — no `dev` or `beta` branch exists. `main` auto-publishes a bakery **dev-track** build on every push. "Beta" and "stable" are both just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track build, push a plain `vX.Y.Z` tag to cut the signed stable release. "Freezing" for stabilization means pausing pushes to `main`, not moving a branch. + +This is not a GTK4 app. There is no `package.yml` pacman workflow here; bakery is the distribution channel. + +## Layout + +- `frontend/` — Svelte 5 + SvelteKit (static adapter) + TypeScript +- `src/` — Tauri 2 Rust crate (`bos-settings`). Commands live in `src/src/commands/`. +- Config edits are non-destructive (`toml_edit` / `bread_utils::tomlcfg`) except for JSON files that have no comments to preserve. + +## Remotes + +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push `origin` only; the github remote auto-mirrors. + +## CI + +- `dev-release.yml` — push to `main`. +- `rc-release.yml` — `vX.Y.Z-rc.N` tags. +- `release.yml` — other `v*` tags (signed stable). + +No build/lint/test CI runs on ordinary commits or PRs to `main` beyond the dev-track workflow above. + +## Don't + +- Don't commit `frontend/node_modules`. +- Don't embed credentials in remote URLs — SSH or a credential helper only. +- Don't write Wi-Fi passwords back into `breadcrumbs.toml`. Networks live in `~/.config/breadcrumbs/networks.toml` (0600). +- Don't expose a generic argv runner to the webview. Streaming updates are typed commands (`bakery_update`, `pacman_system_update`, `fwupd_*`). diff --git a/README.md b/README.md index 62c0620..a705ad7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,24 @@ # bos-settings -System settings app for [BOS (Bread Operating System)](https://git.breadway.dev/Breadway/bos) — GTK4, configures every bread\* app's config plus core system settings (network, sound, power, users, firewall, snapshots, packages, AUR, firmware, Hyprland display/appearance/autostart) non-destructively. +System settings app for [BOS (Bread Operating System)](https://git.breadway.dev/Breadway/bos) — Tauri 2 + Svelte 5. Configures every bread\* app's config plus core system settings (network, sound, power, users, firewall, snapshots, packages, AUR, firmware, Hyprland display/appearance/autostart) non-destructively. -Split out of the `bos` repo into its own repo so a bos-settings release doesn't require a BOS ISO release, and vice versa. Distributed via `bakery` — see `bread-ecosystem`'s `CONTRIBUTING.md` for the dev/beta/stable track workflow shared across the bread ecosystem. +Distributed via `bakery`. There is one long-lived branch, `main`; see `CONTRIBUTING.md` for the single-trunk / RC-tag release model shared across the bread ecosystem. ## Building +The Svelte frontend lives in `frontend/`, the Rust backend in `src/` (this repo's crate is not named `src-tauri`). `cargo tauri build` runs the frontend build hook; a plain `cargo build` does not. + ```bash -cargo build --release +cd frontend && npm ci && npm run build +cd ../src && cargo build --release +``` + +Dev (Vite + `cargo tauri dev`): + +```bash +cd src && cargo tauri dev ``` ## Packaging / releasing -Bump `Cargo.toml`'s version, tag `vX.Y.Z`, push the tag to both remotes — `.forgejo/workflows/release.yml` builds and publishes to `dl.breadway.dev` (bakery) automatically. Pushes to `dev`/`beta` publish a dev/beta-track build the same way, no tag needed. +Bump `src/Cargo.toml` (and `frontend/package.json`) version, then follow `CONTRIBUTING.md`: work lands on `main` via `feature/` / `fix/` branches (every push to `main` publishes a bakery **dev** build). Tag `vX.Y.Z-rc.N` for beta, `vX.Y.Z` for the signed stable release. Do not push to a `dev` branch — there isn't one. diff --git a/bakery.toml b/bakery.toml index e4af5f9..961a4f5 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,7 +1,7 @@ name = "bos-settings" description = "System settings app for Bread OS" binaries = ["bos-settings"] -system_deps = ["gtk4", "glib2", "hicolor-icon-theme"] +system_deps = ["webkitgtk-4.1", "gtk3", "libsoup3", "librsvg", "hicolor-icon-theme"] optional_system_deps = ["snapper"] bread_deps = [] license_file = "LICENSE" diff --git a/frontend/src/lib/components/Placeholder.svelte b/frontend/src/lib/components/Placeholder.svelte index 0f37632..d0d939a 100644 --- a/frontend/src/lib/components/Placeholder.svelte +++ b/frontend/src/lib/components/Placeholder.svelte @@ -3,7 +3,7 @@
-

"{page}" hasn't been migrated to Tauri yet.

+

Unknown page "{page}".

diff --git a/frontend/src/lib/views/Breadlock.svelte b/frontend/src/lib/views/Breadlock.svelte new file mode 100644 index 0000000..9abfb81 --- /dev/null +++ b/frontend/src/lib/views/Breadlock.svelte @@ -0,0 +1,94 @@ + + + + + + + + + + {#if cfg} + + + {#if cfg.background_mode === "image"} + + {/if} + + + + + + + + {/if} + + + + {#if examplePath} + + {:else} + + {/if} + + + + diff --git a/frontend/src/lib/views/Breadmon.svelte b/frontend/src/lib/views/Breadmon.svelte new file mode 100644 index 0000000..5002cc0 --- /dev/null +++ b/frontend/src/lib/views/Breadmon.svelte @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/views/Breadshot.svelte b/frontend/src/lib/views/Breadshot.svelte new file mode 100644 index 0000000..116ddbc --- /dev/null +++ b/frontend/src/lib/views/Breadshot.svelte @@ -0,0 +1,78 @@ + + + + + {#if binds && binds.length > 0} + {#each binds as b (`${b.shortcut}:${b.command}`)} + + {/each} + {:else} + + {/if} + + + + {#if cfg} + + + + + + + + + + {/if} + + + diff --git a/frontend/src/lib/views/Firmware.svelte b/frontend/src/lib/views/Firmware.svelte index 36c9fe9..b5317d7 100644 --- a/frontend/src/lib/views/Firmware.svelte +++ b/frontend/src/lib/views/Firmware.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/views/registry.ts b/frontend/src/lib/views/registry.ts index 9c9ed44..ee0e5e4 100644 --- a/frontend/src/lib/views/registry.ts +++ b/frontend/src/lib/views/registry.ts @@ -1,6 +1,5 @@ -// Maps a sidebar page id to its view component. Pages not yet migrated -// fall back to Placeholder (see +page.svelte) — this map only lists pages -// that actually have a real Tauri-backed view. +// Maps a sidebar page id to its view component. Every sidebar id has a +// real view — +page.svelte's Placeholder is only a safety net for typos. import type { Component } from "svelte"; import About from "./About.svelte"; @@ -27,6 +26,10 @@ import Packages from "./Packages.svelte"; import Aur from "./Aur.svelte"; import Firmware from "./Firmware.svelte"; import Snapshots from "./Snapshots.svelte"; +import Breadlock from "./Breadlock.svelte"; +import Breadshot from "./Breadshot.svelte"; +import Breadmon from "./Breadmon.svelte"; +import Breadhelp from "./Breadhelp.svelte"; export const VIEWS: Record = { about: About, @@ -53,4 +56,8 @@ export const VIEWS: Record = { aur: Aur, firmware: Firmware, snapshots: Snapshots, + breadlock: Breadlock, + breadshot: Breadshot, + breadmon: Breadmon, + breadhelp: Breadhelp, }; diff --git a/src/Cargo.lock b/src/Cargo.lock index ddc6b65..64cc1d8 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -291,9 +291,8 @@ name = "bos-settings" version = "0.8.0" dependencies = [ "anyhow", - "bread-screenshots", "bread-theme", - "bread-utils 0.3.1 (git+https://github.com/Breadway/bread-ecosystem?branch=main)", + "bread-utils", "notify", "regex", "serde", @@ -306,20 +305,10 @@ dependencies = [ "toml_edit 0.22.27", ] -[[package]] -name = "bread-screenshots" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" -dependencies = [ - "anyhow", - "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main)", - "tracing", -] - [[package]] name = "bread-theme" version = "0.3.1" -source = "git+https://github.com/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ "dirs 5.0.1", "serde", @@ -329,17 +318,7 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" -dependencies = [ - "dirs 5.0.1", - "serde", - "serde_json", -] - -[[package]] -name = "bread-utils" -version = "0.3.1" -source = "git+https://github.com/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ "dirs 5.0.1", "serde", diff --git a/src/Cargo.toml b/src/Cargo.toml index b4f9bf1..41dc987 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -36,15 +36,7 @@ toml_edit = "0.22" tokio = { version = "1", features = ["process", "io-util", "time", "macros"] } notify = "7" regex = "1" -# TODO(owner): switch to a tag-pinned git dependency once bread-theme cuts a -# release including css_custom_properties/css_tokens (added 2026-07-21 for -# this migration) — pinned to branch = "main" for now rather than a path -# dependency (which broke bakery/CI builds — no sibling bread-ecosystem -# checkout exists on the runner) since no tag has these functions yet. -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", branch = "main" } -bread-utils = { git = "https://github.com/Breadway/bread-ecosystem", branch = "main", features = ["toml"] } -# Capture primitives for `--screenshot` mode — see src/screenshot.rs. On -# "dev", not "main" like the two deps above: it doesn't exist on main yet. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main" } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["toml"] } anyhow = "1" diff --git a/src/src/commands/breadcrumbs.rs b/src/src/commands/breadcrumbs.rs index 3572f82..7ed5db5 100644 --- a/src/src/commands/breadcrumbs.rs +++ b/src/src/commands/breadcrumbs.rs @@ -1,24 +1,46 @@ //! breadcrumbs.toml — Wi-Fi profile state machine. Schema mirrors //! breadcrumbs/src/config.rs: -//! [settings] scalar tunables -//! [[networks]] saved networks (ssid / password / hidden) +//! [settings] scalar tunables (this file) //! [profiles.] per-location profile (networks, tailscale, …) -//! `[settings]` is edited in place; `networks`/`profiles` are rewritten from -//! their editors on save. Other keys/comments are preserved. +//! +//! Saved networks (SSID + optional local password) live in a *separate* +//! `networks.toml` (0600) next to breadcrumbs.toml. breadcrumbs v2 stores +//! them there so a file people hand-edit / dotfile does not also carry +//! plaintext Wi-Fi credentials. After the first successful connect, +//! breadcrumbs clears the local password and NetworkManager owns the +//! secret; `None` means "NM already has it" or "open network". +//! +//! `[settings]` is edited in place via toml_edit; `profiles` are rewritten +//! from their editor on save. `[[networks]]` is never written back into +//! breadcrumbs.toml — leftover inline blocks from pre-split configs are +//! read once (only if `networks.toml` is missing) and migrated on the +//! next save. Other keys/comments in breadcrumbs.toml are preserved. + +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; use super::config; -fn config_path() -> std::path::PathBuf { +fn settings_path() -> PathBuf { config::config_dir().join("breadcrumbs/breadcrumbs.toml") } -#[derive(Serialize, Deserialize, Clone, Default)] +fn networks_path() -> PathBuf { + config::config_dir().join("breadcrumbs/networks.toml") +} + +#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq, Eq)] pub struct Network { ssid: String, - password: String, + /// Write-only from the UI's point of view. `get_breadcrumbs_config` + /// never returns a stored PSK (always `None`). On save, `None` / empty + /// means "keep whatever is already in networks.toml, or omit — NM + /// remembers." A non-empty value is written only to networks.toml. + #[serde(default, skip_serializing_if = "Option::is_none")] + password: Option, + #[serde(default)] hidden: bool, } @@ -56,24 +78,120 @@ fn read_networks(doc: &DocumentMut) -> Vec { return Vec::new(); }; aot.iter() - .map(|t| Network { - ssid: t.get("ssid").and_then(Item::as_str).unwrap_or("").to_string(), - password: t.get("password").and_then(Item::as_str).unwrap_or("").to_string(), - hidden: t.get("hidden").and_then(Item::as_bool).unwrap_or(false), + .map(|t| { + let password = t + .get("password") + .and_then(Item::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + Network { + ssid: t + .get("ssid") + .and_then(Item::as_str) + .unwrap_or("") + .to_string(), + password, + hidden: t.get("hidden").and_then(Item::as_bool).unwrap_or(false), + } }) .collect() } -fn write_networks(doc: &mut DocumentMut, nets: &[Network]) { +fn networks_document(nets: &[Network]) -> DocumentMut { + let mut doc = DocumentMut::new(); let mut aot = ArrayOfTables::new(); for n in nets { + if n.ssid.trim().is_empty() { + continue; + } let mut t = Table::new(); t.insert("ssid", value(&n.ssid)); - t.insert("password", value(&n.password)); + if let Some(pw) = n + .password + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + t.insert("password", value(pw)); + } t.insert("hidden", value(n.hidden)); aot.push(t); } - doc.as_table_mut().insert("networks", Item::ArrayOfTables(aot)); + doc.as_table_mut() + .insert("networks", Item::ArrayOfTables(aot)); + doc +} + +/// Load saved networks. `networks.toml` wins when present; otherwise fall +/// back to a leftover inline `[[networks]]` block in breadcrumbs.toml so a +/// pre-split config still shows up until the next save migrates it. +fn load_networks(settings_doc: &DocumentMut, net_path: &Path) -> Vec { + if net_path.exists() { + let doc = config::load_doc(net_path); + return read_networks(&doc); + } + read_networks(settings_doc) +} + +fn redact_passwords(nets: Vec) -> Vec { + nets.into_iter() + .map(|n| Network { + password: None, + ..n + }) + .collect() +} + +fn incoming_password(n: &Network) -> Option { + n.password + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +/// Empty / omitted password from the UI means "keep the on-disk secret for +/// this SSID (if any), otherwise let NetworkManager remember." A typed +/// value replaces it. Matching is by SSID; a renamed SSID is a new +/// network and does not inherit the old password. +fn merge_network_passwords(incoming: Vec, existing: &[Network]) -> Vec { + incoming + .into_iter() + .filter(|n| !n.ssid.trim().is_empty()) + .map(|mut n| { + n.password = incoming_password(&n).or_else(|| { + existing + .iter() + .find(|e| e.ssid == n.ssid) + .and_then(|e| e.password.clone()) + }); + n + }) + .collect() +} + +/// Atomic write with mode 0600 set on the temp file *before* any bytes +/// land, so a secrets file is never briefly world-readable. Also re-applies +/// 0600 on the destination in case an older world-readable networks.toml +/// was being replaced (rename keeps the new inode's mode). +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(()) +} + +fn save_networks(path: &Path, nets: &[Network]) -> Result<(), String> { + write_secure(path, &networks_document(nets).to_string()) } fn read_profiles(doc: &DocumentMut) -> Vec { @@ -82,7 +200,11 @@ fn read_profiles(doc: &DocumentMut) -> Vec { }; let str_list = |item: Option<&Item>| -> Vec { item.and_then(Item::as_array) - .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) .unwrap_or_default() }; tbl.iter() @@ -92,10 +214,21 @@ fn read_profiles(doc: &DocumentMut) -> Vec { name: name.to_string(), networks: str_list(p.get("networks")), detect_ssids: str_list(p.get("detect_ssids")), - bootstrap: p.get("bootstrap").and_then(Item::as_str).unwrap_or("").to_string(), - exit_node: p.get("exit_node").and_then(Item::as_str).unwrap_or("").to_string(), + bootstrap: p + .get("bootstrap") + .and_then(Item::as_str) + .unwrap_or("") + .to_string(), + exit_node: p + .get("exit_node") + .and_then(Item::as_str) + .unwrap_or("") + .to_string(), tailscale: p.get("tailscale").and_then(Item::as_bool).unwrap_or(false), - include_all_known: p.get("include_all_known").and_then(Item::as_bool).unwrap_or(false), + include_all_known: p + .get("include_all_known") + .and_then(Item::as_bool) + .unwrap_or(false), }) }) .collect() @@ -132,26 +265,74 @@ fn write_profiles(doc: &mut DocumentMut, profiles: &[Profile]) { doc.as_table_mut().insert("profiles", Item::Table(tbl)); } -#[tauri::command] -pub fn get_breadcrumbs_config() -> BreadcrumbsConfig { - let doc = config::load_doc(&config_path()); +fn apply_settings(doc: &mut DocumentMut, settings: &Settings) { + config::set_str( + doc, + &["settings", "default_profile"], + &settings.default_profile, + ); + config::set_str(doc, &["settings", "dns"], &settings.dns); + config::set_str_or_remove(doc, &["settings", "exit_node"], &settings.exit_node); + config::set_str(doc, &["settings", "ping_host"], &settings.ping_host); + config::set_str( + doc, + &["settings", "connectivity_url"], + &settings.connectivity_url, + ); + config::set_i64(doc, &["settings", "nmcli_wait"], settings.nmcli_wait); + config::set_i64( + doc, + &["settings", "watch_interval"], + settings.watch_interval, + ); +} + +fn load_from(settings_path: &Path, net_path: &Path) -> BreadcrumbsConfig { + let doc = config::load_doc(settings_path); BreadcrumbsConfig { settings: Settings { // breadcrumbs' own default_profile_name() is "away", not "home". - default_profile: config::get_str(&doc, &["settings", "default_profile"]).unwrap_or_else(|| "away".into()), + default_profile: config::get_str(&doc, &["settings", "default_profile"]) + .unwrap_or_else(|| "away".into()), dns: config::get_str(&doc, &["settings", "dns"]).unwrap_or_else(|| "1.1.1.1".into()), exit_node: config::get_str(&doc, &["settings", "exit_node"]).unwrap_or_default(), - ping_host: config::get_str(&doc, &["settings", "ping_host"]).unwrap_or_else(|| "1.1.1.1".into()), + ping_host: config::get_str(&doc, &["settings", "ping_host"]) + .unwrap_or_else(|| "1.1.1.1".into()), connectivity_url: config::get_str(&doc, &["settings", "connectivity_url"]) .unwrap_or_else(|| "http://connectivitycheck.gstatic.com/generate_204".into()), nmcli_wait: config::get_i64(&doc, &["settings", "nmcli_wait"]).unwrap_or(8), watch_interval: config::get_i64(&doc, &["settings", "watch_interval"]).unwrap_or(12), }, - networks: read_networks(&doc), + // Never ship a stored PSK to the webview — the password field is + // write-only (empty = keep existing / let NM remember). + networks: redact_passwords(load_networks(&doc, net_path)), profiles: read_profiles(&doc), } } +fn save_to( + settings_path: &Path, + net_path: &Path, + input: SaveBreadcrumbsInput, +) -> Result<(), String> { + let mut doc = config::load_doc(settings_path); + let existing = load_networks(&doc, net_path); + apply_settings(&mut doc, &input.settings); + write_profiles(&mut doc, &input.profiles); + // Completes the pre-split migration: leftover [[networks]] must not + // survive a save, even if the user only edited settings/profiles. + doc.as_table_mut().remove("networks"); + config::save_doc(settings_path, &doc).map_err(|e| e.to_string())?; + + let merged = merge_network_passwords(input.networks, &existing); + save_networks(net_path, &merged) +} + +#[tauri::command] +pub fn get_breadcrumbs_config() -> BreadcrumbsConfig { + load_from(&settings_path(), &networks_path()) +} + #[derive(Deserialize)] pub struct SaveBreadcrumbsInput { settings: Settings, @@ -161,16 +342,232 @@ pub struct SaveBreadcrumbsInput { #[tauri::command] pub fn save_breadcrumbs_config(input: SaveBreadcrumbsInput) -> Result<(), String> { - let path = config_path(); - let mut doc = config::load_doc(&path); - config::set_str(&mut doc, &["settings", "default_profile"], &input.settings.default_profile); - config::set_str(&mut doc, &["settings", "dns"], &input.settings.dns); - config::set_str_or_remove(&mut doc, &["settings", "exit_node"], &input.settings.exit_node); - config::set_str(&mut doc, &["settings", "ping_host"], &input.settings.ping_host); - config::set_str(&mut doc, &["settings", "connectivity_url"], &input.settings.connectivity_url); - config::set_i64(&mut doc, &["settings", "nmcli_wait"], input.settings.nmcli_wait); - config::set_i64(&mut doc, &["settings", "watch_interval"], input.settings.watch_interval); - write_networks(&mut doc, &input.networks); - write_profiles(&mut doc, &input.profiles); - config::save_doc(&path, &doc).map_err(|e| e.to_string()) + save_to(&settings_path(), &networks_path(), input) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "bos-settings-breadcrumbs-{}-{}-{}", + name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn sample_settings() -> Settings { + Settings { + default_profile: "away".into(), + dns: "1.1.1.1".into(), + exit_node: String::new(), + ping_host: "1.1.1.1".into(), + connectivity_url: "http://connectivitycheck.gstatic.com/generate_204".into(), + nmcli_wait: 8, + watch_interval: 12, + } + } + + #[test] + fn networks_document_omits_password_when_none() { + let nets = vec![Network { + ssid: "Cafe".into(), + password: None, + hidden: false, + }]; + let text = networks_document(&nets).to_string(); + assert!(text.contains("ssid")); + assert!(!text.contains("password"), "text: {text}"); + } + + #[test] + fn networks_document_writes_password_when_present() { + let nets = vec![Network { + ssid: "Cafe".into(), + password: Some("hunter2".into()), + hidden: true, + }]; + let text = networks_document(&nets).to_string(); + assert!(text.contains("hunter2")); + assert!(text.contains("hidden = true")); + let back = read_networks(&text.parse().unwrap()); + assert_eq!(back[0].password.as_deref(), Some("hunter2")); + assert!(back[0].hidden); + } + + #[test] + fn merge_keeps_existing_password_when_incoming_empty() { + let existing = vec![Network { + ssid: "Cafe".into(), + password: Some("hunter2".into()), + hidden: false, + }]; + let incoming = vec![Network { + ssid: "Cafe".into(), + password: Some(String::new()), + hidden: true, + }]; + let merged = merge_network_passwords(incoming, &existing); + assert_eq!(merged[0].password.as_deref(), Some("hunter2")); + assert!(merged[0].hidden); + } + + #[test] + fn merge_replaces_password_when_incoming_set() { + let existing = vec![Network { + ssid: "Cafe".into(), + password: Some("old".into()), + hidden: false, + }]; + let incoming = vec![Network { + ssid: "Cafe".into(), + password: Some("new".into()), + hidden: false, + }]; + let merged = merge_network_passwords(incoming, &existing); + assert_eq!(merged[0].password.as_deref(), Some("new")); + } + + #[test] + fn get_never_returns_stored_password() { + let dir = tmp_dir("redact"); + let settings = dir.join("breadcrumbs.toml"); + let nets = dir.join("networks.toml"); + std::fs::write(&settings, "[settings]\ndns = \"9.9.9.9\"\n").unwrap(); + std::fs::write( + &nets, + "[[networks]]\nssid = \"Cafe\"\npassword = \"hunter2\"\nhidden = false\n", + ) + .unwrap(); + + let cfg = load_from(&settings, &nets); + assert_eq!(cfg.settings.dns, "9.9.9.9"); + assert_eq!(cfg.networks.len(), 1); + assert_eq!(cfg.networks[0].ssid, "Cafe"); + assert_eq!(cfg.networks[0].password, None); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn save_writes_networks_toml_not_inline_and_uses_0600() { + let dir = tmp_dir("split"); + let settings = dir.join("breadcrumbs.toml"); + let nets = dir.join("networks.toml"); + std::fs::write( + &settings, + "# keep me\n[settings]\ndns = \"1.1.1.1\"\n\n[[networks]]\nssid = \"Old\"\npassword = \"legacy\"\n", + ) + .unwrap(); + + save_to( + &settings, + &nets, + SaveBreadcrumbsInput { + settings: sample_settings(), + networks: vec![Network { + ssid: "Cafe".into(), + password: Some("hunter2".into()), + hidden: false, + }], + profiles: vec![], + }, + ) + .unwrap(); + + let settings_text = std::fs::read_to_string(&settings).unwrap(); + assert!( + settings_text.contains("# keep me"), + "toml_edit must keep comments" + ); + assert!( + !settings_text.contains("[[networks]]"), + "inline networks must be gone" + ); + assert!( + !settings_text.contains("hunter2"), + "PSK must not land in breadcrumbs.toml" + ); + assert!(!settings_text.contains("legacy")); + + let nets_text = std::fs::read_to_string(&nets).unwrap(); + assert!(nets_text.contains("Cafe")); + assert!(nets_text.contains("hunter2")); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&nets).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "networks.toml must be 0600, got {mode:o}"); + } + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn save_with_empty_password_keeps_existing_secret() { + let dir = tmp_dir("keep-secret"); + let settings = dir.join("breadcrumbs.toml"); + let nets = dir.join("networks.toml"); + std::fs::write(&settings, "[settings]\ndns = \"1.1.1.1\"\n").unwrap(); + std::fs::write( + &nets, + "[[networks]]\nssid = \"Cafe\"\npassword = \"hunter2\"\nhidden = false\n", + ) + .unwrap(); + + save_to( + &settings, + &nets, + SaveBreadcrumbsInput { + settings: sample_settings(), + networks: vec![Network { + ssid: "Cafe".into(), + password: None, + hidden: true, + }], + profiles: vec![], + }, + ) + .unwrap(); + + let nets_text = std::fs::read_to_string(&nets).unwrap(); + assert!( + nets_text.contains("hunter2"), + "empty password must keep existing secret" + ); + assert!(nets_text.contains("hidden = true")); + assert!(!std::fs::read_to_string(&settings) + .unwrap() + .contains("hunter2")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn legacy_inline_networks_load_when_networks_toml_missing() { + let dir = tmp_dir("legacy"); + let settings = dir.join("breadcrumbs.toml"); + let nets = dir.join("networks.toml"); + std::fs::write( + &settings, + "[settings]\ndns = \"8.8.8.8\"\n\n[[networks]]\nssid = \"LegacyNet\"\npassword = \"secret\"\n", + ) + .unwrap(); + + let cfg = load_from(&settings, &nets); + assert_eq!(cfg.networks.len(), 1); + assert_eq!(cfg.networks[0].ssid, "LegacyNet"); + assert_eq!(cfg.networks[0].password, None, "still redacted to the UI"); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/src/commands/breadhelp.rs b/src/src/commands/breadhelp.rs new file mode 100644 index 0000000..efaa85d --- /dev/null +++ b/src/src/commands/breadhelp.rs @@ -0,0 +1,10 @@ +//! breadhelp is the onboarding / help center. This panel launches it; it +//! does not duplicate the help library, keybind tour, or troubleshoot +//! wizard. First-run autostart (`breadhelp --autostart` in +//! `hypr/autostart.json`) is toggled through the existing autostart +//! commands from the frontend. + +#[tauri::command] +pub fn open_breadhelp() { + let _ = std::process::Command::new("breadhelp").spawn(); +} diff --git a/src/src/commands/breadlock.rs b/src/src/commands/breadlock.rs new file mode 100644 index 0000000..6bad05f --- /dev/null +++ b/src/src/commands/breadlock.rs @@ -0,0 +1,132 @@ +//! Lock screen (breadlock) and greeter (breadgreet). +//! +//! Super+L is `loginctl lock-session`; hypridle's lock_cmd / idle listener +//! then starts breadlock. This panel edits `~/.config/breadlock/breadlock.toml` +//! (appearance + fail timeout) — it does not, and cannot, configure PAM. +//! breadgreet is the greetd greeter; its live config is typically +//! `/etc/greetd/breadgreet.toml` (system, owned by the greeter user) and is +//! not written from here. + +use serde::{Deserialize, Serialize}; + +use super::config; + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("breadlock/breadlock.toml") +} + +const EXAMPLE_CANDIDATES: &[&str] = &[ + "/usr/share/doc/breadlock/breadlock.example.toml", + "/usr/share/breadlock/breadlock.example.toml", + "/usr/share/doc/breadlock/examples/breadlock.example.toml", +]; + +#[derive(Serialize, Deserialize)] +pub struct BreadlockConfig { + background_mode: String, + background_path: String, + background_blur: bool, + clock_format: String, + font_family: String, + fail_timeout_ms: i64, +} + +impl Default for BreadlockConfig { + fn default() -> Self { + Self { + background_mode: "color".into(), + background_path: String::new(), + background_blur: false, + clock_format: "%H:%M".into(), + font_family: "Varela Round".into(), + fail_timeout_ms: 800, + } + } +} + +#[tauri::command] +pub fn get_breadlock_config() -> BreadlockConfig { + let doc = config::load_doc(&config_path()); + let mut cfg = BreadlockConfig::default(); + if let Some(mode) = config::get_str(&doc, &["background", "mode"]) { + cfg.background_mode = mode; + } + if let Some(path) = config::get_str(&doc, &["background", "path"]) { + cfg.background_path = path; + } + if let Some(blur) = config::get_bool(&doc, &["background", "blur"]) { + cfg.background_blur = blur; + } + if let Some(fmt) = config::get_str(&doc, &["clock", "format"]) { + cfg.clock_format = fmt; + } + if let Some(family) = config::get_str(&doc, &["font", "family"]) { + cfg.font_family = family; + } + if let Some(ms) = config::get_i64(&doc, &["input", "fail_timeout_ms"]) { + cfg.fail_timeout_ms = ms; + } + cfg +} + +#[tauri::command] +pub fn save_breadlock_config(cfg: BreadlockConfig) -> Result<(), String> { + let path = config_path(); + let mut doc = config::load_doc(&path); + let mode = if cfg.background_mode == "image" { + "image" + } else { + "color" + }; + config::set_str(&mut doc, &["background", "mode"], mode); + config::set_str_or_remove(&mut doc, &["background", "path"], &cfg.background_path); + config::set_bool(&mut doc, &["background", "blur"], cfg.background_blur); + config::set_str(&mut doc, &["clock", "format"], &cfg.clock_format); + config::set_str(&mut doc, &["font", "family"], &cfg.font_family); + config::set_i64( + &mut doc, + &["input", "fail_timeout_ms"], + cfg.fail_timeout_ms.max(0), + ); + config::save_doc(&path, &doc).map_err(|e| e.to_string()) +} + +/// First existing packaged example, if any — the panel links this rather +/// than pretending the in-app editor is the whole schema. +#[tauri::command] +pub fn breadlock_example_path() -> Option { + EXAMPLE_CANDIDATES + .iter() + .find(|p| std::path::Path::new(p).is_file()) + .map(|p| p.to_string()) +} + +fn open_in_editor(path: &std::path::Path) { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); + let _ = std::process::Command::new("kitty") + .args(["-e", &editor]) + .arg(path) + .spawn(); +} + +#[tauri::command] +pub fn open_breadlock_config() { + open_in_editor(&config_path()); +} + +#[tauri::command] +pub fn open_breadlock_example() { + if let Some(path) = breadlock_example_path() { + open_in_editor(std::path::Path::new(&path)); + } +} + +/// Super+L / hypridle path: `loginctl lock-session` → compositor lock +/// protocol → breadlock. Fire-and-forget; locking the live session is the +/// point of the button. +#[tauri::command] +pub fn lock_session() { + let _ = std::process::Command::new("loginctl") + .arg("lock-session") + .spawn(); +} diff --git a/src/src/commands/breadmon.rs b/src/src/commands/breadmon.rs new file mode 100644 index 0000000..06ea09d --- /dev/null +++ b/src/src/commands/breadmon.rs @@ -0,0 +1,9 @@ +//! breadmon is a TUI for live Hyprland monitor layout, mirroring, and +//! named profiles (`~/.config/breadmon/profiles/`). Display (this app) +//! edits `hypr/monitors.json` — the login-time layout Hyprland itself +//! reads. This module only launches the TUI; it does not write profiles. + +#[tauri::command] +pub fn open_breadmon() { + let _ = std::process::Command::new("breadmon").spawn(); +} diff --git a/src/src/commands/breadshot.rs b/src/src/commands/breadshot.rs new file mode 100644 index 0000000..51f75be --- /dev/null +++ b/src/src/commands/breadshot.rs @@ -0,0 +1,139 @@ +//! Screenshots (breadshot). Binds are read-only here — edit them on the +//! Keybinds panel. Config lives at `~/.config/breadshot/config.toml` and +//! matches breadshot's own `Config` (every field optional). + +use serde::{Deserialize, Serialize}; + +use super::config; +use super::keybinds; + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("breadshot/config.toml") +} + +#[derive(Serialize, Deserialize)] +pub struct BreadshotConfig { + save_dir: String, + silent: bool, + freeze: bool, + notif_timeout: i64, + date_format: String, +} + +impl Default for BreadshotConfig { + fn default() -> Self { + Self { + save_dir: "~/Pictures/Screenshots".into(), + silent: false, + freeze: false, + notif_timeout: 5000, + date_format: "%Y-%m-%d-%H%M%S".into(), + } + } +} + +#[derive(Serialize)] +pub struct ShotBind { + shortcut: String, + command: String, +} + +#[tauri::command] +pub fn get_breadshot_config() -> BreadshotConfig { + let doc = config::load_doc(&config_path()); + let mut cfg = BreadshotConfig::default(); + if let Some(dir) = config::get_str(&doc, &["save_dir"]) { + cfg.save_dir = dir; + } + if let Some(v) = config::get_bool(&doc, &["silent"]) { + cfg.silent = v; + } + if let Some(v) = config::get_bool(&doc, &["freeze"]) { + cfg.freeze = v; + } + if let Some(v) = config::get_i64(&doc, &["notif_timeout"]) { + cfg.notif_timeout = v; + } + if let Some(v) = config::get_str(&doc, &["date_format"]) { + cfg.date_format = v; + } + cfg +} + +#[tauri::command] +pub fn save_breadshot_config(cfg: BreadshotConfig) -> Result<(), String> { + let path = config_path(); + let mut doc = config::load_doc(&path); + config::set_str(&mut doc, &["save_dir"], &cfg.save_dir); + config::set_bool(&mut doc, &["silent"], cfg.silent); + config::set_bool(&mut doc, &["freeze"], cfg.freeze); + config::set_i64(&mut doc, &["notif_timeout"], cfg.notif_timeout.max(0)); + config::set_str(&mut doc, &["date_format"], &cfg.date_format); + config::save_doc(&path, &doc).map_err(|e| e.to_string()) +} + +/// Documented BOS defaults (SUPER+Shift+S/C/P) used when binds.json has no +/// breadshot exec entries — still accurate as a cheatsheet even on a +/// machine whose binds were rewritten. +fn documented_defaults() -> Vec { + vec![ + ShotBind { + shortcut: "Super+Shift+S".into(), + command: "breadshot region".into(), + }, + ShotBind { + shortcut: "Super+Shift+C".into(), + command: "breadshot region --clipboard-only".into(), + }, + ShotBind { + shortcut: "Super+Shift+P".into(), + command: "breadshot active-output".into(), + }, + ] +} + +fn format_shortcut(mods: Option<&[String]>, key: Option<&str>, default_mods: &[String]) -> String { + let mods = mods.unwrap_or(default_mods); + let mut parts: Vec = mods + .iter() + .map(|m| match m.to_ascii_uppercase().as_str() { + "SUPER" | "MOD4" => "Super".into(), + "SHIFT" => "Shift".into(), + "CTRL" | "CONTROL" => "Ctrl".into(), + "ALT" | "MOD1" => "Alt".into(), + other => other.to_string(), + }) + .collect(); + if let Some(k) = key { + if !k.is_empty() { + parts.push(k.to_string()); + } + } + parts.join("+") +} + +/// Read-only: breadshot exec binds from binds.json, or the documented +/// Super+Shift+S/C/P cheatsheet if none are defined. +#[tauri::command] +pub fn get_breadshot_binds() -> Vec { + let from_file = keybinds::breadshot_binds(); + if from_file.is_empty() { + documented_defaults() + } else { + from_file + .into_iter() + .map(|b| ShotBind { + shortcut: format_shortcut(b.mods.as_deref(), b.key.as_deref(), &b.default_mods), + command: b.command, + }) + .collect() + } +} + +/// Interactive region capture, clipboard only — no file written. +#[tauri::command] +pub fn breadshot_region_clipboard() { + let _ = std::process::Command::new("breadshot") + .args(["region", "--clipboard-only"]) + .spawn(); +} diff --git a/src/src/commands/config.rs b/src/src/commands/config.rs index c1e4034..31c140e 100644 --- a/src/src/commands/config.rs +++ b/src/src/commands/config.rs @@ -2,7 +2,8 @@ //! //! Every bread* app owns a TOML config that may contain keys, sections, and //! comments this settings app does not model (e.g. breadpad's calendar -//! credentials, breadcrumbs' saved-network passwords). To edit safely we parse +//! credentials). Saved-network passwords live in breadcrumbs' separate +//! `networks.toml`, not in breadcrumbs.toml. To edit safely we parse //! the file into a `toml_edit::DocumentMut`, mutate only the specific keys the //! UI exposes, and write the document back — preserving everything else, //! formatting and comments included. @@ -18,7 +19,7 @@ use toml_edit::{value, Array, DocumentMut, Item, Table, Value}; /// falling back to an empty document there means the next Save (see /// `save_doc`) overwrites it with only the UI-modelled keys, silently /// destroying anything else in the file (breadpad's calendar credentials, -/// breadcrumbs' saved network passwords, ...). Back up the unparseable file +/// unmodelled keys, ...). Back up the unparseable file /// once before falling back, so a bad edit is always recoverable. pub fn load_doc(path: &Path) -> DocumentMut { bread_utils::tomlcfg::load_doc("bos-settings", path) @@ -69,7 +70,8 @@ pub fn get_i64(doc: &DocumentMut, path: &[&str]) -> Option { } pub fn get_f64(doc: &DocumentMut, path: &[&str]) -> Option { let item = get(doc, path)?; - item.as_float().or_else(|| item.as_integer().map(|i| i as f64)) + item.as_float() + .or_else(|| item.as_integer().map(|i| i as f64)) } /// Read an array of strings (e.g. modules.disable, contexts[].priority). pub fn get_str_list(doc: &DocumentMut, path: &[&str]) -> Vec { @@ -176,7 +178,10 @@ password = \"secret\" # keep me let mut doc = DocumentMut::new(); set_bool(&mut doc, &["adapters", "power", "enabled"], false); set_i64(&mut doc, &["adapters", "power", "poll_interval_secs"], 45); - assert_eq!(get_bool(&doc, &["adapters", "power", "enabled"]), Some(false)); + assert_eq!( + get_bool(&doc, &["adapters", "power", "enabled"]), + Some(false) + ); assert_eq!( get_i64(&doc, &["adapters", "power", "poll_interval_secs"]), Some(45) @@ -200,14 +205,20 @@ password = \"secret\" # keep me #[test] fn atomic_write_backs_up_previous_contents_and_no_tmp_file_left_behind() { - let dir = std::env::temp_dir().join(format!("bos-settings-atomic-write-test-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!( + "bos-settings-atomic-write-test-{}", + std::process::id() + )); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("config.toml"); let backup = dir.join("config.toml.bak"); atomic_write(&path, "first").unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "first"); - assert!(!backup.exists(), "no backup should be made when there's nothing to back up yet"); + assert!( + !backup.exists(), + "no backup should be made when there's nothing to back up yet" + ); atomic_write(&path, "second").unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "second"); @@ -219,7 +230,10 @@ password = \"secret\" # keep me .map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|n| n.contains(".tmp.")) .collect(); - assert!(leftover_tmp.is_empty(), "temp file should be renamed away, not left behind: {leftover_tmp:?}"); + assert!( + leftover_tmp.is_empty(), + "temp file should be renamed away, not left behind: {leftover_tmp:?}" + ); let _ = std::fs::remove_dir_all(&dir); } diff --git a/src/src/commands/keybinds.rs b/src/src/commands/keybinds.rs index f46cf7a..946ea6b 100644 --- a/src/src/commands/keybinds.rs +++ b/src/src/commands/keybinds.rs @@ -201,6 +201,51 @@ fn save(f: &BindsFile, kind: SchemaKind) -> std::io::Result<()> { save_to(&config_path(), f, kind) } +/// One breadshot `exec` bind as binds.json stored it. Used by the +/// Screenshots panel (read-only); editing still happens here. +pub(crate) struct ShotBindRaw { + pub mods: Option>, + pub key: Option, + pub command: String, + pub default_mods: Vec, +} + +pub(crate) fn breadshot_binds() -> Vec { + let (file, _kind) = load(); + let default_mods = file.default_mods.clone(); + let mut out = Vec::new(); + let mut push = |binds: &[Bind]| { + for b in binds { + if b.action != "exec" { + continue; + } + let Some(cmd) = b.extra.get("command").and_then(|v| v.as_str()) else { + continue; + }; + if !cmd + .split_whitespace() + .next() + .is_some_and(|bin| bin == "breadshot" || bin.ends_with("/breadshot")) + { + continue; + } + out.push(ShotBindRaw { + mods: b.mods.clone(), + key: b.key.clone(), + command: cmd.to_string(), + default_mods: default_mods.clone(), + }); + } + }; + push(&file.bindings); + push(&file.globals); + push(&file.common); + for binds in file.layouts.values() { + push(binds); + } + out +} + #[tauri::command] pub fn get_keybinds() -> BindsPayload { let (file, kind) = load(); @@ -265,8 +310,14 @@ mod tests { // active_layout/globals/common/layouts keys leaking in. let saved_obj = saved.as_object().expect("flat save must be a JSON object"); assert_eq!( - saved_obj.keys().cloned().collect::>(), - ["default_mods", "bindings"].into_iter().map(String::from).collect(), + saved_obj + .keys() + .cloned() + .collect::>(), + ["default_mods", "bindings"] + .into_iter() + .map(String::from) + .collect(), "Flat schema must round-trip as exactly {{default_mods, bindings}}" ); @@ -279,7 +330,8 @@ mod tests { #[test] fn round_trip_via_files_preserves_bindings_key_and_extras() { - let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-test-{}", std::process::id())); + let dir = + std::env::temp_dir().join(format!("bos-settings-keybinds-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("binds.json"); std::fs::write(&path, REAL_BOS_FLAT_FIXTURE).unwrap(); @@ -292,7 +344,10 @@ mod tests { let saved: Value = serde_json::from_str(&saved_text).unwrap(); let original: Value = serde_json::from_str(REAL_BOS_FLAT_FIXTURE).unwrap(); - assert!(saved.get("bindings").is_some(), "bindings key must survive a load -> save round trip"); + assert!( + saved.get("bindings").is_some(), + "bindings key must survive a load -> save round trip" + ); assert_eq!(saved["bindings"], original["bindings"]); assert_eq!(saved["default_mods"], original["default_mods"]); @@ -301,7 +356,8 @@ mod tests { save_to(&path, &file, kind).unwrap(); let backup_path = dir.join("binds.json.bak"); assert!(backup_path.exists(), "save must back up the previous file"); - let backup: Value = serde_json::from_str(&std::fs::read_to_string(&backup_path).unwrap()).unwrap(); + let backup: Value = + serde_json::from_str(&std::fs::read_to_string(&backup_path).unwrap()).unwrap(); assert_eq!(backup["bindings"], original["bindings"]); let _ = std::fs::remove_dir_all(&dir); @@ -320,7 +376,10 @@ mod tests { assert_eq!(kind, SchemaKind::MultiLayout); let saved = to_json(&file, kind); - assert!(saved.get("bindings").is_none(), "MultiLayout save must not emit a flat `bindings` key"); + assert!( + saved.get("bindings").is_none(), + "MultiLayout save must not emit a flat `bindings` key" + ); assert_eq!(saved["active_layout"], "qwerty"); assert_eq!(saved["layouts"]["qwerty"][0]["action"], "close"); assert_eq!(saved["globals"][0]["command"], "kitty"); @@ -332,20 +391,32 @@ mod tests { let (file, kind) = parse(text); assert_eq!(kind, SchemaKind::Unknown); - let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-unknown-test-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!( + "bos-settings-keybinds-unknown-test-{}", + std::process::id() + )); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("binds.json"); let result = save_to(&path, &file, kind); - assert!(result.is_err(), "save() must refuse when schema kind is Unknown"); - assert!(!path.exists(), "refusing to save must not create/touch the target file"); + assert!( + result.is_err(), + "save() must refuse when schema kind is Unknown" + ); + assert!( + !path.exists(), + "refusing to save must not create/touch the target file" + ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn missing_file_defaults_to_flat_not_multi_layout() { - let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-missing-test-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!( + "bos-settings-keybinds-missing-test-{}", + std::process::id() + )); // Don't create the file at all. let path = dir.join("binds.json"); let (_, kind) = load_from(&path); diff --git a/src/src/commands/mod.rs b/src/src/commands/mod.rs index e26b415..ccc0e2e 100644 --- a/src/src/commands/mod.rs +++ b/src/src/commands/mod.rs @@ -8,9 +8,13 @@ pub mod breadbar; pub mod breadbox; pub mod breadclip; pub mod breadcrumbs; +pub mod breadhelp; +pub mod breadlock; +pub mod breadmon; pub mod breadpad; pub mod breadpaper; pub mod breadsearch; +pub mod breadshot; pub mod config; pub mod datetime; pub mod firewall; diff --git a/src/src/commands/streaming.rs b/src/src/commands/streaming.rs index d7d6a40..5b87c2b 100644 --- a/src/src/commands/streaming.rs +++ b/src/src/commands/streaming.rs @@ -1,10 +1,12 @@ -//! Shared event-streaming command runner for the genuinely long-running -//! operations (package/firmware updates) where the GTK app treated output -//! as "watch the log scroll" — the Tauri-side analog of -//! `stream_command_then`'s async_channel → glib::spawn_future_local -//! pipeline, using Tauri's event bus instead of a GLib main-loop channel. -//! Most other commands are simple request/response (see the other modules) -//! since the operations they wrap finish in well under a second. +//! Shared event-streaming runner for the genuinely long-running operations +//! (package/firmware updates) where the GTK app treated output as "watch +//! the log scroll" — the Tauri-side analog of `stream_command_then`'s +//! async_channel → glib::spawn_future_local pipeline, using Tauri's event +//! bus instead of a GLib main-loop channel. +//! +//! The runner itself is *not* a Tauri command. A generic argv runner was +//! an arbitrary-command primitive; each public command below hardcodes the +//! program and the allowed argument shape. use serde::Serialize; use std::process::Stdio; @@ -18,18 +20,25 @@ struct CmdOutputEvent { line: String, } -/// Runs `program args...`, emitting one `cmd-output` event per line of -/// stdout/stderr (tagged with `session_id` so the frontend can route -/// concurrent streams), and resolves to whether it exited successfully — -/// the frontend awaits this call directly rather than needing a second -/// "done" event. -#[tauri::command] -pub async fn run_streaming_command(app: AppHandle, session_id: String, program: String, args: Vec) -> bool { - let child = Command::new(&program).args(&args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn(); +/// 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) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); let mut child = match child { Ok(c) => c, Err(e) => { - let _ = app.emit("cmd-output", CmdOutputEvent { session_id, line: format!("Error: {e}") }); + let _ = app.emit( + "cmd-output", + CmdOutputEvent { + session_id, + line: format!("Error: {e}"), + }, + ); return false; } }; @@ -40,7 +49,13 @@ pub async fn run_streaming_command(app: AppHandle, session_id: String, program: let read_stdout = async { let mut lines = BufReader::new(stdout).lines(); while let Ok(Some(line)) = lines.next_line().await { - let _ = app.emit("cmd-output", CmdOutputEvent { session_id: session_id.clone(), line }); + let _ = app.emit( + "cmd-output", + CmdOutputEvent { + session_id: session_id.clone(), + line, + }, + ); } }; let stderr_app = app.clone(); @@ -48,10 +63,96 @@ pub async fn run_streaming_command(app: AppHandle, session_id: String, program: let read_stderr = async move { let mut lines = BufReader::new(stderr).lines(); while let Ok(Some(line)) = lines.next_line().await { - let _ = stderr_app.emit("cmd-output", CmdOutputEvent { session_id: stderr_session.clone(), line }); + let _ = stderr_app.emit( + "cmd-output", + CmdOutputEvent { + session_id: stderr_session.clone(), + line, + }, + ); } }; tokio::join!(read_stdout, read_stderr); child.wait().await.map(|s| s.success()).unwrap_or(false) } + +/// bakery package names are `foo`, `foo-bar`, `foo_bar` — reject flags, +/// paths, and anything else that would change `bakery update`'s shape. +fn valid_bakery_pkg(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() || *b == b'-' || *b == b'_') +} + +#[tauri::command] +pub async fn bakery_update(app: AppHandle, session_id: String, name: String) -> bool { + if !valid_bakery_pkg(&name) { + let _ = app.emit( + "cmd-output", + CmdOutputEvent { + session_id, + line: format!("Error: invalid bakery package name '{name}'"), + }, + ); + return false; + } + run_hardcoded(app, session_id, "bakery", &["update", &name]).await +} + +#[tauri::command] +pub async fn bakery_list(app: AppHandle, session_id: String) -> bool { + run_hardcoded(app, session_id, "bakery", &["list"]).await +} + +#[tauri::command] +pub async fn bakery_update_all(app: AppHandle, session_id: String) -> bool { + run_hardcoded(app, session_id, "bakery", &["update", "--all"]).await +} + +#[tauri::command] +pub async fn pacman_system_update(app: AppHandle, session_id: String) -> bool { + run_hardcoded( + app, + session_id, + "pkexec", + &["pacman", "-Syu", "--noconfirm"], + ) + .await +} + +#[tauri::command] +pub async fn fwupd_refresh(app: AppHandle, session_id: String) -> bool { + run_hardcoded(app, session_id, "fwupdmgr", &["refresh"]).await +} + +#[tauri::command] +pub async fn fwupd_update(app: AppHandle, session_id: String) -> bool { + run_hardcoded(app, session_id, "fwupdmgr", &["update", "-y"]).await +} + +#[cfg(test)] +mod tests { + use super::valid_bakery_pkg; + + #[test] + fn bakery_pkg_accepts_real_names() { + assert!(valid_bakery_pkg("breadbar")); + assert!(valid_bakery_pkg("bos-settings")); + assert!(valid_bakery_pkg("bread_theme")); + } + + #[test] + fn bakery_pkg_rejects_flags_and_paths() { + assert!(!valid_bakery_pkg("")); + assert!(!valid_bakery_pkg("--all")); + assert!(!valid_bakery_pkg("-S")); + assert!(!valid_bakery_pkg("../evil")); + assert!(!valid_bakery_pkg("foo bar")); + assert!(!valid_bakery_pkg("foo;rm")); + } +} diff --git a/src/src/commands/theme.rs b/src/src/commands/theme.rs index 6aaf367..cb290e6 100644 --- a/src/src/commands/theme.rs +++ b/src/src/commands/theme.rs @@ -17,10 +17,68 @@ pub fn get_theme_css() -> String { fn render_theme_css() -> String { let palette = bread_theme::load_palette(); + // 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()) +} + +fn css_custom_properties(p: &bread_theme::Palette) -> String { + let pairs = [ + ("bg", p.background.as_str()), + ("fg", p.foreground.as_str()), + ("surface", p.color0.as_str()), + ("overlay", p.color7.as_str()), + ("accent", p.color4.as_str()), + ("red", p.color1.as_str()), + ("green", p.color2.as_str()), + ("yellow", p.color3.as_str()), + ("blue", p.color4.as_str()), + ("pink", p.color5.as_str()), + ("teal", p.color6.as_str()), + ("on-bg", bread_theme::ink_on(&p.background)), + ("on-surface", bread_theme::ink_on(&p.color0)), + ("on-accent", bread_theme::ink_on(&p.color4)), + ("on-red", bread_theme::ink_on(&p.color1)), + ("on-overlay", bread_theme::ink_on(&p.color7)), + ]; + let vars: String = pairs + .iter() + .map(|(name, value)| format!(" --{name}: {value};\n")) + .collect(); + format!(":root {{\n{vars}}}\n") +} + +fn css_tokens() -> String { + use bread_theme::tokens::*; format!( - "{}\n{}", - bread_theme::css_custom_properties(&palette), - bread_theme::css_tokens(), + ":root {{\n\ + \x20\x20--font-family: '{font}';\n\ + \x20\x20--font-size-base: {base}px;\n\ + \x20\x20--font-size-secondary: {sec}px;\n\ + \x20\x20--space-xs: {xs}px;\n\ + \x20\x20--space-sm: {sm}px;\n\ + \x20\x20--space-md: {md}px;\n\ + \x20\x20--space-lg: {lg}px;\n\ + \x20\x20--space-xl: {xl}px;\n\ + \x20\x20--radius-primary: {r1}px;\n\ + \x20\x20--radius-secondary: {r2}px;\n\ + \x20\x20--radius-tertiary: {r3}px;\n\ + \x20\x20--radius-pill: {pill}px;\n\ + }}\n", + font = FONT_FAMILY, + base = FONT_SIZE_BASE, + sec = FONT_SIZE_SECONDARY, + xs = SPACE_XS, + sm = SPACE_SM, + md = SPACE_MD, + lg = SPACE_LG, + xl = SPACE_XL, + r1 = RADIUS_PRIMARY, + r2 = RADIUS_SECONDARY, + r3 = RADIUS_TERTIARY, + pill = RADIUS_PILL, ) } @@ -58,7 +116,10 @@ pub fn watch_and_emit(app: &AppHandle) { }; if let Err(e) = watcher.watch(dir, RecursiveMode::NonRecursive) { - tracing_or_eprintln(&format!("theme watcher: failed to watch {}: {e}", dir.display())); + tracing_or_eprintln(&format!( + "theme watcher: failed to watch {}: {e}", + dir.display() + )); return; } diff --git a/src/src/lib.rs b/src/src/lib.rs index f6a7be3..d022889 100644 --- a/src/src/lib.rs +++ b/src/src/lib.rs @@ -86,7 +86,12 @@ pub fn run() { commands::users::change_password, commands::users::remove_user, commands::users::add_user, - commands::streaming::run_streaming_command, + commands::streaming::bakery_update, + commands::streaming::bakery_list, + commands::streaming::bakery_update_all, + commands::streaming::pacman_system_update, + commands::streaming::fwupd_refresh, + commands::streaming::fwupd_update, commands::packages::get_installed_packages, commands::aur::search_aur, commands::aur::install_aur_package, @@ -94,6 +99,18 @@ pub fn run() { commands::snapshots::get_snapshots, commands::snapshots::delete_snapshot, commands::snapshots::reboot_system, + commands::breadlock::get_breadlock_config, + commands::breadlock::save_breadlock_config, + commands::breadlock::breadlock_example_path, + commands::breadlock::open_breadlock_config, + commands::breadlock::open_breadlock_example, + commands::breadlock::lock_session, + commands::breadshot::get_breadshot_config, + commands::breadshot::save_breadshot_config, + commands::breadshot::get_breadshot_binds, + commands::breadshot::breadshot_region_clipboard, + commands::breadmon::open_breadmon, + commands::breadhelp::open_breadhelp, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/src/screenshot.rs b/src/src/screenshot.rs index bb432a6..c24b6dc 100644 --- a/src/src/screenshot.rs +++ b/src/src/screenshot.rs @@ -1,5 +1,5 @@ //! `--screenshot` CLI mode: switch the Svelte SPA to the named sidebar -//! section, capture it via `bread-screenshots`, then exit — driven by +//! section, capture it via grim, then exit — driven by //! `bread-ecosystem`'s `bread-capture` orchestrator, or run standalone for //! one-off captures. //! @@ -59,6 +59,10 @@ const KNOWN_VIEWS: &[&str] = &[ "aur", "firmware", "snapshots", + "breadlock", + "breadshot", + "breadmon", + "breadhelp", "about", ]; @@ -107,7 +111,12 @@ pub fn parse(args: &[String]) -> Option { eprintln!("bos-settings: --screenshot requires --output"); std::process::exit(1); }; - Some(ScreenshotRequest { view, output: output.into(), width, height }) + Some(ScreenshotRequest { + view, + output: output.into(), + width, + height, + }) } /// Schedule the switch-view-then-capture-then-exit sequence. Called once @@ -121,7 +130,7 @@ pub fn dispatch(req: ScreenshotRequest, app: tauri::AppHandle) { std::process::exit(1); } tokio::time::sleep(VIEW_SETTLE_DELAY).await; - finish(bread_screenshots::capture_region( + finish(capture_region( 0, 0, req.width as i32, @@ -131,6 +140,28 @@ pub fn dispatch(req: ScreenshotRequest, app: tauri::AppHandle) { }); } +/// Same contract as bread-screenshots::capture_region. That crate is not +/// on bread-ecosystem v0.7.1 (it landed after the tag), so this stays a +/// local grim -g call rather than a branch-pinned git dep. +fn capture_region(x: i32, y: i32, w: i32, h: i32, out: &std::path::Path) -> anyhow::Result<()> { + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent)?; + } + let out_str = out + .to_str() + .ok_or_else(|| anyhow::anyhow!("output path is not valid UTF-8"))?; + let geometry = format!("{x},{y} {w}x{h}"); + let result = + bread_utils::proc::run("grim", &["-g", &geometry, out_str], Duration::from_secs(5)); + if !result.success { + anyhow::bail!( + "grim failed for geometry {geometry}: {}", + result.stderr.trim() + ); + } + Ok(()) +} + fn finish(result: anyhow::Result<()>) { match result { Ok(()) => std::process::exit(0), diff --git a/src/tauri.conf.json b/src/tauri.conf.json index b4c5066..004728d 100644 --- a/src/tauri.conf.json +++ b/src/tauri.conf.json @@ -20,7 +20,7 @@ } ], "security": { - "csp": null, + "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost https://ipc.localhost; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'; object-src 'none'; base-uri 'self'; frame-src 'none'", "assetProtocol": { "enable": true, "scope": ["$HOME/Pictures/Backgrounds/**"]