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

View file

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

View file

@ -27,6 +27,10 @@ import Package from "@lucide/svelte/icons/package";
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
import History from "@lucide/svelte/icons/history";
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 {
/** 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: "datetime", label: "Date & Time", icon: Clock },
{ 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: "breadshot", label: "Screenshots", sublabel: "breadshot", icon: Camera },
{ id: "autostart", label: "Startup Apps", sublabel: "autostart.json", icon: Rocket },
{ id: "users", label: "Users", icon: Users },
];
@ -70,7 +77,10 @@ export const MAINTENANCE_ITEMS: SidebarItem[] = [
{ 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 {
title: string | null;

View file

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

View file

@ -22,7 +22,9 @@
}
interface Network {
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;
}
interface Profile {
@ -51,7 +53,9 @@
let savedSsids = $derived(cfg?.networks.map((n) => n.ssid).filter((s) => s.trim().length > 0) ?? []);
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() {
@ -71,7 +75,20 @@
}
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>
@ -93,12 +110,22 @@
<NumberField label="Check connectivity every (s)" bind:value={cfg.settings.watch_interval} min={1} max={600} />
</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">
{#each cfg.networks as net, i (i)}
<div class="net-row">
<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">
<Switch bind:value={net.hidden} ariaLabel="Hidden network" />
<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">
import { onMount } from "svelte";
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 Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
@ -31,7 +31,7 @@
async function checkForUpdates() {
log = [];
busy = true;
await runStreamingCommand("fwupdmgr", ["refresh"], appendLine);
await runStreamed("fwupd_refresh", {}, appendLine);
busy = false;
await refresh();
}
@ -39,7 +39,7 @@
async function updateAll() {
log = [];
busy = true;
await runStreamingCommand("fwupdmgr", ["update", "-y"], appendLine);
await runStreamed("fwupd_update", {}, appendLine);
busy = false;
await refresh();
}

View file

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

View file

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