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.
This commit is contained in:
Breadway 2026-08-15 21:47:00 +08:00
parent abfb4fd4a4
commit cbac50683e
30 changed files with 1469 additions and 147 deletions

27
src/Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -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.<name>] per-location profile (networks, tailscale, …)
//! `[settings]` is edited in place; `networks`/`profiles` are rewritten from
//! their editors on save. Other keys/comments are preserved.
//!
//! 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<String>,
#[serde(default)]
hidden: bool,
}
@ -56,24 +78,120 @@ fn read_networks(doc: &DocumentMut) -> Vec<Network> {
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<Network> {
if net_path.exists() {
let doc = config::load_doc(net_path);
return read_networks(&doc);
}
read_networks(settings_doc)
}
fn redact_passwords(nets: Vec<Network>) -> Vec<Network> {
nets.into_iter()
.map(|n| Network {
password: None,
..n
})
.collect()
}
fn incoming_password(n: &Network) -> Option<String> {
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<Network>, existing: &[Network]) -> Vec<Network> {
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<Profile> {
@ -82,7 +200,11 @@ fn read_profiles(doc: &DocumentMut) -> Vec<Profile> {
};
let str_list = |item: Option<&Item>| -> Vec<String> {
item.and_then(Item::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.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<Profile> {
name: name.to_string(),
networks: str_list(p.get("networks")),
detect_ssids: str_list(p.get("detect_ssids")),
bootstrap: p.get("bootstrap").and_then(Item::as_str).unwrap_or("").to_string(),
exit_node: p.get("exit_node").and_then(Item::as_str).unwrap_or("").to_string(),
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);
}
}

View file

@ -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();
}

View file

@ -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<String> {
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();
}

View file

@ -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();
}

View file

@ -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<ShotBind> {
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<String> = 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<ShotBind> {
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();
}

View file

@ -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<i64> {
}
pub fn get_f64(doc: &DocumentMut, path: &[&str]) -> Option<f64> {
let item = get(doc, path)?;
item.as_float().or_else(|| item.as_integer().map(|i| i as f64))
item.as_float()
.or_else(|| item.as_integer().map(|i| i as f64))
}
/// Read an array of strings (e.g. modules.disable, contexts[].priority).
pub fn get_str_list(doc: &DocumentMut, path: &[&str]) -> Vec<String> {
@ -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);
}

View file

@ -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<Vec<String>>,
pub key: Option<String>,
pub command: String,
pub default_mods: Vec<String>,
}
pub(crate) fn breadshot_binds() -> Vec<ShotBindRaw> {
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::<std::collections::BTreeSet<_>>(),
["default_mods", "bindings"].into_iter().map(String::from).collect(),
saved_obj
.keys()
.cloned()
.collect::<std::collections::BTreeSet<_>>(),
["default_mods", "bindings"]
.into_iter()
.map(String::from)
.collect(),
"Flat schema must round-trip as exactly {{default_mods, bindings}}"
);
@ -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);

View file

@ -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;

View file

@ -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<String>) -> 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"));
}
}

View file

@ -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;
}

View file

@ -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");

View file

@ -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<ScreenshotRequest> {
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),

View file

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