Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7b114f778 | ||
|
|
77b5a223d8 | ||
|
|
a6cb245ae4 | ||
|
|
11c4ecb3cc | ||
|
|
73589ae36d | ||
|
|
3aad09c9ef | ||
|
|
0798fad697 | ||
|
|
60a473fff0 |
44 changed files with 5521 additions and 294 deletions
3
frontend/src/lib/nav.ts
Normal file
3
frontend/src/lib/nav.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/** Sidebar page switch. Set from +page.svelte; views call it to jump. */
|
||||||
|
export type Navigate = (page: string) => void;
|
||||||
|
export const NAVIGATE_KEY = "bos-settings-navigate";
|
||||||
|
|
@ -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[] = [
|
||||||
|
|
|
||||||
197
frontend/src/lib/views/Accessibility.svelte
Normal file
197
frontend/src/lib/views/Accessibility.svelte
Normal 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="Hyprland’s 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>
|
||||||
254
frontend/src/lib/views/Backup.svelte
Normal file
254
frontend/src/lib/views/Backup.svelte
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
<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;
|
||||||
|
home: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let st = $state<BackupStatus | null>(null);
|
||||||
|
let repo = $state("");
|
||||||
|
let password = $state("");
|
||||||
|
let snapshots = $state<ResticSnapshot[]>([]);
|
||||||
|
let selected = $state("latest");
|
||||||
|
let restoreTarget = $state("");
|
||||||
|
let lastAutoTarget = $state("");
|
||||||
|
let log = $state<string[]>([]);
|
||||||
|
let busy = $state(false);
|
||||||
|
let message = $state("");
|
||||||
|
|
||||||
|
function defaultTarget(id: string): string {
|
||||||
|
const home = st?.home ?? "";
|
||||||
|
if (!home) return "";
|
||||||
|
return `${home}/bos-restore-${id || "latest"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!st?.home) return;
|
||||||
|
const auto = defaultTarget(selected || "latest");
|
||||||
|
if (restoreTarget === "" || restoreTarget === lastAutoTarget) {
|
||||||
|
if (restoreTarget !== auto) restoreTarget = auto;
|
||||||
|
if (lastAutoTarget !== auto) lastAutoTarget = auto;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restore(dryRun: boolean) {
|
||||||
|
const snap = selected || "latest";
|
||||||
|
const target = restoreTarget.trim() || defaultTarget(snap);
|
||||||
|
const home = st?.home ?? "";
|
||||||
|
if (!dryRun && home && (target === home || target === `${home}/`)) {
|
||||||
|
message = "Refusing to restore onto $HOME. Leave the default ~/bos-restore-<id> or pick another folder.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dryRun) {
|
||||||
|
const ok = confirm(
|
||||||
|
`Restore snapshot ${snap} into ${target}?\n\nFiles go into that directory. Your live home is not overwritten.`,
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
await run(dryRun ? "restic_restore_dry_run" : "restic_restore", {
|
||||||
|
snapshot: snap,
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</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="This backs up your @home life — documents, configs, the stuff snapper does not. Snapshots on the Snapshots page are root (@) only. Skips caches, Trash, Steam, containers, cargo/rustup, Flatpak, node_modules, target, and .git."
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{#if message}<Hint text={message} />{/if}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group
|
||||||
|
title="Restore"
|
||||||
|
hint="Writes into a new folder (default ~/bos-restore-<id>). Does not overwrite $HOME. Dry-run previews the same target."
|
||||||
|
>
|
||||||
|
<FileField label="Restore into" bind:value={restoreTarget} mode="folder" placeholder="/home/you/bos-restore-latest" />
|
||||||
|
<div class="btn-row">
|
||||||
|
<button disabled={busy || !st?.restic_installed} onclick={() => restore(true)}>Restore dry-run</button>
|
||||||
|
<button class="primary" disabled={busy || !st?.restic_installed} onclick={() => restore(false)}>Restore</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
127
frontend/src/lib/views/Channel.svelte
Normal file
127
frontend/src/lib/views/Channel.svelte
Normal 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 track’s 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>
|
||||||
104
frontend/src/lib/views/Defaults.svelte
Normal file
104
frontend/src/lib/views/Defaults.svelte
Normal 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>
|
||||||
166
frontend/src/lib/views/InputMethod.svelte
Normal file
166
frontend/src/lib/views/InputMethod.svelte
Normal 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>
|
||||||
145
frontend/src/lib/views/NightLight.svelte
Normal file
145
frontend/src/lib/views/NightLight.svelte
Normal 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>
|
||||||
149
frontend/src/lib/views/Optional.svelte
Normal file
149
frontend/src/lib/views/Optional.svelte
Normal 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>
|
||||||
147
frontend/src/lib/views/Printing.svelte
Normal file
147
frontend/src/lib/views/Printing.svelte
Normal 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>
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
if (
|
if (
|
||||||
confirm(
|
confirm(
|
||||||
`Boot into snapshot #${selected}? Snapshots on BOS are booted directly from the GRUB menu (under "BOS snapshots"), not rolled back in place. Reboot now and pick this snapshot there.`,
|
`Reboot to pick snapshot #${selected} in GRUB? BOS boots snapshots from the GRUB “BOS snapshots” submenu. This does not run snapper rollback.`,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
invoke("reboot_system");
|
invoke("reboot_system");
|
||||||
|
|
@ -62,9 +62,10 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ViewScaffold title="Snapshots">
|
<ViewScaffold title="Snapshots">
|
||||||
|
<Hint text="This is how you undo a bad update: reboot and pick the snapshot in GRUB (BOS snapshots)." />
|
||||||
<Group
|
<Group
|
||||||
title="System snapshots"
|
title="What to pick in GRUB"
|
||||||
hint="Created automatically by snap-pac on each pacman transaction. Boot into one from the GRUB menu to recover; delete old ones here."
|
hint="Number, date, and description match the GRUB “BOS snapshots” submenu. Reboot, then choose that entry. This page does not run snapper rollback."
|
||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
<div class="list">
|
<div class="list">
|
||||||
|
|
@ -75,6 +76,11 @@
|
||||||
{:else if snapshots.length === 0}
|
{:else if snapshots.length === 0}
|
||||||
<EmptyState icon={History} title="No snapshots yet" hint="Snapshots are created automatically on every pacman transaction." />
|
<EmptyState icon={History} title="No snapshots yet" hint="Snapshots are created automatically on every pacman transaction." />
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="row header" aria-hidden="true">
|
||||||
|
<span class="number">#</span>
|
||||||
|
<span class="date">Date</span>
|
||||||
|
<span class="desc">Description</span>
|
||||||
|
</div>
|
||||||
{#each snapshots as snap (snap.number)}
|
{#each snapshots as snap (snap.number)}
|
||||||
<button
|
<button
|
||||||
class="row"
|
class="row"
|
||||||
|
|
@ -91,7 +97,7 @@
|
||||||
|
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button onclick={refresh}>Refresh</button>
|
<button onclick={refresh}>Refresh</button>
|
||||||
<button disabled={!selected} onclick={bootIntoSelected}>Boot into selected…</button>
|
<button disabled={!selected} onclick={bootIntoSelected}>Reboot to pick in GRUB</button>
|
||||||
<button class="destructive" disabled={!selected} onclick={deleteSelected}>Delete selected</button>
|
<button class="destructive" disabled={!selected} onclick={deleteSelected}>Delete selected</button>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
@ -121,7 +127,15 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:hover {
|
.row.header {
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.55;
|
||||||
|
font-size: var(--font-size-secondary, 12px);
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover:not(.header) {
|
||||||
background-color: color-mix(in srgb, var(--surface), var(--on-surface) 8%);
|
background-color: color-mix(in srgb, var(--surface), var(--on-surface) 8%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
253
frontend/src/lib/views/Updates.svelte
Normal file
253
frontend/src/lib/views/Updates.svelte
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext, 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 { NAVIGATE_KEY, type Navigate } from "$lib/nav";
|
||||||
|
import Download from "@lucide/svelte/icons/download";
|
||||||
|
import Cpu from "@lucide/svelte/icons/cpu";
|
||||||
|
|
||||||
|
const navigate = getContext<Navigate | undefined>(NAVIGATE_KEY);
|
||||||
|
|
||||||
|
interface PendingUpdate {
|
||||||
|
name: string;
|
||||||
|
current: string;
|
||||||
|
latest: string;
|
||||||
|
}
|
||||||
|
interface FwDevice {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
interface NvidiaOffer {
|
||||||
|
gpu: string;
|
||||||
|
reason: string;
|
||||||
|
packages: string[];
|
||||||
|
installed: boolean;
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
{#if status.nvidia.installed}
|
||||||
|
<Hint text="Driver and Hyprland env drop-in are in place. Reboot to start a working session." />
|
||||||
|
{:else}
|
||||||
|
<Hint text="Installs nvidia + nvidia-utils (not cuda) and writes ~/.config/hypr/nvidia.lua. hyprland.lua loads that file only if it exists. Reboot after." />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="primary"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => run("nvidia_setup")}
|
||||||
|
>
|
||||||
|
{status.nvidia.installed ? "Re-run setup" : "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="Boot a snapshot from GRUB (BOS snapshots)."
|
||||||
|
>
|
||||||
|
<Hint text="Snapshots lists number, date, and description so you know which GRUB entry to pick. snapper rollback will not change what GRUB boots (rootflags=subvol=@)." />
|
||||||
|
<Hint text="Bakery also has bakery rollback <pkg> for a single ecosystem binary — that is not a system rollback." />
|
||||||
|
<div class="btn-row">
|
||||||
|
<button disabled={!navigate} onclick={() => navigate?.("snapshots")}>Open Snapshots</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
148
frontend/src/lib/views/Vpn.svelte
Normal file
148
frontend/src/lib/views/Vpn.svelte
Normal 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>
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount, setContext } from "svelte";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import Sidebar from "$lib/components/Sidebar.svelte";
|
import Sidebar from "$lib/components/Sidebar.svelte";
|
||||||
import Placeholder from "$lib/components/Placeholder.svelte";
|
import Placeholder from "$lib/components/Placeholder.svelte";
|
||||||
import { DEFAULT_PAGE } from "$lib/sidebar";
|
import { DEFAULT_PAGE } from "$lib/sidebar";
|
||||||
|
import { NAVIGATE_KEY, type Navigate } from "$lib/nav";
|
||||||
import { initTheme } from "$lib/theme";
|
import { initTheme } from "$lib/theme";
|
||||||
import { VIEWS } from "$lib/views/registry";
|
import { VIEWS } from "$lib/views/registry";
|
||||||
|
|
||||||
let activePage = $state(DEFAULT_PAGE);
|
let activePage = $state(DEFAULT_PAGE);
|
||||||
let ActiveView = $derived(VIEWS[activePage]);
|
let ActiveView = $derived(VIEWS[activePage]);
|
||||||
|
setContext<Navigate>(NAVIGATE_KEY, (page) => {
|
||||||
|
activePage = page;
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
initTheme();
|
initTheme();
|
||||||
|
|
|
||||||
456
src/Cargo.lock
generated
456
src/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "bos-settings"
|
name = "bos-settings"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
description = "System settings app for BOS (Bread Operating System)"
|
description = "System settings app for BOS (Bread Operating System)"
|
||||||
authors = ["Breadway"]
|
authors = ["Breadway"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
@ -36,7 +36,7 @@ toml_edit = "0.22"
|
||||||
tokio = { version = "1", features = ["process", "io-util", "time", "macros"] }
|
tokio = { version = "1", features = ["process", "io-util", "time", "macros"] }
|
||||||
notify = "7"
|
notify = "7"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" }
|
||||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml"] }
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
|
||||||
"dialog:default"
|
"dialog:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
116
src/src/commands/a11y.rs
Normal file
116
src/src/commands/a11y.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,8 +23,10 @@ fn os_pretty_name() -> String {
|
||||||
fs::read_to_string("/etc/os-release")
|
fs::read_to_string("/etc/os-release")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| {
|
.and_then(|s| {
|
||||||
s.lines()
|
s.lines().find_map(|l| {
|
||||||
.find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string()))
|
l.strip_prefix("PRETTY_NAME=")
|
||||||
|
.map(|v| v.trim_matches('"').to_string())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "BOS".to_string())
|
.unwrap_or_else(|| "BOS".to_string())
|
||||||
}
|
}
|
||||||
|
|
@ -49,11 +51,15 @@ fn cpu() -> String {
|
||||||
let model = fs::read_to_string("/proc/cpuinfo")
|
let model = fs::read_to_string("/proc/cpuinfo")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| {
|
.and_then(|s| {
|
||||||
s.lines()
|
s.lines().find_map(|l| {
|
||||||
.find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string()))
|
l.strip_prefix("model name")
|
||||||
|
.map(|v| v.trim_start_matches([':', ' ', '\t']).to_string())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
|
let cores = std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(0);
|
||||||
if cores > 0 {
|
if cores > 0 {
|
||||||
format!("{model} ({cores} threads)")
|
format!("{model} ({cores} threads)")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -62,9 +68,7 @@ fn cpu() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn memory() -> String {
|
fn memory() -> String {
|
||||||
let kb = fs::read_to_string("/proc/meminfo")
|
let kb = fs::read_to_string("/proc/meminfo").ok().and_then(|s| {
|
||||||
.ok()
|
|
||||||
.and_then(|s| {
|
|
||||||
s.lines()
|
s.lines()
|
||||||
.find(|l| l.starts_with("MemTotal:"))
|
.find(|l| l.starts_with("MemTotal:"))
|
||||||
.and_then(|l| l.split_whitespace().nth(1))
|
.and_then(|l| l.split_whitespace().nth(1))
|
||||||
|
|
@ -96,7 +100,11 @@ async fn gpu() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn disk_usage() -> String {
|
async fn disk_usage() -> String {
|
||||||
let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output().await else {
|
let Ok(output) = Command::new("df")
|
||||||
|
.args(["-h", "--output=used,size,pcent", "/"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
return "unknown".to_string();
|
return "unknown".to_string();
|
||||||
};
|
};
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
@ -136,11 +144,35 @@ pub async fn get_system_info() -> SystemInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RFC 1123 labels (digit start allowed), no leading `-`. Linux static
|
||||||
|
/// hostnames are also capped at `HOST_NAME_MAX` (64).
|
||||||
|
fn valid_hostname(name: &str) -> bool {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() || name.len() > 64 || name.starts_with('-') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if name.contains('\n') || name.contains('\r') || name.contains('\0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
name.split('.').all(valid_dns_label)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_dns_label(label: &str) -> bool {
|
||||||
|
let b = label.as_bytes();
|
||||||
|
if b.is_empty() || b.len() > 63 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !b[0].is_ascii_alphanumeric() || !b[b.len() - 1].is_ascii_alphanumeric() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
b.iter().all(|c| c.is_ascii_alphanumeric() || *c == b'-')
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_hostname(name: String) -> Result<(), String> {
|
pub async fn set_hostname(name: String) -> Result<(), String> {
|
||||||
let name = name.trim();
|
let name = name.trim();
|
||||||
if name.is_empty() {
|
if !valid_hostname(name) {
|
||||||
return Err("Hostname can't be empty".into());
|
return Err("invalid hostname".into());
|
||||||
}
|
}
|
||||||
let output = Command::new("pkexec")
|
let output = Command::new("pkexec")
|
||||||
.args(["hostnamectl", "set-hostname", name])
|
.args(["hostnamectl", "set-hostname", name])
|
||||||
|
|
@ -153,3 +185,24 @@ pub async fn set_hostname(name: String) -> Result<(), String> {
|
||||||
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
|
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostname_rfc1123() {
|
||||||
|
assert!(valid_hostname("bos"));
|
||||||
|
assert!(valid_hostname("bos.local"));
|
||||||
|
assert!(valid_hostname("a1-b"));
|
||||||
|
assert!(valid_hostname("1host"));
|
||||||
|
assert!(!valid_hostname(""));
|
||||||
|
assert!(!valid_hostname("-bos"));
|
||||||
|
assert!(!valid_hostname("bos-"));
|
||||||
|
assert!(!valid_hostname("-foo.bar"));
|
||||||
|
assert!(!valid_hostname("foo_bar"));
|
||||||
|
assert!(!valid_hostname("bos\n-set-hostname evil"));
|
||||||
|
assert!(!valid_hostname("--help"));
|
||||||
|
assert!(!valid_hostname(&"a".repeat(65)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::util;
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct AurResult {
|
pub struct AurResult {
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -19,7 +21,11 @@ pub struct AurResult {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn search_aur(query: String) -> Vec<AurResult> {
|
pub async fn search_aur(query: String) -> Vec<AurResult> {
|
||||||
let Ok(output) = tokio::process::Command::new("yay").args(["-Ss", "--aur", &query]).output().await else {
|
let Ok(output) = tokio::process::Command::new("yay")
|
||||||
|
.args(["-Ss", "--aur", &query])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
@ -28,12 +34,18 @@ pub async fn search_aur(query: String) -> Vec<AurResult> {
|
||||||
while let Some(header) = lines.next() {
|
while let Some(header) = lines.next() {
|
||||||
// "aur/name version (+votes score) [Orphaned]" — name/version are
|
// "aur/name version (+votes score) [Orphaned]" — name/version are
|
||||||
// always the first two whitespace-separated fields after "aur/".
|
// always the first two whitespace-separated fields after "aur/".
|
||||||
let Some(rest) = header.strip_prefix("aur/") else { continue };
|
let Some(rest) = header.strip_prefix("aur/") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let mut parts = rest.split_whitespace();
|
let mut parts = rest.split_whitespace();
|
||||||
let Some(name) = parts.next() else { continue };
|
let Some(name) = parts.next() else { continue };
|
||||||
let version = parts.next().unwrap_or("").to_string();
|
let version = parts.next().unwrap_or("").to_string();
|
||||||
let description = lines.next().unwrap_or("").trim().to_string();
|
let description = lines.next().unwrap_or("").trim().to_string();
|
||||||
results.push(AurResult { name: name.to_string(), version, description });
|
results.push(AurResult {
|
||||||
|
name: name.to_string(),
|
||||||
|
version,
|
||||||
|
description,
|
||||||
|
});
|
||||||
if results.len() >= 50 {
|
if results.len() >= 50 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -42,6 +54,13 @@ pub async fn search_aur(query: String) -> Vec<AurResult> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn install_aur_package(pkg: String) {
|
pub fn install_aur_package(pkg: String) -> Result<(), String> {
|
||||||
let _ = std::process::Command::new("kitty").args(["-e", "yay", "-S", &pkg]).spawn();
|
if !util::valid_pkg_name(&pkg) {
|
||||||
|
return Err(format!("refusing to install '{pkg}'"));
|
||||||
|
}
|
||||||
|
std::process::Command::new("kitty")
|
||||||
|
.args(["-e", "yay", "-S", &pkg])
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
488
src/src/commands/backup.rs
Normal file
488
src/src/commands/backup.rs
Normal file
|
|
@ -0,0 +1,488 @@
|
||||||
|
//! 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 home_dir() -> PathBuf {
|
||||||
|
std::env::var("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("/root"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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>,
|
||||||
|
home: 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,
|
||||||
|
home: home_dir().to_string_lossy().into_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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",
|
||||||
|
".local/share/containers",
|
||||||
|
".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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `~/bos-restore-<id>`. Never `$HOME` itself — restore writes into a new
|
||||||
|
/// directory so a bad snapshot cannot clobber the live home.
|
||||||
|
pub fn default_restore_dir(snapshot: &str) -> PathBuf {
|
||||||
|
home_dir().join(format!("bos-restore-{snapshot}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_abs(path: &Path) -> PathBuf {
|
||||||
|
path.components().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute path, not `$HOME` and not `/`. Empty target means the default.
|
||||||
|
pub fn valid_restore_target(path: &Path) -> bool {
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let s = path.to_string_lossy();
|
||||||
|
if s.is_empty() || s.len() > 512 || s.contains('\n') || s.contains('\0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let normalized = normalize_abs(path);
|
||||||
|
if normalized == PathBuf::from("/") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
normalized != normalize_abs(&home_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_restore_target(snapshot: &str, target: Option<&str>) -> Result<PathBuf, String> {
|
||||||
|
if !valid_snapshot_id(snapshot) {
|
||||||
|
return Err("invalid snapshot id".into());
|
||||||
|
}
|
||||||
|
let dest = match target.map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
Some(t) => PathBuf::from(t),
|
||||||
|
None => default_restore_dir(snapshot),
|
||||||
|
};
|
||||||
|
if !valid_restore_target(&dest) {
|
||||||
|
return Err(
|
||||||
|
"restore target must be an absolute path that is not $HOME (default is ~/bos-restore-<id>)"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_restic_restore(
|
||||||
|
app: AppHandle,
|
||||||
|
session_id: String,
|
||||||
|
snapshot: String,
|
||||||
|
target: Option<String>,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> 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();
|
||||||
|
let dest = match resolve_restore_target(snap, target.as_deref()) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
streaming::emit_line(&app, &session_id, &format!("Error: {e}"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let dest_s = dest.to_string_lossy().into_owned();
|
||||||
|
let password = s.password.clone().unwrap_or_default();
|
||||||
|
let mut extra = vec![
|
||||||
|
"restore".to_string(),
|
||||||
|
snap.to_string(),
|
||||||
|
"--target".into(),
|
||||||
|
dest_s.clone(),
|
||||||
|
];
|
||||||
|
if dry_run {
|
||||||
|
extra.push("--dry-run".into());
|
||||||
|
}
|
||||||
|
streaming::emit_line(
|
||||||
|
&app,
|
||||||
|
&session_id,
|
||||||
|
&format!(
|
||||||
|
"{} {snap} → {dest_s}",
|
||||||
|
if dry_run {
|
||||||
|
"Dry-run restore"
|
||||||
|
} else {
|
||||||
|
"Restoring"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
|
||||||
|
let args = restic_args(&s.repo, &extra_refs);
|
||||||
|
streaming::run_hardcoded_env(
|
||||||
|
app,
|
||||||
|
session_id,
|
||||||
|
"restic",
|
||||||
|
&args,
|
||||||
|
&[("RESTIC_PASSWORD", password)],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restic_restore_dry_run(
|
||||||
|
app: AppHandle,
|
||||||
|
session_id: String,
|
||||||
|
snapshot: String,
|
||||||
|
target: Option<String>,
|
||||||
|
) -> bool {
|
||||||
|
run_restic_restore(app, session_id, snapshot, target, true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restic_restore(
|
||||||
|
app: AppHandle,
|
||||||
|
session_id: String,
|
||||||
|
snapshot: String,
|
||||||
|
target: Option<String>,
|
||||||
|
) -> bool {
|
||||||
|
run_restic_restore(app, session_id, snapshot, target, false).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");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_defaults_to_bos_restore_id_not_home() {
|
||||||
|
let dest = default_restore_dir("a1b2c3d4");
|
||||||
|
let home = home_dir();
|
||||||
|
assert_eq!(dest, home.join("bos-restore-a1b2c3d4"));
|
||||||
|
assert_ne!(dest, home);
|
||||||
|
assert!(valid_restore_target(&dest));
|
||||||
|
assert!(!valid_restore_target(&home));
|
||||||
|
assert!(!valid_restore_target(Path::new("/")));
|
||||||
|
assert!(!valid_restore_target(Path::new("relative/path")));
|
||||||
|
assert!(valid_restore_target(Path::new("/tmp/bos-restore-custom")));
|
||||||
|
let resolved = resolve_restore_target("latest", None).unwrap();
|
||||||
|
assert_eq!(resolved, home.join("bos-restore-latest"));
|
||||||
|
assert!(resolve_restore_target("latest", Some(home.to_str().unwrap())).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exclude_covers_caches_and_containers() {
|
||||||
|
let args = exclude_args("/home/a");
|
||||||
|
let joined = args.join(" ");
|
||||||
|
assert!(joined.contains("/home/a/.cache"));
|
||||||
|
assert!(joined.contains("/home/a/.local/share/Trash"));
|
||||||
|
assert!(joined.contains("/home/a/.local/share/Steam"));
|
||||||
|
assert!(joined.contains("/home/a/.local/share/containers"));
|
||||||
|
assert!(joined.contains("node_modules"));
|
||||||
|
assert!(joined.contains("target"));
|
||||||
|
assert!(joined.contains(".git"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,22 +35,35 @@ pub struct BreadpadConfig {
|
||||||
pub fn get_breadpad_config() -> BreadpadConfig {
|
pub fn get_breadpad_config() -> BreadpadConfig {
|
||||||
let doc = config::load_doc(&config_path());
|
let doc = config::load_doc(&config_path());
|
||||||
BreadpadConfig {
|
BreadpadConfig {
|
||||||
default_type: config::get_str(&doc, &["settings", "default_type"]).unwrap_or_else(|| "note".into()),
|
default_type: config::get_str(&doc, &["settings", "default_type"])
|
||||||
|
.unwrap_or_else(|| "note".into()),
|
||||||
workspace_tag: config::get_bool(&doc, &["settings", "workspace_tag"]).unwrap_or(true),
|
workspace_tag: config::get_bool(&doc, &["settings", "workspace_tag"]).unwrap_or(true),
|
||||||
snooze_options: config::get_str_list(&doc, &["settings", "snooze_options"]),
|
snooze_options: config::get_str_list(&doc, &["settings", "snooze_options"]),
|
||||||
archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"]).unwrap_or(30),
|
archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"])
|
||||||
|
.unwrap_or(30),
|
||||||
model_path: config::get_str(&doc, &["model", "path"]).unwrap_or_default(),
|
model_path: config::get_str(&doc, &["model", "path"]).unwrap_or_default(),
|
||||||
tokenizer_path: config::get_str(&doc, &["model", "tokenizer"]).unwrap_or_default(),
|
tokenizer_path: config::get_str(&doc, &["model", "tokenizer"]).unwrap_or_default(),
|
||||||
ollama_enabled: config::get_bool(&doc, &["model", "ollama", "enabled"]).unwrap_or(true),
|
ollama_enabled: config::get_bool(&doc, &["model", "ollama", "enabled"]).unwrap_or(true),
|
||||||
ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"]).unwrap_or_default(),
|
ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"])
|
||||||
|
.unwrap_or_default(),
|
||||||
ollama_model: config::get_str(&doc, &["model", "ollama", "model"]).unwrap_or_default(),
|
ollama_model: config::get_str(&doc, &["model", "ollama", "model"]).unwrap_or_default(),
|
||||||
ollama_confidence_threshold: config::get_f64(&doc, &["model", "ollama", "confidence_threshold"]).unwrap_or(0.6),
|
ollama_confidence_threshold: config::get_f64(
|
||||||
reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"]).unwrap_or_else(|| "7:00".into()),
|
&doc,
|
||||||
reminders_missed_grace_minutes: config::get_i64(&doc, &["reminders", "missed_grace_minutes"]).unwrap_or(60),
|
&["model", "ollama", "confidence_threshold"],
|
||||||
|
)
|
||||||
|
.unwrap_or(0.6),
|
||||||
|
reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"])
|
||||||
|
.unwrap_or_else(|| "7:00".into()),
|
||||||
|
reminders_missed_grace_minutes: config::get_i64(
|
||||||
|
&doc,
|
||||||
|
&["reminders", "missed_grace_minutes"],
|
||||||
|
)
|
||||||
|
.unwrap_or(60),
|
||||||
calendar_enabled: config::get_bool(&doc, &["calendar", "enabled"]).unwrap_or(false),
|
calendar_enabled: config::get_bool(&doc, &["calendar", "enabled"]).unwrap_or(false),
|
||||||
calendar_url: config::get_str(&doc, &["calendar", "url"]).unwrap_or_default(),
|
calendar_url: config::get_str(&doc, &["calendar", "url"]).unwrap_or_default(),
|
||||||
calendar_username: config::get_str(&doc, &["calendar", "username"]).unwrap_or_default(),
|
calendar_username: config::get_str(&doc, &["calendar", "username"]).unwrap_or_default(),
|
||||||
calendar_password: config::get_str(&doc, &["calendar", "password"]).unwrap_or_default(),
|
// Write-only to the webview, same as restic — never round-trip the secret.
|
||||||
|
calendar_password: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,19 +73,76 @@ pub fn save_breadpad_config(cfg: BreadpadConfig) -> Result<(), String> {
|
||||||
let mut doc = config::load_doc(&path);
|
let mut doc = config::load_doc(&path);
|
||||||
config::set_str(&mut doc, &["settings", "default_type"], &cfg.default_type);
|
config::set_str(&mut doc, &["settings", "default_type"], &cfg.default_type);
|
||||||
config::set_bool(&mut doc, &["settings", "workspace_tag"], cfg.workspace_tag);
|
config::set_bool(&mut doc, &["settings", "workspace_tag"], cfg.workspace_tag);
|
||||||
config::set_str_list(&mut doc, &["settings", "snooze_options"], &cfg.snooze_options);
|
config::set_str_list(
|
||||||
config::set_i64(&mut doc, &["settings", "archive_after_days"], cfg.archive_after_days);
|
&mut doc,
|
||||||
|
&["settings", "snooze_options"],
|
||||||
|
&cfg.snooze_options,
|
||||||
|
);
|
||||||
|
config::set_i64(
|
||||||
|
&mut doc,
|
||||||
|
&["settings", "archive_after_days"],
|
||||||
|
cfg.archive_after_days,
|
||||||
|
);
|
||||||
config::set_str_or_remove(&mut doc, &["model", "path"], &cfg.model_path);
|
config::set_str_or_remove(&mut doc, &["model", "path"], &cfg.model_path);
|
||||||
config::set_str_or_remove(&mut doc, &["model", "tokenizer"], &cfg.tokenizer_path);
|
config::set_str_or_remove(&mut doc, &["model", "tokenizer"], &cfg.tokenizer_path);
|
||||||
config::set_bool(&mut doc, &["model", "ollama", "enabled"], cfg.ollama_enabled);
|
config::set_bool(
|
||||||
config::set_str_or_remove(&mut doc, &["model", "ollama", "endpoint"], &cfg.ollama_endpoint);
|
&mut doc,
|
||||||
|
&["model", "ollama", "enabled"],
|
||||||
|
cfg.ollama_enabled,
|
||||||
|
);
|
||||||
|
config::set_str_or_remove(
|
||||||
|
&mut doc,
|
||||||
|
&["model", "ollama", "endpoint"],
|
||||||
|
&cfg.ollama_endpoint,
|
||||||
|
);
|
||||||
config::set_str_or_remove(&mut doc, &["model", "ollama", "model"], &cfg.ollama_model);
|
config::set_str_or_remove(&mut doc, &["model", "ollama", "model"], &cfg.ollama_model);
|
||||||
config::set_f64(&mut doc, &["model", "ollama", "confidence_threshold"], cfg.ollama_confidence_threshold);
|
config::set_f64(
|
||||||
config::set_str_or_remove(&mut doc, &["reminders", "default_morning"], &cfg.reminders_default_morning);
|
&mut doc,
|
||||||
config::set_i64(&mut doc, &["reminders", "missed_grace_minutes"], cfg.reminders_missed_grace_minutes);
|
&["model", "ollama", "confidence_threshold"],
|
||||||
|
cfg.ollama_confidence_threshold,
|
||||||
|
);
|
||||||
|
config::set_str_or_remove(
|
||||||
|
&mut doc,
|
||||||
|
&["reminders", "default_morning"],
|
||||||
|
&cfg.reminders_default_morning,
|
||||||
|
);
|
||||||
|
config::set_i64(
|
||||||
|
&mut doc,
|
||||||
|
&["reminders", "missed_grace_minutes"],
|
||||||
|
cfg.reminders_missed_grace_minutes,
|
||||||
|
);
|
||||||
config::set_bool(&mut doc, &["calendar", "enabled"], cfg.calendar_enabled);
|
config::set_bool(&mut doc, &["calendar", "enabled"], cfg.calendar_enabled);
|
||||||
config::set_str_or_remove(&mut doc, &["calendar", "url"], &cfg.calendar_url);
|
config::set_str_or_remove(&mut doc, &["calendar", "url"], &cfg.calendar_url);
|
||||||
config::set_str_or_remove(&mut doc, &["calendar", "username"], &cfg.calendar_username);
|
config::set_str_or_remove(&mut doc, &["calendar", "username"], &cfg.calendar_username);
|
||||||
config::set_str_or_remove(&mut doc, &["calendar", "password"], &cfg.calendar_password);
|
apply_calendar_password(&mut doc, &cfg.calendar_password);
|
||||||
config::save_doc(&path, &doc).map_err(|e| e.to_string())
|
config::save_doc(&path, &doc).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Empty incoming password keeps the existing secret (PasswordField is write-only).
|
||||||
|
fn apply_calendar_password(doc: &mut toml_edit::DocumentMut, incoming: &str) {
|
||||||
|
if incoming.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
config::set_str(doc, &["calendar", "password"], incoming);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_password_keeps_existing_secret() {
|
||||||
|
let mut doc: toml_edit::DocumentMut =
|
||||||
|
"[calendar]\npassword = \"secret\"\n".parse().unwrap();
|
||||||
|
apply_calendar_password(&mut doc, "");
|
||||||
|
assert_eq!(
|
||||||
|
config::get_str(&doc, &["calendar", "password"]).as_deref(),
|
||||||
|
Some("secret")
|
||||||
|
);
|
||||||
|
apply_calendar_password(&mut doc, "newpass");
|
||||||
|
assert_eq!(
|
||||||
|
config::get_str(&doc, &["calendar", "password"]).as_deref(),
|
||||||
|
Some("newpass")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
91
src/src/commands/channel.rs
Normal file
91
src/src/commands/channel.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,12 @@ async fn list_timezones() -> Vec<String> {
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect())
|
.map(|o| {
|
||||||
|
String::from_utf8_lossy(&o.stdout)
|
||||||
|
.lines()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,10 +65,32 @@ pub async fn get_datetime_info() -> DateTimeInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reject flags, path traversal, and newlines before we ever exec. Charset
|
||||||
|
/// matches IANA names (`Area/City`, `UTC`, `Etc/GMT+6`).
|
||||||
|
fn timezone_looks_safe(tz: &str) -> bool {
|
||||||
|
let tz = tz.trim();
|
||||||
|
if tz.is_empty() || tz.len() > 64 || tz.starts_with('-') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if tz.contains('\n') || tz.contains('\r') || tz.contains('\0') || tz.contains("..") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tz.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-'))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_timezone(tz: String) -> Result<(), String> {
|
pub async fn set_timezone(tz: String) -> Result<(), String> {
|
||||||
|
let tz = tz.trim();
|
||||||
|
if !timezone_looks_safe(tz) {
|
||||||
|
return Err("invalid timezone".into());
|
||||||
|
}
|
||||||
|
let listed = list_timezones().await;
|
||||||
|
if !listed.is_empty() && !listed.iter().any(|t| t == tz) {
|
||||||
|
return Err("unknown timezone".into());
|
||||||
|
}
|
||||||
let output = Command::new("pkexec")
|
let output = Command::new("pkexec")
|
||||||
.args(["timedatectl", "set-timezone", &tz])
|
.args(["timedatectl", "set-timezone", tz])
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
@ -77,6 +104,28 @@ pub async fn set_timezone(tz: String) -> Result<(), String> {
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_ntp_enabled(enabled: bool) -> Result<(), String> {
|
pub async fn set_ntp_enabled(enabled: bool) -> Result<(), String> {
|
||||||
let val = if enabled { "true" } else { "false" };
|
let val = if enabled { "true" } else { "false" };
|
||||||
Command::new("pkexec").args(["timedatectl", "set-ntp", val]).status().await.map_err(|e| e.to_string())?;
|
Command::new("pkexec")
|
||||||
|
.args(["timedatectl", "set-ntp", val])
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timezone_rejects_flags_and_traversal() {
|
||||||
|
assert!(timezone_looks_safe("UTC"));
|
||||||
|
assert!(timezone_looks_safe("America/New_York"));
|
||||||
|
assert!(timezone_looks_safe("Etc/GMT+6"));
|
||||||
|
assert!(!timezone_looks_safe(""));
|
||||||
|
assert!(!timezone_looks_safe("-UTC"));
|
||||||
|
assert!(!timezone_looks_safe("--help"));
|
||||||
|
assert!(!timezone_looks_safe("America/../UTC"));
|
||||||
|
assert!(!timezone_looks_safe("UTC\n--adjust"));
|
||||||
|
assert!(!timezone_looks_safe("UTC;reboot"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
431
src/src/commands/defaults.rs
Normal file
431
src/src/commands/defaults.rs
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,9 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
return Err(if stderr.is_empty() {
|
return Err(if stderr.is_empty() {
|
||||||
match output.status.code() {
|
match output.status.code() {
|
||||||
Some(127) => "no polkit authentication agent is available in this session".to_string(),
|
Some(127) => {
|
||||||
|
"no polkit authentication agent is available in this session".to_string()
|
||||||
|
}
|
||||||
Some(code) => format!("pkexec exited with status {code}"),
|
Some(code) => format!("pkexec exited with status {code}"),
|
||||||
None => "pkexec was terminated by a signal".to_string(),
|
None => "pkexec was terminated by a signal".to_string(),
|
||||||
}
|
}
|
||||||
|
|
@ -41,7 +43,10 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active");
|
let active = text
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|l| l.trim() == "Status: active");
|
||||||
let rules = text
|
let rules = text
|
||||||
.lines()
|
.lines()
|
||||||
.filter_map(|l| {
|
.filter_map(|l| {
|
||||||
|
|
@ -51,7 +56,10 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
|
||||||
}
|
}
|
||||||
let (num, rest) = l.split_once(']')?;
|
let (num, rest) = l.split_once(']')?;
|
||||||
let number = num.trim_start_matches('[').trim().to_string();
|
let number = num.trim_start_matches('[').trim().to_string();
|
||||||
Some(FirewallRule { number, text: rest.trim().to_string() })
|
Some(FirewallRule {
|
||||||
|
number,
|
||||||
|
text: rest.trim().to_string(),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(FirewallStatus { active, rules })
|
Ok(FirewallStatus { active, rules })
|
||||||
|
|
@ -60,7 +68,11 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> {
|
pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> {
|
||||||
let verb = if enabled { "enable" } else { "disable" };
|
let verb = if enabled { "enable" } else { "disable" };
|
||||||
let output = Command::new("pkexec").args(["ufw", "--force", verb]).output().await.map_err(|e| e.to_string())?;
|
let output = Command::new("pkexec")
|
||||||
|
.args(["ufw", "--force", verb])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -68,9 +80,54 @@ pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Port, optional `/tcp`/`/udp`, or optional space-separated proto. No
|
||||||
|
/// service names, IPs, or flags — those become extra `ufw allow` operands.
|
||||||
|
fn valid_firewall_rule(rule: &str) -> bool {
|
||||||
|
let rule = rule.trim();
|
||||||
|
if rule.is_empty() || rule.len() > 16 || rule.starts_with('-') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if rule.contains('\n') || rule.contains('\r') || rule.contains('\0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let (port, proto) = if let Some((p, rest)) = rule.split_once('/') {
|
||||||
|
(p, Some(rest))
|
||||||
|
} else if let Some((p, rest)) = rule.split_once(' ') {
|
||||||
|
(p, Some(rest.trim()))
|
||||||
|
} else {
|
||||||
|
(rule, None)
|
||||||
|
};
|
||||||
|
let Ok(n) = port.parse::<u16>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if n == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match proto {
|
||||||
|
None => true,
|
||||||
|
Some(p) => p == "tcp" || p == "udp",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_rule_number(number: &str) -> bool {
|
||||||
|
let t = number.trim();
|
||||||
|
!t.is_empty()
|
||||||
|
&& t.len() <= 8
|
||||||
|
&& t.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
&& t.parse::<u32>().is_ok_and(|n| n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn add_firewall_rule(rule: String) -> Result<(), String> {
|
pub async fn add_firewall_rule(rule: String) -> Result<(), String> {
|
||||||
let output = Command::new("pkexec").args(["ufw", "allow", rule.trim()]).output().await.map_err(|e| e.to_string())?;
|
let rule = rule.trim();
|
||||||
|
if !valid_firewall_rule(rule) {
|
||||||
|
return Err("invalid firewall rule".into());
|
||||||
|
}
|
||||||
|
let output = Command::new("pkexec")
|
||||||
|
.args(["ufw", "allow", rule])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -80,10 +137,48 @@ pub async fn add_firewall_rule(rule: String) -> Result<(), String> {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn remove_firewall_rule(number: String) -> Result<(), String> {
|
pub async fn remove_firewall_rule(number: String) -> Result<(), String> {
|
||||||
let output = Command::new("pkexec").args(["ufw", "--force", "delete", &number]).output().await.map_err(|e| e.to_string())?;
|
if !valid_rule_number(&number) {
|
||||||
|
return Err("invalid rule number".into());
|
||||||
|
}
|
||||||
|
let output = Command::new("pkexec")
|
||||||
|
.args(["ufw", "--force", "delete", number.trim()])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
|
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn firewall_rule_is_port_and_optional_proto() {
|
||||||
|
assert!(valid_firewall_rule("22"));
|
||||||
|
assert!(valid_firewall_rule("8080/tcp"));
|
||||||
|
assert!(valid_firewall_rule("53/udp"));
|
||||||
|
assert!(valid_firewall_rule("80 tcp"));
|
||||||
|
assert!(!valid_firewall_rule("OpenSSH"));
|
||||||
|
assert!(!valid_firewall_rule("-f"));
|
||||||
|
assert!(!valid_firewall_rule("22;id"));
|
||||||
|
assert!(!valid_firewall_rule("22/tcp\nallow 23"));
|
||||||
|
assert!(!valid_firewall_rule("0"));
|
||||||
|
assert!(!valid_firewall_rule("65536"));
|
||||||
|
assert!(!valid_firewall_rule("22/all"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn firewall_delete_is_positive_int() {
|
||||||
|
assert!(valid_rule_number("1"));
|
||||||
|
assert!(valid_rule_number("12"));
|
||||||
|
assert!(!valid_rule_number("0"));
|
||||||
|
assert!(!valid_rule_number("-1"));
|
||||||
|
assert!(!valid_rule_number("1;2"));
|
||||||
|
assert!(!valid_rule_number("1\n2"));
|
||||||
|
assert!(!valid_rule_number(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
177
src/src/commands/ime.rs
Normal file
177
src/src/commands/ime.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
216
src/src/commands/nightlight.rs
Normal file
216
src/src/commands/nightlight.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
260
src/src/commands/nvidia.rs
Normal file
260
src/src/commands/nvidia.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
//! 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;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use super::streaming;
|
||||||
|
use super::util::{self, command_exists, pacman_installed};
|
||||||
|
|
||||||
|
/// Same drop-in bos-nvidia-setup writes. hyprland.lua dofiles it only
|
||||||
|
/// when the file exists (Mesa machines have no file).
|
||||||
|
/// Hyprland 0.56 (Aquamarine): wiki requires LIBVA + GLX vendor.
|
||||||
|
/// NVD_BACKEND is the current VA-API hint. No WLR_* / GBM_BACKEND.
|
||||||
|
const NVIDIA_LUA: &str = "\
|
||||||
|
-- Written by bos-nvidia-setup. hyprland.lua dofiles this only when it exists.
|
||||||
|
-- Hyprland 0.56 (Aquamarine) — no WLR_* variables.
|
||||||
|
-- https://wiki.hypr.land/Nvidia/
|
||||||
|
hl.env(\"LIBVA_DRIVER_NAME\", \"nvidia\")
|
||||||
|
hl.env(\"__GLX_VENDOR_LIBRARY_NAME\", \"nvidia\")
|
||||||
|
hl.env(\"NVD_BACKEND\", \"direct\")
|
||||||
|
";
|
||||||
|
|
||||||
|
const HYPR_INCLUDE: &str = "\
|
||||||
|
-- bos-nvidia-setup: optional proprietary env; no-op when the file is absent
|
||||||
|
do
|
||||||
|
local nvidia = (os.getenv(\"HOME\") or \"\") .. \"/.config/hypr/nvidia.lua\"
|
||||||
|
local f = io.open(nvidia, \"r\")
|
||||||
|
if f then
|
||||||
|
f:close()
|
||||||
|
pcall(dofile, nvidia)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
";
|
||||||
|
|
||||||
|
const NVIDIA_PACKAGES: &[&str] = &["nvidia", "nvidia-utils"];
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct NvidiaOffer {
|
||||||
|
gpu: String,
|
||||||
|
reason: String,
|
||||||
|
packages: Vec<String>,
|
||||||
|
installed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offer_paths() -> Vec<PathBuf> {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
|
||||||
|
let state = Path::new(&home).join(".local/state/bos");
|
||||||
|
vec![
|
||||||
|
state.join("nvidia-offer.json"),
|
||||||
|
state.join("nvidia-probe.json"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nvidia_dropin_path() -> PathBuf {
|
||||||
|
util::hypr_dir().join("nvidia.lua")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nvidia_ready() -> bool {
|
||||||
|
nvidia_dropin_path().is_file() && NVIDIA_PACKAGES.iter().all(|p| pacman_installed(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
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(|| default_packages());
|
||||||
|
return Some(NvidiaOffer {
|
||||||
|
gpu,
|
||||||
|
reason,
|
||||||
|
packages,
|
||||||
|
installed: nvidia_ready(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Some(generic_offer());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_packages() -> Vec<String> {
|
||||||
|
NVIDIA_PACKAGES.iter().map(|s| (*s).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
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: default_packages(),
|
||||||
|
installed: nvidia_ready(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_nvidia_offer() -> Option<NvidiaOffer> {
|
||||||
|
read_nvidia_offer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install nvidia + nvidia-utils and write the Hyprland env drop-in.
|
||||||
|
/// Prefers `/usr/local/bin/bos-nvidia-setup` (ISO script) so package
|
||||||
|
/// install + env stay one path. Falls back to allowlisted pacman plus
|
||||||
|
/// the same drop-in when the script is not on this install yet.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn nvidia_setup(app: AppHandle, session_id: String) -> bool {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_default();
|
||||||
|
if command_exists("bos-nvidia-setup") {
|
||||||
|
let ok = streaming::run_hardcoded(
|
||||||
|
app.clone(),
|
||||||
|
session_id.clone(),
|
||||||
|
"pkexec",
|
||||||
|
&["bos-nvidia-setup", "--home", &home],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if ok {
|
||||||
|
streaming::emit_line(&app, &session_id, "reboot required");
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
streaming::emit_line(
|
||||||
|
&app,
|
||||||
|
&session_id,
|
||||||
|
"bos-nvidia-setup not on PATH; installing via pacman and writing the env drop-in",
|
||||||
|
);
|
||||||
|
let packages = default_packages();
|
||||||
|
if !streaming::pacman_install(app.clone(), session_id.clone(), packages).await {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match write_nvidia_dropin() {
|
||||||
|
Ok(()) => {
|
||||||
|
streaming::emit_line(&app, &session_id, "wrote ~/.config/hypr/nvidia.lua");
|
||||||
|
streaming::emit_line(&app, &session_id, "reboot required");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
streaming::emit_line(&app, &session_id, &format!("Error: {e}"));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_nvidia_dropin() -> Result<(), String> {
|
||||||
|
let dir = util::hypr_dir();
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
let path = dir.join("nvidia.lua");
|
||||||
|
super::config::atomic_write(&path, NVIDIA_LUA).map_err(|e| e.to_string())?;
|
||||||
|
ensure_hyprland_include()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn include_already_present(text: &str) -> bool {
|
||||||
|
text.contains("nvidia.lua")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_hyprland_include() -> Result<(), String> {
|
||||||
|
let path = util::hypr_dir().join("hyprland.lua");
|
||||||
|
let existing = std::fs::read_to_string(&path).unwrap_or_default();
|
||||||
|
if include_already_present(&existing) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if existing.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut text = existing;
|
||||||
|
if !text.ends_with('\n') {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
text.push('\n');
|
||||||
|
text.push_str(HYPR_INCLUDE);
|
||||||
|
if !text.ends_with('\n') {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
super::config::atomic_write(&path, &text).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[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"}"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropin_has_current_wiki_env_and_no_obsolete_vars() {
|
||||||
|
assert!(NVIDIA_LUA.contains("LIBVA_DRIVER_NAME"));
|
||||||
|
assert!(NVIDIA_LUA.contains("__GLX_VENDOR_LIBRARY_NAME"));
|
||||||
|
assert!(NVIDIA_LUA.contains("NVD_BACKEND"));
|
||||||
|
assert!(!NVIDIA_LUA.contains("hl.env(\"WLR_"));
|
||||||
|
assert!(!NVIDIA_LUA.contains("hl.env(\"GBM_BACKEND"));
|
||||||
|
assert!(!NVIDIA_LUA.contains("cuda"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn include_snippet_is_conditional() {
|
||||||
|
assert!(HYPR_INCLUDE.contains("nvidia.lua"));
|
||||||
|
assert!(HYPR_INCLUDE.contains("io.open"));
|
||||||
|
assert!(HYPR_INCLUDE.contains("pcall(dofile"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn include_detects_existing_snippet() {
|
||||||
|
assert!(include_already_present("pcall(dofile, nvidia.lua)"));
|
||||||
|
assert!(!include_already_present("hl.env(\"XCURSOR_SIZE\", \"24\")"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packages_are_nvidia_and_utils_only() {
|
||||||
|
assert_eq!(NVIDIA_PACKAGES, &["nvidia", "nvidia-utils"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/src/commands/optional.rs
Normal file
112
src/src/commands/optional.rs
Normal 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,22 @@
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use super::util;
|
||||||
|
|
||||||
async fn upower_device(kind: &str) -> Option<String> {
|
async fn upower_device(kind: &str) -> Option<String> {
|
||||||
let out = Command::new("upower").arg("-e").output().await.ok()?;
|
let out = Command::new("upower").arg("-e").output().await.ok()?;
|
||||||
String::from_utf8_lossy(&out.stdout).lines().find(|l| l.to_lowercase().contains(kind)).map(str::to_string)
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.to_lowercase().contains(kind))
|
||||||
|
.map(str::to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upower_field(device: &str, field: &str) -> Option<String> {
|
async fn upower_field(device: &str, field: &str) -> Option<String> {
|
||||||
let out = Command::new("upower").args(["-i", device]).output().await.ok()?;
|
let out = Command::new("upower")
|
||||||
|
.args(["-i", device])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
text.lines()
|
text.lines()
|
||||||
.find(|l| l.trim_start().starts_with(field))
|
.find(|l| l.trim_start().starts_with(field))
|
||||||
|
|
@ -39,12 +48,18 @@ async fn battery_summary() -> Vec<(String, String)> {
|
||||||
if let Some(t) = t {
|
if let Some(t) = t {
|
||||||
rows.push(("Time remaining".to_string(), t));
|
rows.push(("Time remaining".to_string(), t));
|
||||||
}
|
}
|
||||||
let full: Option<f64> = upower_field(&bat, "energy-full").await.and_then(|v| v.split_whitespace().next()?.parse().ok());
|
let full: Option<f64> = upower_field(&bat, "energy-full")
|
||||||
let design: Option<f64> =
|
.await
|
||||||
upower_field(&bat, "energy-full-design").await.and_then(|v| v.split_whitespace().next()?.parse().ok());
|
.and_then(|v| v.split_whitespace().next()?.parse().ok());
|
||||||
|
let design: Option<f64> = upower_field(&bat, "energy-full-design")
|
||||||
|
.await
|
||||||
|
.and_then(|v| v.split_whitespace().next()?.parse().ok());
|
||||||
if let (Some(full), Some(design)) = (full, design) {
|
if let (Some(full), Some(design)) = (full, design) {
|
||||||
if design > 0.0 {
|
if design > 0.0 {
|
||||||
rows.push(("Battery health".to_string(), format!("{:.0}% of design capacity", full / design * 100.0)));
|
rows.push((
|
||||||
|
"Battery health".to_string(),
|
||||||
|
format!("{:.0}% of design capacity", full / design * 100.0),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows
|
rows
|
||||||
|
|
@ -64,12 +79,19 @@ async fn power_source() -> String {
|
||||||
async fn tlp_profile() -> Option<String> {
|
async fn tlp_profile() -> Option<String> {
|
||||||
let out = Command::new("tlp-stat").arg("-s").output().await.ok()?;
|
let out = Command::new("tlp-stat").arg("-s").output().await.ok()?;
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
text.lines().find(|l| l.trim_start().starts_with("TLP profile")).and_then(|l| l.split('=').nth(1)).map(|v| v.trim().to_string())
|
text.lines()
|
||||||
|
.find(|l| l.trim_start().starts_with("TLP profile"))
|
||||||
|
.and_then(|l| l.split('=').nth(1))
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn brightness_device() -> Option<String> {
|
async fn brightness_device() -> Option<String> {
|
||||||
let out = Command::new("brightnessctl").output().await.ok()?;
|
let out = Command::new("brightnessctl").output().await.ok()?;
|
||||||
String::from_utf8_lossy(&out.stdout).lines().find(|l| l.starts_with("Device")).and_then(|l| l.split('\'').nth(1)).map(str::to_string)
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("Device"))
|
||||||
|
.and_then(|l| l.split('\'').nth(1))
|
||||||
|
.map(str::to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn brightness_pct() -> Option<u32> {
|
async fn brightness_pct() -> Option<u32> {
|
||||||
|
|
@ -98,7 +120,10 @@ fn charge_threshold_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)>
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_threshold(path: &std::path::Path) -> i64 {
|
fn read_threshold(path: &std::path::Path) -> i64 {
|
||||||
std::fs::read_to_string(path).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(100)
|
std::fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.trim().parse().ok())
|
||||||
|
.unwrap_or(100)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -130,16 +155,57 @@ pub async fn set_brightness(percent: i64) -> Result<(), String> {
|
||||||
return Err("No controllable backlight found".into());
|
return Err("No controllable backlight found".into());
|
||||||
};
|
};
|
||||||
let pct = format!("{percent}%");
|
let pct = format!("{percent}%");
|
||||||
Command::new("brightnessctl").args(["--device", &device, "set", &pct]).status().await.map_err(|e| e.to_string())?;
|
Command::new("brightnessctl")
|
||||||
|
.args(["--device", &device, "set", &pct])
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn charge_threshold_write(which: &str, percent: i64) -> Result<(String, i64), String> {
|
||||||
|
if which != "start" && which != "end" {
|
||||||
|
return Err("threshold must be start or end".into());
|
||||||
|
}
|
||||||
|
Ok((which.to_string(), percent.clamp(0, 100)))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_charge_threshold(which: String, percent: i64) -> Result<(), String> {
|
pub async fn set_charge_threshold(which: String, percent: i64) -> Result<(), String> {
|
||||||
|
let (which, percent) = charge_threshold_write(&which, percent)?;
|
||||||
let Some((start, end)) = charge_threshold_paths() else {
|
let Some((start, end)) = charge_threshold_paths() else {
|
||||||
return Err("No charge threshold support on this hardware".into());
|
return Err("No charge threshold support on this hardware".into());
|
||||||
};
|
};
|
||||||
let path = if which == "start" { start } else { end };
|
let path = if which == "start" { start } else { end };
|
||||||
Command::new("pkexec").args(["tee", &path.display().to_string()]).arg(percent.to_string()).output().await.map_err(|e| e.to_string())?;
|
// GNU tee writes stdin to its path operands — the percent must be piped,
|
||||||
|
// not passed as a second path argument.
|
||||||
|
let input = format!("{percent}\n");
|
||||||
|
if util::run_with_stdin(&["pkexec", "tee", &path.display().to_string()], &input).await {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Failed to set charge threshold".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn charge_threshold_clamps_and_restricts_which() {
|
||||||
|
assert_eq!(
|
||||||
|
charge_threshold_write("start", 80).unwrap(),
|
||||||
|
("start".into(), 80)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
charge_threshold_write("end", 150).unwrap(),
|
||||||
|
("end".into(), 100)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
charge_threshold_write("start", -5).unwrap(),
|
||||||
|
("start".into(), 0)
|
||||||
|
);
|
||||||
|
assert!(charge_threshold_write("both", 50).is_err());
|
||||||
|
assert!(charge_threshold_write("-start", 50).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
204
src/src/commands/printing.rs
Normal file
204
src/src/commands/printing.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,19 @@ pub enum ServiceAction {
|
||||||
Restart,
|
Restart,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Units the frontend already hardcodes in ServiceControl call sites.
|
||||||
|
const ALLOWED_UNITS: &[&str] = &[
|
||||||
|
"breadd.service",
|
||||||
|
"breadclipd.service",
|
||||||
|
"breadcrumbs.service",
|
||||||
|
"breadmill.service",
|
||||||
|
"breadbox-sync.service",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn allowed_unit(unit: &str) -> bool {
|
||||||
|
ALLOWED_UNITS.contains(&unit)
|
||||||
|
}
|
||||||
|
|
||||||
async fn systemctl_active(unit: &str) -> bool {
|
async fn systemctl_active(unit: &str) -> bool {
|
||||||
Command::new("systemctl")
|
Command::new("systemctl")
|
||||||
.args(["--user", "is-active", "--quiet", unit])
|
.args(["--user", "is-active", "--quiet", unit])
|
||||||
|
|
@ -40,15 +53,21 @@ async fn systemctl_enabled(unit: &str) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_service_status(unit: String) -> ServiceStatus {
|
pub async fn get_service_status(unit: String) -> Result<ServiceStatus, String> {
|
||||||
ServiceStatus {
|
if !allowed_unit(&unit) {
|
||||||
|
return Err("unknown service".into());
|
||||||
|
}
|
||||||
|
Ok(ServiceStatus {
|
||||||
active: systemctl_active(&unit).await,
|
active: systemctl_active(&unit).await,
|
||||||
enabled: systemctl_enabled(&unit).await,
|
enabled: systemctl_enabled(&unit).await,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), String> {
|
pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), String> {
|
||||||
|
if !allowed_unit(&unit) {
|
||||||
|
return Err("unknown service".into());
|
||||||
|
}
|
||||||
let verb = match action {
|
let verb = match action {
|
||||||
ServiceAction::Start => "start",
|
ServiceAction::Start => "start",
|
||||||
ServiceAction::Stop => "stop",
|
ServiceAction::Stop => "stop",
|
||||||
|
|
@ -70,8 +89,31 @@ pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), S
|
||||||
/// panel, no reason to pull an open-ended `journalctl -f` tail into the
|
/// panel, no reason to pull an open-ended `journalctl -f` tail into the
|
||||||
/// webview.
|
/// webview.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn open_logs(unit: String) {
|
pub fn open_logs(unit: String) -> Result<(), String> {
|
||||||
let _ = std::process::Command::new("kitty")
|
if !allowed_unit(&unit) {
|
||||||
|
return Err("unknown service".into());
|
||||||
|
}
|
||||||
|
std::process::Command::new("kitty")
|
||||||
.args(["-e", "journalctl", "--user", "-u", &unit, "-f"])
|
.args(["-e", "journalctl", "--user", "-u", &unit, "-f"])
|
||||||
.spawn();
|
.spawn()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn units_match_frontend_hardcoded_list() {
|
||||||
|
assert!(allowed_unit("breadd.service"));
|
||||||
|
assert!(allowed_unit("breadclipd.service"));
|
||||||
|
assert!(allowed_unit("breadcrumbs.service"));
|
||||||
|
assert!(allowed_unit("breadmill.service"));
|
||||||
|
assert!(allowed_unit("breadbox-sync.service"));
|
||||||
|
assert!(!allowed_unit("sshd.service"));
|
||||||
|
assert!(!allowed_unit("breadd.service;reboot"));
|
||||||
|
assert!(!allowed_unit("../sshd.service"));
|
||||||
|
assert!(!allowed_unit("-u sshd"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,26 @@ use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
/// Initial theme fetch — called once by the frontend at startup.
|
/// Initial theme fetch — called once by the frontend at startup.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_theme_css() -> String {
|
pub fn get_theme_css(window: tauri::WebviewWindow) -> String {
|
||||||
render_theme_css()
|
render_theme_css(&palette_for_window(&window))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_theme_css() -> String {
|
fn palette_for_window(window: &tauri::WebviewWindow) -> bread_theme::Palette {
|
||||||
let palette = bread_theme::load_palette();
|
window
|
||||||
|
.current_monitor()
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|m| m.name().map(|s| s.to_string()))
|
||||||
|
.map(|name| bread_theme::load_palette_for(&name))
|
||||||
|
.unwrap_or_else(bread_theme::load_palette)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_theme_css(palette: &bread_theme::Palette) -> String {
|
||||||
// bread-theme v0.7.1 exposes Palette + ink_on + tokens, but not the
|
// bread-theme v0.7.1 exposes Palette + ink_on + tokens, but not the
|
||||||
// later css_custom_properties / css_tokens helpers (those landed after
|
// later css_custom_properties / css_tokens helpers (those landed after
|
||||||
// the tag). Emit the same :root custom-property names the Svelte app
|
// the tag). Emit the same :root custom-property names the Svelte app
|
||||||
// already uses so a tag pin doesn't require a web-side rename.
|
// already uses so a tag pin doesn't require a web-side rename.
|
||||||
format!("{}\n{}", css_custom_properties(&palette), css_tokens())
|
format!("{}\n{}", css_custom_properties(palette), css_tokens())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn css_custom_properties(p: &bread_theme::Palette) -> String {
|
fn css_custom_properties(p: &bread_theme::Palette) -> String {
|
||||||
|
|
@ -103,7 +112,11 @@ pub fn watch_and_emit(app: &AppHandle) {
|
||||||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
|
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
|
||||||
) && event.paths.iter().any(|p| p == &target_for_watcher);
|
) && event.paths.iter().any(|p| p == &target_for_watcher);
|
||||||
if touches_target {
|
if touches_target {
|
||||||
let _ = app_for_watcher.emit("theme-changed", render_theme_css());
|
let css = app_for_watcher
|
||||||
|
.get_webview_window("main")
|
||||||
|
.map(|w| render_theme_css(&palette_for_window(&w)))
|
||||||
|
.unwrap_or_else(|| render_theme_css(&bread_theme::load_palette()));
|
||||||
|
let _ = app_for_watcher.emit("theme-changed", css);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
notify::Config::default(),
|
notify::Config::default(),
|
||||||
|
|
|
||||||
184
src/src/commands/updates.rs
Normal file
184
src/src/commands/updates.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,10 @@
|
||||||
//! `pkexec`.
|
//! `pkexec`.
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use super::util;
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct Account {
|
pub struct Account {
|
||||||
username: String,
|
username: String,
|
||||||
|
|
@ -26,10 +27,16 @@ fn list_accounts() -> Vec<Account> {
|
||||||
let shell = f[6];
|
let shell = f[6];
|
||||||
// Real human accounts: normal UID range, a real login shell
|
// Real human accounts: normal UID range, a real login shell
|
||||||
// (excludes system/service accounts like greeter, avahi, etc).
|
// (excludes system/service accounts like greeter, avahi, etc).
|
||||||
if !(1000..60000).contains(&uid) || shell.ends_with("nologin") || shell.ends_with("/false") {
|
if !(1000..60000).contains(&uid)
|
||||||
|
|| shell.ends_with("nologin")
|
||||||
|
|| shell.ends_with("/false")
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(Account { username: f[0].to_string(), full_name: f[4].split(',').next().unwrap_or("").to_string() })
|
Some(Account {
|
||||||
|
username: f[0].to_string(),
|
||||||
|
full_name: f[4].split(',').next().unwrap_or("").to_string(),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
@ -42,29 +49,69 @@ pub struct UsersInfo {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_users_info() -> UsersInfo {
|
pub fn get_users_info() -> UsersInfo {
|
||||||
UsersInfo { accounts: list_accounts(), current_user: std::env::var("USER").unwrap_or_default() }
|
UsersInfo {
|
||||||
|
accounts: list_accounts(),
|
||||||
|
current_user: std::env::var("USER").unwrap_or_default(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a root command that needs a line of input on stdin (chpasswd's own
|
/// shadow-utils `USER_NAME_MAX` is 32; keep chpasswd/useradd operands inside it.
|
||||||
/// "user:password" format). `pkexec` inherits the spawning process's stdin
|
const USERNAME_MAX: usize = 32;
|
||||||
/// only when explicitly piped, so this pipes it through.
|
|
||||||
async fn run_with_stdin(args: &[&str], input: String) -> bool {
|
/// `[a-z_][a-z0-9_-]*`, length-capped, no leading `-`. Also rejects `:`,
|
||||||
let Ok(mut child) = Command::new(args[0]).args(&args[1..]).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
|
/// newlines, and other chpasswd field/line separators.
|
||||||
else {
|
fn valid_username(name: &str) -> bool {
|
||||||
return false;
|
let bytes = name.as_bytes();
|
||||||
};
|
if bytes.is_empty() || bytes.len() > USERNAME_MAX {
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
|
||||||
if stdin.write_all(input.as_bytes()).await.is_err() {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
let first = bytes[0];
|
||||||
|
if first != b'_' && !first.is_ascii_lowercase() {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
child.wait().await.map(|s| s.success()).unwrap_or(false)
|
bytes[1..]
|
||||||
|
.iter()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(*b, b'_' | b'-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// chpasswd reads `user:password` lines — a `:`, `\n`, or `\r` in either
|
||||||
|
/// field injects extra passwd entries or shifts columns.
|
||||||
|
fn valid_chpasswd_password(password: &str) -> bool {
|
||||||
|
!password.is_empty()
|
||||||
|
&& password.len() <= 512
|
||||||
|
&& !password.contains('\n')
|
||||||
|
&& !password.contains('\r')
|
||||||
|
&& !password.contains(':')
|
||||||
|
&& !password.contains('\0')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chpasswd_input(username: &str, password: &str) -> Result<String, String> {
|
||||||
|
if !valid_username(username) {
|
||||||
|
return Err("invalid username".into());
|
||||||
|
}
|
||||||
|
if !valid_chpasswd_password(password) {
|
||||||
|
return Err("invalid password".into());
|
||||||
|
}
|
||||||
|
Ok(format!("{username}:{password}\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn may_delete_user(username: &str, current: &str) -> Result<(), String> {
|
||||||
|
if !valid_username(username) {
|
||||||
|
return Err("invalid username".into());
|
||||||
|
}
|
||||||
|
if username == "root" {
|
||||||
|
return Err("refusing to remove root".into());
|
||||||
|
}
|
||||||
|
if !current.is_empty() && username == current {
|
||||||
|
return Err("refusing to remove the current user".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn change_password(username: String, password: String) -> Result<(), String> {
|
pub async fn change_password(username: String, password: String) -> Result<(), String> {
|
||||||
let input = format!("{username}:{password}\n");
|
let input = chpasswd_input(&username, &password)?;
|
||||||
if run_with_stdin(&["pkexec", "chpasswd"], input).await {
|
if util::run_with_stdin(&["pkexec", "chpasswd"], &input).await {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err("Failed to change password".into())
|
Err("Failed to change password".into())
|
||||||
|
|
@ -73,7 +120,13 @@ pub async fn change_password(username: String, password: String) -> Result<(), S
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn remove_user(username: String) -> Result<(), String> {
|
pub async fn remove_user(username: String) -> Result<(), String> {
|
||||||
let output = Command::new("pkexec").args(["userdel", "-r", &username]).output().await.map_err(|e| e.to_string())?;
|
let current = std::env::var("USER").unwrap_or_default();
|
||||||
|
may_delete_user(&username, ¤t)?;
|
||||||
|
let output = Command::new("pkexec")
|
||||||
|
.args(["userdel", "-r", &username])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -83,22 +136,68 @@ pub async fn remove_user(username: String) -> Result<(), String> {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn add_user(username: String, full_name: String, password: String) -> Result<(), String> {
|
pub async fn add_user(username: String, full_name: String, password: String) -> Result<(), String> {
|
||||||
let username = username.trim().to_string();
|
let username = username.trim();
|
||||||
let mut useradd_args = vec!["pkexec".to_string(), "useradd".to_string(), "-m".to_string(), "-s".to_string(), "/bin/bash".to_string()];
|
let input = chpasswd_input(username, &password)?;
|
||||||
|
let mut useradd_args = vec![
|
||||||
|
"pkexec".to_string(),
|
||||||
|
"useradd".to_string(),
|
||||||
|
"-m".to_string(),
|
||||||
|
"-s".to_string(),
|
||||||
|
"/bin/bash".to_string(),
|
||||||
|
];
|
||||||
if !full_name.trim().is_empty() {
|
if !full_name.trim().is_empty() {
|
||||||
useradd_args.push("-c".to_string());
|
useradd_args.push("-c".to_string());
|
||||||
useradd_args.push(full_name.trim().to_string());
|
useradd_args.push(full_name.trim().to_string());
|
||||||
}
|
}
|
||||||
useradd_args.push(username.clone());
|
useradd_args.push(username.to_string());
|
||||||
let args_ref: Vec<&str> = useradd_args.iter().map(String::as_str).collect();
|
let args_ref: Vec<&str> = useradd_args.iter().map(String::as_str).collect();
|
||||||
let output = Command::new(args_ref[0]).args(&args_ref[1..]).output().await.map_err(|e| e.to_string())?;
|
let output = Command::new(args_ref[0])
|
||||||
|
.args(&args_ref[1..])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
|
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
|
||||||
}
|
}
|
||||||
let input = format!("{username}:{password}\n");
|
if util::run_with_stdin(&["pkexec", "chpasswd"], &input).await {
|
||||||
if run_with_stdin(&["pkexec", "chpasswd"], input).await {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err("User created, but setting the password failed.".into())
|
Err("User created, but setting the password failed.".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chpasswd_rejects_newline_injection() {
|
||||||
|
assert!(chpasswd_input("alice", "pw\nroot:evil").is_err());
|
||||||
|
assert!(chpasswd_input("alice\nroot", "pw").is_err());
|
||||||
|
assert!(chpasswd_input("alice\rroot", "pw").is_err());
|
||||||
|
assert!(chpasswd_input("alice", "pw\rroot:x").is_err());
|
||||||
|
assert!(chpasswd_input("al:ice", "pw").is_err());
|
||||||
|
assert!(chpasswd_input("alice", "p:w").is_err());
|
||||||
|
assert_eq!(chpasswd_input("alice", "secret").unwrap(), "alice:secret\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn username_grammar() {
|
||||||
|
assert!(valid_username("alice"));
|
||||||
|
assert!(valid_username("_svc"));
|
||||||
|
assert!(valid_username("a1-b_c"));
|
||||||
|
assert!(!valid_username(""));
|
||||||
|
assert!(!valid_username("-alice"));
|
||||||
|
assert!(!valid_username("Alice"));
|
||||||
|
assert!(!valid_username("root user"));
|
||||||
|
assert!(!valid_username(&"a".repeat(USERNAME_MAX + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_user_refuses_root_and_self() {
|
||||||
|
assert!(may_delete_user("root", "alice").is_err());
|
||||||
|
assert!(may_delete_user("alice", "alice").is_err());
|
||||||
|
assert!(may_delete_user("root\n", "alice").is_err());
|
||||||
|
assert!(may_delete_user("bob", "alice").is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
282
src/src/commands/util.rs
Normal file
282
src/src/commands/util.rs
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
//! 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pipe `input` to a command's stdin (`pkexec` does not inherit a piped
|
||||||
|
/// stdin unless we set it). Used by chpasswd and `pkexec tee`.
|
||||||
|
pub async fn run_with_stdin(args: &[&str], input: &str) -> bool {
|
||||||
|
if args.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(mut child) = tokio::process::Command::new(args[0])
|
||||||
|
.args(&args[1..])
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
if stdin.write_all(input.as_bytes()).await.is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.wait().await.map(|s| s.success()).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
179
src/src/commands/vpn.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,39 @@ 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::nvidia::nvidia_setup,
|
||||||
|
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::restic_restore,
|
||||||
|
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");
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@
|
||||||
//! `KNOWN_VIEWS`) — every one of them has a real registered component (see
|
//! `KNOWN_VIEWS`) — every one of them has a real registered component (see
|
||||||
//! `frontend/src/lib/views/registry.ts`), no Placeholder fallbacks to skip.
|
//! `frontend/src/lib/views/registry.ts`), no Placeholder fallbacks to skip.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
|
|
||||||
|
|
@ -59,6 +60,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",
|
||||||
|
|
@ -74,13 +85,13 @@ pub struct ScreenshotRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `None` for a normal run. Exits the process with an error for an unknown
|
/// `None` for a normal run. Exits the process with an error for an unknown
|
||||||
/// view, or if `--screenshot` was given without `--output` — before any
|
/// view, or if the `--screenshot` / `--output` pair is incomplete — before
|
||||||
/// Tauri setup happens.
|
/// any Tauri setup happens.
|
||||||
pub fn parse(args: &[String]) -> Option<ScreenshotRequest> {
|
pub fn parse(args: &[String]) -> Option<ScreenshotRequest> {
|
||||||
let mut view = None;
|
let mut view = None;
|
||||||
let mut output = None;
|
let mut output = None;
|
||||||
let mut width = 1920u32;
|
let mut width = DEFAULT_WIDTH;
|
||||||
let mut height = 1080u32;
|
let mut height = DEFAULT_HEIGHT;
|
||||||
let mut it = args.iter().skip(1);
|
let mut it = args.iter().skip(1);
|
||||||
while let Some(arg) = it.next() {
|
while let Some(arg) = it.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
|
|
@ -99,6 +110,10 @@ pub fn parse(args: &[String]) -> Option<ScreenshotRequest> {
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Err(e) = validate_pair(view.as_deref(), output.as_deref().map(Path::new)) {
|
||||||
|
eprintln!("bos-settings: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
let view = view?;
|
let view = view?;
|
||||||
if !KNOWN_VIEWS.contains(&view.as_str()) {
|
if !KNOWN_VIEWS.contains(&view.as_str()) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|
@ -107,13 +122,9 @@ pub fn parse(args: &[String]) -> Option<ScreenshotRequest> {
|
||||||
);
|
);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
let Some(output) = output else {
|
|
||||||
eprintln!("bos-settings: --screenshot requires --output");
|
|
||||||
std::process::exit(1);
|
|
||||||
};
|
|
||||||
Some(ScreenshotRequest {
|
Some(ScreenshotRequest {
|
||||||
view,
|
view,
|
||||||
output: output.into(),
|
output: output?.into(),
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue