Add OS settings panels for updates, printing, VPN, and related surfaces
All checks were successful
dev release / build (push) Successful in 2m11s

Typed commands only: pacman/bakery/fwupd compose on Updates, CUPS/nmcli,
hyprsunset, fcitx5, MIME defaults, bakery track, restic (backup.toml 0600),
curated optional software, and an NVIDIA offer card gated on the probe file.
This commit is contained in:
Breadway 2026-08-16 00:07:04 +08:00
parent 32604b492b
commit 60a473fff0
29 changed files with 4229 additions and 8 deletions

View file

@ -31,6 +31,16 @@ import Lock from "@lucide/svelte/icons/lock";
import Camera from "@lucide/svelte/icons/camera"; import Camera from "@lucide/svelte/icons/camera";
import AppWindow from "@lucide/svelte/icons/app-window"; import AppWindow from "@lucide/svelte/icons/app-window";
import CircleHelp from "@lucide/svelte/icons/circle-help"; import CircleHelp from "@lucide/svelte/icons/circle-help";
import Download from "@lucide/svelte/icons/download";
import Printer from "@lucide/svelte/icons/printer";
import ShieldEllipsis from "@lucide/svelte/icons/shield-ellipsis";
import Moon from "@lucide/svelte/icons/moon";
import Languages from "@lucide/svelte/icons/languages";
import Accessibility from "@lucide/svelte/icons/accessibility";
import AppWindowMac from "@lucide/svelte/icons/app-window-mac";
import GitBranch from "@lucide/svelte/icons/git-branch";
import Archive from "@lucide/svelte/icons/archive";
import Boxes from "@lucide/svelte/icons/boxes";
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). */
@ -45,15 +55,20 @@ export interface SidebarItem {
export const SYSTEM_ITEMS: SidebarItem[] = [ export const SYSTEM_ITEMS: SidebarItem[] = [
{ id: "network", label: "Network", icon: Wifi }, { id: "network", label: "Network", icon: Wifi },
{ id: "breadcrumbs", label: "Wi-Fi Profiles", sublabel: "breadcrumbs", icon: Network }, { id: "breadcrumbs", label: "Wi-Fi Profiles", sublabel: "breadcrumbs", icon: Network },
{ id: "vpn", label: "VPN / WireGuard", sublabel: "NetworkManager", icon: ShieldEllipsis },
{ id: "bluetooth", label: "Bluetooth", icon: Bluetooth }, { id: "bluetooth", label: "Bluetooth", icon: Bluetooth },
{ id: "printing", label: "Printing", sublabel: "CUPS", icon: Printer },
{ id: "firewall", label: "Firewall", icon: Shield }, { id: "firewall", label: "Firewall", icon: Shield },
{ id: "sound", label: "Sound", icon: Volume2 }, { id: "sound", label: "Sound", icon: Volume2 },
{ 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: "nightlight", label: "Night light", sublabel: "hyprsunset", icon: Moon },
{ id: "breadmon", label: "Monitors", sublabel: "breadmon", icon: AppWindow }, { id: "breadmon", label: "Monitors", sublabel: "breadmon", icon: AppWindow },
{ id: "breadlock", label: "Lock & greet", sublabel: "breadlock", icon: Lock }, { 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: "ime", label: "Input method", sublabel: "fcitx5", icon: Languages },
{ id: "accessibility", label: "Accessibility", icon: Accessibility },
{ id: "breadshot", label: "Screenshots", sublabel: "breadshot", icon: Camera }, { 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 },
@ -67,14 +82,19 @@ export const PERSONALIZATION_ITEMS: SidebarItem[] = [
{ id: "breadclip", label: "Clipboard", sublabel: "breadclipd", icon: Clipboard }, { id: "breadclip", label: "Clipboard", sublabel: "breadclipd", icon: Clipboard },
{ id: "breadpad", label: "Notes", sublabel: "breadpad", icon: NotebookPen }, { id: "breadpad", label: "Notes", sublabel: "breadpad", icon: NotebookPen },
{ id: "breadsearch", label: "File Search", sublabel: "breadsearch", icon: Search }, { id: "breadsearch", label: "File Search", sublabel: "breadsearch", icon: Search },
{ id: "defaults", label: "Default apps", sublabel: "mimeapps.list", icon: AppWindowMac },
{ id: "bread", label: "Daemon", sublabel: "breadd", icon: Cog }, { id: "bread", label: "Daemon", sublabel: "breadd", icon: Cog },
]; ];
export const MAINTENANCE_ITEMS: SidebarItem[] = [ export const MAINTENANCE_ITEMS: SidebarItem[] = [
{ id: "updates", label: "Updates", icon: Download },
{ id: "packages", label: "Packages", icon: Package }, { id: "packages", label: "Packages", icon: Package },
{ id: "aur", label: "AUR", icon: Search }, { id: "aur", label: "AUR", icon: Search },
{ id: "firmware", label: "Firmware", icon: RefreshCw }, { id: "firmware", label: "Firmware", icon: RefreshCw },
{ id: "snapshots", label: "Snapshots", icon: History }, { id: "snapshots", label: "Snapshots", icon: History },
{ id: "channel", label: "Bakery channel", sublabel: "track", icon: GitBranch },
{ id: "backup", label: "Backup", sublabel: "restic", icon: Archive },
{ id: "optional", label: "Optional software", icon: Boxes },
]; ];
export const ABOUT_ITEMS: SidebarItem[] = [ export const ABOUT_ITEMS: SidebarItem[] = [

View file

@ -0,0 +1,197 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import NumberField from "$lib/components/NumberField.svelte";
import LogView from "$lib/components/LogView.svelte";
interface A11yStatus {
orca_installed: boolean;
orca_running: boolean;
zoom_factor: number;
sticky_keys_supported: boolean;
slow_keys_supported: boolean;
kmag_installed: boolean;
note: string;
}
let st = $state<A11yStatus | null>(null);
let zoom = $state(1);
let log = $state<string[]>([]);
let busy = $state(false);
let message = $state("");
async function refresh() {
st = await invoke<A11yStatus>("get_a11y_status");
zoom = st.zoom_factor;
}
onMount(refresh);
async function install(packages: string[]) {
log = [];
busy = true;
await runStreamed("pacman_install", { packages }, (line) => {
log = [...log, line];
});
busy = false;
await refresh();
}
async function toggleOrca(running: boolean) {
message = "";
try {
await invoke("set_orca_running", { running });
await refresh();
} catch (e) {
message = `${e}`;
}
}
async function applyZoom() {
message = "";
try {
zoom = await invoke<number>("set_cursor_zoom", { factor: zoom });
} catch (e) {
message = `${e}`;
}
}
async function openKmag() {
message = "";
try {
await invoke("open_kmag");
} catch (e) {
message = `${e}`;
}
}
</script>
<ViewScaffold title="Accessibility">
<Group title="Screen reader" hint="Orca. Works on Wayland; start/stop is process-level, not a compositor setting.">
{#if !st}
<Hint text="Loading…" />
{:else if !st.orca_installed}
<Hint text="orca is not installed." />
<button class="primary" disabled={busy} onclick={() => install(["orca"])}>Install orca</button>
{:else}
<div class="row-switch">
<span>Orca</span>
<button
class="switch"
class:on={st.orca_running}
role="switch"
aria-checked={st.orca_running}
aria-label="Orca"
onclick={() => toggleOrca(!st!.orca_running)}
>
<span class="knob"></span>
</button>
</div>
{/if}
</Group>
<Group title="Magnifier" hint="Hyprlands real magnifier is cursor:zoom_factor — pointer-centered zoom. Applies to this session.">
{#if st}
<NumberField label="Zoom factor" bind:value={zoom} min={1} max={8} step={0.25} />
<button class="primary" onclick={applyZoom}>Apply zoom</button>
<Hint text="1.0 is off. Values above 1 enlarge around the cursor." />
{#if !st.kmag_installed}
<Hint text="kmag is a separate KDE magnifier and is not wired into Hyprland. Optional install only." />
<button disabled={busy} onclick={() => install(["kmag"])}>Install kmag</button>
{:else}
<button onclick={openKmag}>Open kmag</button>
{/if}
{/if}
</Group>
<Group title="Sticky keys / Slow keys">
{#if st}
<div class="row-switch disabled">
<span>Sticky keys</span>
<button class="switch" disabled role="switch" aria-checked="false" aria-label="Sticky keys">
<span class="knob"></span>
</button>
</div>
<div class="row-switch disabled">
<span>Slow keys</span>
<button class="switch" disabled role="switch" aria-checked="false" aria-label="Slow keys">
<span class="knob"></span>
</button>
</div>
<Hint text={st.note} />
{/if}
</Group>
{#if message}<Hint text={message} />{/if}
<LogView lines={log} />
</ViewScaffold>
<style>
.row-switch {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px) var(--space-lg, 16px);
margin-bottom: var(--space-sm, 8px);
}
.row-switch.disabled {
opacity: 0.55;
}
.switch {
width: 40px;
height: 22px;
flex-shrink: 0;
border-radius: 999px;
border: none;
background-color: var(--overlay);
padding: 2px;
cursor: pointer;
display: flex;
align-items: center;
}
.switch.on {
background-color: var(--accent);
justify-content: flex-end;
}
.switch:disabled {
cursor: not-allowed;
}
.knob {
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--on-surface);
display: block;
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
align-self: flex-start;
margin-top: var(--space-xs, 4px);
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,205 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import TextField from "$lib/components/TextField.svelte";
import FileField from "$lib/components/FileField.svelte";
import PasswordField from "$lib/components/PasswordField.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import LogView from "$lib/components/LogView.svelte";
import Archive from "@lucide/svelte/icons/archive";
interface ResticSnapshot {
id: string;
time: string;
paths: string[];
}
interface BackupStatus {
restic_installed: boolean;
repo: string;
has_password: boolean;
snapshots: ResticSnapshot[];
error: string | null;
}
let st = $state<BackupStatus | null>(null);
let repo = $state("");
let password = $state("");
let snapshots = $state<ResticSnapshot[]>([]);
let selected = $state("latest");
let log = $state<string[]>([]);
let busy = $state(false);
let message = $state("");
async function refresh() {
st = await invoke<BackupStatus>("get_backup_config");
repo = st.repo;
}
onMount(refresh);
async function save() {
await invoke("save_backup_config", { input: { repo, password: password || null } });
password = "";
await refresh();
}
async function installRestic() {
log = [];
busy = true;
await runStreamed("pacman_install", { packages: ["restic"] }, (line) => {
log = [...log, line];
});
busy = false;
await refresh();
}
async function run(command: string, args: Record<string, unknown> = {}) {
log = [];
busy = true;
message = "";
const ok = await runStreamed(command, args, (line) => {
log = [...log, line];
});
busy = false;
if (!ok) message = "Command failed — see the log.";
}
async function listSnaps() {
message = "";
try {
snapshots = await invoke<ResticSnapshot[]>("list_restic_snapshots");
if (snapshots.length === 0) message = "No snapshots in this repo yet.";
} catch (e) {
message = `${e}`;
snapshots = [];
}
}
</script>
<ViewScaffold title="Backup">
<Group
title="Repository"
hint="restic to a local directory or sftp:user@host:/path. Path and password are stored in ~/.config/bos-settings/backup.toml (mode 0600). The password field is write-only."
wide
>
{#if !st}
<Hint text="Loading…" />
{:else if !st.restic_installed}
<Hint text="restic is not installed." />
<button class="primary" disabled={busy} onclick={installRestic}>Install restic</button>
{:else}
<FileField label="Local path" bind:value={repo} mode="folder" placeholder="/mnt/backup/bos" />
<TextField label="Or SFTP" bind:value={repo} placeholder="sftp:user@host:/backups/bos" />
<PasswordField label={st.has_password ? "Password (leave empty to keep)" : "Password"} bind:value={password} />
<SaveButton onSave={save} />
{/if}
</Group>
<Group title="Actions" hint="Backup covers $HOME and skips caches, Trash, Steam, cargo/rustup, node_modules, target, and .git. Restore is dry-run only.">
<div class="btn-row">
<button disabled={busy} onclick={() => run("restic_init")}>Init repo</button>
<button class="primary" disabled={busy} onclick={() => run("restic_backup")}>Backup home</button>
<button disabled={busy} onclick={listSnaps}>List snapshots</button>
<button disabled={busy} onclick={() => run("restic_restore_dry_run", { snapshot: selected || "latest" })}>
Restore dry-run
</button>
</div>
{#if message}<Hint text={message} />{/if}
</Group>
<Group title="Snapshots" wide>
{#if snapshots.length === 0}
<EmptyState icon={Archive} title="No snapshots loaded" hint="Init, backup, then list." />
{:else}
<div class="list">
{#each snapshots as s (s.id)}
<button class="row" class:selected={selected === s.id} onclick={() => (selected = s.id)}>
<span class="id">{s.id}</span>
<span class="time">{s.time}</span>
<span class="paths">{s.paths.join(", ")}</span>
</button>
{/each}
</div>
{/if}
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.btn-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm, 8px);
}
.list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
overflow-y: auto;
}
.row {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
background-color: var(--surface);
border: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
padding: var(--space-sm, 8px) var(--space-md, 12px);
border-radius: var(--radius-primary, 8px);
}
.row.selected {
background-color: var(--accent);
color: var(--on-accent);
}
.id {
width: 10ch;
flex-shrink: 0;
font-family: monospace;
}
.time {
width: 22ch;
flex-shrink: 0;
opacity: 0.85;
}
.paths {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,127 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import LogView from "$lib/components/LogView.svelte";
interface BakeryTrack {
current: string;
tracks: string[];
}
const BLURBS: Record<string, string> = {
stable: "Last tagged release.",
beta: "Latest release candidate (vX.Y.Z-rc.N).",
dev: "Every push to main.",
};
let track = $state<BakeryTrack | null>(null);
let message = $state("");
let log = $state<string[]>([]);
let busy = $state(false);
async function refresh() {
try {
track = await invoke<BakeryTrack>("get_bakery_track");
} catch (e) {
message = `${e}`;
}
}
onMount(refresh);
async function setTrack(name: string) {
message = "";
try {
track = await invoke<BakeryTrack>("set_bakery_track", { track: name });
message = `Now on ${name}. Run bakery update --all to install this tracks builds.`;
} catch (e) {
message = `${e}`;
}
}
async function updateAll() {
log = [];
busy = true;
await runStreamed("bakery_update_all", {}, (line) => {
log = [...log, line];
});
busy = false;
}
</script>
<ViewScaffold title="Bakery channel">
<Group
title="Track"
hint="bakery track show / bakery track set. This only changes the preference — nothing is downloaded until you update."
>
{#if !track}
<Hint text="Loading…" />
{:else}
<div class="tracks">
{#each track.tracks as name (name)}
<button class="track" class:on={track.current === name} onclick={() => setTrack(name)}>
<strong>{name}</strong>
<span>{BLURBS[name] ?? ""}</span>
</button>
{/each}
</div>
<Hint text={`Current track: ${track.current}`} />
{/if}
<button class="primary" disabled={busy} onclick={updateAll}>Update all on this track</button>
{#if message}<Hint text={message} />{/if}
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.tracks {
display: flex;
flex-direction: column;
gap: 6px;
}
.track {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
background-color: var(--surface);
color: inherit;
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px) var(--space-lg, 16px);
cursor: pointer;
text-align: left;
font: inherit;
}
.track span {
opacity: 0.7;
font-size: var(--font-size-secondary, 12px);
}
.track.on {
background-color: var(--accent);
color: var(--on-accent);
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
align-self: flex-start;
margin-top: var(--space-sm, 8px);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,104 @@
<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 SaveButton from "$lib/components/SaveButton.svelte";
interface DesktopApp {
id: string;
name: string;
}
interface DefaultsStatus {
path: string;
current: Record<string, string>;
options: Record<string, DesktopApp[]>;
}
const CATEGORIES: { id: string; label: string }[] = [
{ id: "browser", label: "Browser" },
{ id: "files", label: "File manager" },
{ id: "terminal", label: "Terminal" },
{ id: "image", label: "Images" },
{ id: "pdf", label: "PDF" },
{ id: "editor", label: "Editor" },
];
let st = $state<DefaultsStatus | null>(null);
onMount(async () => {
st = await invoke<DefaultsStatus>("get_default_apps");
});
async function save() {
if (!st) return;
await invoke("save_default_apps", { input: { current: st.current } });
st = await invoke<DefaultsStatus>("get_default_apps");
}
</script>
<ViewScaffold title="Default apps">
<Group title="MIME associations" hint={st ? `Read and written at ${st.path}. Terminal also writes ~/.config/xdg-terminals.list.` : "Loading…"} wide>
{#if st}
{#each CATEGORIES as cat (cat.id)}
<div class="field-row">
<span class="label">{cat.label}</span>
<select bind:value={st.current[cat.id]}>
<option value=""></option>
{#each st.options[cat.id] ?? [] as app (app.id)}
<option value={app.id}>{app.name}</option>
{/each}
</select>
</div>
{/each}
<SaveButton onSave={save} />
{:else}
<Hint text="Loading…" />
{/if}
</Group>
</ViewScaffold>
<style>
.field-row {
display: flex;
align-items: center;
gap: var(--space-lg, 16px);
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px) var(--space-lg, 16px);
margin-bottom: var(--space-sm, 8px);
}
:global(.field-row + .field-row) {
margin-top: calc(var(--space-sm, 8px) * -1);
border-top: 1px solid var(--bg);
border-top-left-radius: 0;
border-top-right-radius: 0;
}
:global(.field-row:has(+ .field-row)) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
.label {
flex: 1;
}
select {
color-scheme: dark;
background-color: var(--bg);
color: var(--on-surface);
border: 1px solid transparent;
border-radius: var(--radius-secondary, 6px);
padding: var(--space-xs, 4px) var(--space-sm, 8px);
max-width: 28ch;
}
select:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

@ -0,0 +1,166 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import LogView from "$lib/components/LogView.svelte";
interface ImePackage {
name: string;
installed: boolean;
}
interface ImeStatus {
enabled: boolean;
running: boolean;
packages: ImePackage[];
error: string | null;
}
let st = $state<ImeStatus | null>(null);
let log = $state<string[]>([]);
let busy = $state(false);
let message = $state("");
const installSet = ["fcitx5-im", "fcitx5-gtk", "fcitx5-qt", "fcitx5-chinese-addons"];
async function refresh() {
st = await invoke<ImeStatus>("get_ime_status");
}
onMount(refresh);
async function toggle(enabled: boolean) {
message = "";
try {
st = await invoke<ImeStatus>("set_ime_enabled", { enabled });
} catch (e) {
message = `${e}`;
}
}
async function installMissing() {
const missing = st?.packages.filter((p) => !p.installed).map((p) => p.name) ?? [];
const packages = ["fcitx5-im", ...missing.filter((n) => n !== "fcitx5")];
const unique = [...new Set(packages.length ? packages : installSet)];
log = [];
busy = true;
await runStreamed("pacman_install", { packages: unique }, (line) => {
log = [...log, line];
});
busy = false;
await refresh();
}
let missing = $derived(st?.packages.filter((p) => !p.installed) ?? []);
</script>
<ViewScaffold title="Input method">
<Group
title="fcitx5"
hint="Writes ~/.config/environment.d/90-fcitx5.conf and a Hyprland source fragment, then starts fcitx5 for this user. Already-running apps keep their old IM until you log out."
>
{#if !st}
<Hint text="Loading…" />
{:else}
<div class="row-switch">
<span>Enable fcitx5 for this session</span>
<button
class="switch"
class:on={st.enabled}
role="switch"
aria-checked={st.enabled}
aria-label="Enable fcitx5"
onclick={() => toggle(!st!.enabled)}
>
<span class="knob"></span>
</button>
</div>
<Hint text={st.running ? "fcitx5 is running." : "fcitx5 is not running."} />
<button onclick={() => invoke("open_fcitx_config")}>Open fcitx5 config</button>
{/if}
{#if message}<Hint text={message} />{/if}
</Group>
<Group title="Packages" hint="fcitx5-im (group) plus GTK/Qt modules and a CJK table (fcitx5-chinese-addons).">
{#if st}
<ul>
{#each st.packages as p (p.name)}
<li class:missing={!p.installed}>{p.name}{p.installed ? "" : " — not installed"}</li>
{/each}
</ul>
{/if}
{#if missing.length}
<button class="primary" disabled={busy} onclick={installMissing}>Install missing</button>
{/if}
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.row-switch {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px) var(--space-lg, 16px);
margin-bottom: var(--space-sm, 8px);
}
.switch {
width: 40px;
height: 22px;
flex-shrink: 0;
border-radius: 999px;
border: none;
background-color: var(--overlay);
padding: 2px;
cursor: pointer;
display: flex;
align-items: center;
}
.switch.on {
background-color: var(--accent);
justify-content: flex-end;
}
.knob {
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--on-surface);
display: block;
}
ul {
margin: 0 0 var(--space-sm, 8px);
padding-left: 1.2em;
}
.missing {
opacity: 0.7;
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
align-self: flex-start;
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,145 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import NumberField from "$lib/components/NumberField.svelte";
import LogView from "$lib/components/LogView.svelte";
interface NightlightStatus {
installed: boolean;
running: boolean;
enabled: boolean;
temperature: number;
error: string | null;
}
let st = $state<NightlightStatus | null>(null);
let log = $state<string[]>([]);
let busy = $state(false);
let message = $state("");
async function refresh() {
st = await invoke<NightlightStatus>("get_nightlight");
}
onMount(refresh);
async function apply(enabled: boolean) {
if (!st) return;
message = "";
try {
st = await invoke<NightlightStatus>("set_nightlight", {
enabled,
temperature: st.temperature,
});
if (st.error) message = st.error;
} catch (e) {
message = `${e}`;
}
}
async function install() {
log = [];
busy = true;
await runStreamed("pacman_install", { packages: ["hyprsunset"] }, (line) => {
log = [...log, line];
});
busy = false;
await refresh();
}
</script>
<ViewScaffold title="Night light">
<Group
title="Hyprland twilight"
hint="hyprsunset owns the compositor color filter. hyprctl hyprsunset talks to its socket — the binary has to be installed and running."
>
{#if !st}
<Hint text="Loading…" />
{:else if !st.installed}
<Hint text="hyprsunset is not installed. Night light cannot run without it." />
<button class="primary" disabled={busy} onclick={install}>Install hyprsunset</button>
{:else}
<div class="row-switch">
<span>Night light</span>
<button
class="switch"
class:on={st.enabled}
role="switch"
aria-checked={st.enabled}
aria-label="Night light"
onclick={() => apply(!st!.enabled)}
>
<span class="knob"></span>
</button>
</div>
<NumberField label="Temperature (K)" bind:value={st.temperature} min={2000} max={6500} step={100} />
<button disabled={!st.enabled} onclick={() => apply(true)}>Apply temperature</button>
<Hint text={st.running ? "hyprsunset is running." : "hyprsunset will start when you enable night light."} />
{/if}
{#if message}<Hint text={message} />{/if}
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.row-switch {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px) var(--space-lg, 16px);
margin-bottom: var(--space-sm, 8px);
}
.switch {
width: 40px;
height: 22px;
flex-shrink: 0;
border-radius: 999px;
border: none;
background-color: var(--overlay);
padding: 2px;
cursor: pointer;
display: flex;
align-items: center;
}
.switch.on {
background-color: var(--accent);
justify-content: flex-end;
}
.knob {
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--on-surface);
display: block;
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
align-self: flex-start;
margin-top: var(--space-sm, 8px);
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,149 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import LogView from "$lib/components/LogView.svelte";
interface OptionalItem {
id: string;
title: string;
detail: string;
installed: boolean;
via: string;
}
interface OptionalStatus {
items: OptionalItem[];
flathub: boolean;
}
let st = $state<OptionalStatus | null>(null);
let log = $state<string[]>([]);
let busy = $state(false);
let message = $state("");
async function refresh() {
st = await invoke<OptionalStatus>("get_optional_software");
}
onMount(refresh);
async function stream(command: string, args: Record<string, unknown> = {}) {
log = [];
busy = true;
message = "";
const ok = await runStreamed(command, args, (line) => {
log = [...log, line];
});
busy = false;
if (!ok) message = "Install failed — see the log.";
await refresh();
return ok;
}
async function install(id: string) {
if (id === "breadcast") {
await stream("bakery_install", { name: "breadcast" });
return;
}
if (id === "flatpak") {
const ok = await stream("pacman_install", { packages: ["flatpak"] });
if (ok) {
try {
await invoke("enable_flathub");
message = "Flathub user remote added.";
} catch (e) {
message = `${e}`;
}
await refresh();
}
return;
}
if (id === "office") {
await stream("pacman_install", { packages: ["libreoffice-fresh", "papers"] });
return;
}
if (id === "steam") {
await stream("pacman_install", { packages: ["steam"] });
}
}
</script>
<ViewScaffold title="Optional software">
<Group
title="Curated extras"
hint="Not an AUR browser. breadcast is bakery-only and is not on the ISO. Pacman items use the allowlisted installer."
wide
>
{#if !st}
<Hint text="Loading…" />
{:else}
{#each st.items as item (item.id)}
<div class="card">
<div class="meta">
<strong>{item.title}</strong>
<p>{item.detail}</p>
<span class="via">{item.via}{item.installed ? " · installed" : ""}</span>
</div>
{#if item.installed}
<span class="done">Installed</span>
{:else}
<button class="primary" disabled={busy} onclick={() => install(item.id)}>Install</button>
{/if}
</div>
{/each}
{/if}
{#if message}<Hint text={message} />{/if}
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.card {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px) var(--space-lg, 16px);
margin-bottom: var(--space-sm, 8px);
}
.meta {
flex: 1;
min-width: 0;
}
.meta p {
margin: 4px 0;
opacity: 0.8;
font-size: var(--font-size-secondary, 12px);
}
.via,
.done {
opacity: 0.65;
font-size: var(--font-size-secondary, 12px);
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,147 @@
<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 EmptyState from "$lib/components/EmptyState.svelte";
import TextField from "$lib/components/TextField.svelte";
import Printer from "@lucide/svelte/icons/printer";
interface PrinterRow {
name: string;
status: string;
enabled: boolean;
is_default: boolean;
}
interface PrintingStatus {
printers: PrinterRow[];
default: string | null;
cups_ok: boolean;
error: string | null;
}
let status = $state<PrintingStatus | null>(null);
let message = $state("");
let newName = $state("");
let newUri = $state("");
async function refresh() {
status = await invoke<PrintingStatus>("get_printers");
}
onMount(refresh);
async function setDefault(name: string) {
message = "";
try {
await invoke("set_default_printer", { name });
message = `${name} is the default printer.`;
await refresh();
} catch (e) {
message = `${e}`;
}
}
async function addIpp() {
message = "";
try {
await invoke("add_ipp_printer", { name: newName.trim(), uri: newUri.trim() });
message = `Added ${newName.trim()}.`;
newName = "";
newUri = "";
await refresh();
} catch (e) {
message = `${e}`;
}
}
</script>
<ViewScaffold title="Printing">
<Group title="Printers" hint="CUPS + Avahi ship on the ISO. Discovery and drivers for odd hardware live in system-config-printer." wide>
{#if !status}
<Hint text="Loading…" />
{:else if status.error}
<EmptyState icon={Printer} title="Couldn't talk to CUPS" hint={status.error} />
{:else if status.printers.length === 0}
<EmptyState icon={Printer} title="No printers yet" hint="Add one below, or open the CUPS printer wizard." />
{:else}
<div class="list">
{#each status.printers as p (p.name)}
<div class="row">
<div class="meta">
<span class="name">{p.name}{p.is_default ? " (default)" : ""}</span>
<span class="status">{p.enabled ? p.status : "disabled"}</span>
</div>
<button disabled={p.is_default} onclick={() => setDefault(p.name)}>Set default</button>
</div>
{/each}
</div>
{/if}
<div class="btn-row">
<button onclick={refresh}>Refresh</button>
<button class="primary" onclick={() => invoke("open_printer_settings")}>Add printer…</button>
</div>
{#if message}<Hint text={message} />{/if}
</Group>
<Group title="IPP Everywhere" hint="For a printer that already speaks IPP. Name must be letters, digits, dash, underscore.">
<TextField label="Name" bind:value={newName} placeholder="Office" />
<TextField label="URI" bind:value={newUri} placeholder="ipp://192.168.1.20/ipp/print" />
<button disabled={!newName.trim() || !newUri.trim()} onclick={addIpp}>Add IPP printer</button>
</Group>
</ViewScaffold>
<style>
.list {
display: flex;
flex-direction: column;
gap: 6px;
}
.row {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
}
.meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.status {
opacity: 0.7;
font-size: var(--font-size-secondary, 12px);
}
.btn-row {
display: flex;
gap: var(--space-sm, 8px);
margin-top: var(--space-sm, 8px);
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,243 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import LogView from "$lib/components/LogView.svelte";
import Download from "@lucide/svelte/icons/download";
import Cpu from "@lucide/svelte/icons/cpu";
interface PendingUpdate {
name: string;
current: string;
latest: string;
}
interface FwDevice {
name: string;
version: string;
}
interface NvidiaOffer {
gpu: string;
reason: string;
packages: string[];
}
interface UpdatesStatus {
pacman: PendingUpdate[];
pacman_error: string | null;
bakery: PendingUpdate[];
bakery_error: string | null;
firmware: FwDevice[];
nvidia: NvidiaOffer | null;
}
let status = $state<UpdatesStatus | null>(null);
let log = $state<string[]>([]);
let busy = $state(false);
async function refresh() {
status = await invoke<UpdatesStatus>("get_updates_status");
}
onMount(refresh);
function appendLine(line: string) {
log = [...log, line];
}
async function run(command: string, args: Record<string, unknown> = {}) {
log = [];
busy = true;
await runStreamed(command, args, appendLine);
busy = false;
await refresh();
}
</script>
<ViewScaffold title="Updates">
{#if status?.nvidia}
<Group title="NVIDIA driver" wide>
<div class="offer">
<Cpu size={20} />
<div class="offer-text">
<strong>{status.nvidia.gpu}</strong>
<p>{status.nvidia.reason}</p>
<Hint
text="Installs nvidia + nvidia-utils only. Afterward, Hyprland usually needs env = LIBVA_DRIVER_NAME,nvidia / __GLX_VENDOR_LIBRARY_NAME,nvidia / NVD_BACKEND,direct — not written for you. Reboot after install."
/>
</div>
<button
class="primary"
disabled={busy}
onclick={() => run("pacman_install", { packages: status?.nvidia?.packages ?? ["nvidia", "nvidia-utils"] })}
>
Install driver
</button>
</div>
</Group>
{/if}
<Group
title="System packages (pacman)"
hint="The same set Packages covers with pacman -Syu. Needs your password (polkit)."
wide
>
{#if !status}
<Hint text="Loading…" />
{:else if status.pacman_error}
<Hint text={status.pacman_error} />
{:else if status.pacman.length === 0}
<EmptyState icon={Download} title="Pacman is up to date" hint="No pending official-repo upgrades." />
{:else}
<div class="list">
{#each status.pacman as pkg (pkg.name)}
<div class="row">
<span class="name">{pkg.name}</span>
<span class="version">{pkg.current}{pkg.latest}</span>
</div>
{/each}
</div>
{/if}
<div class="btn-row">
<button disabled={busy} class="primary" onclick={() => run("pacman_system_update")}>Update system</button>
<button disabled={busy} onclick={refresh}>Refresh</button>
</div>
</Group>
<Group
title="Bread ecosystem (bakery)"
hint="bakery --dry-run update --all — bakery has no separate outdated command. Per-package install still lives on Packages."
wide
>
{#if !status}
<Hint text="Loading…" />
{:else if status.bakery_error}
<Hint text={status.bakery_error} />
{:else if status.bakery.length === 0}
<EmptyState icon={Download} title="Bakery packages are current" hint="Nothing on this track wants an update." />
{:else}
<div class="list">
{#each status.bakery as pkg (pkg.name)}
<div class="row">
<span class="name">{pkg.name}</span>
<span class="version">{pkg.current ? `${pkg.current} ` : ""}{pkg.latest}</span>
<button disabled={busy} onclick={() => run("bakery_update", { name: pkg.name })}>Update</button>
</div>
{/each}
</div>
{/if}
<div class="btn-row">
<button disabled={busy} class="primary" onclick={() => run("bakery_update_all")}>Update all bakery</button>
</div>
</Group>
<Group title="Firmware (fwupd)" hint="Same list as the Firmware page. fwupd-refresh.timer already refreshes metadata in the background." wide>
{#if !status}
<Hint text="Loading…" />
{:else if status.firmware.length === 0}
<EmptyState icon={Download} title="No updatable firmware" hint="Not every device speaks fwupd." />
{:else}
<div class="list">
{#each status.firmware as dev (dev.name)}
<div class="row">
<span class="name">{dev.name}</span>
<span class="version">{dev.version}</span>
</div>
{/each}
</div>
{/if}
<div class="btn-row">
<button disabled={busy} onclick={() => run("fwupd_refresh")}>Check for updates</button>
<button disabled={busy} class="primary" onclick={() => run("fwupd_update")}>Update firmware</button>
</div>
</Group>
<Group
title="Rollback"
hint="BOS pins GRUB to rootflags=subvol=@, so snapper rollback does not change the running root. Boot a snapshot from the GRUB “BOS snapshots” submenu (grub-btrfs), or use the Snapshots page to reboot and pick one there."
>
<Hint text="Bakery also has bakery rollback <pkg> for a single ecosystem binary — that is not a system rollback." />
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.offer {
display: flex;
align-items: flex-start;
gap: var(--space-md, 12px);
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px);
}
.offer-text {
flex: 1;
min-width: 0;
}
.offer-text p {
margin: 4px 0;
opacity: 0.8;
}
.list {
max-height: 240px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.row {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
min-width: 0;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.version {
opacity: 0.75;
font-size: var(--font-size-secondary, 12px);
flex-shrink: 0;
}
.btn-row {
display: flex;
gap: var(--space-sm, 8px);
margin-top: var(--space-sm, 8px);
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,148 @@
<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 EmptyState from "$lib/components/EmptyState.svelte";
import FileField from "$lib/components/FileField.svelte";
import ShieldEllipsis from "@lucide/svelte/icons/shield-ellipsis";
interface VpnConnection {
name: string;
kind: string;
active: boolean;
autoconnect: boolean;
}
interface VpnStatus {
connections: VpnConnection[];
error: string | null;
}
let status = $state<VpnStatus | null>(null);
let importPath = $state("");
let message = $state("");
let busy = $state(false);
async function refresh() {
status = await invoke<VpnStatus>("get_vpn_connections");
}
onMount(refresh);
async function connect(name: string, up: boolean) {
busy = true;
message = "";
try {
await invoke(up ? "vpn_connect" : "vpn_disconnect", { name });
message = up ? `Connected ${name}` : `Disconnected ${name}`;
await refresh();
} catch (e) {
message = `${e}`;
}
busy = false;
}
async function doImport() {
if (!importPath.trim()) return;
busy = true;
message = "";
try {
await invoke("vpn_import", { path: importPath.trim() });
message = "Imported. Connect it from the list.";
importPath = "";
await refresh();
} catch (e) {
message = `${e}`;
}
busy = false;
}
</script>
<ViewScaffold title="VPN / WireGuard">
<Group
title="NetworkManager tunnels"
hint="Lists connection type vpn and wireguard only. Wi-Fi profiles stay on breadcrumbs; Tailscale is not managed here."
wide
>
{#if !status}
<Hint text="Loading…" />
{:else if status.error}
<EmptyState icon={ShieldEllipsis} title="Couldn't list connections" hint={status.error} />
{:else if status.connections.length === 0}
<EmptyState icon={ShieldEllipsis} title="No VPN or WireGuard connections" hint="Import a .conf below, or use nm-connection-editor from Network." />
{:else}
<div class="list">
{#each status.connections as c (c.name)}
<div class="row">
<div class="meta">
<span class="name">{c.name}</span>
<span class="kind">{c.kind}{c.active ? " · connected" : ""}{c.autoconnect ? " · autoconnect" : ""}</span>
</div>
{#if c.active}
<button disabled={busy} onclick={() => connect(c.name, false)}>Disconnect</button>
{:else}
<button class="primary" disabled={busy} onclick={() => connect(c.name, true)}>Connect</button>
{/if}
</div>
{/each}
</div>
{/if}
<button onclick={refresh}>Refresh</button>
{#if message}<Hint text={message} />{/if}
</Group>
<Group title="Import" hint="WireGuard .conf or OpenVPN .ovpn. NetworkManager stores the secret after import.">
<FileField label="Config" bind:value={importPath} placeholder="wg0.conf" extensions={["conf", "ovpn"]} />
<button class="primary" disabled={busy || !importPath.trim()} onclick={doImport}>Import</button>
</Group>
</ViewScaffold>
<style>
.list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: var(--space-sm, 8px);
}
.row {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
}
.meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.kind {
opacity: 0.7;
font-size: var(--font-size-secondary, 12px);
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -30,6 +30,16 @@ import Breadlock from "./Breadlock.svelte";
import Breadshot from "./Breadshot.svelte"; import Breadshot from "./Breadshot.svelte";
import Breadmon from "./Breadmon.svelte"; import Breadmon from "./Breadmon.svelte";
import Breadhelp from "./Breadhelp.svelte"; import Breadhelp from "./Breadhelp.svelte";
import Updates from "./Updates.svelte";
import Printing from "./Printing.svelte";
import Vpn from "./Vpn.svelte";
import NightLight from "./NightLight.svelte";
import InputMethod from "./InputMethod.svelte";
import Accessibility from "./Accessibility.svelte";
import Defaults from "./Defaults.svelte";
import Channel from "./Channel.svelte";
import Backup from "./Backup.svelte";
import Optional from "./Optional.svelte";
export const VIEWS: Record<string, Component> = { export const VIEWS: Record<string, Component> = {
about: About, about: About,
@ -60,4 +70,14 @@ export const VIEWS: Record<string, Component> = {
breadshot: Breadshot, breadshot: Breadshot,
breadmon: Breadmon, breadmon: Breadmon,
breadhelp: Breadhelp, breadhelp: Breadhelp,
updates: Updates,
printing: Printing,
vpn: Vpn,
nightlight: NightLight,
ime: InputMethod,
accessibility: Accessibility,
defaults: Defaults,
channel: Channel,
backup: Backup,
optional: Optional,
}; };

116
src/src/commands/a11y.rs Normal file
View file

@ -0,0 +1,116 @@
//! Accessibility toggles that actually do something on Hyprland.
//! Orca launches. Magnifier is Hyprland `cursor:zoom_factor`. Sticky/slow
//! keys are not exposed by Hyprland or xkeyboard-config rules — the UI
//! must show that honestly rather than a dead switch.
use serde::Serialize;
use tokio::process::Command;
use super::util::{command_exists, fail_output, pacman_installed};
#[derive(Serialize)]
pub struct A11yStatus {
orca_installed: bool,
orca_running: bool,
zoom_factor: f64,
sticky_keys_supported: bool,
slow_keys_supported: bool,
kmag_installed: bool,
note: String,
}
async fn orca_running() -> bool {
Command::new("pgrep")
.args(["-x", "orca"])
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}
async fn read_zoom() -> f64 {
let output = Command::new("hyprctl")
.args(["getoption", "cursor:zoom_factor", "-j"])
.output()
.await;
let Ok(output) = output else {
return 1.0;
};
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
return 1.0;
};
v.get("float")
.and_then(|x| x.as_f64())
.or_else(|| v.get("int").and_then(|x| x.as_i64()).map(|i| i as f64))
.unwrap_or(1.0)
}
#[tauri::command]
pub async fn get_a11y_status() -> A11yStatus {
A11yStatus {
orca_installed: command_exists("orca") || pacman_installed("orca"),
orca_running: orca_running().await,
zoom_factor: read_zoom().await,
sticky_keys_supported: false,
slow_keys_supported: false,
kmag_installed: command_exists("kmag") || pacman_installed("kmag"),
note: "Hyprland does not expose XKB AccessX (sticky keys / slow keys). Those toggles stay off because they would not do anything.".into(),
}
}
#[tauri::command]
pub async fn set_cursor_zoom(factor: f64) -> Result<f64, String> {
let factor = factor.clamp(1.0, 8.0);
let value = format!("{factor:.2}");
let output = Command::new("hyprctl")
.args(["keyword", "cursor:zoom_factor", &value])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(factor)
} else {
Err(fail_output(&output, "hyprctl keyword cursor:zoom_factor"))
}
}
#[tauri::command]
pub async fn set_orca_running(running: bool) -> Result<(), String> {
if running {
if !command_exists("orca") {
return Err("orca is not installed".into());
}
std::process::Command::new("orca")
.arg("--replace")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("couldn't start orca: {e}"))?;
Ok(())
} else {
let _ = Command::new("pkill").args(["-x", "orca"]).status().await;
Ok(())
}
}
#[tauri::command]
pub fn open_kmag() -> Result<(), String> {
if !command_exists("kmag") {
return Err("kmag is not installed".into());
}
std::process::Command::new("kmag")
.spawn()
.map_err(|e| format!("couldn't start kmag: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn zoom_clamp_bounds() {
let f = 0.2_f64.clamp(1.0, 8.0);
assert_eq!(f, 1.0);
assert_eq!(12.0_f64.clamp(1.0, 8.0), 8.0);
}
}

358
src/src/commands/backup.rs Normal file
View file

@ -0,0 +1,358 @@
//! restic backups of `$HOME`. Repo path + password live in
//! `~/.config/bos-settings/backup.toml` (0600). The password is write-only
//! to the webview — empty on save keeps the stored secret.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri::AppHandle;
use tokio::process::Command;
use super::config;
use super::streaming;
use super::util::{self, command_exists, fail_output};
fn backup_toml() -> PathBuf {
util::bos_settings_dir().join("backup.toml")
}
#[derive(Clone)]
pub struct BackupSecrets {
pub repo: String,
pub password: Option<String>,
}
impl BackupSecrets {
fn empty() -> Self {
Self {
repo: String::new(),
password: None,
}
}
}
pub fn load_secrets() -> BackupSecrets {
load_secrets_from(&backup_toml())
}
fn load_secrets_from(path: &Path) -> BackupSecrets {
let Ok(text) = std::fs::read_to_string(path) else {
return BackupSecrets::empty();
};
let doc = text.parse::<toml_edit::DocumentMut>().unwrap_or_default();
BackupSecrets {
repo: config::get_str(&doc, &["repo"]).unwrap_or_default(),
password: config::get_str(&doc, &["password"]).filter(|s| !s.is_empty()),
}
}
fn save_secrets_to(path: &Path, repo: &str, password: Option<&str>) -> Result<(), String> {
let existing = load_secrets_from(path);
let password = match password.map(str::trim).filter(|s| !s.is_empty()) {
Some(p) => Some(p.to_string()),
None => existing.password,
};
let mut doc = toml_edit::DocumentMut::new();
config::set_str(&mut doc, &["repo"], repo.trim());
if let Some(p) = password.as_deref() {
config::set_str(&mut doc, &["password"], p);
}
util::write_secure(path, &doc.to_string())
}
#[derive(Serialize)]
pub struct BackupStatus {
restic_installed: bool,
repo: String,
has_password: bool,
snapshots: Vec<ResticSnapshot>,
error: Option<String>,
}
#[derive(Serialize, Clone)]
pub struct ResticSnapshot {
id: String,
time: String,
paths: Vec<String>,
}
#[tauri::command]
pub fn get_backup_config() -> BackupStatus {
let s = load_secrets();
BackupStatus {
restic_installed: command_exists("restic"),
repo: s.repo,
has_password: s.password.is_some(),
snapshots: Vec::new(),
error: None,
}
}
#[derive(Deserialize)]
pub struct SaveBackupInput {
repo: String,
#[serde(default)]
password: Option<String>,
}
#[tauri::command]
pub fn save_backup_config(input: SaveBackupInput) -> Result<(), String> {
if !valid_repo(&input.repo) {
return Err("repo must be an absolute path or sftp:user@host:path".into());
}
save_secrets_to(&backup_toml(), &input.repo, input.password.as_deref())
}
pub fn valid_repo(repo: &str) -> bool {
let repo = repo.trim();
if repo.is_empty() || repo.len() > 512 || repo.contains('\n') || repo.contains('\0') {
return false;
}
if let Some(rest) = repo.strip_prefix("sftp:") {
return !rest.is_empty() && rest.contains('@') && rest.contains(':') && !rest.contains(' ');
}
std::path::Path::new(repo).is_absolute()
}
fn require_ready() -> Result<BackupSecrets, String> {
if !command_exists("restic") {
return Err("restic is not installed".into());
}
let s = load_secrets();
if !valid_repo(&s.repo) {
return Err("set a repository path first".into());
}
if s.password.is_none() {
return Err("set a repository password first".into());
}
Ok(s)
}
fn restic_args<'a>(repo: &'a str, extra: &'a [&'a str]) -> Vec<&'a str> {
let mut args = vec!["--repo", repo];
args.extend_from_slice(extra);
args
}
#[tauri::command]
pub async fn restic_init(app: AppHandle, session_id: String) -> bool {
let Ok(s) = require_ready() else {
streaming::emit_line(
&app,
&session_id,
"Error: configure repo and password first",
);
return false;
};
let password = s.password.clone().unwrap_or_default();
let extra = ["init"];
let args = restic_args(&s.repo, &extra);
streaming::run_hardcoded_env(
app,
session_id,
"restic",
&args,
&[("RESTIC_PASSWORD", password)],
)
.await
}
fn exclude_args(home: &str) -> Vec<String> {
let extras = [
".cache",
".local/share/Trash",
".local/share/Steam",
".npm",
".cargo/registry",
".cargo/git",
".rustup",
".var/app",
];
let mut args = vec![
"--exclude-caches".into(),
"--exclude".into(),
"node_modules".into(),
"--exclude".into(),
"target".into(),
"--exclude".into(),
".git".into(),
];
for rel in extras {
args.push("--exclude".into());
args.push(format!("{home}/{rel}"));
}
args
}
#[tauri::command]
pub async fn restic_backup(app: AppHandle, session_id: String) -> bool {
let s = match require_ready() {
Ok(s) => s,
Err(e) => {
streaming::emit_line(&app, &session_id, &format!("Error: {e}"));
return false;
}
};
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
let password = s.password.clone().unwrap_or_default();
let excludes = exclude_args(&home);
let mut args = vec!["--repo".to_string(), s.repo.clone()];
args.extend(excludes);
args.push("backup".into());
args.push(home);
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
streaming::run_hardcoded_env(
app,
session_id,
"restic",
&arg_refs,
&[("RESTIC_PASSWORD", password)],
)
.await
}
#[tauri::command]
pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot: String) -> bool {
let s = match require_ready() {
Ok(s) => s,
Err(e) => {
streaming::emit_line(&app, &session_id, &format!("Error: {e}"));
return false;
}
};
let snap = snapshot.trim();
if !valid_snapshot_id(snap) {
streaming::emit_line(&app, &session_id, "Error: invalid snapshot id");
return false;
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
let password = s.password.clone().unwrap_or_default();
let extra = ["restore", snap, "--target", home.as_str(), "--dry-run"];
let args = restic_args(&s.repo, &extra);
streaming::run_hardcoded_env(
app,
session_id,
"restic",
&args,
&[("RESTIC_PASSWORD", password)],
)
.await
}
fn valid_snapshot_id(id: &str) -> bool {
if id == "latest" {
return true;
}
let bytes = id.as_bytes();
!bytes.is_empty() && bytes.len() <= 64 && bytes.iter().all(|b| b.is_ascii_hexdigit())
}
#[tauri::command]
pub async fn list_restic_snapshots() -> Result<Vec<ResticSnapshot>, String> {
let s = require_ready()?;
let password = s.password.clone().unwrap_or_default();
let output = Command::new("restic")
.args(["--repo", &s.repo, "snapshots", "--json"])
.env("RESTIC_PASSWORD", password)
.output()
.await
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(fail_output(&output, "restic snapshots"));
}
parse_snapshots(&output.stdout)
}
fn parse_snapshots(bytes: &[u8]) -> Result<Vec<ResticSnapshot>, String> {
let v: serde_json::Value =
serde_json::from_slice(bytes).map_err(|e| format!("restic json: {e}"))?;
let Some(arr) = v.as_array() else {
return Ok(Vec::new());
};
Ok(arr
.iter()
.filter_map(|s| {
let id = s
.get("short_id")
.or_else(|| s.get("id"))
.and_then(|x| x.as_str())?
.to_string();
let time = s
.get("time")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let paths = s
.get("paths")
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|p| p.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
Some(ResticSnapshot { id, time, paths })
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repo_accepts_abs_and_sftp() {
assert!(valid_repo("/mnt/backup/bos"));
assert!(valid_repo("sftp:user@host:/backups/bos"));
assert!(!valid_repo("relative/path"));
assert!(!valid_repo("sftp:nocolon"));
assert!(!valid_repo("sftp:user host:/x"));
assert!(!valid_repo(""));
}
#[test]
fn snapshot_id_hex_or_latest() {
assert!(valid_snapshot_id("latest"));
assert!(valid_snapshot_id("a1b2c3d4"));
assert!(!valid_snapshot_id("../x"));
assert!(!valid_snapshot_id("latest;rm"));
}
#[test]
fn write_secure_is_0600_and_keeps_password() {
let dir = std::env::temp_dir().join(format!(
"bos-settings-backup-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("backup.toml");
save_secrets_to(&path, "/tmp/repo", Some("hunter2")).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("hunter2"));
assert!(text.contains("/tmp/repo"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "backup.toml must be 0600, got {mode:o}");
}
save_secrets_to(&path, "/tmp/repo2", Some("")).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("hunter2"), "empty password keeps secret");
assert!(text.contains("/tmp/repo2"));
let loaded = load_secrets_from(&path);
assert_eq!(loaded.password.as_deref(), Some("hunter2"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parse_restic_json() {
let json = br#"[{"short_id":"abc123","time":"2026-08-15T01:00:00Z","paths":["/home/a"]}]"#;
let v = parse_snapshots(json).unwrap();
assert_eq!(v[0].id, "abc123");
assert_eq!(v[0].paths[0], "/home/a");
}
}

View file

@ -0,0 +1,91 @@
//! Bakery track (stable / beta / dev). Preference only — `bakery update
//! --all` afterwards actually installs the new track's builds.
use serde::Serialize;
use tokio::process::Command;
use super::util::{fail_output, strip_ansi};
const TRACKS: &[&str] = &["stable", "beta", "dev"];
#[derive(Serialize)]
pub struct BakeryTrack {
current: String,
tracks: Vec<String>,
}
fn parse_track_show(text: &str) -> String {
let text = strip_ansi(text);
for line in text.lines() {
let line = line.trim();
let lower = line.to_ascii_lowercase();
if let Some(rest) = lower.strip_prefix("current track:") {
let raw = line[line.len() - rest.len()..].trim();
return raw.to_ascii_lowercase();
}
if TRACKS.contains(&line) {
return line.to_string();
}
}
let lower = text.to_ascii_lowercase();
for track in TRACKS {
if lower.contains(track) {
return (*track).to_string();
}
}
"stable".into()
}
#[tauri::command]
pub async fn get_bakery_track() -> Result<BakeryTrack, String> {
let output = Command::new("bakery")
.args(["track", "show"])
.output()
.await
.map_err(|e| format!("couldn't run bakery: {e}"))?;
if !output.status.success() {
return Err(fail_output(&output, "bakery track show"));
}
let text = String::from_utf8_lossy(&output.stdout);
Ok(BakeryTrack {
current: parse_track_show(&text),
tracks: TRACKS.iter().map(|s| (*s).to_string()).collect(),
})
}
#[tauri::command]
pub async fn set_bakery_track(track: String) -> Result<BakeryTrack, String> {
let track = track.trim().to_ascii_lowercase();
if !TRACKS.contains(&track.as_str()) {
return Err(format!(
"unknown track '{track}' — expected stable, beta, or dev"
));
}
let output = Command::new("bakery")
.args(["track", "set", &track])
.output()
.await
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(fail_output(&output, "bakery track set"));
}
get_bakery_track().await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_current_track_line() {
assert_eq!(parse_track_show("current track: dev\n"), "dev");
assert_eq!(parse_track_show("current track: stable"), "stable");
assert_eq!(parse_track_show("beta"), "beta");
}
#[test]
fn rejects_unknown_in_set_guard() {
assert!(!TRACKS.contains(&"nightly"));
assert!(TRACKS.contains(&"stable"));
}
}

View file

@ -0,0 +1,431 @@
//! Default applications via `~/.config/mimeapps.list`. Categories cover
//! the associations BOS already ships in skel (browser, files, images,
//! PDF, editor) plus a terminal entry.
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use serde::Serialize;
use super::config;
const CATEGORIES: &[(&str, &[&str])] = &[
(
"browser",
&[
"x-scheme-handler/http",
"x-scheme-handler/https",
"text/html",
],
),
("files", &["inode/directory"]),
("terminal", &["x-scheme-handler/terminal"]),
(
"image",
&[
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"image/svg+xml",
],
),
("pdf", &["application/pdf"]),
("editor", &["text/plain", "text/markdown"]),
];
#[derive(Serialize, Clone)]
pub struct DesktopApp {
id: String,
name: String,
}
#[derive(Serialize)]
pub struct DefaultsStatus {
path: String,
current: HashMap<String, String>,
options: HashMap<String, Vec<DesktopApp>>,
}
fn mimeapps_path() -> PathBuf {
config::config_dir().join("mimeapps.list")
}
fn xdg_terminals_path() -> PathBuf {
config::config_dir().join("xdg-terminals.list")
}
fn applications_dirs() -> Vec<PathBuf> {
let mut dirs = vec![
PathBuf::from("/usr/share/applications"),
PathBuf::from("/usr/local/share/applications"),
];
if let Ok(home) = std::env::var("HOME") {
dirs.push(PathBuf::from(home).join(".local/share/applications"));
}
dirs
}
#[derive(Clone)]
struct DesktopMeta {
id: String,
name: String,
mimes: Vec<String>,
terminal: bool,
}
fn parse_desktop(id: &str, text: &str) -> Option<DesktopMeta> {
let mut in_entry = false;
let mut name = String::new();
let mut mimes = Vec::new();
let mut terminal = false;
let mut hidden = false;
for line in text.lines() {
let line = line.trim();
if line.starts_with('[') {
in_entry = line.eq_ignore_ascii_case("[Desktop Entry]");
continue;
}
if !in_entry {
continue;
}
if let Some(v) = line.strip_prefix("Name=") {
if name.is_empty() {
name = v.to_string();
}
} else if let Some(v) = line.strip_prefix("MimeType=") {
mimes = v
.split(';')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
} else if let Some(v) = line.strip_prefix("Categories=") {
terminal |= v.split(';').any(|c| c.trim() == "TerminalEmulator");
} else if line == "Hidden=true" || line == "NoDisplay=true" {
hidden = true;
}
}
if hidden || name.is_empty() {
return None;
}
Some(DesktopMeta {
id: id.to_string(),
name,
mimes,
terminal,
})
}
fn scan_desktops() -> Vec<DesktopMeta> {
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
for dir in applications_dirs() {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
let Some(id) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !seen.insert(id.to_string()) {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
if let Some(meta) = parse_desktop(id, &text) {
out.push(meta);
}
}
}
out.sort_by_key(|a| a.name.to_lowercase());
out
}
fn parse_default_applications(text: &str) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
let mut in_defaults = false;
for line in text.lines() {
let t = line.trim();
if t.starts_with('[') {
in_defaults = t.eq_ignore_ascii_case("[Default Applications]");
continue;
}
if !in_defaults || t.is_empty() || t.starts_with('#') {
continue;
}
if let Some((k, v)) = t.split_once('=') {
let desktop = v.split(';').next().unwrap_or("").trim();
if !desktop.is_empty() {
map.insert(k.trim().to_string(), desktop.to_string());
}
}
}
map
}
fn current_for_category(defaults: &BTreeMap<String, String>, mimes: &[&str]) -> String {
for mime in mimes {
if let Some(v) = defaults.get(*mime) {
return v.clone();
}
}
String::new()
}
fn options_for(
apps: &[DesktopMeta],
category: &str,
mimes: &[&str],
current: &str,
) -> Vec<DesktopApp> {
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
for app in apps {
let matches = if category == "terminal" {
app.terminal || app.mimes.iter().any(|m| mimes.contains(&m.as_str()))
} else {
app.mimes.iter().any(|m| mimes.contains(&m.as_str()))
};
if matches && seen.insert(app.id.clone()) {
out.push(DesktopApp {
id: app.id.clone(),
name: app.name.clone(),
});
}
}
if !current.is_empty() && !seen.contains(current) {
out.insert(
0,
DesktopApp {
id: current.to_string(),
name: current.trim_end_matches(".desktop").to_string(),
},
);
}
out
}
#[tauri::command]
pub fn get_default_apps() -> DefaultsStatus {
let path = mimeapps_path();
let text = std::fs::read_to_string(&path).unwrap_or_default();
let defaults = parse_default_applications(&text);
let apps = scan_desktops();
let mut current = HashMap::new();
let mut options = HashMap::new();
for (cat, mimes) in CATEGORIES {
let cur = if *cat == "terminal" {
read_terminal_default(&defaults)
} else {
current_for_category(&defaults, mimes)
};
options.insert((*cat).to_string(), options_for(&apps, cat, mimes, &cur));
current.insert((*cat).to_string(), cur);
}
DefaultsStatus {
path: path.display().to_string(),
current,
options,
}
}
fn read_terminal_default(defaults: &BTreeMap<String, String>) -> String {
if let Ok(text) = std::fs::read_to_string(xdg_terminals_path()) {
if let Some(id) = text
.lines()
.map(str::trim)
.find(|l| !l.is_empty() && !l.starts_with('#'))
{
return id.to_string();
}
}
current_for_category(defaults, &["x-scheme-handler/terminal"])
}
#[derive(serde::Deserialize)]
pub struct SaveDefaultsInput {
current: HashMap<String, String>,
}
#[tauri::command]
pub fn save_default_apps(input: SaveDefaultsInput) -> Result<(), String> {
let path = mimeapps_path();
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut replacements = BTreeMap::new();
for (cat, mimes) in CATEGORIES {
let Some(desktop) = input.current.get(*cat).map(|s| s.trim()) else {
continue;
};
if desktop.is_empty() {
continue;
}
if !valid_desktop_id(desktop) {
return Err(format!("invalid desktop id '{desktop}'"));
}
for mime in *mimes {
replacements.insert((*mime).to_string(), desktop.to_string());
}
if *cat == "terminal" {
write_terminal_list(desktop)?;
}
}
let text = upsert_defaults(&existing, &replacements);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
config::atomic_write(&path, &text).map_err(|e| e.to_string())
}
fn write_terminal_list(desktop: &str) -> Result<(), String> {
let path = xdg_terminals_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
config::atomic_write(&path, &format!("{desktop}\n")).map_err(|e| e.to_string())
}
fn valid_desktop_id(id: &str) -> bool {
let bytes = id.as_bytes();
bytes.ends_with(b".desktop")
&& bytes.len() > ".desktop".len()
&& bytes.len() <= 128
&& bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.' | b'+'))
}
fn upsert_defaults(existing: &str, replacements: &BTreeMap<String, String>) -> String {
if existing.trim().is_empty() {
let mut out = String::from("[Default Applications]\n");
for (mime, desktop) in replacements {
out.push_str(&format!("{mime}={desktop}\n"));
}
return out;
}
let mut out = String::new();
let mut in_defaults = false;
let mut seen = std::collections::HashSet::new();
let mut wrote_header = false;
for line in existing.lines() {
let t = line.trim();
if t.starts_with('[') {
if in_defaults {
for (mime, desktop) in replacements {
if seen.insert(mime.clone()) {
out.push_str(&format!("{mime}={desktop}\n"));
}
}
}
in_defaults = t.eq_ignore_ascii_case("[Default Applications]");
if in_defaults {
wrote_header = true;
}
out.push_str(line);
out.push('\n');
continue;
}
if in_defaults {
if let Some((k, _)) = t.split_once('=') {
let key = k.trim();
if let Some(desktop) = replacements.get(key) {
out.push_str(&format!("{key}={desktop}\n"));
seen.insert(key.to_string());
continue;
}
}
}
out.push_str(line);
out.push('\n');
}
if in_defaults {
for (mime, desktop) in replacements {
if seen.insert(mime.clone()) {
out.push_str(&format!("{mime}={desktop}\n"));
}
}
} else if !wrote_header {
if !out.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
out.push_str("\n[Default Applications]\n");
for (mime, desktop) in replacements {
out.push_str(&format!("{mime}={desktop}\n"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_skel_defaults() {
let text = "\
[Default Applications]
text/html=zen.desktop
x-scheme-handler/http=zen.desktop
inode/directory=org.gnome.Nautilus.desktop
";
let map = parse_default_applications(text);
assert_eq!(map.get("text/html").unwrap(), "zen.desktop");
assert_eq!(
current_for_category(&map, &["x-scheme-handler/http", "text/html"]),
"zen.desktop"
);
}
#[test]
fn upsert_replaces_only_named_keys() {
let existing = "\
# keep
[Default Applications]
text/html=old.desktop
image/png=org.gnome.Loupe.desktop
[Added Associations]
text/html=extra.desktop;
";
let mut rep = BTreeMap::new();
rep.insert("text/html".into(), "zen.desktop".into());
rep.insert("x-scheme-handler/http".into(), "zen.desktop".into());
let out = upsert_defaults(existing, &rep);
assert!(out.contains("# keep"));
assert!(out.contains("text/html=zen.desktop"));
assert!(out.contains("x-scheme-handler/http=zen.desktop"));
assert!(out.contains("image/png=org.gnome.Loupe.desktop"));
assert!(out.contains("[Added Associations]"));
assert!(out.contains("text/html=extra.desktop;"));
assert_eq!(out.matches("text/html=zen.desktop").count(), 1);
}
#[test]
fn desktop_id_check() {
assert!(valid_desktop_id("zen.desktop"));
assert!(valid_desktop_id("org.gnome.Nautilus.desktop"));
assert!(!valid_desktop_id("zen"));
assert!(!valid_desktop_id("../evil.desktop"));
}
#[test]
fn parse_desktop_skips_hidden() {
let hidden = parse_desktop(
"x.desktop",
"[Desktop Entry]\nName=X\nNoDisplay=true\nMimeType=text/plain;\n",
);
assert!(hidden.is_none());
let ok = parse_desktop(
"ed.desktop",
"[Desktop Entry]\nName=Editor\nMimeType=text/plain;\nCategories=Utility;\n",
)
.unwrap();
assert_eq!(ok.name, "Editor");
assert!(ok.mimes.contains(&"text/plain".into()));
}
}

177
src/src/commands/ime.rs Normal file
View file

@ -0,0 +1,177 @@
//! fcitx5 input method for this session: environment.d + Hyprland env +
//! systemd --user / `fcitx5 -d`. Missing packages are offered via the
//! allowlisted pacman installer, not installed on page load.
use serde::Serialize;
use tokio::process::Command;
use super::config;
use super::util::{self, command_exists, pacman_installed};
const FRAGMENT: &str = "fcitx5.conf";
const ENV_FILE: &str = "90-fcitx5.conf";
const ENV_LINES_SYSTEMD: &str = "\
GTK_IM_MODULE=fcitx
QT_IM_MODULE=fcitx
XMODIFIERS=@im=fcitx
SDL_IM_MODULE=fcitx
";
const ENV_LINES_HYPR: &str = "\
env = GTK_IM_MODULE,fcitx
env = QT_IM_MODULE,fcitx
env = XMODIFIERS,@im=fcitx
env = SDL_IM_MODULE,fcitx
exec-once = fcitx5 -d
";
#[derive(Serialize, Clone)]
pub struct ImePackage {
name: String,
installed: bool,
}
#[derive(Serialize)]
pub struct ImeStatus {
enabled: bool,
running: bool,
packages: Vec<ImePackage>,
error: Option<String>,
}
fn env_path() -> std::path::PathBuf {
config::config_dir().join("environment.d").join(ENV_FILE)
}
fn wanted_packages() -> &'static [&'static str] {
&[
"fcitx5",
"fcitx5-gtk",
"fcitx5-qt",
"fcitx5-configtool",
"fcitx5-chinese-addons",
]
}
fn packages_status() -> Vec<ImePackage> {
wanted_packages()
.iter()
.map(|name| ImePackage {
name: (*name).to_string(),
installed: pacman_installed(name),
})
.collect()
}
fn env_file_present() -> bool {
env_path().is_file()
}
async fn fcitx_running() -> bool {
Command::new("pgrep")
.args(["-x", "fcitx5"])
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}
#[tauri::command]
pub async fn get_ime_status() -> ImeStatus {
ImeStatus {
enabled: env_file_present(),
running: fcitx_running().await,
packages: packages_status(),
error: None,
}
}
#[tauri::command]
pub async fn set_ime_enabled(enabled: bool) -> Result<ImeStatus, String> {
if enabled {
enable_ime().await?;
} else {
disable_ime().await?;
}
Ok(ImeStatus {
enabled: env_file_present(),
running: fcitx_running().await,
packages: packages_status(),
error: None,
})
}
async fn enable_ime() -> Result<(), String> {
if !command_exists("fcitx5") {
return Err("fcitx5 is not installed".into());
}
let env = env_path();
if let Some(parent) = env.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
config::atomic_write(&env, ENV_LINES_SYSTEMD).map_err(|e| e.to_string())?;
let hypr = util::hypr_dir().join(FRAGMENT);
std::fs::create_dir_all(util::hypr_dir()).map_err(|e| e.to_string())?;
config::atomic_write(&hypr, ENV_LINES_HYPR).map_err(|e| e.to_string())?;
util::ensure_hypr_source(FRAGMENT)?;
let _ = Command::new("systemctl")
.args([
"--user",
"import-environment",
"GTK_IM_MODULE",
"QT_IM_MODULE",
"XMODIFIERS",
"SDL_IM_MODULE",
])
.status()
.await;
let enabled_unit = Command::new("systemctl")
.args(["--user", "enable", "--now", "fcitx5.service"])
.status()
.await
.map(|s| s.success())
.unwrap_or(false);
if !enabled_unit && !fcitx_running().await {
std::process::Command::new("fcitx5")
.arg("-d")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("couldn't start fcitx5: {e}"))?;
}
Ok(())
}
async fn disable_ime() -> Result<(), String> {
let _ = std::fs::remove_file(env_path());
let _ = std::fs::remove_file(util::hypr_dir().join(FRAGMENT));
util::remove_hypr_source(FRAGMENT)?;
let _ = Command::new("systemctl")
.args(["--user", "disable", "--now", "fcitx5.service"])
.status()
.await;
let _ = Command::new("pkill").args(["-x", "fcitx5"]).status().await;
Ok(())
}
#[tauri::command]
pub fn open_fcitx_config() {
let _ = std::process::Command::new("fcitx5-configtool").spawn();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_files_use_fcitx_module_name() {
assert!(ENV_LINES_SYSTEMD.contains("GTK_IM_MODULE=fcitx"));
assert!(ENV_LINES_HYPR.contains("XMODIFIERS,@im=fcitx"));
assert!(ENV_LINES_HYPR.contains("exec-once = fcitx5 -d"));
}
}

View file

@ -1,7 +1,9 @@
pub mod a11y;
pub mod about; pub mod about;
pub mod appearance; pub mod appearance;
pub mod aur; pub mod aur;
pub mod autostart; pub mod autostart;
pub mod backup;
pub mod bluetooth; pub mod bluetooth;
pub mod bread; pub mod bread;
pub mod breadbar; pub mod breadbar;
@ -15,18 +17,28 @@ pub mod breadpad;
pub mod breadpaper; pub mod breadpaper;
pub mod breadsearch; pub mod breadsearch;
pub mod breadshot; pub mod breadshot;
pub mod channel;
pub mod config; pub mod config;
pub mod datetime; pub mod datetime;
pub mod defaults;
pub mod firewall; pub mod firewall;
pub mod firmware; pub mod firmware;
pub mod hyprland; pub mod hyprland;
pub mod ime;
pub mod keybinds; pub mod keybinds;
pub mod network; pub mod network;
pub mod nightlight;
pub mod nvidia;
pub mod optional;
pub mod packages; pub mod packages;
pub mod power; pub mod power;
pub mod printing;
pub mod service; pub mod service;
pub mod snapshots; pub mod snapshots;
pub mod sound; pub mod sound;
pub mod streaming; pub mod streaming;
pub mod theme; pub mod theme;
pub mod updates;
pub mod users; pub mod users;
pub mod util;
pub mod vpn;

View file

@ -0,0 +1,216 @@
//! Night light via hyprsunset (Hyprland twilight IPC). The compositor
//! talks to a hyprsunset daemon socket; if the binary is missing we offer
//! a pacman install rather than pretending the toggle works.
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use super::config;
use super::util::{self, command_exists, fail_output};
const FRAGMENT: &str = "nightlight.conf";
const DEFAULT_TEMP: u32 = 3500;
const MIN_TEMP: u32 = 2000;
const MAX_TEMP: u32 = 6500;
#[derive(Serialize, Deserialize, Clone)]
pub struct NightlightConfig {
enabled: bool,
temperature: u32,
}
impl Default for NightlightConfig {
fn default() -> Self {
Self {
enabled: false,
temperature: DEFAULT_TEMP,
}
}
}
#[derive(Serialize)]
pub struct NightlightStatus {
installed: bool,
running: bool,
enabled: bool,
temperature: u32,
error: Option<String>,
}
fn persist_path() -> std::path::PathBuf {
util::bos_settings_dir().join("nightlight.toml")
}
fn load_persist() -> NightlightConfig {
let Ok(text) = std::fs::read_to_string(persist_path()) else {
return NightlightConfig::default();
};
let doc = text.parse::<toml_edit::DocumentMut>().unwrap_or_default();
NightlightConfig {
enabled: config::get_bool(&doc, &["enabled"]).unwrap_or(false),
temperature: config::get_i64(&doc, &["temperature"])
.unwrap_or(DEFAULT_TEMP as i64)
.clamp(MIN_TEMP as i64, MAX_TEMP as i64) as u32,
}
}
fn save_persist(cfg: &NightlightConfig) -> Result<(), String> {
let mut doc = toml_edit::DocumentMut::new();
config::set_bool(&mut doc, &["enabled"], cfg.enabled);
config::set_i64(&mut doc, &["temperature"], cfg.temperature as i64);
let path = persist_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
config::atomic_write(&path, &doc.to_string()).map_err(|e| e.to_string())
}
fn clamp_temp(t: u32) -> u32 {
t.clamp(MIN_TEMP, MAX_TEMP)
}
async fn hyprsunset_running() -> bool {
Command::new("hyprctl")
.args(["hyprsunset", "gamma", "1.0"])
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false)
}
async fn start_daemon() -> Result<(), String> {
if hyprsunset_running().await {
return Ok(());
}
if !command_exists("hyprsunset") {
return Err("hyprsunset is not installed".into());
}
std::process::Command::new("hyprsunset")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("couldn't start hyprsunset: {e}"))?;
for _ in 0..15 {
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
if hyprsunset_running().await {
return Ok(());
}
}
Err("hyprsunset started but Hyprland twilight socket never came up".into())
}
async fn apply_temperature(temp: u32) -> Result<(), String> {
start_daemon().await?;
let t = clamp_temp(temp).to_string();
let output = Command::new("hyprctl")
.args(["hyprsunset", "temperature", &t])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(fail_output(&output, "hyprctl hyprsunset"))
}
}
async fn apply_identity() -> Result<(), String> {
if !hyprsunset_running().await {
return Ok(());
}
let output = Command::new("hyprctl")
.args(["hyprsunset", "identity"])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(fail_output(&output, "hyprctl hyprsunset"))
}
}
fn write_autostart() -> Result<(), String> {
let path = util::hypr_dir().join(FRAGMENT);
std::fs::create_dir_all(util::hypr_dir()).map_err(|e| e.to_string())?;
config::atomic_write(&path, "exec-once = hyprsunset\n").map_err(|e| e.to_string())?;
util::ensure_hypr_source(FRAGMENT)
}
fn clear_autostart() -> Result<(), String> {
let path = util::hypr_dir().join(FRAGMENT);
let _ = std::fs::remove_file(path);
util::remove_hypr_source(FRAGMENT)
}
#[tauri::command]
pub async fn get_nightlight() -> NightlightStatus {
let persist = load_persist();
let installed = command_exists("hyprsunset");
let running = if installed {
hyprsunset_running().await
} else {
false
};
NightlightStatus {
installed,
running,
enabled: persist.enabled && running,
temperature: persist.temperature,
error: None,
}
}
#[tauri::command]
pub async fn set_nightlight(enabled: bool, temperature: u32) -> Result<NightlightStatus, String> {
if !command_exists("hyprsunset") {
return Ok(NightlightStatus {
installed: false,
running: false,
enabled: false,
temperature: clamp_temp(temperature),
error: Some("hyprsunset is not installed".into()),
});
}
let mut cfg = NightlightConfig {
enabled,
temperature: clamp_temp(temperature),
};
let mut error = None;
if enabled {
if let Err(e) = apply_temperature(cfg.temperature).await {
error = Some(e);
cfg.enabled = false;
} else if let Err(e) = write_autostart() {
error = Some(e);
}
} else {
if let Err(e) = apply_identity().await {
error = Some(e);
}
if let Err(e) = clear_autostart() {
error = Some(error.unwrap_or(e));
}
}
save_persist(&cfg)?;
Ok(NightlightStatus {
installed: true,
running: hyprsunset_running().await,
enabled: cfg.enabled,
temperature: cfg.temperature,
error,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn temp_clamps() {
assert_eq!(clamp_temp(100), MIN_TEMP);
assert_eq!(clamp_temp(9000), MAX_TEMP);
assert_eq!(clamp_temp(3500), 3500);
}
}

106
src/src/commands/nvidia.rs Normal file
View file

@ -0,0 +1,106 @@
//! NVIDIA driver offer. BOS writes a probe file when it sees a discrete
//! NVIDIA GPU; Settings only shows the card if that file exists and does
//! not install anything until the user clicks.
use serde::Serialize;
#[derive(Serialize, Clone)]
pub struct NvidiaOffer {
gpu: String,
reason: String,
packages: Vec<String>,
}
fn offer_paths() -> Vec<std::path::PathBuf> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
let state = std::path::Path::new(&home).join(".local/state/bos");
vec![
state.join("nvidia-offer.json"),
state.join("nvidia-probe.json"),
]
}
pub fn read_nvidia_offer() -> Option<NvidiaOffer> {
for path in offer_paths() {
if !path.is_file() {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
return Some(generic_offer());
};
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
if v.get("offer").and_then(|x| x.as_bool()) == Some(false)
|| v.get("dismissed").and_then(|x| x.as_bool()) == Some(true)
{
return None;
}
let gpu = v
.get("gpu")
.or_else(|| v.get("name"))
.or_else(|| v.get("device"))
.and_then(|x| x.as_str())
.unwrap_or("NVIDIA GPU")
.to_string();
let reason = v
.get("reason")
.or_else(|| v.get("message"))
.and_then(|x| x.as_str())
.unwrap_or("A discrete NVIDIA GPU was detected. The proprietary driver is not installed until you choose it.")
.to_string();
let packages = v
.get("packages")
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect::<Vec<_>>()
})
.filter(|p| !p.is_empty())
.unwrap_or_else(|| vec!["nvidia".into(), "nvidia-utils".into()]);
return Some(NvidiaOffer {
gpu,
reason,
packages,
});
}
return Some(generic_offer());
}
None
}
fn generic_offer() -> NvidiaOffer {
NvidiaOffer {
gpu: "NVIDIA GPU".into(),
reason: "BOS found an NVIDIA device. Install the proprietary driver only if you want it — nouveau stays otherwise.".into(),
packages: vec!["nvidia".into(), "nvidia-utils".into()],
}
}
#[tauri::command]
pub fn get_nvidia_offer() -> Option<NvidiaOffer> {
read_nvidia_offer()
}
#[cfg(test)]
mod tests {
#[test]
fn missing_file_is_none() {
// This machine's real probe path is not something the unit test
// should depend on; the helper is covered via parse cases below.
let parsed = serde_json::from_str::<serde_json::Value>("{\"offer\":false}").unwrap();
assert_eq!(parsed["offer"], false);
}
#[test]
fn dismissed_or_offer_false_hides() {
// Inlined copies of the hide conditions so a schema change is obvious.
let hide = |v: &str| {
let v: serde_json::Value = serde_json::from_str(v).unwrap();
v.get("offer").and_then(|x| x.as_bool()) == Some(false)
|| v.get("dismissed").and_then(|x| x.as_bool()) == Some(true)
};
assert!(hide(r#"{"offer":false}"#));
assert!(hide(r#"{"dismissed":true}"#));
assert!(!hide(r#"{"gpu":"RTX 4060"}"#));
}
}

View file

@ -0,0 +1,112 @@
//! Curated optional software. Not an AUR dump — four explicit offers,
//! each installed through a typed command (bakery or allowlisted pacman).
use serde::Serialize;
use tokio::process::Command;
use super::packages::get_installed_packages;
use super::util::{command_exists, fail_output, pacman_installed};
#[derive(Serialize, Clone)]
pub struct OptionalItem {
id: String,
title: String,
detail: String,
installed: bool,
via: String,
}
#[derive(Serialize)]
pub struct OptionalStatus {
items: Vec<OptionalItem>,
flathub: bool,
}
fn bakery_has(name: &str) -> bool {
get_installed_packages().iter().any(|p| p.name == name)
}
fn flathub_enabled() -> bool {
if !command_exists("flatpak") {
return false;
}
std::process::Command::new("flatpak")
.args(["remotes"])
.output()
.ok()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.to_ascii_lowercase()
.contains("flathub")
})
.unwrap_or(false)
}
#[tauri::command]
pub fn get_optional_software() -> OptionalStatus {
let breadcast = bakery_has("breadcast") || command_exists("breadcast");
let flatpak = pacman_installed("flatpak") || command_exists("flatpak");
let office = pacman_installed("libreoffice-fresh")
&& (pacman_installed("papers") || pacman_installed("evince"));
let steam = pacman_installed("steam") || command_exists("steam");
OptionalStatus {
items: vec![
OptionalItem {
id: "breadcast".into(),
title: "breadcast".into(),
detail:
"Optional bread-ecosystem app. Installed through bakery — it is not on the ISO."
.into(),
installed: breadcast,
via: "bakery".into(),
},
OptionalItem {
id: "flatpak".into(),
title: "Flatpak + Flathub".into(),
detail: "Enables the Flatpak runtime and the Flathub user remote.".into(),
installed: flatpak && flathub_enabled(),
via: "pacman".into(),
},
OptionalItem {
id: "office".into(),
title: "LibreOffice + PDF".into(),
detail: "libreoffice-fresh and papers (GNOME document viewer).".into(),
installed: office,
via: "pacman".into(),
},
OptionalItem {
id: "steam".into(),
title: "Steam".into(),
detail: "Valve Steam from the multilib repo.".into(),
installed: steam,
via: "pacman".into(),
},
],
flathub: flathub_enabled(),
}
}
/// User Flathub remote — no root. Flatpak itself is installed separately
/// via the allowlisted pacman command when missing.
#[tauri::command]
pub async fn enable_flathub() -> Result<(), String> {
if !command_exists("flatpak") {
return Err("flatpak is not installed".into());
}
let output = Command::new("flatpak")
.args([
"remote-add",
"--if-not-exists",
"--user",
"flathub",
"https://dl.flathub.org/repo/flathub.flatpakrepo",
])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(fail_output(&output, "flatpak remote-add"))
}
}

View file

@ -3,7 +3,7 @@ use std::collections::HashMap;
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
pub struct InstalledPackage { pub struct InstalledPackage {
name: String, pub name: String,
version: String, version: String,
} }
@ -23,14 +23,19 @@ pub fn get_installed_packages() -> Vec<InstalledPackage> {
let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else { let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else {
return Vec::new(); return Vec::new();
}; };
let Ok(packages) = serde_json::from_value::<HashMap<String, serde_json::Value>>(packages) else { let Ok(packages) = serde_json::from_value::<HashMap<String, serde_json::Value>>(packages)
else {
return Vec::new(); return Vec::new();
}; };
let mut list: Vec<InstalledPackage> = packages let mut list: Vec<InstalledPackage> = packages
.into_iter() .into_iter()
.map(|(name, val)| { .map(|(name, val)| {
let version = val.get("version").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); let version = val
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
InstalledPackage { name, version } InstalledPackage { name, version }
}) })
.collect(); .collect();

View file

@ -0,0 +1,204 @@
//! CUPS printers via lpstat / lpadmin. Adding a printer through the full
//! device wizard is `system-config-printer`; a simple IPP Everywhere queue
//! can be created here when the user has a URI.
use serde::Serialize;
use tokio::process::Command;
use super::util::{fail_output, valid_printer_name};
#[derive(Serialize, Clone)]
pub struct Printer {
name: String,
status: String,
enabled: bool,
is_default: bool,
}
#[derive(Serialize)]
pub struct PrintingStatus {
printers: Vec<Printer>,
default: Option<String>,
cups_ok: bool,
error: Option<String>,
}
#[tauri::command]
pub async fn get_printers() -> PrintingStatus {
let output = match Command::new("lpstat").args(["-p", "-d"]).output().await {
Ok(o) => o,
Err(e) => {
return PrintingStatus {
printers: Vec::new(),
default: None,
cups_ok: false,
error: Some(format!("couldn't run lpstat: {e}")),
};
}
};
if !output.status.success() {
return PrintingStatus {
printers: Vec::new(),
default: None,
cups_ok: false,
error: Some(fail_output(&output, "lpstat")),
};
}
let text = String::from_utf8_lossy(&output.stdout);
parse_lpstat(&text)
}
fn parse_lpstat(text: &str) -> PrintingStatus {
let mut printers = Vec::new();
let mut default = None;
for line in text.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("printer ") {
let mut parts = rest.splitn(2, ' ');
let name = parts.next().unwrap_or("").to_string();
let rest = parts.next().unwrap_or("");
if name.is_empty() {
continue;
}
let enabled = !rest.contains("disabled");
let status = rest
.strip_prefix("is ")
.unwrap_or(rest)
.split(". ")
.next()
.unwrap_or(rest)
.trim()
.to_string();
printers.push(Printer {
name,
status,
enabled,
is_default: false,
});
} else if let Some(name) = line.strip_prefix("system default destination: ") {
default = Some(name.trim().to_string());
} else if line == "no system default destination" {
default = None;
}
}
if let Some(def) = default.as_deref() {
for p in &mut printers {
p.is_default = p.name == def;
}
}
PrintingStatus {
printers,
default,
cups_ok: true,
error: None,
}
}
#[tauri::command]
pub async fn set_default_printer(name: String) -> Result<(), String> {
if !valid_printer_name(&name) {
return Err(format!("invalid printer name '{name}'"));
}
let output = Command::new("lpadmin")
.args(["-d", &name])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
return Ok(());
}
let output = Command::new("pkexec")
.args(["lpadmin", "-d", &name])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(fail_output(&output, "lpadmin"))
}
}
#[tauri::command]
pub async fn add_ipp_printer(name: String, uri: String) -> Result<(), String> {
if !valid_printer_name(&name) {
return Err(format!("invalid printer name '{name}'"));
}
if !valid_printer_uri(&uri) {
return Err("URI must be ipp://, ipps://, socket://, usb://, or dnssd://".into());
}
let args_owned = [
"-p".into(),
name.clone(),
"-E".into(),
"-v".into(),
uri,
"-m".into(),
"everywhere".into(),
];
let output = Command::new("lpadmin")
.args(&args_owned)
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
return Ok(());
}
let mut pk = vec!["lpadmin".to_string()];
pk.extend(args_owned);
let output = Command::new("pkexec")
.args(&pk)
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(fail_output(&output, "lpadmin"))
}
}
fn valid_printer_uri(uri: &str) -> bool {
let u = uri.trim();
!u.is_empty()
&& u.len() <= 512
&& !u.contains(char::is_whitespace)
&& (u.starts_with("ipp://")
|| u.starts_with("ipps://")
|| u.starts_with("socket://")
|| u.starts_with("usb://")
|| u.starts_with("dnssd://"))
}
#[tauri::command]
pub fn open_printer_settings() {
let _ = std::process::Command::new("system-config-printer").spawn();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lpstat_parses_idle_and_default() {
let text = "\
printer Canon-TS6360a is idle. enabled since Mon 10 Aug 2026
printer Hall is disabled since yesterday
system default destination: Canon-TS6360a
";
let st = parse_lpstat(text);
assert_eq!(st.printers.len(), 2);
assert!(st.printers[0].is_default);
assert!(st.printers[0].enabled);
assert!(!st.printers[1].enabled);
assert_eq!(st.default.as_deref(), Some("Canon-TS6360a"));
}
#[test]
fn uri_schemes() {
assert!(valid_printer_uri("ipp://192.168.1.5/ipp/print"));
assert!(valid_printer_uri("ipps://printer.local/ipp"));
assert!(!valid_printer_uri("http://evil"));
assert!(!valid_printer_uri("ipp://x y"));
}
}

View file

@ -15,20 +15,49 @@ use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command; use tokio::process::Command;
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
struct CmdOutputEvent { pub(crate) struct CmdOutputEvent {
session_id: String, session_id: String,
line: String, line: String,
} }
pub(crate) fn emit_line(app: &AppHandle, session_id: &str, line: &str) {
let _ = app.emit(
"cmd-output",
CmdOutputEvent {
session_id: session_id.to_string(),
line: line.to_string(),
},
);
}
/// Runs a hardcoded `program args...`, emitting one `cmd-output` event per /// Runs a hardcoded `program args...`, emitting one `cmd-output` event per
/// line of 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.
async fn run_hardcoded(app: AppHandle, session_id: String, program: &str, args: &[&str]) -> bool { pub(crate) async fn run_hardcoded(
let child = Command::new(program) app: AppHandle,
.args(args) session_id: String,
program: &str,
args: &[&str],
) -> bool {
run_hardcoded_env(app, session_id, program, args, &[]).await
}
pub(crate) async fn run_hardcoded_env(
app: AppHandle,
session_id: String,
program: &str,
args: &[&str],
envs: &[(&str, String)],
) -> bool {
let mut cmd = Command::new(program);
cmd.args(args)
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn(); .kill_on_drop(true);
for (k, v) in envs {
cmd.env(k, v);
}
let child = cmd.spawn();
let mut child = match child { let mut child = match child {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@ -135,6 +164,35 @@ pub async fn fwupd_update(app: AppHandle, session_id: String) -> bool {
run_hardcoded(app, session_id, "fwupdmgr", &["update", "-y"]).await run_hardcoded(app, session_id, "fwupdmgr", &["update", "-y"]).await
} }
#[tauri::command]
pub async fn bakery_install(app: AppHandle, session_id: String, name: String) -> bool {
if let Err(e) = super::util::allowed_bakery_install(&name) {
emit_line(&app, &session_id, &format!("Error: {e}"));
return false;
}
run_hardcoded(app, session_id, "bakery", &["-y", "install", &name]).await
}
#[tauri::command]
pub async fn pacman_install(app: AppHandle, session_id: String, packages: Vec<String>) -> bool {
let names = match super::util::allowed_pacman_packages(&packages) {
Ok(n) => n,
Err(e) => {
emit_line(&app, &session_id, &format!("Error: {e}"));
return false;
}
};
let mut args: Vec<String> = vec![
"pacman".into(),
"-S".into(),
"--noconfirm".into(),
"--".into(),
];
args.extend(names);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
run_hardcoded(app, session_id, "pkexec", &refs).await
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::valid_bakery_pkg; use super::valid_bakery_pkg;

184
src/src/commands/updates.rs Normal file
View file

@ -0,0 +1,184 @@
//! Aggregated Updates page: pacman -Qu, bakery dry-run, fwupd devices.
//! Rollback is Snapshots / grub-btrfs — not `snapper rollback`.
use serde::Serialize;
use tokio::process::Command;
use super::firmware::{get_updatable_firmware, FwDevice};
use super::nvidia::{read_nvidia_offer, NvidiaOffer};
use super::util::strip_ansi;
#[derive(Serialize, Clone)]
pub struct PendingUpdate {
name: String,
current: String,
latest: String,
}
#[derive(Serialize)]
pub struct UpdatesStatus {
pacman: Vec<PendingUpdate>,
pacman_error: Option<String>,
bakery: Vec<PendingUpdate>,
bakery_error: Option<String>,
firmware: Vec<FwDevice>,
nvidia: Option<NvidiaOffer>,
}
#[tauri::command]
pub async fn get_updates_status() -> UpdatesStatus {
let (pacman, bakery, firmware) = tokio::join!(
list_pacman_upgrades(),
list_bakery_outdated(),
get_updatable_firmware()
);
let (pacman, pacman_error) = match pacman {
Ok(v) => (v, None),
Err(e) => (Vec::new(), Some(e)),
};
let (bakery, bakery_error) = match bakery {
Ok(v) => (v, None),
Err(e) => (Vec::new(), Some(e)),
};
UpdatesStatus {
pacman,
pacman_error,
bakery,
bakery_error,
firmware,
nvidia: read_nvidia_offer(),
}
}
async fn list_pacman_upgrades() -> Result<Vec<PendingUpdate>, String> {
let output = Command::new("pacman")
.args(["-Qu"])
.output()
.await
.map_err(|e| format!("couldn't run pacman: {e}"))?;
// pacman -Qu exits 1 when there is nothing to upgrade.
let text = String::from_utf8_lossy(&output.stdout);
Ok(parse_pacman_qu(&text))
}
fn parse_pacman_qu(text: &str) -> Vec<PendingUpdate> {
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() {
return None;
}
// "name old -> new" — extra fields after new are ignored.
let mut parts = line.split_whitespace();
let name = parts.next()?.to_string();
let current = parts.next()?.to_string();
let arrow = parts.next()?;
if arrow != "->" {
return None;
}
let latest = parts.next()?.to_string();
Some(PendingUpdate {
name,
current,
latest,
})
})
.collect()
}
async fn list_bakery_outdated() -> Result<Vec<PendingUpdate>, String> {
let output = Command::new("bakery")
.args(["--dry-run", "update", "--all"])
.output()
.await
.map_err(|e| format!("couldn't run bakery: {e}"))?;
let text = strip_ansi(&String::from_utf8_lossy(&output.stdout));
let err = strip_ansi(&String::from_utf8_lossy(&output.stderr));
let combined = format!("{text}\n{err}");
Ok(parse_bakery_outdated(&combined))
}
/// bakery has no `outdated` subcommand. `--dry-run update --all` is the
/// CLI's own preview of what a track-aware update would change.
fn parse_bakery_outdated(text: &str) -> Vec<PendingUpdate> {
let mut out = Vec::new();
for raw in text.lines() {
let line = raw.trim();
if let Some(pkg) = parse_would_update(line).or_else(|| parse_updating_arrow(line)) {
if !out.iter().any(|p: &PendingUpdate| p.name == pkg.name) {
out.push(pkg);
}
}
}
out
}
fn parse_would_update(line: &str) -> Option<PendingUpdate> {
// "dry-run: would update bakery to 0.7.3-dev.…"
// "Would update bakery 0.7.3-dev.…"
let lower = line.to_ascii_lowercase();
let i = lower.find("would update")?;
let rest = line[i + "would update".len()..].trim();
let rest = rest.strip_prefix(':').unwrap_or(rest).trim();
let rest = rest.strip_prefix("to ").unwrap_or(rest);
let mut parts = rest.split_whitespace();
let name = parts.next()?.to_string();
let mut latest = parts.next().unwrap_or("").to_string();
if latest.eq_ignore_ascii_case("to") {
latest = parts.next().unwrap_or("").to_string();
}
if name.is_empty() {
return None;
}
Some(PendingUpdate {
name,
current: String::new(),
latest,
})
}
fn parse_updating_arrow(line: &str) -> Option<PendingUpdate> {
// "updating bakery 0.7.2 → 0.7.3"
let line = line.strip_prefix("updating ")?;
let (name, rest) = line.split_once(' ')?;
let (current, latest) = rest.split_once('→')?;
Some(PendingUpdate {
name: name.trim().to_string(),
current: current.trim().to_string(),
latest: latest.trim().to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pacman_qu_parses_arrow_lines() {
let text =
"linux 6.15.1-1 -> 6.15.2-1\nextra-note\nbos-settings 0.8.0-1 -> 0.8.1-1 [ignored]\n";
let v = parse_pacman_qu(text);
assert_eq!(v.len(), 2);
assert_eq!(v[0].name, "linux");
assert_eq!(v[0].current, "6.15.1-1");
assert_eq!(v[0].latest, "6.15.2-1");
assert_eq!(v[1].name, "bos-settings");
}
#[test]
fn bakery_dry_run_parses_would_update_and_arrow() {
let text = "\
· breadbar is already at 0.3.2
updating bakery 0.7.2-dev.1 0.7.3-dev.2
dry-run: would update bakery to 0.7.3-dev.2
Would update breadcast 1.2.3
1 updated, 14 already up to date
";
let v = parse_bakery_outdated(text);
assert_eq!(v.len(), 2);
assert_eq!(v[0].name, "bakery");
assert_eq!(v[0].latest, "0.7.3-dev.2");
assert_eq!(v[1].name, "breadcast");
assert_eq!(v[1].latest, "1.2.3");
}
}

258
src/src/commands/util.rs Normal file
View file

@ -0,0 +1,258 @@
//! Shared helpers for the OS-panel commands: PATH lookups, tight name
//! checks, 0600 writes, and the Hyprland `source =` fragment convention.
use std::path::{Path, PathBuf};
use super::config;
/// Pacman packages these panels may install. A generic `pacman -S` runner
/// is an arbitrary-package primitive; every name must be on this list.
pub const PACMAN_ALLOWLIST: &[&str] = &[
"hyprsunset",
"fcitx5",
"fcitx5-configtool",
"fcitx5-gtk",
"fcitx5-qt",
"fcitx5-im",
"fcitx5-chinese-addons",
"fcitx5-table-extra",
"orca",
"kmag",
"restic",
"flatpak",
"libreoffice-fresh",
"papers",
"evince",
"steam",
"nvidia",
"nvidia-utils",
];
/// Bakery packages these panels may `bakery install`. breadcast is optional
/// software and is not on the ISO; do not add breadarr.
pub const BAKERY_INSTALL_ALLOWLIST: &[&str] = &["breadcast"];
pub fn command_exists(name: &str) -> bool {
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&paths).any(|dir| {
let candidate = dir.join(name);
candidate.is_file()
})
}
pub fn pacman_installed(pkg: &str) -> bool {
std::process::Command::new("pacman")
.args(["-Q", pkg])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Arch package / bakery name: starts alphanumeric, then `[A-Za-z0-9+._-]`.
pub fn valid_pkg_name(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() || matches!(*b, b'-' | b'_' | b'+' | b'.'))
}
pub fn allowed_pacman_packages(names: &[String]) -> Result<Vec<String>, String> {
if names.is_empty() {
return Err("no packages given".into());
}
let mut out = Vec::with_capacity(names.len());
for name in names {
if !valid_pkg_name(name) || !PACMAN_ALLOWLIST.contains(&name.as_str()) {
return Err(format!("refusing to install '{name}'"));
}
if !out.iter().any(|e| e == name) {
out.push(name.clone());
}
}
Ok(out)
}
pub fn allowed_bakery_install(name: &str) -> Result<(), String> {
if !valid_pkg_name(name) || !BAKERY_INSTALL_ALLOWLIST.contains(&name) {
return Err(format!("refusing to bakery-install '{name}'"));
}
Ok(())
}
pub fn bos_settings_dir() -> PathBuf {
config::config_dir().join("bos-settings")
}
/// Atomic write with mode 0600 set on the new inode before/after replace,
/// matching breadcrumbs' `networks.toml` care.
pub 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(())
}
pub fn strip_ansi(s: &str) -> String {
let re = regex::Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").expect("ansi regex");
re.replace_all(s, "").into_owned()
}
pub fn fail_output(output: &std::process::Output, what: &str) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let msg = stderr.trim();
if !msg.is_empty() {
return msg.to_string();
}
let msg = stdout.trim();
if !msg.is_empty() {
return msg.to_string();
}
format!("{what} failed")
}
pub fn hypr_dir() -> PathBuf {
config::config_dir().join("hypr")
}
pub fn hyprland_conf() -> PathBuf {
hypr_dir().join("hyprland.conf")
}
/// Ensure `hyprland.conf` sources `~/.config/hypr/{fragment}`. Appends a
/// single source line when missing; does not rewrite the rest of the file.
pub fn ensure_hypr_source(fragment: &str) -> Result<(), String> {
if !valid_fragment(fragment) {
return Err(format!("invalid hypr fragment '{fragment}'"));
}
let dir = hypr_dir();
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = hyprland_conf();
let marker = format!("hypr/{fragment}");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
if existing.lines().any(|l| l.contains(&marker)) {
return Ok(());
}
let mut text = existing;
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&format!("source = ~/.config/hypr/{fragment}\n"));
config::atomic_write(&path, &text).map_err(|e| e.to_string())
}
pub fn remove_hypr_source(fragment: &str) -> Result<(), String> {
if !valid_fragment(fragment) {
return Err(format!("invalid hypr fragment '{fragment}'"));
}
let path = hyprland_conf();
let Ok(existing) = std::fs::read_to_string(&path) else {
return Ok(());
};
let marker = format!("hypr/{fragment}");
let filtered: String =
existing
.lines()
.filter(|l| !l.contains(&marker))
.fold(String::new(), |mut acc, l| {
acc.push_str(l);
acc.push('\n');
acc
});
if filtered != existing {
config::atomic_write(&path, &filtered).map_err(|e| e.to_string())?;
}
Ok(())
}
fn valid_fragment(name: &str) -> bool {
let bytes = name.as_bytes();
!bytes.is_empty()
&& bytes.len() <= 64
&& bytes[0].is_ascii_alphanumeric()
&& bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.'))
}
/// Connection / printer names: no flags, no newlines. Spaces are allowed
/// (NetworkManager connection ids often have them).
pub fn valid_nm_id(name: &str) -> bool {
let t = name.trim();
!t.is_empty()
&& t.len() <= 256
&& !t.starts_with('-')
&& !t.contains('\n')
&& !t.contains('\0')
&& !t.contains(';')
}
pub fn valid_printer_name(name: &str) -> bool {
let bytes = name.as_bytes();
!bytes.is_empty()
&& bytes.len() <= 127
&& bytes[0].is_ascii_alphanumeric()
&& bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pkg_name_accepts_arch_names() {
assert!(valid_pkg_name("hyprsunset"));
assert!(valid_pkg_name("fcitx5-chinese-addons"));
assert!(valid_pkg_name("libreoffice-fresh"));
assert!(valid_pkg_name("nvidia-utils"));
}
#[test]
fn pkg_name_rejects_flags() {
assert!(!valid_pkg_name(""));
assert!(!valid_pkg_name("-S"));
assert!(!valid_pkg_name("--noconfirm"));
assert!(!valid_pkg_name("foo;rm"));
assert!(!valid_pkg_name("foo bar"));
}
#[test]
fn allowlist_rejects_unknown() {
assert!(allowed_pacman_packages(&["steam".into()]).is_ok());
assert!(allowed_pacman_packages(&["evil".into()]).is_err());
assert!(allowed_bakery_install("breadcast").is_ok());
assert!(allowed_bakery_install("breadarr").is_err());
}
#[test]
fn nm_id_allows_spaces_not_flags() {
assert!(valid_nm_id("Home VPN"));
assert!(!valid_nm_id("-evil"));
assert!(!valid_nm_id("a\nb"));
assert!(!valid_nm_id(""));
}
#[test]
fn printer_name_is_tight() {
assert!(valid_printer_name("Canon-TS6360a"));
assert!(!valid_printer_name("foo bar"));
assert!(!valid_printer_name("-d"));
}
}

179
src/src/commands/vpn.rs Normal file
View file

@ -0,0 +1,179 @@
//! NetworkManager VPN / WireGuard connections. breadcrumbs stays Wi-Fi
//! profiles; this panel only lists `vpn` and `wireguard` connection types.
use serde::Serialize;
use tokio::process::Command;
use super::util::{fail_output, valid_nm_id};
#[derive(Serialize, Clone)]
pub struct VpnConnection {
name: String,
kind: String,
active: bool,
autoconnect: bool,
}
#[derive(Serialize)]
pub struct VpnStatus {
connections: Vec<VpnConnection>,
error: Option<String>,
}
#[tauri::command]
pub async fn get_vpn_connections() -> VpnStatus {
let output = match Command::new("nmcli")
.args([
"-t",
"-f",
"NAME,TYPE,STATE,AUTOCONNECT",
"connection",
"show",
])
.output()
.await
{
Ok(o) => o,
Err(e) => {
return VpnStatus {
connections: Vec::new(),
error: Some(format!("couldn't run nmcli: {e}")),
};
}
};
if !output.status.success() {
return VpnStatus {
connections: Vec::new(),
error: Some(fail_output(&output, "nmcli")),
};
}
let text = String::from_utf8_lossy(&output.stdout);
VpnStatus {
connections: parse_nm_connections(&text),
error: None,
}
}
fn parse_nm_connections(text: &str) -> Vec<VpnConnection> {
text.lines()
.filter_map(|line| {
// nmcli -t escapes ":" in names as "\:".
let cols = split_nmcli(line);
if cols.len() < 3 {
return None;
}
let kind = cols[1].as_str();
if kind != "vpn" && kind != "wireguard" {
return None;
}
let state = cols[2].as_str();
let autoconnect = cols.get(3).map(|s| s == "yes").unwrap_or(false);
Some(VpnConnection {
name: cols[0].clone(),
kind: kind.to_string(),
active: state == "activated" || state == "activating",
autoconnect,
})
})
.collect()
}
fn split_nmcli(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(n) = chars.next() {
cur.push(n);
}
} else if c == ':' {
out.push(std::mem::take(&mut cur));
} else {
cur.push(c);
}
}
out.push(cur);
out
}
#[tauri::command]
pub async fn vpn_connect(name: String) -> Result<(), String> {
nmcli_con(&["connection", "up", "id", &checked_id(&name)?]).await
}
#[tauri::command]
pub async fn vpn_disconnect(name: String) -> Result<(), String> {
nmcli_con(&["connection", "down", "id", &checked_id(&name)?]).await
}
fn checked_id(name: &str) -> Result<String, String> {
if !valid_nm_id(name) {
return Err(format!("invalid connection name '{name}'"));
}
Ok(name.trim().to_string())
}
async fn nmcli_con(args: &[&str]) -> Result<(), String> {
let output = Command::new("nmcli")
.args(args)
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(fail_output(&output, "nmcli"))
}
}
#[tauri::command]
pub async fn vpn_import(path: String) -> Result<(), String> {
let path = path.trim();
if path.is_empty() || path.contains('\0') || path.contains('\n') {
return Err("invalid path".into());
}
let p = std::path::Path::new(path);
if !p.is_absolute() || !p.is_file() {
return Err("pick an existing .conf or .ovpn file".into());
}
let kind = match p
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase())
.as_deref()
{
Some("ovpn") => "openvpn",
Some("conf") => "wireguard",
_ => return Err("import a WireGuard .conf or OpenVPN .ovpn file".into()),
};
nmcli_con(&["connection", "import", "type", kind, "file", path]).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_wireguard_and_skips_wifi() {
let text = "\
Home WG:wireguard:activated:yes
Office:vpn:
NetComm:802-11-wireless:activated
tailscale0:tun:activated:yes
";
let v = parse_nm_connections(text);
assert_eq!(v.len(), 2);
assert_eq!(v[0].name, "Home WG");
assert!(v[0].active);
assert_eq!(v[1].kind, "vpn");
assert!(!v[1].active);
}
#[test]
fn unescapes_colon_in_name() {
let text = r"Work\:VPN:vpn:activated:no";
let v = parse_nm_connections(text);
assert_eq!(v[0].name, "Work:VPN");
}
}

View file

@ -89,7 +89,9 @@ pub fn run() {
commands::streaming::bakery_update, commands::streaming::bakery_update,
commands::streaming::bakery_list, commands::streaming::bakery_list,
commands::streaming::bakery_update_all, commands::streaming::bakery_update_all,
commands::streaming::bakery_install,
commands::streaming::pacman_system_update, commands::streaming::pacman_system_update,
commands::streaming::pacman_install,
commands::streaming::fwupd_refresh, commands::streaming::fwupd_refresh,
commands::streaming::fwupd_update, commands::streaming::fwupd_update,
commands::packages::get_installed_packages, commands::packages::get_installed_packages,
@ -111,6 +113,37 @@ pub fn run() {
commands::breadshot::breadshot_region_clipboard, commands::breadshot::breadshot_region_clipboard,
commands::breadmon::open_breadmon, commands::breadmon::open_breadmon,
commands::breadhelp::open_breadhelp, commands::breadhelp::open_breadhelp,
commands::updates::get_updates_status,
commands::nvidia::get_nvidia_offer,
commands::printing::get_printers,
commands::printing::set_default_printer,
commands::printing::add_ipp_printer,
commands::printing::open_printer_settings,
commands::vpn::get_vpn_connections,
commands::vpn::vpn_connect,
commands::vpn::vpn_disconnect,
commands::vpn::vpn_import,
commands::nightlight::get_nightlight,
commands::nightlight::set_nightlight,
commands::ime::get_ime_status,
commands::ime::set_ime_enabled,
commands::ime::open_fcitx_config,
commands::a11y::get_a11y_status,
commands::a11y::set_cursor_zoom,
commands::a11y::set_orca_running,
commands::a11y::open_kmag,
commands::defaults::get_default_apps,
commands::defaults::save_default_apps,
commands::channel::get_bakery_track,
commands::channel::set_bakery_track,
commands::backup::get_backup_config,
commands::backup::save_backup_config,
commands::backup::restic_init,
commands::backup::restic_backup,
commands::backup::restic_restore_dry_run,
commands::backup::list_restic_snapshots,
commands::optional::get_optional_software,
commands::optional::enable_flathub,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View file

@ -59,6 +59,16 @@ const KNOWN_VIEWS: &[&str] = &[
"aur", "aur",
"firmware", "firmware",
"snapshots", "snapshots",
"updates",
"printing",
"vpn",
"nightlight",
"ime",
"accessibility",
"defaults",
"channel",
"backup",
"optional",
"breadlock", "breadlock",
"breadshot", "breadshot",
"breadmon", "breadmon",