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

3
.gitignore vendored
View file

@ -32,6 +32,3 @@ logs/
# Claude Code local agent state # Claude Code local agent state
.claude/ .claude/
# Local hygiene notes (not for commit)
CLAUDE.md

33
CLAUDE.md Normal file
View file

@ -0,0 +1,33 @@
# CLAUDE.md — Repo hygiene
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation.
This repo is a **Tauri 2 + Svelte 5** settings app, bakery-distributed. It follows the branch/release workflow in `CONTRIBUTING.md` — read and follow it for any git, branch, or release work here (the single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, etc). Don't improvise a different workflow. The short version: there is one long-lived branch, `main` — no `dev` or `beta` branch exists. `main` auto-publishes a bakery **dev-track** build on every push. "Beta" and "stable" are both just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track build, push a plain `vX.Y.Z` tag to cut the signed stable release. "Freezing" for stabilization means pausing pushes to `main`, not moving a branch.
This is not a GTK4 app. There is no `package.yml` pacman workflow here; bakery is the distribution channel.
## Layout
- `frontend/` — Svelte 5 + SvelteKit (static adapter) + TypeScript
- `src/` — Tauri 2 Rust crate (`bos-settings`). Commands live in `src/src/commands/`.
- Config edits are non-destructive (`toml_edit` / `bread_utils::tomlcfg`) except for JSON files that have no comments to preserve.
## Remotes
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
- `github` — GitHub mirror. Push `origin` only; the github remote auto-mirrors.
## CI
- `dev-release.yml` — push to `main`.
- `rc-release.yml``vX.Y.Z-rc.N` tags.
- `release.yml` — other `v*` tags (signed stable).
No build/lint/test CI runs on ordinary commits or PRs to `main` beyond the dev-track workflow above.
## Don't
- Don't commit `frontend/node_modules`.
- Don't embed credentials in remote URLs — SSH or a credential helper only.
- Don't write Wi-Fi passwords back into `breadcrumbs.toml`. Networks live in `~/.config/breadcrumbs/networks.toml` (0600).
- Don't expose a generic argv runner to the webview. Streaming updates are typed commands (`bakery_update`, `pacman_system_update`, `fwupd_*`).

View file

@ -1,15 +1,24 @@
# bos-settings # bos-settings
System settings app for [BOS (Bread Operating System)](https://git.breadway.dev/Breadway/bos) — GTK4, configures every bread\* app's config plus core system settings (network, sound, power, users, firewall, snapshots, packages, AUR, firmware, Hyprland display/appearance/autostart) non-destructively. System settings app for [BOS (Bread Operating System)](https://git.breadway.dev/Breadway/bos) — Tauri 2 + Svelte 5. Configures every bread\* app's config plus core system settings (network, sound, power, users, firewall, snapshots, packages, AUR, firmware, Hyprland display/appearance/autostart) non-destructively.
Split out of the `bos` repo into its own repo so a bos-settings release doesn't require a BOS ISO release, and vice versa. Distributed via `bakery` — see `bread-ecosystem`'s `CONTRIBUTING.md` for the dev/beta/stable track workflow shared across the bread ecosystem. Distributed via `bakery`. There is one long-lived branch, `main`; see `CONTRIBUTING.md` for the single-trunk / RC-tag release model shared across the bread ecosystem.
## Building ## Building
The Svelte frontend lives in `frontend/`, the Rust backend in `src/` (this repo's crate is not named `src-tauri`). `cargo tauri build` runs the frontend build hook; a plain `cargo build` does not.
```bash ```bash
cargo build --release cd frontend && npm ci && npm run build
cd ../src && cargo build --release
```
Dev (Vite + `cargo tauri dev`):
```bash
cd src && cargo tauri dev
``` ```
## Packaging / releasing ## Packaging / releasing
Bump `Cargo.toml`'s version, tag `vX.Y.Z`, push the tag to both remotes — `.forgejo/workflows/release.yml` builds and publishes to `dl.breadway.dev` (bakery) automatically. Pushes to `dev`/`beta` publish a dev/beta-track build the same way, no tag needed. Bump `src/Cargo.toml` (and `frontend/package.json`) version, then follow `CONTRIBUTING.md`: work lands on `main` via `feature/` / `fix/` branches (every push to `main` publishes a bakery **dev** build). Tag `vX.Y.Z-rc.N` for beta, `vX.Y.Z` for the signed stable release. Do not push to a `dev` branch — there isn't one.

View file

@ -1,7 +1,7 @@
name = "bos-settings" name = "bos-settings"
description = "System settings app for Bread OS" description = "System settings app for Bread OS"
binaries = ["bos-settings"] binaries = ["bos-settings"]
system_deps = ["gtk4", "glib2", "hicolor-icon-theme"] system_deps = ["webkitgtk-4.1", "gtk3", "libsoup3", "librsvg", "hicolor-icon-theme"]
optional_system_deps = ["snapper"] optional_system_deps = ["snapper"]
bread_deps = [] bread_deps = []
license_file = "LICENSE" license_file = "LICENSE"

View file

@ -3,7 +3,7 @@
</script> </script>
<div class="placeholder"> <div class="placeholder">
<p>"{page}" hasn't been migrated to Tauri yet.</p> <p>Unknown page "{page}".</p>
</div> </div>
<style> <style>

View file

@ -27,6 +27,10 @@ import Package from "@lucide/svelte/icons/package";
import RefreshCw from "@lucide/svelte/icons/refresh-cw"; import RefreshCw from "@lucide/svelte/icons/refresh-cw";
import History from "@lucide/svelte/icons/history"; import History from "@lucide/svelte/icons/history";
import Info from "@lucide/svelte/icons/info"; import Info from "@lucide/svelte/icons/info";
import Lock from "@lucide/svelte/icons/lock";
import Camera from "@lucide/svelte/icons/camera";
import AppWindow from "@lucide/svelte/icons/app-window";
import CircleHelp from "@lucide/svelte/icons/circle-help";
export interface SidebarItem { export interface SidebarItem {
/** Must match a key in the view component map (see routing in +page.svelte). */ /** Must match a key in the view component map (see routing in +page.svelte). */
@ -47,7 +51,10 @@ export const SYSTEM_ITEMS: SidebarItem[] = [
{ id: "power", label: "Power", icon: BatteryFull }, { id: "power", label: "Power", icon: BatteryFull },
{ id: "datetime", label: "Date & Time", icon: Clock }, { id: "datetime", label: "Date & Time", icon: Clock },
{ id: "hyprland", label: "Display", sublabel: "monitors.json", icon: Monitor }, { id: "hyprland", label: "Display", sublabel: "monitors.json", icon: Monitor },
{ id: "breadmon", label: "Monitors", sublabel: "breadmon", icon: AppWindow },
{ id: "breadlock", label: "Lock & greet", sublabel: "breadlock", icon: Lock },
{ id: "keybinds", label: "Keybinds", sublabel: "binds.json", icon: Keyboard }, { id: "keybinds", label: "Keybinds", sublabel: "binds.json", icon: Keyboard },
{ id: "breadshot", label: "Screenshots", sublabel: "breadshot", icon: Camera },
{ id: "autostart", label: "Startup Apps", sublabel: "autostart.json", icon: Rocket }, { id: "autostart", label: "Startup Apps", sublabel: "autostart.json", icon: Rocket },
{ id: "users", label: "Users", icon: Users }, { id: "users", label: "Users", icon: Users },
]; ];
@ -70,7 +77,10 @@ export const MAINTENANCE_ITEMS: SidebarItem[] = [
{ id: "snapshots", label: "Snapshots", icon: History }, { id: "snapshots", label: "Snapshots", icon: History },
]; ];
export const ABOUT_ITEMS: SidebarItem[] = [{ id: "about", label: "About", icon: Info }]; export const ABOUT_ITEMS: SidebarItem[] = [
{ id: "breadhelp", label: "Help", sublabel: "breadhelp", icon: CircleHelp },
{ id: "about", label: "About", icon: Info },
];
export interface SidebarSection { export interface SidebarSection {
title: string | null; title: string | null;

View file

@ -1,14 +1,14 @@
// Frontend half of the event-streaming command pattern (see // Frontend half of the event-streaming command pattern (see
// src-tauri/src/commands/streaming.rs) — runs a command, appends each // src/src/commands/streaming.rs) — listens for `cmd-output` lines from a
// stdout/stderr line to a reactive log as it arrives, and resolves once the // typed Tauri command that runs a hardcoded program, then resolves once
// process exits with whether it succeeded. // the process exits.
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
export async function runStreamingCommand( export async function runStreamed(
program: string, command: string,
args: string[], args: Record<string, unknown>,
onLine: (line: string) => void, onLine: (line: string) => void,
): Promise<boolean> { ): Promise<boolean> {
const sessionId = crypto.randomUUID(); const sessionId = crypto.randomUUID();
@ -18,7 +18,7 @@ export async function runStreamingCommand(
}); });
try { try {
return await invoke<boolean>("run_streaming_command", { sessionId, program, args }); return await invoke<boolean>(command, { sessionId, ...args });
} finally { } finally {
unlisten(); unlisten();
} }

View file

@ -22,7 +22,9 @@
} }
interface Network { interface Network {
ssid: string; ssid: string;
password: string; // Write-only. The backend never returns a stored PSK; empty means
// "keep the on-disk secret / let NetworkManager remember."
password?: string | null;
hidden: boolean; hidden: boolean;
} }
interface Profile { interface Profile {
@ -51,7 +53,9 @@
let savedSsids = $derived(cfg?.networks.map((n) => n.ssid).filter((s) => s.trim().length > 0) ?? []); let savedSsids = $derived(cfg?.networks.map((n) => n.ssid).filter((s) => s.trim().length > 0) ?? []);
onMount(async () => { onMount(async () => {
cfg = await invoke<BreadcrumbsConfig>("get_breadcrumbs_config"); const loaded = await invoke<BreadcrumbsConfig>("get_breadcrumbs_config");
loaded.networks = loaded.networks.map((n) => ({ ...n, password: "" }));
cfg = loaded;
}); });
function addNetwork() { function addNetwork() {
@ -71,7 +75,20 @@
} }
async function save() { async function save() {
await invoke("save_breadcrumbs_config", { input: cfg }); const payload = {
...cfg!,
networks: cfg!.networks.map((n) => ({
ssid: n.ssid,
hidden: n.hidden,
// Omit empty so the backend treats it as "keep / let NM remember"
// rather than writing password = "".
...(n.password && n.password.length > 0 ? { password: n.password } : {}),
})),
};
await invoke("save_breadcrumbs_config", { input: payload });
// Clear typed secrets from the UI after a successful save so a later
// glance at the field doesn't look like a stored PSK came back.
cfg!.networks = cfg!.networks.map((n) => ({ ...n, password: "" }));
} }
</script> </script>
@ -93,12 +110,22 @@
<NumberField label="Check connectivity every (s)" bind:value={cfg.settings.watch_interval} min={1} max={600} /> <NumberField label="Check connectivity every (s)" bind:value={cfg.settings.watch_interval} min={1} max={600} />
</Group> </Group>
<Group title="Saved networks" wide> <Group
title="Saved networks"
hint="Password is write-only — leave it blank to keep an already-saved secret or let NetworkManager remember it after the first connect. Breadcrumbs never writes a PSK back into breadcrumbs.toml; new passwords go only to networks.toml (0600) and are cleared there after the first successful connect."
wide
>
<div class="list"> <div class="list">
{#each cfg.networks as net, i (i)} {#each cfg.networks as net, i (i)}
<div class="net-row"> <div class="net-row">
<input type="text" bind:value={net.ssid} placeholder="Network name (SSID)" class="ssid" /> <input type="text" bind:value={net.ssid} placeholder="Network name (SSID)" class="ssid" />
<input type="password" bind:value={net.password} placeholder="Password" class="pass" /> <input
type="password"
autocomplete="new-password"
bind:value={net.password}
placeholder="New password (blank = keep / NM remembers)"
class="pass"
/>
<div class="hidden-label"> <div class="hidden-label">
<Switch bind:value={net.hidden} ariaLabel="Hidden network" /> <Switch bind:value={net.hidden} ariaLabel="Hidden network" />
<button type="button" class="label-text" onclick={() => (net.hidden = !net.hidden)}>Hidden network</button> <button type="button" class="label-text" onclick={() => (net.hidden = !net.hidden)}>Hidden network</button>

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import SwitchField from "$lib/components/SwitchField.svelte";
interface AutostartEntry {
command: string;
label: string;
enabled: boolean;
}
const BREADHELP_CMD = "breadhelp --autostart";
const BREADHELP_LABEL = "BOS Help (first-run onboarding)";
let entries = $state<AutostartEntry[] | null>(null);
let status = $state("");
let helpEntry = $derived(entries?.find((e) => e.command.trim().startsWith("breadhelp")) ?? null);
let autostartOn = $derived(helpEntry?.enabled ?? false);
onMount(async () => {
entries = await invoke<AutostartEntry[]>("get_autostart_entries");
});
async function setAutostart(enabled: boolean) {
if (!entries) return;
const next = entries.map((e) => ({ ...e }));
const i = next.findIndex((e) => e.command.trim().startsWith("breadhelp"));
if (i >= 0) {
next[i].enabled = enabled;
} else if (enabled) {
next.push({ command: BREADHELP_CMD, label: BREADHELP_LABEL, enabled: true });
}
try {
await invoke("save_autostart_entries", { entries: next });
entries = next;
status = enabled ? "First-run help will launch at login." : "First-run help won't launch at login.";
} catch (e) {
status = `Error: ${e}`;
}
}
</script>
<ViewScaffold title="Help">
<Group
title="BOS Help"
hint="breadhelp is the onboarding and help center — searchable guides, a keybind cheatsheet, a troubleshoot wizard, and a live tour. This panel just launches it; it doesn't copy the help app into Settings."
>
<button class="primary" onclick={() => invoke("open_breadhelp")}>Open breadhelp</button>
</Group>
<Group title="First-run autostart" hint="The extra autostart entry in hypr/autostart.json (breadhelp --autostart). Core desktop launch is separate and always runs.">
<SwitchField label="Show help on login" bind:value={() => autostartOn, (v) => setAutostart(v)} />
{#if !helpEntry}
<Hint text="No breadhelp entry in autostart.json yet — turning this on adds the default first-run command." />
{/if}
{#if status}
<Hint text={status} />
{/if}
</Group>
</ViewScaffold>
<style>
.primary {
align-self: flex-start;
background-color: var(--accent);
color: var(--on-accent);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
}
</style>

View file

@ -0,0 +1,94 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import SelectField from "$lib/components/SelectField.svelte";
import FileField from "$lib/components/FileField.svelte";
import TextField from "$lib/components/TextField.svelte";
import NumberField from "$lib/components/NumberField.svelte";
import SwitchField from "$lib/components/SwitchField.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
interface BreadlockConfig {
background_mode: string;
background_path: string;
background_blur: boolean;
clock_format: string;
font_family: string;
fail_timeout_ms: number;
}
let cfg = $state<BreadlockConfig | null>(null);
let examplePath = $state<string | null>(null);
onMount(async () => {
cfg = await invoke<BreadlockConfig>("get_breadlock_config");
examplePath = await invoke<string | null>("breadlock_example_path");
});
async function save() {
await invoke("save_breadlock_config", { cfg });
}
</script>
<ViewScaffold title="Lock & greet">
<Group
title="How locking works"
hint="This panel does not configure PAM. Authentication is the breadlock PAM service (/etc/pam.d/breadlock) plus greetd for login — both are packaged, not user-editable from Settings."
>
<Hint
text="Super+L runs loginctl lock-session. hypridle's lock_cmd / idle listener then starts breadlock, which owns the already-running Hyprland session via ext-session-lock-v1."
/>
<Hint
text="breadgreet is the graphical greetd greeter (replacing tuigreet). Its live config is typically /etc/greetd/breadgreet.toml, owned by the greeter user — not written from here."
/>
<button class="primary" onclick={() => invoke("lock_session")}>Lock now</button>
</Group>
{#if cfg}
<Group title="Lock screen" hint="~/.config/breadlock/breadlock.toml — every field is optional; breadlock runs with these defaults if the file is missing.">
<SelectField label="Background" bind:value={cfg.background_mode} options={["color", "image"]} />
{#if cfg.background_mode === "image"}
<FileField label="Image" bind:value={cfg.background_path} placeholder="PNG, cover-fit" extensions={["png"]} />
{/if}
<SwitchField label="Blur background" bind:value={cfg.background_blur} />
<Hint text="Blur is accepted in the file but not implemented yet (needs a wlr-screencopy capture). breadlock logs a warning and shows the background unblurred." />
<TextField label="Clock format" bind:value={cfg.clock_format} placeholder="%H:%M" />
<TextField label="Font" bind:value={cfg.font_family} placeholder="Varela Round" />
<NumberField label="Wrong-password timeout (ms)" bind:value={cfg.fail_timeout_ms} min={0} max={10000} />
<SaveButton onSave={save} />
</Group>
{/if}
<Group title="Files">
<button class="secondary" onclick={() => invoke("open_breadlock_config")}>Open breadlock.toml in editor</button>
{#if examplePath}
<button class="secondary" onclick={() => invoke("open_breadlock_example")}>Open example ({examplePath})</button>
{:else}
<Hint text="No packaged breadlock.example.toml found. The schema is [background] mode/path/blur, [clock] format, [font] family, [input] fail_timeout_ms." />
{/if}
</Group>
</ViewScaffold>
<style>
button {
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
align-self: flex-start;
margin-top: var(--space-xs, 4px);
}
.primary {
background-color: var(--accent);
color: var(--on-accent);
}
.secondary {
background-color: var(--surface);
color: var(--on-surface);
}
</style>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
</script>
<ViewScaffold title="Monitors">
<Group
title="Two different jobs"
hint="This is not a second Display editor. Display (monitors.json) is the login-time layout Hyprland itself reads. breadmon is a TUI for live arrange / mirror / named profiles."
>
<Hint
text="Use Display when you want the persistent Hyprland rule set (output / mode / position / scale) that applies on next login or hyprctl reload."
/>
<Hint
text="Use breadmon when you want to drag monitors around live, pick a common mirror mode, or save/load a profile under ~/.config/breadmon/profiles/. Settings never writes those profiles."
/>
<button class="primary" onclick={() => invoke("open_breadmon")}>Open breadmon</button>
</Group>
</ViewScaffold>
<style>
.primary {
align-self: flex-start;
margin-top: var(--space-sm, 8px);
background-color: var(--accent);
color: var(--on-accent);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
}
</style>

View file

@ -0,0 +1,78 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import InfoRow from "$lib/components/InfoRow.svelte";
import FileField from "$lib/components/FileField.svelte";
import TextField from "$lib/components/TextField.svelte";
import NumberField from "$lib/components/NumberField.svelte";
import SwitchField from "$lib/components/SwitchField.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
interface ShotBind {
shortcut: string;
command: string;
}
interface BreadshotConfig {
save_dir: string;
silent: boolean;
freeze: boolean;
notif_timeout: number;
date_format: string;
}
let binds = $state<ShotBind[] | null>(null);
let cfg = $state<BreadshotConfig | null>(null);
onMount(async () => {
binds = await invoke<ShotBind[]>("get_breadshot_binds");
cfg = await invoke<BreadshotConfig>("get_breadshot_config");
});
async function save() {
await invoke("save_breadshot_config", { cfg });
}
</script>
<ViewScaffold title="Screenshots">
<Group
title="Keybinds"
hint="Read-only from binds.json. Super+Shift+S/C/P is the BOS default (region→file / region→clipboard / screen→file). Change them on the Keybinds panel."
>
{#if binds && binds.length > 0}
{#each binds as b (`${b.shortcut}:${b.command}`)}
<InfoRow label={b.shortcut} value={b.command} />
{/each}
{:else}
<Hint text="No breadshot binds found." />
{/if}
<button class="primary" onclick={() => invoke("breadshot_region_clipboard")}>Capture region to clipboard</button>
</Group>
{#if cfg}
<Group title="breadshot" hint="~/.config/breadshot/config.toml — grim + slurp + wl-copy, Hyprland-aware. All keys optional.">
<FileField label="Save directory" bind:value={cfg.save_dir} placeholder="~/Pictures/Screenshots" mode="folder" />
<SwitchField label="Silent (no notifications)" bind:value={cfg.silent} />
<SwitchField label="Freeze screen during select" bind:value={cfg.freeze} />
<Hint text="Freeze needs hyprpicker. Filenames are <date_format>_breadshot.png." />
<NumberField label="Notification timeout (ms)" bind:value={cfg.notif_timeout} min={0} max={60000} />
<TextField label="Filename date format" bind:value={cfg.date_format} placeholder="%Y-%m-%d-%H%M%S" />
<SaveButton onSave={save} />
</Group>
{/if}
</ViewScaffold>
<style>
.primary {
align-self: flex-start;
margin-top: var(--space-sm, 8px);
background-color: var(--accent);
color: var(--on-accent);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
}
</style>

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { runStreamingCommand } from "$lib/streaming"; import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte"; import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte"; import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte"; import Hint from "$lib/components/Hint.svelte";
@ -31,7 +31,7 @@
async function checkForUpdates() { async function checkForUpdates() {
log = []; log = [];
busy = true; busy = true;
await runStreamingCommand("fwupdmgr", ["refresh"], appendLine); await runStreamed("fwupd_refresh", {}, appendLine);
busy = false; busy = false;
await refresh(); await refresh();
} }
@ -39,7 +39,7 @@
async function updateAll() { async function updateAll() {
log = []; log = [];
busy = true; busy = true;
await runStreamingCommand("fwupdmgr", ["update", "-y"], appendLine); await runStreamed("fwupd_update", {}, appendLine);
busy = false; busy = false;
await refresh(); await refresh();
} }

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { runStreamingCommand } from "$lib/streaming"; import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte"; import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte"; import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte"; import Hint from "$lib/components/Hint.svelte";
@ -31,7 +31,7 @@
async function updatePackage(name: string) { async function updatePackage(name: string) {
log = []; log = [];
busy = true; busy = true;
await runStreamingCommand("bakery", ["update", name], appendLine); await runStreamed("bakery_update", { name }, appendLine);
busy = false; busy = false;
await refresh(); await refresh();
} }
@ -39,14 +39,14 @@
async function listInstalled() { async function listInstalled() {
log = []; log = [];
busy = true; busy = true;
await runStreamingCommand("bakery", ["list"], appendLine); await runStreamed("bakery_list", {}, appendLine);
busy = false; busy = false;
} }
async function updateAll() { async function updateAll() {
log = []; log = [];
busy = true; busy = true;
await runStreamingCommand("bakery", ["update", "--all"], appendLine); await runStreamed("bakery_update_all", {}, appendLine);
busy = false; busy = false;
await refresh(); await refresh();
} }
@ -54,7 +54,7 @@
async function updateSystem() { async function updateSystem() {
log = []; log = [];
busy = true; busy = true;
await runStreamingCommand("pkexec", ["pacman", "-Syu", "--noconfirm"], appendLine); await runStreamed("pacman_system_update", {}, appendLine);
busy = false; busy = false;
} }
</script> </script>

View file

@ -1,6 +1,5 @@
// Maps a sidebar page id to its view component. Pages not yet migrated // Maps a sidebar page id to its view component. Every sidebar id has a
// fall back to Placeholder (see +page.svelte) — this map only lists pages // real view — +page.svelte's Placeholder is only a safety net for typos.
// that actually have a real Tauri-backed view.
import type { Component } from "svelte"; import type { Component } from "svelte";
import About from "./About.svelte"; import About from "./About.svelte";
@ -27,6 +26,10 @@ import Packages from "./Packages.svelte";
import Aur from "./Aur.svelte"; import Aur from "./Aur.svelte";
import Firmware from "./Firmware.svelte"; import Firmware from "./Firmware.svelte";
import Snapshots from "./Snapshots.svelte"; import Snapshots from "./Snapshots.svelte";
import Breadlock from "./Breadlock.svelte";
import Breadshot from "./Breadshot.svelte";
import Breadmon from "./Breadmon.svelte";
import Breadhelp from "./Breadhelp.svelte";
export const VIEWS: Record<string, Component> = { export const VIEWS: Record<string, Component> = {
about: About, about: About,
@ -53,4 +56,8 @@ export const VIEWS: Record<string, Component> = {
aur: Aur, aur: Aur,
firmware: Firmware, firmware: Firmware,
snapshots: Snapshots, snapshots: Snapshots,
breadlock: Breadlock,
breadshot: Breadshot,
breadmon: Breadmon,
breadhelp: Breadhelp,
}; };

27
src/Cargo.lock generated
View file

@ -291,9 +291,8 @@ name = "bos-settings"
version = "0.8.0" version = "0.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-screenshots",
"bread-theme", "bread-theme",
"bread-utils 0.3.1 (git+https://github.com/Breadway/bread-ecosystem?branch=main)", "bread-utils",
"notify", "notify",
"regex", "regex",
"serde", "serde",
@ -306,20 +305,10 @@ dependencies = [
"toml_edit 0.22.27", "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]] [[package]]
name = "bread-theme" name = "bread-theme"
version = "0.3.1" 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 = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"serde", "serde",
@ -329,17 +318,7 @@ dependencies = [
[[package]] [[package]]
name = "bread-utils" name = "bread-utils"
version = "0.3.1" version = "0.3.1"
source = "git+https://git.breadway.dev/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",
"serde_json",
]
[[package]]
name = "bread-utils"
version = "0.3.1"
source = "git+https://github.com/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"serde", "serde",

View file

@ -36,15 +36,7 @@ toml_edit = "0.22"
tokio = { version = "1", features = ["process", "io-util", "time", "macros"] } tokio = { version = "1", features = ["process", "io-util", "time", "macros"] }
notify = "7" notify = "7"
regex = "1" regex = "1"
# TODO(owner): switch to a tag-pinned git dependency once bread-theme cuts a bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" }
# release including css_custom_properties/css_tokens (added 2026-07-21 for bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["toml"] }
# 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" }
anyhow = "1" anyhow = "1"

View file

@ -1,24 +1,46 @@
//! breadcrumbs.toml — Wi-Fi profile state machine. Schema mirrors //! breadcrumbs.toml — Wi-Fi profile state machine. Schema mirrors
//! breadcrumbs/src/config.rs: //! breadcrumbs/src/config.rs:
//! [settings] scalar tunables //! [settings] scalar tunables (this file)
//! [[networks]] saved networks (ssid / password / hidden)
//! [profiles.<name>] per-location profile (networks, tailscale, …) //! [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 serde::{Deserialize, Serialize};
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
use super::config; use super::config;
fn config_path() -> std::path::PathBuf { fn settings_path() -> PathBuf {
config::config_dir().join("breadcrumbs/breadcrumbs.toml") 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 { pub struct Network {
ssid: String, 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, hidden: bool,
} }
@ -56,24 +78,120 @@ fn read_networks(doc: &DocumentMut) -> Vec<Network> {
return Vec::new(); return Vec::new();
}; };
aot.iter() aot.iter()
.map(|t| Network { .map(|t| {
ssid: t.get("ssid").and_then(Item::as_str).unwrap_or("").to_string(), let password = t
password: t.get("password").and_then(Item::as_str).unwrap_or("").to_string(), .get("password")
hidden: t.get("hidden").and_then(Item::as_bool).unwrap_or(false), .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() .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(); let mut aot = ArrayOfTables::new();
for n in nets { for n in nets {
if n.ssid.trim().is_empty() {
continue;
}
let mut t = Table::new(); let mut t = Table::new();
t.insert("ssid", value(&n.ssid)); 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)); t.insert("hidden", value(n.hidden));
aot.push(t); 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> { 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> { let str_list = |item: Option<&Item>| -> Vec<String> {
item.and_then(Item::as_array) 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() .unwrap_or_default()
}; };
tbl.iter() tbl.iter()
@ -92,10 +214,21 @@ fn read_profiles(doc: &DocumentMut) -> Vec<Profile> {
name: name.to_string(), name: name.to_string(),
networks: str_list(p.get("networks")), networks: str_list(p.get("networks")),
detect_ssids: str_list(p.get("detect_ssids")), detect_ssids: str_list(p.get("detect_ssids")),
bootstrap: p.get("bootstrap").and_then(Item::as_str).unwrap_or("").to_string(), bootstrap: p
exit_node: p.get("exit_node").and_then(Item::as_str).unwrap_or("").to_string(), .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), 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() .collect()
@ -132,26 +265,74 @@ fn write_profiles(doc: &mut DocumentMut, profiles: &[Profile]) {
doc.as_table_mut().insert("profiles", Item::Table(tbl)); doc.as_table_mut().insert("profiles", Item::Table(tbl));
} }
#[tauri::command] fn apply_settings(doc: &mut DocumentMut, settings: &Settings) {
pub fn get_breadcrumbs_config() -> BreadcrumbsConfig { config::set_str(
let doc = config::load_doc(&config_path()); 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 { BreadcrumbsConfig {
settings: Settings { settings: Settings {
// breadcrumbs' own default_profile_name() is "away", not "home". // 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()), 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(), 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"]) connectivity_url: config::get_str(&doc, &["settings", "connectivity_url"])
.unwrap_or_else(|| "http://connectivitycheck.gstatic.com/generate_204".into()), .unwrap_or_else(|| "http://connectivitycheck.gstatic.com/generate_204".into()),
nmcli_wait: config::get_i64(&doc, &["settings", "nmcli_wait"]).unwrap_or(8), nmcli_wait: config::get_i64(&doc, &["settings", "nmcli_wait"]).unwrap_or(8),
watch_interval: config::get_i64(&doc, &["settings", "watch_interval"]).unwrap_or(12), 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), 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)] #[derive(Deserialize)]
pub struct SaveBreadcrumbsInput { pub struct SaveBreadcrumbsInput {
settings: Settings, settings: Settings,
@ -161,16 +342,232 @@ pub struct SaveBreadcrumbsInput {
#[tauri::command] #[tauri::command]
pub fn save_breadcrumbs_config(input: SaveBreadcrumbsInput) -> Result<(), String> { pub fn save_breadcrumbs_config(input: SaveBreadcrumbsInput) -> Result<(), String> {
let path = config_path(); save_to(&settings_path(), &networks_path(), input)
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); #[cfg(test)]
config::set_str_or_remove(&mut doc, &["settings", "exit_node"], &input.settings.exit_node); mod tests {
config::set_str(&mut doc, &["settings", "ping_host"], &input.settings.ping_host); use super::*;
config::set_str(&mut doc, &["settings", "connectivity_url"], &input.settings.connectivity_url);
config::set_i64(&mut doc, &["settings", "nmcli_wait"], input.settings.nmcli_wait); fn tmp_dir(name: &str) -> PathBuf {
config::set_i64(&mut doc, &["settings", "watch_interval"], input.settings.watch_interval); let dir = std::env::temp_dir().join(format!(
write_networks(&mut doc, &input.networks); "bos-settings-breadcrumbs-{}-{}-{}",
write_profiles(&mut doc, &input.profiles); name,
config::save_doc(&path, &doc).map_err(|e| e.to_string()) 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 //! 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 //! 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 //! the file into a `toml_edit::DocumentMut`, mutate only the specific keys the
//! UI exposes, and write the document back — preserving everything else, //! UI exposes, and write the document back — preserving everything else,
//! formatting and comments included. //! 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 /// falling back to an empty document there means the next Save (see
/// `save_doc`) overwrites it with only the UI-modelled keys, silently /// `save_doc`) overwrites it with only the UI-modelled keys, silently
/// destroying anything else in the file (breadpad's calendar credentials, /// 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. /// once before falling back, so a bad edit is always recoverable.
pub fn load_doc(path: &Path) -> DocumentMut { pub fn load_doc(path: &Path) -> DocumentMut {
bread_utils::tomlcfg::load_doc("bos-settings", path) 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> { pub fn get_f64(doc: &DocumentMut, path: &[&str]) -> Option<f64> {
let item = get(doc, path)?; 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). /// Read an array of strings (e.g. modules.disable, contexts[].priority).
pub fn get_str_list(doc: &DocumentMut, path: &[&str]) -> Vec<String> { pub fn get_str_list(doc: &DocumentMut, path: &[&str]) -> Vec<String> {
@ -176,7 +178,10 @@ password = \"secret\" # keep me
let mut doc = DocumentMut::new(); let mut doc = DocumentMut::new();
set_bool(&mut doc, &["adapters", "power", "enabled"], false); set_bool(&mut doc, &["adapters", "power", "enabled"], false);
set_i64(&mut doc, &["adapters", "power", "poll_interval_secs"], 45); 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!( assert_eq!(
get_i64(&doc, &["adapters", "power", "poll_interval_secs"]), get_i64(&doc, &["adapters", "power", "poll_interval_secs"]),
Some(45) Some(45)
@ -200,14 +205,20 @@ password = \"secret\" # keep me
#[test] #[test]
fn atomic_write_backs_up_previous_contents_and_no_tmp_file_left_behind() { 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(); std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml"); let path = dir.join("config.toml");
let backup = dir.join("config.toml.bak"); let backup = dir.join("config.toml.bak");
atomic_write(&path, "first").unwrap(); atomic_write(&path, "first").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first"); 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(); atomic_write(&path, "second").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "second"); 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()) .map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".tmp.")) .filter(|n| n.contains(".tmp."))
.collect(); .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); 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) 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] #[tauri::command]
pub fn get_keybinds() -> BindsPayload { pub fn get_keybinds() -> BindsPayload {
let (file, kind) = load(); let (file, kind) = load();
@ -265,8 +310,14 @@ mod tests {
// active_layout/globals/common/layouts keys leaking in. // active_layout/globals/common/layouts keys leaking in.
let saved_obj = saved.as_object().expect("flat save must be a JSON object"); let saved_obj = saved.as_object().expect("flat save must be a JSON object");
assert_eq!( assert_eq!(
saved_obj.keys().cloned().collect::<std::collections::BTreeSet<_>>(), saved_obj
["default_mods", "bindings"].into_iter().map(String::from).collect(), .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}}" "Flat schema must round-trip as exactly {{default_mods, bindings}}"
); );
@ -279,7 +330,8 @@ mod tests {
#[test] #[test]
fn round_trip_via_files_preserves_bindings_key_and_extras() { 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(); std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("binds.json"); let path = dir.join("binds.json");
std::fs::write(&path, REAL_BOS_FLAT_FIXTURE).unwrap(); 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 saved: Value = serde_json::from_str(&saved_text).unwrap();
let original: Value = serde_json::from_str(REAL_BOS_FLAT_FIXTURE).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["bindings"], original["bindings"]);
assert_eq!(saved["default_mods"], original["default_mods"]); assert_eq!(saved["default_mods"], original["default_mods"]);
@ -301,7 +356,8 @@ mod tests {
save_to(&path, &file, kind).unwrap(); save_to(&path, &file, kind).unwrap();
let backup_path = dir.join("binds.json.bak"); let backup_path = dir.join("binds.json.bak");
assert!(backup_path.exists(), "save must back up the previous file"); 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"]); assert_eq!(backup["bindings"], original["bindings"]);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
@ -320,7 +376,10 @@ mod tests {
assert_eq!(kind, SchemaKind::MultiLayout); assert_eq!(kind, SchemaKind::MultiLayout);
let saved = to_json(&file, kind); 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["active_layout"], "qwerty");
assert_eq!(saved["layouts"]["qwerty"][0]["action"], "close"); assert_eq!(saved["layouts"]["qwerty"][0]["action"], "close");
assert_eq!(saved["globals"][0]["command"], "kitty"); assert_eq!(saved["globals"][0]["command"], "kitty");
@ -332,20 +391,32 @@ mod tests {
let (file, kind) = parse(text); let (file, kind) = parse(text);
assert_eq!(kind, SchemaKind::Unknown); 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(); std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("binds.json"); let path = dir.join("binds.json");
let result = save_to(&path, &file, kind); let result = save_to(&path, &file, kind);
assert!(result.is_err(), "save() must refuse when schema kind is Unknown"); assert!(
assert!(!path.exists(), "refusing to save must not create/touch the target file"); 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); let _ = std::fs::remove_dir_all(&dir);
} }
#[test] #[test]
fn missing_file_defaults_to_flat_not_multi_layout() { 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. // Don't create the file at all.
let path = dir.join("binds.json"); let path = dir.join("binds.json");
let (_, kind) = load_from(&path); let (_, kind) = load_from(&path);

View file

@ -8,9 +8,13 @@ pub mod breadbar;
pub mod breadbox; pub mod breadbox;
pub mod breadclip; pub mod breadclip;
pub mod breadcrumbs; pub mod breadcrumbs;
pub mod breadhelp;
pub mod breadlock;
pub mod breadmon;
pub mod breadpad; pub mod breadpad;
pub mod breadpaper; pub mod breadpaper;
pub mod breadsearch; pub mod breadsearch;
pub mod breadshot;
pub mod config; pub mod config;
pub mod datetime; pub mod datetime;
pub mod firewall; pub mod firewall;

View file

@ -1,10 +1,12 @@
//! Shared event-streaming command runner for the genuinely long-running //! Shared event-streaming runner for the genuinely long-running operations
//! operations (package/firmware updates) where the GTK app treated output //! (package/firmware updates) where the GTK app treated output as "watch
//! as "watch the log scroll" — the Tauri-side analog of //! the log scroll" — the Tauri-side analog of `stream_command_then`'s
//! `stream_command_then`'s async_channel → glib::spawn_future_local //! async_channel → glib::spawn_future_local pipeline, using Tauri's event
//! pipeline, using Tauri's event bus instead of a GLib main-loop channel. //! 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. //! 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 serde::Serialize;
use std::process::Stdio; use std::process::Stdio;
@ -18,18 +20,25 @@ struct CmdOutputEvent {
line: String, line: String,
} }
/// Runs `program args...`, emitting one `cmd-output` event per line of /// Runs a hardcoded `program args...`, emitting one `cmd-output` event per
/// stdout/stderr (tagged with `session_id` so the frontend can route /// line of stdout/stderr (tagged with `session_id` so the frontend can route
/// concurrent streams), and resolves to whether it exited successfully — /// concurrent streams), and resolves to whether it exited successfully.
/// the frontend awaits this call directly rather than needing a second async fn run_hardcoded(app: AppHandle, session_id: String, program: &str, args: &[&str]) -> bool {
/// "done" event. let child = Command::new(program)
#[tauri::command] .args(args)
pub async fn run_streaming_command(app: AppHandle, session_id: String, program: String, args: Vec<String>) -> bool { .stdout(Stdio::piped())
let child = Command::new(&program).args(&args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn(); .stderr(Stdio::piped())
.spawn();
let mut child = match child { let mut child = match child {
Ok(c) => c, Ok(c) => c,
Err(e) => { 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; return false;
} }
}; };
@ -40,7 +49,13 @@ pub async fn run_streaming_command(app: AppHandle, session_id: String, program:
let read_stdout = async { let read_stdout = async {
let mut lines = BufReader::new(stdout).lines(); let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await { 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(); 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 read_stderr = async move {
let mut lines = BufReader::new(stderr).lines(); let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await { 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); tokio::join!(read_stdout, read_stderr);
child.wait().await.map(|s| s.success()).unwrap_or(false) 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 { fn render_theme_css() -> String {
let palette = bread_theme::load_palette(); 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!( format!(
"{}\n{}", ":root {{\n\
bread_theme::css_custom_properties(&palette), \x20\x20--font-family: '{font}';\n\
bread_theme::css_tokens(), \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) { 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; return;
} }

View file

@ -86,7 +86,12 @@ pub fn run() {
commands::users::change_password, commands::users::change_password,
commands::users::remove_user, commands::users::remove_user,
commands::users::add_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::packages::get_installed_packages,
commands::aur::search_aur, commands::aur::search_aur,
commands::aur::install_aur_package, commands::aur::install_aur_package,
@ -94,6 +99,18 @@ pub fn run() {
commands::snapshots::get_snapshots, commands::snapshots::get_snapshots,
commands::snapshots::delete_snapshot, commands::snapshots::delete_snapshot,
commands::snapshots::reboot_system, 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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View file

@ -1,5 +1,5 @@
//! `--screenshot` CLI mode: switch the Svelte SPA to the named sidebar //! `--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 //! `bread-ecosystem`'s `bread-capture` orchestrator, or run standalone for
//! one-off captures. //! one-off captures.
//! //!
@ -59,6 +59,10 @@ const KNOWN_VIEWS: &[&str] = &[
"aur", "aur",
"firmware", "firmware",
"snapshots", "snapshots",
"breadlock",
"breadshot",
"breadmon",
"breadhelp",
"about", "about",
]; ];
@ -107,7 +111,12 @@ pub fn parse(args: &[String]) -> Option<ScreenshotRequest> {
eprintln!("bos-settings: --screenshot requires --output"); eprintln!("bos-settings: --screenshot requires --output");
std::process::exit(1); 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 /// 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); std::process::exit(1);
} }
tokio::time::sleep(VIEW_SETTLE_DELAY).await; tokio::time::sleep(VIEW_SETTLE_DELAY).await;
finish(bread_screenshots::capture_region( finish(capture_region(
0, 0,
0, 0,
req.width as i32, 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<()>) { fn finish(result: anyhow::Result<()>) {
match result { match result {
Ok(()) => std::process::exit(0), Ok(()) => std::process::exit(0),

View file

@ -20,7 +20,7 @@
} }
], ],
"security": { "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": { "assetProtocol": {
"enable": true, "enable": true,
"scope": ["$HOME/Pictures/Backgrounds/**"] "scope": ["$HOME/Pictures/Backgrounds/**"]