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:
parent
abfb4fd4a4
commit
cbac50683e
30 changed files with 1469 additions and 147 deletions
27
src/Cargo.lock
generated
27
src/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
src/src/commands/breadhelp.rs
Normal file
10
src/src/commands/breadhelp.rs
Normal 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();
|
||||
}
|
||||
132
src/src/commands/breadlock.rs
Normal file
132
src/src/commands/breadlock.rs
Normal 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();
|
||||
}
|
||||
9
src/src/commands/breadmon.rs
Normal file
9
src/src/commands/breadmon.rs
Normal 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();
|
||||
}
|
||||
139
src/src/commands/breadshot.rs
Normal file
139
src/src/commands/breadshot.rs
Normal 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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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/**"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue