Add OS settings panels for updates, printing, VPN, and related surfaces
All checks were successful
dev release / build (push) Successful in 2m11s
All checks were successful
dev release / build (push) Successful in 2m11s
Typed commands only: pacman/bakery/fwupd compose on Updates, CUPS/nmcli, hyprsunset, fcitx5, MIME defaults, bakery track, restic (backup.toml 0600), curated optional software, and an NVIDIA offer card gated on the probe file.
This commit is contained in:
parent
32604b492b
commit
60a473fff0
29 changed files with 4229 additions and 8 deletions
|
|
@ -31,6 +31,16 @@ import Lock from "@lucide/svelte/icons/lock";
|
|||
import Camera from "@lucide/svelte/icons/camera";
|
||||
import AppWindow from "@lucide/svelte/icons/app-window";
|
||||
import CircleHelp from "@lucide/svelte/icons/circle-help";
|
||||
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 {
|
||||
/** 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[] = [
|
||||
{ id: "network", label: "Network", icon: Wifi },
|
||||
{ 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: "printing", label: "Printing", sublabel: "CUPS", icon: Printer },
|
||||
{ id: "firewall", label: "Firewall", icon: Shield },
|
||||
{ id: "sound", label: "Sound", icon: Volume2 },
|
||||
{ id: "power", label: "Power", icon: BatteryFull },
|
||||
{ id: "datetime", label: "Date & Time", icon: Clock },
|
||||
{ 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: "breadlock", label: "Lock & greet", sublabel: "breadlock", icon: Lock },
|
||||
{ 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: "autostart", label: "Startup Apps", sublabel: "autostart.json", icon: Rocket },
|
||||
{ id: "users", label: "Users", icon: Users },
|
||||
|
|
@ -67,14 +82,19 @@ export const PERSONALIZATION_ITEMS: SidebarItem[] = [
|
|||
{ id: "breadclip", label: "Clipboard", sublabel: "breadclipd", icon: Clipboard },
|
||||
{ id: "breadpad", label: "Notes", sublabel: "breadpad", icon: NotebookPen },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
export const MAINTENANCE_ITEMS: SidebarItem[] = [
|
||||
{ id: "updates", label: "Updates", icon: Download },
|
||||
{ id: "packages", label: "Packages", icon: Package },
|
||||
{ id: "aur", label: "AUR", icon: Search },
|
||||
{ id: "firmware", label: "Firmware", icon: RefreshCw },
|
||||
{ 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[] = [
|
||||
|
|
|
|||
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>
|
||||
205
frontend/src/lib/views/Backup.svelte
Normal file
205
frontend/src/lib/views/Backup.svelte
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import FileField from "$lib/components/FileField.svelte";
|
||||
import PasswordField from "$lib/components/PasswordField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import Archive from "@lucide/svelte/icons/archive";
|
||||
|
||||
interface ResticSnapshot {
|
||||
id: string;
|
||||
time: string;
|
||||
paths: string[];
|
||||
}
|
||||
interface BackupStatus {
|
||||
restic_installed: boolean;
|
||||
repo: string;
|
||||
has_password: boolean;
|
||||
snapshots: ResticSnapshot[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
let st = $state<BackupStatus | null>(null);
|
||||
let repo = $state("");
|
||||
let password = $state("");
|
||||
let snapshots = $state<ResticSnapshot[]>([]);
|
||||
let selected = $state("latest");
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
let message = $state("");
|
||||
|
||||
async function refresh() {
|
||||
st = await invoke<BackupStatus>("get_backup_config");
|
||||
repo = st.repo;
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function save() {
|
||||
await invoke("save_backup_config", { input: { repo, password: password || null } });
|
||||
password = "";
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function installRestic() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("pacman_install", { packages: ["restic"] }, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function run(command: string, args: Record<string, unknown> = {}) {
|
||||
log = [];
|
||||
busy = true;
|
||||
message = "";
|
||||
const ok = await runStreamed(command, args, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
if (!ok) message = "Command failed — see the log.";
|
||||
}
|
||||
|
||||
async function listSnaps() {
|
||||
message = "";
|
||||
try {
|
||||
snapshots = await invoke<ResticSnapshot[]>("list_restic_snapshots");
|
||||
if (snapshots.length === 0) message = "No snapshots in this repo yet.";
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
snapshots = [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Backup">
|
||||
<Group
|
||||
title="Repository"
|
||||
hint="restic to a local directory or sftp:user@host:/path. Path and password are stored in ~/.config/bos-settings/backup.toml (mode 0600). The password field is write-only."
|
||||
wide
|
||||
>
|
||||
{#if !st}
|
||||
<Hint text="Loading…" />
|
||||
{:else if !st.restic_installed}
|
||||
<Hint text="restic is not installed." />
|
||||
<button class="primary" disabled={busy} onclick={installRestic}>Install restic</button>
|
||||
{:else}
|
||||
<FileField label="Local path" bind:value={repo} mode="folder" placeholder="/mnt/backup/bos" />
|
||||
<TextField label="Or SFTP" bind:value={repo} placeholder="sftp:user@host:/backups/bos" />
|
||||
<PasswordField label={st.has_password ? "Password (leave empty to keep)" : "Password"} bind:value={password} />
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Actions" hint="Backup covers $HOME and skips caches, Trash, Steam, cargo/rustup, node_modules, target, and .git. Restore is dry-run only.">
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} onclick={() => run("restic_init")}>Init repo</button>
|
||||
<button class="primary" disabled={busy} onclick={() => run("restic_backup")}>Backup home</button>
|
||||
<button disabled={busy} onclick={listSnaps}>List snapshots</button>
|
||||
<button disabled={busy} onclick={() => run("restic_restore_dry_run", { snapshot: selected || "latest" })}>
|
||||
Restore dry-run
|
||||
</button>
|
||||
</div>
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Snapshots" wide>
|
||||
{#if snapshots.length === 0}
|
||||
<EmptyState icon={Archive} title="No snapshots loaded" hint="Init, backup, then list." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each snapshots as s (s.id)}
|
||||
<button class="row" class:selected={selected === s.id} onclick={() => (selected = s.id)}>
|
||||
<span class="id">{s.id}</span>
|
||||
<span class="time">{s.time}</span>
|
||||
<span class="paths">{s.paths.join(", ")}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Group>
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
}
|
||||
|
||||
.row.selected {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.id {
|
||||
width: 10ch;
|
||||
flex-shrink: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.time {
|
||||
width: 22ch;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.paths {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
243
frontend/src/lib/views/Updates.svelte
Normal file
243
frontend/src/lib/views/Updates.svelte
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import Download from "@lucide/svelte/icons/download";
|
||||
import Cpu from "@lucide/svelte/icons/cpu";
|
||||
|
||||
interface PendingUpdate {
|
||||
name: string;
|
||||
current: string;
|
||||
latest: string;
|
||||
}
|
||||
interface FwDevice {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
interface NvidiaOffer {
|
||||
gpu: string;
|
||||
reason: string;
|
||||
packages: string[];
|
||||
}
|
||||
interface UpdatesStatus {
|
||||
pacman: PendingUpdate[];
|
||||
pacman_error: string | null;
|
||||
bakery: PendingUpdate[];
|
||||
bakery_error: string | null;
|
||||
firmware: FwDevice[];
|
||||
nvidia: NvidiaOffer | null;
|
||||
}
|
||||
|
||||
let status = $state<UpdatesStatus | null>(null);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
status = await invoke<UpdatesStatus>("get_updates_status");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
function appendLine(line: string) {
|
||||
log = [...log, line];
|
||||
}
|
||||
|
||||
async function run(command: string, args: Record<string, unknown> = {}) {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed(command, args, appendLine);
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Updates">
|
||||
{#if status?.nvidia}
|
||||
<Group title="NVIDIA driver" wide>
|
||||
<div class="offer">
|
||||
<Cpu size={20} />
|
||||
<div class="offer-text">
|
||||
<strong>{status.nvidia.gpu}</strong>
|
||||
<p>{status.nvidia.reason}</p>
|
||||
<Hint
|
||||
text="Installs nvidia + nvidia-utils only. Afterward, Hyprland usually needs env = LIBVA_DRIVER_NAME,nvidia / __GLX_VENDOR_LIBRARY_NAME,nvidia / NVD_BACKEND,direct — not written for you. Reboot after install."
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="primary"
|
||||
disabled={busy}
|
||||
onclick={() => run("pacman_install", { packages: status?.nvidia?.packages ?? ["nvidia", "nvidia-utils"] })}
|
||||
>
|
||||
Install driver
|
||||
</button>
|
||||
</div>
|
||||
</Group>
|
||||
{/if}
|
||||
|
||||
<Group
|
||||
title="System packages (pacman)"
|
||||
hint="The same set Packages covers with pacman -Syu. Needs your password (polkit)."
|
||||
wide
|
||||
>
|
||||
{#if !status}
|
||||
<Hint text="Loading…" />
|
||||
{:else if status.pacman_error}
|
||||
<Hint text={status.pacman_error} />
|
||||
{:else if status.pacman.length === 0}
|
||||
<EmptyState icon={Download} title="Pacman is up to date" hint="No pending official-repo upgrades." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each status.pacman as pkg (pkg.name)}
|
||||
<div class="row">
|
||||
<span class="name">{pkg.name}</span>
|
||||
<span class="version">{pkg.current} → {pkg.latest}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} class="primary" onclick={() => run("pacman_system_update")}>Update system</button>
|
||||
<button disabled={busy} onclick={refresh}>Refresh</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Bread ecosystem (bakery)"
|
||||
hint="bakery --dry-run update --all — bakery has no separate outdated command. Per-package install still lives on Packages."
|
||||
wide
|
||||
>
|
||||
{#if !status}
|
||||
<Hint text="Loading…" />
|
||||
{:else if status.bakery_error}
|
||||
<Hint text={status.bakery_error} />
|
||||
{:else if status.bakery.length === 0}
|
||||
<EmptyState icon={Download} title="Bakery packages are current" hint="Nothing on this track wants an update." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each status.bakery as pkg (pkg.name)}
|
||||
<div class="row">
|
||||
<span class="name">{pkg.name}</span>
|
||||
<span class="version">{pkg.current ? `${pkg.current} → ` : ""}{pkg.latest}</span>
|
||||
<button disabled={busy} onclick={() => run("bakery_update", { name: pkg.name })}>Update</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} class="primary" onclick={() => run("bakery_update_all")}>Update all bakery</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Firmware (fwupd)" hint="Same list as the Firmware page. fwupd-refresh.timer already refreshes metadata in the background." wide>
|
||||
{#if !status}
|
||||
<Hint text="Loading…" />
|
||||
{:else if status.firmware.length === 0}
|
||||
<EmptyState icon={Download} title="No updatable firmware" hint="Not every device speaks fwupd." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each status.firmware as dev (dev.name)}
|
||||
<div class="row">
|
||||
<span class="name">{dev.name}</span>
|
||||
<span class="version">{dev.version}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} onclick={() => run("fwupd_refresh")}>Check for updates</button>
|
||||
<button disabled={busy} class="primary" onclick={() => run("fwupd_update")}>Update firmware</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Rollback"
|
||||
hint="BOS pins GRUB to rootflags=subvol=@, so snapper rollback does not change the running root. Boot a snapshot from the GRUB “BOS snapshots” submenu (grub-btrfs), or use the Snapshots page to reboot and pick one there."
|
||||
>
|
||||
<Hint text="Bakery also has bakery rollback <pkg> for a single ecosystem binary — that is not a system rollback." />
|
||||
</Group>
|
||||
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.offer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.offer-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.offer-text p {
|
||||
margin: 4px 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version {
|
||||
opacity: 0.75;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
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 Breadmon from "./Breadmon.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> = {
|
||||
about: About,
|
||||
|
|
@ -60,4 +70,14 @@ export const VIEWS: Record<string, Component> = {
|
|||
breadshot: Breadshot,
|
||||
breadmon: Breadmon,
|
||||
breadhelp: Breadhelp,
|
||||
updates: Updates,
|
||||
printing: Printing,
|
||||
vpn: Vpn,
|
||||
nightlight: NightLight,
|
||||
ime: InputMethod,
|
||||
accessibility: Accessibility,
|
||||
defaults: Defaults,
|
||||
channel: Channel,
|
||||
backup: Backup,
|
||||
optional: Optional,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue