Harden settings: split breadcrumbs secrets, typed exec, new panels
Load and save Wi-Fi networks in networks.toml (0600) instead of writing PSKs back into breadcrumbs.toml. The password field is write-only. Replace the generic argv runner with typed bakery/pacman/fwupd commands and set a real Tauri CSP. Add Lock, Screenshots, Monitors, and Help panels. Pin bread-theme and bread-utils to bread-ecosystem v0.7.1. bread-screenshots is not on that tag, so --screenshot calls grim locally. bakery.toml lists webkitgtk-4.1 deps; README/CLAUDE.md match Tauri 2 + Svelte 5 and single-trunk main.
This commit is contained in:
parent
abfb4fd4a4
commit
cbac50683e
30 changed files with 1469 additions and 147 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
33
CLAUDE.md
Normal 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_*`).
|
||||||
17
README.md
17
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
76
frontend/src/lib/views/Breadhelp.svelte
Normal file
76
frontend/src/lib/views/Breadhelp.svelte
Normal 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>
|
||||||
94
frontend/src/lib/views/Breadlock.svelte
Normal file
94
frontend/src/lib/views/Breadlock.svelte
Normal 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>
|
||||||
34
frontend/src/lib/views/Breadmon.svelte
Normal file
34
frontend/src/lib/views/Breadmon.svelte
Normal 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>
|
||||||
78
frontend/src/lib/views/Breadshot.svelte
Normal file
78
frontend/src/lib/views/Breadshot.svelte
Normal 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>
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
27
src/Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
.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),
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
src/src/commands/breadhelp.rs
Normal file
10
src/src/commands/breadhelp.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
//! breadhelp is the onboarding / help center. This panel launches it; it
|
||||||
|
//! does not duplicate the help library, keybind tour, or troubleshoot
|
||||||
|
//! wizard. First-run autostart (`breadhelp --autostart` in
|
||||||
|
//! `hypr/autostart.json`) is toggled through the existing autostart
|
||||||
|
//! commands from the frontend.
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_breadhelp() {
|
||||||
|
let _ = std::process::Command::new("breadhelp").spawn();
|
||||||
|
}
|
||||||
132
src/src/commands/breadlock.rs
Normal file
132
src/src/commands/breadlock.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
//! Lock screen (breadlock) and greeter (breadgreet).
|
||||||
|
//!
|
||||||
|
//! Super+L is `loginctl lock-session`; hypridle's lock_cmd / idle listener
|
||||||
|
//! then starts breadlock. This panel edits `~/.config/breadlock/breadlock.toml`
|
||||||
|
//! (appearance + fail timeout) — it does not, and cannot, configure PAM.
|
||||||
|
//! breadgreet is the greetd greeter; its live config is typically
|
||||||
|
//! `/etc/greetd/breadgreet.toml` (system, owned by the greeter user) and is
|
||||||
|
//! not written from here.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::config;
|
||||||
|
|
||||||
|
fn config_path() -> std::path::PathBuf {
|
||||||
|
config::config_dir().join("breadlock/breadlock.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXAMPLE_CANDIDATES: &[&str] = &[
|
||||||
|
"/usr/share/doc/breadlock/breadlock.example.toml",
|
||||||
|
"/usr/share/breadlock/breadlock.example.toml",
|
||||||
|
"/usr/share/doc/breadlock/examples/breadlock.example.toml",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct BreadlockConfig {
|
||||||
|
background_mode: String,
|
||||||
|
background_path: String,
|
||||||
|
background_blur: bool,
|
||||||
|
clock_format: String,
|
||||||
|
font_family: String,
|
||||||
|
fail_timeout_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BreadlockConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
background_mode: "color".into(),
|
||||||
|
background_path: String::new(),
|
||||||
|
background_blur: false,
|
||||||
|
clock_format: "%H:%M".into(),
|
||||||
|
font_family: "Varela Round".into(),
|
||||||
|
fail_timeout_ms: 800,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_breadlock_config() -> BreadlockConfig {
|
||||||
|
let doc = config::load_doc(&config_path());
|
||||||
|
let mut cfg = BreadlockConfig::default();
|
||||||
|
if let Some(mode) = config::get_str(&doc, &["background", "mode"]) {
|
||||||
|
cfg.background_mode = mode;
|
||||||
|
}
|
||||||
|
if let Some(path) = config::get_str(&doc, &["background", "path"]) {
|
||||||
|
cfg.background_path = path;
|
||||||
|
}
|
||||||
|
if let Some(blur) = config::get_bool(&doc, &["background", "blur"]) {
|
||||||
|
cfg.background_blur = blur;
|
||||||
|
}
|
||||||
|
if let Some(fmt) = config::get_str(&doc, &["clock", "format"]) {
|
||||||
|
cfg.clock_format = fmt;
|
||||||
|
}
|
||||||
|
if let Some(family) = config::get_str(&doc, &["font", "family"]) {
|
||||||
|
cfg.font_family = family;
|
||||||
|
}
|
||||||
|
if let Some(ms) = config::get_i64(&doc, &["input", "fail_timeout_ms"]) {
|
||||||
|
cfg.fail_timeout_ms = ms;
|
||||||
|
}
|
||||||
|
cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_breadlock_config(cfg: BreadlockConfig) -> Result<(), String> {
|
||||||
|
let path = config_path();
|
||||||
|
let mut doc = config::load_doc(&path);
|
||||||
|
let mode = if cfg.background_mode == "image" {
|
||||||
|
"image"
|
||||||
|
} else {
|
||||||
|
"color"
|
||||||
|
};
|
||||||
|
config::set_str(&mut doc, &["background", "mode"], mode);
|
||||||
|
config::set_str_or_remove(&mut doc, &["background", "path"], &cfg.background_path);
|
||||||
|
config::set_bool(&mut doc, &["background", "blur"], cfg.background_blur);
|
||||||
|
config::set_str(&mut doc, &["clock", "format"], &cfg.clock_format);
|
||||||
|
config::set_str(&mut doc, &["font", "family"], &cfg.font_family);
|
||||||
|
config::set_i64(
|
||||||
|
&mut doc,
|
||||||
|
&["input", "fail_timeout_ms"],
|
||||||
|
cfg.fail_timeout_ms.max(0),
|
||||||
|
);
|
||||||
|
config::save_doc(&path, &doc).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First existing packaged example, if any — the panel links this rather
|
||||||
|
/// than pretending the in-app editor is the whole schema.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn breadlock_example_path() -> Option<String> {
|
||||||
|
EXAMPLE_CANDIDATES
|
||||||
|
.iter()
|
||||||
|
.find(|p| std::path::Path::new(p).is_file())
|
||||||
|
.map(|p| p.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_in_editor(path: &std::path::Path) {
|
||||||
|
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
|
||||||
|
let _ = std::process::Command::new("kitty")
|
||||||
|
.args(["-e", &editor])
|
||||||
|
.arg(path)
|
||||||
|
.spawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_breadlock_config() {
|
||||||
|
open_in_editor(&config_path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_breadlock_example() {
|
||||||
|
if let Some(path) = breadlock_example_path() {
|
||||||
|
open_in_editor(std::path::Path::new(&path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Super+L / hypridle path: `loginctl lock-session` → compositor lock
|
||||||
|
/// protocol → breadlock. Fire-and-forget; locking the live session is the
|
||||||
|
/// point of the button.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn lock_session() {
|
||||||
|
let _ = std::process::Command::new("loginctl")
|
||||||
|
.arg("lock-session")
|
||||||
|
.spawn();
|
||||||
|
}
|
||||||
9
src/src/commands/breadmon.rs
Normal file
9
src/src/commands/breadmon.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
//! breadmon is a TUI for live Hyprland monitor layout, mirroring, and
|
||||||
|
//! named profiles (`~/.config/breadmon/profiles/`). Display (this app)
|
||||||
|
//! edits `hypr/monitors.json` — the login-time layout Hyprland itself
|
||||||
|
//! reads. This module only launches the TUI; it does not write profiles.
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_breadmon() {
|
||||||
|
let _ = std::process::Command::new("breadmon").spawn();
|
||||||
|
}
|
||||||
139
src/src/commands/breadshot.rs
Normal file
139
src/src/commands/breadshot.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
//! Screenshots (breadshot). Binds are read-only here — edit them on the
|
||||||
|
//! Keybinds panel. Config lives at `~/.config/breadshot/config.toml` and
|
||||||
|
//! matches breadshot's own `Config` (every field optional).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::config;
|
||||||
|
use super::keybinds;
|
||||||
|
|
||||||
|
fn config_path() -> std::path::PathBuf {
|
||||||
|
config::config_dir().join("breadshot/config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct BreadshotConfig {
|
||||||
|
save_dir: String,
|
||||||
|
silent: bool,
|
||||||
|
freeze: bool,
|
||||||
|
notif_timeout: i64,
|
||||||
|
date_format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BreadshotConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
save_dir: "~/Pictures/Screenshots".into(),
|
||||||
|
silent: false,
|
||||||
|
freeze: false,
|
||||||
|
notif_timeout: 5000,
|
||||||
|
date_format: "%Y-%m-%d-%H%M%S".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ShotBind {
|
||||||
|
shortcut: String,
|
||||||
|
command: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_breadshot_config() -> BreadshotConfig {
|
||||||
|
let doc = config::load_doc(&config_path());
|
||||||
|
let mut cfg = BreadshotConfig::default();
|
||||||
|
if let Some(dir) = config::get_str(&doc, &["save_dir"]) {
|
||||||
|
cfg.save_dir = dir;
|
||||||
|
}
|
||||||
|
if let Some(v) = config::get_bool(&doc, &["silent"]) {
|
||||||
|
cfg.silent = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = config::get_bool(&doc, &["freeze"]) {
|
||||||
|
cfg.freeze = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = config::get_i64(&doc, &["notif_timeout"]) {
|
||||||
|
cfg.notif_timeout = v;
|
||||||
|
}
|
||||||
|
if let Some(v) = config::get_str(&doc, &["date_format"]) {
|
||||||
|
cfg.date_format = v;
|
||||||
|
}
|
||||||
|
cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_breadshot_config(cfg: BreadshotConfig) -> Result<(), String> {
|
||||||
|
let path = config_path();
|
||||||
|
let mut doc = config::load_doc(&path);
|
||||||
|
config::set_str(&mut doc, &["save_dir"], &cfg.save_dir);
|
||||||
|
config::set_bool(&mut doc, &["silent"], cfg.silent);
|
||||||
|
config::set_bool(&mut doc, &["freeze"], cfg.freeze);
|
||||||
|
config::set_i64(&mut doc, &["notif_timeout"], cfg.notif_timeout.max(0));
|
||||||
|
config::set_str(&mut doc, &["date_format"], &cfg.date_format);
|
||||||
|
config::save_doc(&path, &doc).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Documented BOS defaults (SUPER+Shift+S/C/P) used when binds.json has no
|
||||||
|
/// breadshot exec entries — still accurate as a cheatsheet even on a
|
||||||
|
/// machine whose binds were rewritten.
|
||||||
|
fn documented_defaults() -> Vec<ShotBind> {
|
||||||
|
vec![
|
||||||
|
ShotBind {
|
||||||
|
shortcut: "Super+Shift+S".into(),
|
||||||
|
command: "breadshot region".into(),
|
||||||
|
},
|
||||||
|
ShotBind {
|
||||||
|
shortcut: "Super+Shift+C".into(),
|
||||||
|
command: "breadshot region --clipboard-only".into(),
|
||||||
|
},
|
||||||
|
ShotBind {
|
||||||
|
shortcut: "Super+Shift+P".into(),
|
||||||
|
command: "breadshot active-output".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_shortcut(mods: Option<&[String]>, key: Option<&str>, default_mods: &[String]) -> String {
|
||||||
|
let mods = mods.unwrap_or(default_mods);
|
||||||
|
let mut parts: Vec<String> = mods
|
||||||
|
.iter()
|
||||||
|
.map(|m| match m.to_ascii_uppercase().as_str() {
|
||||||
|
"SUPER" | "MOD4" => "Super".into(),
|
||||||
|
"SHIFT" => "Shift".into(),
|
||||||
|
"CTRL" | "CONTROL" => "Ctrl".into(),
|
||||||
|
"ALT" | "MOD1" => "Alt".into(),
|
||||||
|
other => other.to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Some(k) = key {
|
||||||
|
if !k.is_empty() {
|
||||||
|
parts.push(k.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.join("+")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only: breadshot exec binds from binds.json, or the documented
|
||||||
|
/// Super+Shift+S/C/P cheatsheet if none are defined.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_breadshot_binds() -> Vec<ShotBind> {
|
||||||
|
let from_file = keybinds::breadshot_binds();
|
||||||
|
if from_file.is_empty() {
|
||||||
|
documented_defaults()
|
||||||
|
} else {
|
||||||
|
from_file
|
||||||
|
.into_iter()
|
||||||
|
.map(|b| ShotBind {
|
||||||
|
shortcut: format_shortcut(b.mods.as_deref(), b.key.as_deref(), &b.default_mods),
|
||||||
|
command: b.command,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interactive region capture, clipboard only — no file written.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn breadshot_region_clipboard() {
|
||||||
|
let _ = std::process::Command::new("breadshot")
|
||||||
|
.args(["region", "--clipboard-only"])
|
||||||
|
.spawn();
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
//!
|
//!
|
||||||
//! Every bread* app owns a TOML config that may contain keys, sections, and
|
//! 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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/**"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue