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/**"]