Migrate bos-settings from GTK4 to Tauri + Svelte 5

Ports every remaining view (Keybinds, the last one — dual Flat/MultiLayout
schema detection, per-action dedicated fields with a raw-JSON fallback for
anything not modeled) and removes the now-fully-dead GTK4 crate (root
Cargo.toml + src/), leaving src-tauri/ as the single Rust binary crate.

Also folds in the UX pass done alongside the migration: responsive
multi-column layout instead of a single centered column, chip/dropdown
pickers replacing free-typed fields (Wi-Fi networks, Bar style, keyboard
layout, tag lists), consistent pill-switch toggles and boxed-list card
styling across every page, and a sidebar scroll-chaining fix.
This commit is contained in:
Breadway 2026-07-22 19:23:02 +08:00
parent 62365ebf10
commit 93c3b88b10
147 changed files with 15804 additions and 7516 deletions

13
frontend/src/app.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>BOS Settings</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,126 @@
<script lang="ts">
let {
label,
value = $bindable(),
options,
emptyOptionsHint = "Nothing to pick from yet.",
}: { label: string; value: string[]; options: string[]; emptyOptionsHint?: string } = $props();
let adding = $state(false);
let available = $derived(options.filter((o) => !value.includes(o)));
function add(name: string) {
if (!name) return;
value = [...value, name];
adding = false;
}
function remove(name: string) {
value = value.filter((v) => v !== name);
}
</script>
<div class="field">
<div class="header">
<span class="label">{label}</span>
</div>
<div class="chips">
{#each value as name (name)}
<span class="chip">
{name}
<button type="button" class="remove" onclick={() => remove(name)} aria-label={`Remove ${name}`}>×</button>
</span>
{/each}
{#if adding}
{#if available.length > 0}
<!-- svelte-ignore a11y_autofocus -- responds to the user's own
"+" click just now, not a page-load focus grab -->
<select autofocus onchange={(e) => add(e.currentTarget.value)}>
<option value="" selected disabled>Choose…</option>
{#each available as opt (opt)}
<option value={opt}>{opt}</option>
{/each}
</select>
{:else}
<span class="empty-hint">{emptyOptionsHint}</span>
{/if}
{:else if available.length > 0}
<button type="button" class="chip add" onclick={() => (adding = true)}>+</button>
{/if}
</div>
</div>
<style>
.field {
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);
}
.label {
display: block;
margin-bottom: var(--space-xs, 4px);
}
.chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.chip {
display: flex;
align-items: center;
gap: 6px;
background-color: var(--overlay);
color: var(--on-overlay);
border-radius: 999px;
padding: 4px 6px 4px 12px;
font-size: var(--font-size-secondary, 12px);
}
.chip.add {
border: none;
cursor: pointer;
padding: 4px 10px;
font-weight: bold;
color: var(--on-surface);
}
.chip.add:hover {
background-color: var(--accent);
color: var(--on-accent);
}
.remove {
background: transparent;
border: none;
color: inherit;
opacity: 0.6;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
}
.remove:hover {
opacity: 1;
}
select {
background-color: var(--bg);
color: var(--on-surface);
border: 1px solid var(--accent);
border-radius: var(--radius-secondary, 6px);
padding: 4px var(--space-sm, 8px);
font-size: var(--font-size-secondary, 12px);
}
.empty-hint {
opacity: 0.5;
font-size: var(--font-size-secondary, 12px);
}
</style>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import Row from "./Row.svelte";
let { label, value = $bindable(), placeholder = "" }: { label: string; value: string[]; placeholder?: string } =
$props();
let text = $state(value.join(", "));
function onInput() {
value = text
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
</script>
<Row {label}>
<input type="text" bind:value={text} oninput={onInput} {placeholder} />
</Row>
<style>
input {
width: 28ch;
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);
}
input:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import type { Component } from "svelte";
let { icon: Icon, title, hint }: { icon: Component; title: string; hint: string } = $props();
</script>
<div class="empty">
<Icon size={40} />
<span class="title">{title}</span>
<span class="hint">{hint}</span>
</div>
<style>
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: var(--space-xl, 20px) 0;
opacity: 0.7;
text-align: center;
}
.title {
font-weight: bold;
}
.hint {
font-size: var(--font-size-secondary, 12px);
max-width: 40ch;
}
</style>

View file

@ -0,0 +1,67 @@
<script lang="ts">
import { open } from "@tauri-apps/plugin-dialog";
import Row from "./Row.svelte";
import FolderOpen from "@lucide/svelte/icons/folder-open";
let {
label,
value = $bindable(),
placeholder = "",
mode = "file",
extensions,
}: { label: string; value: string; placeholder?: string; mode?: "file" | "folder"; extensions?: string[] } =
$props();
async function browse() {
const picked = await open({
directory: mode === "folder",
filters: extensions ? [{ name: "Files", extensions }] : undefined,
});
if (typeof picked === "string") value = picked;
}
</script>
<Row {label}>
<div class="wrap">
<input type="text" bind:value {placeholder} />
<button type="button" onclick={browse} aria-label="Browse…">
<FolderOpen size={14} />
</button>
</div>
</Row>
<style>
.wrap {
display: flex;
gap: var(--space-xs, 4px);
}
input {
width: 22ch;
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);
}
input:focus {
outline: none;
border-color: var(--accent);
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-secondary, 6px);
padding: var(--space-xs, 4px) var(--space-sm, 8px);
cursor: pointer;
display: flex;
align-items: center;
}
button:hover {
background-color: color-mix(in srgb, var(--on-surface) 10%, transparent);
}
</style>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
title,
hint,
wide = false,
children,
}: { title: string; hint?: string; wide?: boolean; children: Snippet } = $props();
</script>
<div class="group" class:wide>
<h2 class="title">{title}</h2>
{#if hint}<p class="hint">{hint}</p>{/if}
<div class="rows">
{@render children()}
</div>
</div>
<style>
.group {
display: flex;
flex-direction: column;
min-width: 0;
}
.group.wide {
grid-column: 1 / -1;
}
.title {
font-weight: 600;
font-size: 1.05em;
margin: 0 0 var(--space-sm, 8px);
}
.hint {
opacity: 0.75;
font-size: var(--font-size-secondary, 12px);
line-height: 1.4;
margin: 0 0 var(--space-sm, 8px);
}
.rows {
display: flex;
flex-direction: column;
/* Flex children default to refusing to shrink below their content's
natural width (min-width: auto) — without this, a button/input
with enough text overflows past the grid column's actual pixel
width instead of wrapping, visually spilling into the next
column. */
min-width: 0;
}
.rows > :global(*) {
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
</style>

View file

@ -0,0 +1,18 @@
<script lang="ts">
let { text }: { text: string } = $props();
</script>
<p class="hint">{text}</p>
<style>
.hint {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
margin: 0 0 var(--space-xs, 4px);
/* No-op unless this Hint is a direct child of ViewScaffold's CSS
grid (i.e. used standalone between Groups, not nested inside
one) — in that case it spans full width instead of getting
squeezed into whichever grid column it happened to land in. */
grid-column: 1 / -1;
}
</style>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import Row from "./Row.svelte";
// Hyprland's own color format: "rgba(RRGGBBAA)" — a plain hex color input
// has no alpha channel, so this pairs one with an alpha slider and
// recombines them into that exact string on every change.
let { label, value = $bindable() }: { label: string; value: string } = $props();
function parse(v: string): { hex: string; alpha: number } {
const inner = v.match(/^rgba\(([0-9a-fA-F]{8})\)$/)?.[1];
if (!inner) return { hex: "#808080", alpha: 255 };
return { hex: `#${inner.slice(0, 6)}`, alpha: parseInt(inner.slice(6, 8), 16) };
}
let parsed = $derived(parse(value));
function update(hex: string, alpha: number) {
const a = alpha.toString(16).padStart(2, "0");
value = `rgba(${hex.slice(1)}${a})`;
}
</script>
<Row {label}>
<div class="wrap">
<input type="color" value={parsed.hex} oninput={(e) => update(e.currentTarget.value, parsed.alpha)} />
<input
type="range"
min="0"
max="255"
value={parsed.alpha}
oninput={(e) => update(parsed.hex, Number(e.currentTarget.value))}
/>
</div>
</Row>
<style>
.wrap {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
}
input[type="color"] {
width: 36px;
height: 26px;
border: 1px solid color-mix(in srgb, var(--on-surface) 20%, transparent);
border-radius: var(--radius-tertiary, 4px);
background: transparent;
padding: 0;
cursor: pointer;
}
input[type="range"] {
width: 100px;
}
</style>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import Row from "./Row.svelte";
let { label, value }: { label: string; value: string } = $props();
</script>
<Row {label}>
<span class="value">{value}</span>
</Row>
<style>
.value {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
</style>

View file

@ -0,0 +1,24 @@
<script lang="ts">
let { lines }: { lines: string[] } = $props();
</script>
{#if lines.length > 0}
<pre class="log">{lines.join("\n")}</pre>
{/if}
<style>
.log {
background-color: var(--surface);
color: var(--on-surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px);
font-family: monospace;
font-size: 12px;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
margin-top: var(--space-sm, 8px);
grid-column: 1 / -1;
}
</style>

View file

@ -0,0 +1,31 @@
<script lang="ts">
import Row from "./Row.svelte";
let {
label,
value = $bindable(),
min,
max,
step = 1,
}: { label: string; value: number; min: number; max: number; step?: number } = $props();
</script>
<Row {label}>
<input type="number" bind:value {min} {max} {step} />
</Row>
<style>
input {
width: 10ch;
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);
}
input:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import Row from "./Row.svelte";
import Eye from "@lucide/svelte/icons/eye";
import EyeOff from "@lucide/svelte/icons/eye-off";
let { label, value = $bindable() }: { label: string; value: string } = $props();
let reveal = $state(false);
</script>
<Row {label}>
<div class="wrap">
<input type={reveal ? "text" : "password"} bind:value />
<button type="button" class="peek" onclick={() => (reveal = !reveal)}>
{#if reveal}<EyeOff size={14} />{:else}<Eye size={14} />{/if}
</button>
</div>
</Row>
<style>
.wrap {
display: flex;
align-items: center;
width: 28ch;
background-color: var(--bg);
border: 1px solid transparent;
border-radius: var(--radius-secondary, 6px);
padding-inline-end: var(--space-xs, 4px);
}
.wrap:focus-within {
border-color: var(--accent);
}
input {
flex: 1;
min-width: 0;
background: transparent;
color: var(--on-surface);
border: none;
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
input:focus {
outline: none;
}
.peek {
background: transparent;
border: none;
color: var(--on-surface);
opacity: 0.6;
cursor: pointer;
display: flex;
}
.peek:hover {
opacity: 1;
}
</style>

View file

@ -0,0 +1,135 @@
<script lang="ts">
import { open } from "@tauri-apps/plugin-dialog";
import Plus from "@lucide/svelte/icons/plus";
import X from "@lucide/svelte/icons/x";
let { label, value = $bindable(), hint }: { label: string; value: string[]; hint?: string } = $props();
async function addFolder() {
const picked = await open({ directory: true });
if (typeof picked === "string" && !value.includes(picked)) {
value = [...value, picked];
}
}
function remove(path: string) {
value = value.filter((p) => p !== path);
}
function shorten(path: string): string {
const home = "/home/";
const idx = path.indexOf(home);
if (idx === -1) return path;
const afterHome = path.slice(idx + home.length);
const slash = afterHome.indexOf("/");
return slash === -1 ? path : `~/${afterHome.slice(slash + 1)}`;
}
</script>
<div class="field">
<div class="header">
<span class="label">{label}</span>
<button type="button" class="add" onclick={addFolder}>
<Plus size={13} />
Add folder…
</button>
</div>
{#if hint}<p class="hint">{hint}</p>{/if}
{#if value.length === 0}
<p class="empty">None added.</p>
{:else}
<div class="chips">
{#each value as path (path)}
<span class="chip" title={path}>
{shorten(path)}
<button type="button" class="remove" onclick={() => remove(path)} aria-label={`Remove ${path}`}>
<X size={12} />
</button>
</span>
{/each}
</div>
{/if}
</div>
<style>
.field {
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);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.label {
font-weight: 500;
}
.hint {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
margin: 4px 0 0;
}
.empty {
opacity: 0.5;
font-size: var(--font-size-secondary, 12px);
margin: var(--space-sm, 8px) 0 0;
}
.add {
display: flex;
align-items: center;
gap: 4px;
background: transparent;
color: var(--accent);
border: none;
font-size: var(--font-size-secondary, 12px);
cursor: pointer;
padding: 2px 4px;
}
.add:hover {
text-decoration: underline;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: var(--space-sm, 8px);
}
.chip {
display: flex;
align-items: center;
gap: 6px;
background-color: var(--overlay);
color: var(--on-overlay);
border-radius: 999px;
padding: 4px 6px 4px 12px;
font-size: var(--font-size-secondary, 12px);
font-family: monospace;
}
.remove {
background: transparent;
border: none;
color: inherit;
opacity: 0.6;
cursor: pointer;
display: flex;
align-items: center;
padding: 2px;
border-radius: 50%;
}
.remove:hover {
opacity: 1;
background-color: color-mix(in srgb, var(--on-overlay) 15%, transparent);
}
</style>

View file

@ -0,0 +1,17 @@
<script lang="ts">
let { page }: { page: string } = $props();
</script>
<div class="placeholder">
<p>"{page}" hasn't been migrated to Tauri yet.</p>
</div>
<style>
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
opacity: 0.5;
}
</style>

View file

@ -0,0 +1,57 @@
<script lang="ts">
import type { Snippet } from "svelte";
let { label, children }: { label: string; children: Snippet } = $props();
</script>
<div class="field-row">
<span class="label">{label}</span>
<div class="control">
{@render children()}
</div>
</div>
<style>
/* GNOME Settings' "boxed list" look: consecutive rows merge into one
card with a thin divider between them, rounding only the outer
corners of the run — not each row individually. `.field-row` is a
distinctive name (not just `.row`) so the :global() adjacency rules
below can't collide with an unrelated `.row` class elsewhere (e.g.
Sidebar's own nav rows) — every Row instance (and everything that
wraps it — InfoRow, the field components) renders this same element,
so a run of them is just plain DOM adjacency; no parent wrapper
component needed. */
.field-row {
display: flex;
align-items: center;
gap: var(--space-lg, 16px);
background-color: var(--surface);
color: var(--on-surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px) var(--space-lg, 16px);
margin-bottom: var(--space-sm, 8px);
min-height: 24px;
}
: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;
}
.control {
display: flex;
align-items: center;
}
</style>

View file

@ -0,0 +1,55 @@
<script lang="ts">
let { onSave }: { onSave: () => Promise<void> } = $props();
let status = $state("");
let saving = $state(false);
async function save() {
saving = true;
try {
await onSave();
status = "Saved";
setTimeout(() => (status = ""), 3000);
} catch (e) {
status = `Error: ${e}`;
} finally {
saving = false;
}
}
</script>
<div class="row">
<button disabled={saving} onclick={save}>Save</button>
<span class="status">{status}</span>
</div>
<style>
.row {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
margin-top: var(--space-lg, 16px);
/* Always spans the full grid width, regardless of how many
Group columns the rest of the page laid out above it. */
grid-column: 1 / -1;
}
button {
background-color: var(--accent);
color: var(--on-accent);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.status {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
</style>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import Row from "./Row.svelte";
let { label, value = $bindable(), options }: { label: string; value: string; options: string[] } = $props();
</script>
<Row {label}>
<select bind:value>
{#each options as opt (opt)}
<option value={opt}>{opt}</option>
{/each}
</select>
</Row>
<style>
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);
}
option {
background-color: var(--bg);
color: var(--on-surface);
}
select:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

@ -0,0 +1,93 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import Row from "./Row.svelte";
import Group from "./Group.svelte";
import Hint from "./Hint.svelte";
let { unit, critical = false, hasConfig = false }: { unit: string; critical?: boolean; hasConfig?: boolean } =
$props();
let active = $state(false);
let enabled = $state(false);
let busy = $state(false);
async function refresh() {
const status = await invoke<{ active: boolean; enabled: boolean }>("get_service_status", { unit });
active = status.active;
enabled = status.enabled;
}
async function run(action: "Start" | "Stop" | "Restart") {
busy = true;
try {
await invoke("service_action", { unit, action });
} finally {
busy = false;
await refresh();
}
}
function toggle() {
if (active && critical) {
if (!confirm(`Stop ${unit}? This is a core part of the desktop's event handling — stopping it may affect other bread apps until it's restarted.`)) {
return;
}
}
run(active ? "Stop" : "Start");
}
function openLogs() {
invoke("open_logs", { unit });
}
onMount(refresh);
</script>
<Group title="Service">
<Row label={unit}>
<span class="dim">{active ? "Running" : "Stopped"}</span>
</Row>
<Row label="Starts at login">
<span class="dim">{enabled ? "Yes" : "No"}</span>
</Row>
<div class="buttons">
<button disabled={busy} onclick={toggle}>{active ? "Stop" : "Start"}</button>
<button disabled={busy} onclick={() => run("Restart")}>Restart</button>
<button onclick={openLogs}>View logs</button>
</div>
{#if hasConfig}
<Hint text="Save (below) only writes the config file — click Restart above for the running service to pick up the change." />
{/if}
</Group>
<style>
.dim {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
.buttons {
display: flex;
gap: var(--space-sm, 8px);
margin-top: var(--space-xs, 4px);
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
}
button:hover {
background-color: color-mix(in srgb, var(--on-surface) 14%, transparent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,84 @@
<script lang="ts">
import { SIDEBAR_SECTIONS } from "$lib/sidebar";
let { activePage = $bindable() }: { activePage: string } = $props();
</script>
<nav class="sidebar">
{#each SIDEBAR_SECTIONS as section (section.title ?? "untitled")}
{#if section.title}
<div class="section-header">{section.title}</div>
{/if}
{#each section.items as item (item.id)}
<button
class="row"
class:selected={activePage === item.id}
onclick={() => (activePage = item.id)}
>
<item.icon size={16} />
<span class="label">{item.label}</span>
</button>
{/each}
{/each}
</nav>
<style>
.sidebar {
display: flex;
flex-direction: column;
width: 220px;
flex-shrink: 0;
min-height: 0;
background-color: var(--surface);
color: var(--on-surface);
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--space-sm, 8px);
gap: 1px;
}
.section-header {
padding: var(--space-lg, 16px) var(--space-sm, 8px) var(--space-xs, 4px);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.5;
}
.section-header:first-child {
padding-top: var(--space-xs, 4px);
}
.row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
border: none;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
padding: 9px var(--space-md, 12px);
border-radius: var(--radius-secondary, 6px);
transition: background-color 0.1s ease;
}
.row:hover {
background-color: color-mix(in srgb, var(--on-surface) 8%, transparent);
}
.row.selected {
background-color: var(--accent);
color: var(--on-accent);
font-weight: 500;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,43 @@
<script lang="ts">
let { value = $bindable(), ariaLabel }: { value: boolean; ariaLabel?: string } = $props();
</script>
<button
type="button"
class="switch"
class:on={value}
role="switch"
aria-checked={value}
aria-label={ariaLabel}
onclick={() => (value = !value)}
>
<span class="knob"></span>
</button>
<style>
.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;
}
</style>

View file

@ -0,0 +1,10 @@
<script lang="ts">
import Row from "./Row.svelte";
import Switch from "./Switch.svelte";
let { label, value = $bindable() }: { label: string; value: boolean } = $props();
</script>
<Row {label}>
<Switch bind:value ariaLabel={label} />
</Row>

View file

@ -0,0 +1,104 @@
<script lang="ts">
let { label, value = $bindable() }: { label: string; value: string[] } = $props();
let text = $state("");
function commit() {
const v = text.trim();
if (v && !value.includes(v)) {
value = [...value, v];
}
text = "";
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commit();
} else if (e.key === "Backspace" && text === "" && value.length > 0) {
value = value.slice(0, -1);
}
}
function remove(name: string) {
value = value.filter((v) => v !== name);
}
</script>
<div class="field">
<div class="header">
<span class="label">{label}</span>
</div>
<div class="chips">
{#each value as name (name)}
<span class="chip">
{name}
<button type="button" class="remove" onclick={() => remove(name)} aria-label={`Remove ${name}`}>×</button>
</span>
{/each}
<input type="text" bind:value={text} onkeydown={onKeydown} placeholder="Type and press Enter…" />
</div>
</div>
<style>
.field {
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);
}
.label {
display: block;
margin-bottom: var(--space-xs, 4px);
}
.chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.chip {
display: flex;
align-items: center;
gap: 6px;
background-color: var(--overlay);
color: var(--on-overlay);
border-radius: 999px;
padding: 4px 6px 4px 12px;
font-size: var(--font-size-secondary, 12px);
}
.remove {
background: transparent;
border: none;
color: inherit;
opacity: 0.6;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
}
.remove:hover {
opacity: 1;
}
input {
flex: 1;
min-width: 12ch;
background-color: var(--bg);
color: var(--on-surface);
border: 1px solid transparent;
border-radius: var(--radius-secondary, 6px);
padding: 4px var(--space-sm, 8px);
font-size: var(--font-size-secondary, 12px);
}
input:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import Row from "./Row.svelte";
let {
label,
value = $bindable(),
placeholder = "",
}: { label: string; value: string; placeholder?: string } = $props();
</script>
<Row {label}>
<input type="text" bind:value {placeholder} />
</Row>
<style>
input {
width: 28ch;
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);
}
input:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import type { Snippet } from "svelte";
let { title, children }: { title: string; children: Snippet } = $props();
</script>
<div class="view">
<h1 class="title">{title}</h1>
<div class="content">
{@render children()}
</div>
</div>
<style>
.view {
padding: var(--space-xl, 20px) var(--space-xl, 20px) 48px;
height: 100%;
overflow-y: auto;
}
.title {
font-size: 1.6em;
font-weight: bold;
margin: 0 0 var(--space-xl, 20px);
}
/* Groups lay out as a responsive grid rather than a single narrow
column — on a wide (tiled/maximized) window that means multiple
independent setting groups sit side by side instead of one column
with a wall of empty space either side. `.group.wide` (device
lists, thumbnail grids, log views — anything that reads badly
squeezed into a card) opts out via `grid-column: 1 / -1`. */
.content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: var(--space-xl, 20px) 32px;
align-items: start;
max-width: 1400px;
}
</style>

View file

@ -0,0 +1,87 @@
// Ports src/ui/sidebar.rs's declarative item lists verbatim. Grouped by task,
// not "app vs system internals" — a user thinks "I want to change my Wi-Fi",
// not "which of these is a bread-ecosystem app" (why breadcrumbs/Wi-Fi
// Profiles lives in System, not Personalization).
import type { Component } from "svelte";
import Wifi from "@lucide/svelte/icons/wifi";
import Network from "@lucide/svelte/icons/network";
import Bluetooth from "@lucide/svelte/icons/bluetooth";
import Shield from "@lucide/svelte/icons/shield";
import Volume2 from "@lucide/svelte/icons/volume-2";
import BatteryFull from "@lucide/svelte/icons/battery-full";
import Clock from "@lucide/svelte/icons/clock";
import Monitor from "@lucide/svelte/icons/monitor";
import Keyboard from "@lucide/svelte/icons/keyboard";
import Rocket from "@lucide/svelte/icons/rocket";
import Users from "@lucide/svelte/icons/users";
import Palette from "@lucide/svelte/icons/palette";
import Image from "@lucide/svelte/icons/image";
import LayoutGrid from "@lucide/svelte/icons/layout-grid";
import Grid3x3 from "@lucide/svelte/icons/grid-3x3";
import Clipboard from "@lucide/svelte/icons/clipboard";
import NotebookPen from "@lucide/svelte/icons/notebook-pen";
import Search from "@lucide/svelte/icons/search";
import Cog from "@lucide/svelte/icons/cog";
import Package from "@lucide/svelte/icons/package";
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
import History from "@lucide/svelte/icons/history";
import Info from "@lucide/svelte/icons/info";
export interface SidebarItem {
/** Must match a key in the view component map (see routing in +page.svelte). */
id: string;
label: string;
/** Dim second line the underlying binary/config name, for items whose
* human label doesn't already make that obvious. */
sublabel?: string;
icon: Component;
}
export const SYSTEM_ITEMS: SidebarItem[] = [
{ id: "network", label: "Network", icon: Wifi },
{ id: "breadcrumbs", label: "Wi-Fi Profiles", sublabel: "breadcrumbs", icon: Network },
{ id: "bluetooth", label: "Bluetooth", icon: Bluetooth },
{ 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: "keybinds", label: "Keybinds", sublabel: "binds.json", icon: Keyboard },
{ id: "autostart", label: "Startup Apps", sublabel: "autostart.json", icon: Rocket },
{ id: "users", label: "Users", icon: Users },
];
export const PERSONALIZATION_ITEMS: SidebarItem[] = [
{ id: "appearance", label: "Appearance", sublabel: "settings.json", icon: Palette },
{ id: "breadpaper", label: "Wallpaper", sublabel: "breadpaper", icon: Image },
{ id: "breadbar", label: "Bar", sublabel: "breadbar", icon: LayoutGrid },
{ id: "breadbox", label: "Launcher", sublabel: "breadbox", icon: Grid3x3 },
{ 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: "bread", label: "Daemon", sublabel: "breadd", icon: Cog },
];
export const MAINTENANCE_ITEMS: SidebarItem[] = [
{ id: "packages", label: "Packages", icon: Package },
{ id: "aur", label: "AUR", icon: Search },
{ id: "firmware", label: "Firmware", icon: RefreshCw },
{ id: "snapshots", label: "Snapshots", icon: History },
];
export const ABOUT_ITEMS: SidebarItem[] = [{ id: "about", label: "About", icon: Info }];
export interface SidebarSection {
title: string | null;
items: SidebarItem[];
}
export const SIDEBAR_SECTIONS: SidebarSection[] = [
{ title: "System", items: SYSTEM_ITEMS },
{ title: "Personalization", items: PERSONALIZATION_ITEMS },
{ title: "Maintenance", items: MAINTENANCE_ITEMS },
{ title: null, items: ABOUT_ITEMS },
];
export const DEFAULT_PAGE = "about";

View file

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

View file

@ -0,0 +1,29 @@
// Bridges bread-theme's pywal-derived palette into the webview. Mirrors
// bread_theme::gtk::apply_shared()'s two-phase pattern: fetch once at
// startup, then keep it live via a backend-pushed event — see
// src-tauri/src/commands/theme.rs for the file-watch side of this.
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
const STYLE_ELEMENT_ID = "bread-theme";
function applyThemeCss(css: string) {
let style = document.getElementById(STYLE_ELEMENT_ID);
if (!style) {
style = document.createElement("style");
style.id = STYLE_ELEMENT_ID;
document.head.appendChild(style);
}
style.textContent = css;
}
/** Call once at startup (e.g. from a root `$effect`/`onMount`). */
export async function initTheme(): Promise<void> {
const css = await invoke<string>("get_theme_css");
applyThemeCss(css);
await listen<string>("theme-changed", (event) => {
applyThemeCss(event.payload);
});
}

View file

@ -0,0 +1,132 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import InfoRow from "$lib/components/InfoRow.svelte";
import Group from "$lib/components/Group.svelte";
interface SystemInfo {
os: string;
kernel: string;
cpu: string;
gpu: string;
memory: string;
disk: string;
uptime: string;
hostname: string;
}
let info = $state<SystemInfo | null>(null);
let hostnameInput = $state("");
let status = $state("");
let applying = $state(false);
// The backend reports the GPU as a raw lspci device string, e.g.
// "Advanced Micro Devices, Inc. [AMD/ATI] Krackan [Radeon 840M / 860M Graphics] (rev c2)".
// That's too technical for a general settings page, so extract just the
// marketing model name. Heuristic (not exhaustive — just needs to read
// well for common vendors): drop the trailing "(rev ..)", prefer the
// text inside the last [...] bracket group (usually the model name),
// collapse "840M / 860M" style multi-model lists to the last variant,
// and drop a trailing generic word like "Graphics"/"Controller".
function friendlyGpu(raw: string): string {
if (!raw) return raw;
let s = raw.replace(/\s*\(rev [^)]*\)\s*$/i, "").trim();
const brackets = [...s.matchAll(/\[([^\]]+)\]/g)];
if (brackets.length > 0) {
s = brackets[brackets.length - 1][1];
} else {
s = s.replace(/^.*?,\s*Inc\.\s*/i, "").trim();
}
s = s.replace(/\b(\w+)\s+[\w.]+\s*\/\s*(\w+)\b/, "$1 $2");
s = s.replace(/\s+(Graphics|Controller|Series)\s*$/i, "");
return s.trim() || raw;
}
onMount(async () => {
info = await invoke<SystemInfo>("get_system_info");
hostnameInput = info.hostname;
});
async function applyHostname() {
if (!hostnameInput.trim()) {
status = "Hostname can't be empty";
return;
}
applying = true;
status = "Applying…";
try {
await invoke("set_hostname", { name: hostnameInput.trim() });
status = "Applied";
} catch (e) {
status = `Error: ${e}`;
} finally {
applying = false;
}
}
</script>
<ViewScaffold title="About">
{#if info}
<Group title="This machine">
<InfoRow label="Operating system" value={info.os} />
<InfoRow label="Kernel" value={info.kernel} />
<InfoRow label="CPU" value={info.cpu} />
<InfoRow label="GPU" value={friendlyGpu(info.gpu)} />
<InfoRow label="Memory" value={info.memory} />
<InfoRow label="Disk (/)" value={info.disk} />
<InfoRow label="Uptime" value={info.uptime} />
</Group>
<Group title="Hostname" hint="Changes the machine's network name. Takes effect immediately; needs your password (polkit).">
<div class="hostname-row">
<input type="text" bind:value={hostnameInput} disabled={applying} />
<button disabled={applying} onclick={applyHostname}>Apply</button>
</div>
{#if status}
<span class="status">{status}</span>
{/if}
</Group>
{/if}
</ViewScaffold>
<style>
.hostname-row {
display: flex;
gap: var(--space-md, 12px);
flex: 1;
}
input {
flex: 1;
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);
}
input:focus {
outline: none;
border-color: var(--accent);
}
button {
background-color: var(--accent);
color: var(--on-accent);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.status {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
</style>

View file

@ -0,0 +1,128 @@
<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 SwitchField from "$lib/components/SwitchField.svelte";
import SelectField from "$lib/components/SelectField.svelte";
import NumberField from "$lib/components/NumberField.svelte";
import HyprColorField from "$lib/components/HyprColorField.svelte";
import Row from "$lib/components/Row.svelte";
import Hint from "$lib/components/Hint.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
// Common XKB layout codes. Not exhaustive — if the config holds
// something else, it's merged in below so it's never dropped from the
// dropdown.
const COMMON_KB_LAYOUTS = [
"us", "gb", "de", "fr", "es", "it", "pt", "nl", "se", "no", "dk", "fi",
"pl", "cz", "sk", "hu", "ro", "gr", "tr", "ru", "ua", "jp", "kr", "cn",
"br", "ca", "ch", "be", "at", "ie",
];
// Hyprland's follow_mouse variable (see the wiki's Variables page):
// 0 = cursor movement never changes focus; 1 = focus always follows the
// window under the cursor; 2 = focus follows the cursor, but clicking a
// window keeps keyboard focus there until the mouse moves again; 3 =
// cursor and keyboard focus are fully independent.
const FOLLOW_MOUSE_MODES: { value: number; label: string }[] = [
{ value: 0, label: "Off — click to focus" },
{ value: 1, label: "Follow mouse" },
{ value: 2, label: "Follow mouse, detach on click" },
{ value: 3, label: "Fully detached from keyboard focus" },
];
interface Appearance {
gaps_in: number;
gaps_out: number;
border_size: number;
active_border: string;
inactive_border: string;
layout: string;
resize_on_border: boolean;
rounding: number;
blur_enabled: boolean;
blur_size: number;
blur_passes: number;
shadow_enabled: boolean;
shadow_range: number;
shadow_render_power: number;
kb_layout: string;
follow_mouse: number;
natural_scroll: boolean;
}
let cfg = $state<Appearance | null>(null);
let kbLayoutOptions = $derived(
cfg && !COMMON_KB_LAYOUTS.includes(cfg.kb_layout) ? [...COMMON_KB_LAYOUTS, cfg.kb_layout] : COMMON_KB_LAYOUTS,
);
onMount(async () => {
cfg = await invoke<Appearance>("get_appearance");
});
async function save() {
await invoke("save_appearance", { appearance: cfg });
}
</script>
<ViewScaffold title="Appearance">
{#if cfg}
<Group title="Windows & borders" hint="Gaps, borders, and tiling — the same settings.json Hyprland reads at login.">
<NumberField label="Gaps between windows" bind:value={cfg.gaps_in} min={0} max={50} />
<NumberField label="Gaps around screen edge" bind:value={cfg.gaps_out} min={0} max={50} />
<NumberField label="Border width" bind:value={cfg.border_size} min={0} max={10} />
<HyprColorField label="Active border color" bind:value={cfg.active_border} />
<HyprColorField label="Inactive border color" bind:value={cfg.inactive_border} />
<SelectField label="Tiling layout" bind:value={cfg.layout} options={["dwindle", "master"]} />
<SwitchField label="Resize by dragging borders" bind:value={cfg.resize_on_border} />
</Group>
<Group title="Effects">
<NumberField label="Corner rounding" bind:value={cfg.rounding} min={0} max={30} />
<SwitchField label="Blur" bind:value={cfg.blur_enabled} />
<NumberField label="Blur size" bind:value={cfg.blur_size} min={0} max={20} />
<NumberField label="Blur passes" bind:value={cfg.blur_passes} min={1} max={5} />
<SwitchField label="Window shadows" bind:value={cfg.shadow_enabled} />
<NumberField label="Shadow range" bind:value={cfg.shadow_range} min={0} max={40} />
<NumberField label="Shadow render power" bind:value={cfg.shadow_render_power} min={1} max={4} />
</Group>
<Group title="Input">
<SelectField label="Keyboard layout" bind:value={cfg.kb_layout} options={kbLayoutOptions} />
<Row label="Focus-follows-mouse mode">
<select bind:value={cfg.follow_mouse}>
{#each FOLLOW_MOUSE_MODES as mode (mode.value)}
<option value={mode.value}>{mode.label}</option>
{/each}
</select>
</Row>
<SwitchField label="Natural scrolling (touchpad)" bind:value={cfg.natural_scroll} />
</Group>
<Hint text="Changes apply on next login or Hyprland reload — this saves settings.json, it doesn't reload Hyprland live." />
<SaveButton onSave={save} />
{/if}
</ViewScaffold>
<style>
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);
}
option {
background-color: var(--bg);
color: var(--on-surface);
}
select:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

@ -0,0 +1,151 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import Search from "@lucide/svelte/icons/search";
interface AurResult {
name: string;
version: string;
description: string;
}
let query = $state("");
let results = $state<AurResult[] | null>(null);
let status = $state("Search for a package to see results here.");
let searching = $state(false);
async function search() {
if (!query.trim()) return;
searching = true;
status = "Searching…";
results = await invoke<AurResult[]>("search_aur", { query });
status = results.length === 0 ? "No results." : `${results.length} result(s)`;
searching = false;
}
function install(pkg: string) {
invoke("install_aur_package", { pkg });
}
</script>
<ViewScaffold title="AUR">
<Group
title="Search"
hint="Search the Arch User Repository via yay. Installing opens a terminal — AUR packages run arbitrary build scripts, and reviewing what yay is about to do (and entering your password) is a real safety step."
wide
>
<div class="search-row">
<input type="text" bind:value={query} placeholder="Search the AUR…" onkeydown={(e) => e.key === "Enter" && search()} />
<button disabled={searching} onclick={search}>Search</button>
</div>
<Hint text={status} />
<div class="results">
{#if results && results.length === 0}
<EmptyState icon={Search} title="No results" hint="Try a different search term." />
{:else if results}
{#each results as r (r.name)}
<div class="card">
<div class="top">
<span class="name" title={r.name}>{r.name}</span>
<span class="version" title={r.version}>{r.version}</span>
<button class="install" onclick={() => install(r.name)}>Install</button>
</div>
<span class="desc">{r.description}</span>
</div>
{/each}
{/if}
</div>
</Group>
</ViewScaffold>
<style>
.search-row {
display: flex;
gap: var(--space-sm, 8px);
}
.search-row input {
flex: 1;
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);
}
.search-row button {
background-color: var(--accent);
color: var(--on-accent);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-lg, 16px);
cursor: pointer;
}
.search-row button:disabled {
opacity: 0.5;
cursor: default;
}
.results {
max-height: 400px;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 6px;
align-content: start;
}
.card {
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.top {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
}
.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;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.desc {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
.install {
background-color: var(--bg);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
</style>

View file

@ -0,0 +1,168 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
import FolderOpen from "@lucide/svelte/icons/folder-open";
interface AutostartEntry {
command: string;
label: string;
enabled: boolean;
}
let entries = $state<AutostartEntry[] | null>(null);
onMount(async () => {
entries = await invoke<AutostartEntry[]>("get_autostart_entries");
});
function addEntry() {
entries = [...(entries ?? []), { command: "", label: "", enabled: true }];
}
function removeEntry(i: number) {
entries = entries!.filter((_, idx) => idx !== i);
}
async function browseFor(entry: AutostartEntry) {
const picked = await open({ directory: false, defaultPath: "/usr/bin" });
if (typeof picked === "string") entry.command = picked;
}
async function save() {
await invoke("save_autostart_entries", { entries });
}
</script>
<ViewScaffold title="Startup Apps">
{#if entries}
<Group
title="Extra autostart apps"
hint="What launches after login, beyond the core desktop (bar, theme, clipboard, etc. always start regardless). Toggle off, edit, or add your own."
wide
>
{#each entries as entry, i (i)}
<div class="row">
<button
class="switch"
class:on={entry.enabled}
role="switch"
aria-checked={entry.enabled}
aria-label="Enabled"
onclick={() => (entry.enabled = !entry.enabled)}
>
<span class="knob"></span>
</button>
<div class="fields">
<input type="text" bind:value={entry.label} placeholder="Label" class="label" title={entry.label} />
<input type="text" bind:value={entry.command} placeholder="command" class="command" title={entry.command} />
</div>
<button class="browse" onclick={() => browseFor(entry)} aria-label="Browse for program">
<FolderOpen size={14} />
</button>
<button class="remove" onclick={() => removeEntry(i)}>Remove</button>
</div>
{/each}
<button class="add" onclick={addEntry}>Add app</button>
<SaveButton onSave={save} />
</Group>
{/if}
</ViewScaffold>
<style>
.row {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
margin-bottom: var(--space-xs, 4px);
}
.fields {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.label,
.command {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command {
opacity: 0.65;
font-size: var(--font-size-secondary, 12px);
}
input[type="text"] {
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);
}
input:focus {
outline: none;
border-color: var(--accent);
}
button {
border: none;
border-radius: var(--radius-primary, 8px);
cursor: pointer;
}
.browse {
background-color: var(--surface);
color: var(--on-surface);
border: none;
padding: var(--space-xs, 4px) var(--space-sm, 8px);
display: flex;
}
.remove {
background-color: var(--red);
color: var(--on-red);
padding: var(--space-xs, 4px) var(--space-md, 12px);
}
.add {
background-color: var(--surface);
color: var(--on-surface);
margin-top: var(--space-sm, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
}
.switch {
width: 36px;
height: 20px;
flex-shrink: 0;
border-radius: 999px;
background-color: var(--overlay);
padding: 2px;
display: flex;
align-items: center;
}
.switch.on {
background-color: var(--accent);
justify-content: flex-end;
}
.knob {
width: 16px;
height: 16px;
border-radius: 50%;
background-color: var(--on-surface);
display: block;
}
</style>

View file

@ -0,0 +1,188 @@
<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 SwitchField from "$lib/components/SwitchField.svelte";
import Hint from "$lib/components/Hint.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import LogView from "$lib/components/LogView.svelte";
import BluetoothIcon from "@lucide/svelte/icons/bluetooth";
import BluetoothOff from "@lucide/svelte/icons/bluetooth-off";
interface BtDevice {
address: string;
name: string;
connected: boolean;
}
let powered = $state<boolean | null>(null);
let paired = $state<BtDevice[] | null>(null);
let scanResults = $state<BtDevice[] | null>(null);
let scanning = $state(false);
let log = $state<string[]>([]);
async function refreshPaired() {
paired = await invoke<BtDevice[]>("get_paired_devices");
}
onMount(async () => {
powered = await invoke<boolean | null>("get_adapter_powered");
if (powered !== null) await refreshPaired();
});
async function togglePower(on: boolean) {
powered = on;
await invoke("set_adapter_powered", { on });
}
async function toggleConnect(dev: BtDevice) {
log = [];
try {
await invoke(dev.connected ? "bt_disconnect" : "bt_connect", { address: dev.address });
} catch (e) {
log = [...log, `${e}`];
}
await refreshPaired();
}
async function forget(dev: BtDevice) {
if (!confirm(`Forget "${dev.name}"? You'll need to pair it again to reconnect.`)) return;
log = [];
try {
await invoke("bt_forget", { address: dev.address });
} catch (e) {
log = [...log, `${e}`];
}
await refreshPaired();
}
async function scan() {
scanning = true;
scanResults = await invoke<BtDevice[]>("scan_bluetooth");
scanning = false;
}
async function pair(dev: BtDevice) {
log = [];
try {
await invoke("bt_pair", { address: dev.address });
await refreshPaired();
scanResults = scanResults!.filter((d) => d.address !== dev.address);
} catch (e) {
log = [...log, `${e}`];
}
}
</script>
<ViewScaffold title="Bluetooth">
{#if powered === null}
<EmptyState
icon={BluetoothOff}
title="No Bluetooth adapter found"
hint="This machine doesn't have Bluetooth hardware, or the kernel module isn't loaded."
/>
{:else}
<Group title="Adapter">
<SwitchField label="Bluetooth" bind:value={() => powered ?? false, (v) => togglePower(v)} />
</Group>
<Group title="Paired devices">
<div class="list">
{#if paired === null}
<Hint text="Loading…" />
{:else if paired.length === 0}
<EmptyState icon={BluetoothIcon} title="No paired devices" hint="Scan and pair a device to see it here." />
{:else}
{#each paired as dev (dev.address)}
<div class="row">
<span class="name" class:active={dev.connected}>{dev.name}{dev.connected ? " (connected)" : ""}</span>
<button class="action" onclick={() => toggleConnect(dev)}>{dev.connected ? "Disconnect" : "Connect"}</button>
<button class="remove" onclick={() => forget(dev)}>Forget</button>
</div>
{/each}
{/if}
</div>
</Group>
<Group
title="Available devices"
hint="Scanning takes a few seconds. Devices needing a PIN aren't supported — only &quot;just works&quot; pairing (most headphones, speakers, keyboards, and mice)."
>
<div class="list">
{#if scanResults === null}
<EmptyState icon={BluetoothIcon} title="Not scanned yet" hint="Press Scan to look for nearby devices." />
{:else if scanResults.length === 0}
<EmptyState icon={BluetoothIcon} title="No new devices found" hint="Make sure the device is powered on and in pairing mode, then Scan again." />
{:else}
{#each scanResults as dev (dev.address)}
<div class="row">
<span class="name">{dev.name}</span>
<button class="action" onclick={() => pair(dev)}>Pair</button>
</div>
{/each}
{/if}
</div>
<button class="scan" disabled={scanning} onclick={scan}>{scanning ? "Scanning…" : "Scan"}</button>
</Group>
<LogView lines={log} />
{/if}
</ViewScaffold>
<style>
.list {
min-height: 60px;
max-height: 220px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.row {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
background-color: var(--surface);
border-radius: var(--radius-secondary, 6px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
}
.name {
flex: 1;
}
.name.active {
font-weight: bold;
}
button {
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
.action {
background-color: var(--surface);
color: var(--on-surface);
}
.remove {
background-color: var(--red);
color: var(--on-red);
}
.scan {
background-color: var(--accent);
color: var(--on-accent);
align-self: flex-start;
margin-top: var(--space-sm, 8px);
}
.scan:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,86 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import ServiceControl from "$lib/components/ServiceControl.svelte";
import Group from "$lib/components/Group.svelte";
import SwitchField from "$lib/components/SwitchField.svelte";
import TextField from "$lib/components/TextField.svelte";
import SelectField from "$lib/components/SelectField.svelte";
import NumberField from "$lib/components/NumberField.svelte";
import TagsField from "$lib/components/TagsField.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
interface BreadConfig {
log_level: string;
socket_path: string;
lua_entry_point: string;
lua_module_path: string;
modules_builtin: boolean;
modules_disable: string[];
adapter_hyprland: boolean;
adapter_udev: boolean;
udev_subsystems: string[];
adapter_power: boolean;
power_poll_interval_secs: number;
adapter_network: boolean;
adapter_bluetooth: boolean;
dedup_window_ms: number;
notif_default_timeout_ms: number;
notif_default_urgency: string;
notif_notify_send_path: string;
}
let cfg = $state<BreadConfig | null>(null);
onMount(async () => {
cfg = await invoke<BreadConfig>("get_bread_config");
});
async function save() {
await invoke("save_bread_config", { cfg });
}
</script>
<ViewScaffold title="Daemon">
<ServiceControl unit="breadd.service" critical hasConfig />
{#if cfg}
<Group title="Daemon">
<SelectField label="Log level" bind:value={cfg.log_level} options={["error", "warn", "info", "debug", "trace"]} />
<TextField label="Socket path" bind:value={cfg.socket_path} placeholder="default (XDG runtime dir)" />
</Group>
<Group title="Lua">
<TextField label="Entry point" bind:value={cfg.lua_entry_point} placeholder="~/.config/bread/init.lua" />
<TextField label="Module path" bind:value={cfg.lua_module_path} placeholder="~/.config/bread/modules" />
</Group>
<Group title="Modules">
<SwitchField label="Load built-in modules" bind:value={cfg.modules_builtin} />
<TagsField label="Disabled modules" bind:value={cfg.modules_disable} />
</Group>
<Group title="Adapters" hint="Sources breadd normalises into events. Disable any you don't use.">
<SwitchField label="Hyprland" bind:value={cfg.adapter_hyprland} />
<SwitchField label="udev (devices)" bind:value={cfg.adapter_udev} />
<TagsField label="udev subsystems" bind:value={cfg.udev_subsystems} />
<SwitchField label="Power" bind:value={cfg.adapter_power} />
<NumberField label="Power poll interval (s)" bind:value={cfg.power_poll_interval_secs} min={1} max={3600} />
<SwitchField label="Network" bind:value={cfg.adapter_network} />
<SwitchField label="Bluetooth" bind:value={cfg.adapter_bluetooth} />
</Group>
<Group title="Events">
<NumberField label="Dedup window (ms)" bind:value={cfg.dedup_window_ms} min={0} max={10000} step={50} />
</Group>
<Group title="Notifications">
<NumberField label="Default timeout (ms)" bind:value={cfg.notif_default_timeout_ms} min={0} max={60000} step={500} />
<SelectField label="Default urgency" bind:value={cfg.notif_default_urgency} options={["low", "normal", "critical"]} />
<TextField label="notify-send path" bind:value={cfg.notif_notify_send_path} placeholder="auto-detected" />
</Group>
<SaveButton onSave={save} />
{/if}
</ViewScaffold>

View file

@ -0,0 +1,178 @@
<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 Row from "$lib/components/Row.svelte";
import TextField from "$lib/components/TextField.svelte";
import NumberField from "$lib/components/NumberField.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
interface BreadbarStyle {
font_family: string;
font_size: number;
bar_border_radius: number;
bar_padding: number;
workspace_inactive_opacity: number;
workspace_font_size: number;
stat_gap: number;
tray_icon_size: number;
notification_border_radius: number;
}
let style = $state<BreadbarStyle | null>(null);
let advancedOpen = $state(false);
let css = $state("");
let cssStatus = $state("");
let cssSaving = $state(false);
onMount(async () => {
style = await invoke<BreadbarStyle>("get_breadbar_style");
css = await invoke<string>("get_breadbar_css");
});
async function saveStyle() {
await invoke("save_breadbar_style", { style });
css = await invoke<string>("get_breadbar_css");
}
async function saveCss() {
cssSaving = true;
try {
const reloaded = await invoke<boolean>("save_breadbar_css", { css });
cssStatus = reloaded ? "Saved & reloaded" : "Saved";
style = await invoke<BreadbarStyle>("get_breadbar_style");
setTimeout(() => (cssStatus = ""), 3000);
} catch (e) {
cssStatus = `Error: ${e}`;
} finally {
cssSaving = false;
}
}
</script>
<ViewScaffold title="Bar">
{#if style}
<Group title="Text" hint="Applies to the clock, workspace numbers, and stat labels.">
<TextField label="Font" bind:value={style.font_family} placeholder="Varela Round" />
<NumberField label="Font size" bind:value={style.font_size} min={8} max={32} />
</Group>
<Group title="Bar shape">
<NumberField label="Corner rounding" bind:value={style.bar_border_radius} min={0} max={40} />
<NumberField label="Inner padding" bind:value={style.bar_padding} min={0} max={40} />
</Group>
<Group title="Workspace indicator">
<NumberField label="Size" bind:value={style.workspace_font_size} min={8} max={40} />
<Row label="Inactive dimness">
<div class="opacity-row">
<input type="range" min="0" max="1" step="0.05" bind:value={style.workspace_inactive_opacity} />
<span class="pct">{Math.round(style.workspace_inactive_opacity * 100)}%</span>
</div>
</Row>
</Group>
<Group title="Spacing & icons">
<NumberField label="Gap between stats" bind:value={style.stat_gap} min={0} max={40} />
<NumberField label="Tray icon size" bind:value={style.tray_icon_size} min={8} max={32} />
<NumberField label="Notification corner rounding" bind:value={style.notification_border_radius} min={0} max={24} />
</Group>
<SaveButton onSave={saveStyle} />
{/if}
<Group title="Advanced" hint="Raw stylesheet. Anything set here can also be changed above — those fields edit this same file." wide>
<button class="toggle" onclick={() => (advancedOpen = !advancedOpen)}>
{#if advancedOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
Edit raw CSS
</button>
{#if advancedOpen}
<textarea bind:value={css} spellcheck="false"></textarea>
<div class="row">
<button class="save-css" disabled={cssSaving} onclick={saveCss}>Save CSS</button>
<span class="status">{cssStatus}</span>
</div>
{/if}
</Group>
</ViewScaffold>
<style>
.opacity-row {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
}
.opacity-row input[type="range"] {
width: 120px;
}
.pct {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
width: 4ch;
}
.toggle {
display: flex;
align-items: center;
gap: var(--space-xs, 4px);
background: transparent;
border: none;
color: var(--on-surface);
opacity: 0.7;
cursor: pointer;
padding: var(--space-xs, 4px) 0;
}
.toggle:hover {
opacity: 1;
}
textarea {
width: 100%;
min-height: 360px;
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px);
font-family: monospace;
resize: vertical;
margin-top: var(--space-sm, 8px);
}
textarea:focus {
outline: none;
}
.row {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
margin-top: var(--space-md, 12px);
}
.save-css {
background-color: var(--accent);
color: var(--on-accent);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
}
.save-css:disabled {
opacity: 0.5;
cursor: default;
}
.status {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
</style>

View file

@ -0,0 +1,128 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import ServiceControl from "$lib/components/ServiceControl.svelte";
import Group from "$lib/components/Group.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
interface Context {
name: string;
priority: string[];
}
let contexts = $state<Context[] | null>(null);
onMount(async () => {
contexts = await invoke<Context[]>("get_breadbox_contexts");
});
function addContext() {
contexts = [...(contexts ?? []), { name: "new", priority: [] }];
}
function removeContext(i: number) {
contexts = contexts!.filter((_, idx) => idx !== i);
}
function priorityText(ctx: Context) {
return ctx.priority.join(", ");
}
function setPriority(ctx: Context, text: string) {
ctx.priority = text
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
async function save() {
await invoke("save_breadbox_contexts", { contexts });
}
</script>
<ViewScaffold title="Launcher">
{#if contexts}
<Group
title="Contexts"
hint="Launcher contexts — each lists, in priority order, the apps/categories surfaced first."
wide
>
{#if contexts.length === 0}
<div class="empty">No launcher contexts yet. Add one to control which apps/categories breadbox surfaces first.</div>
{/if}
{#each contexts as ctx, i (i)}
<div class="row">
<input type="text" bind:value={ctx.name} placeholder="name" class="name" />
<input
type="text"
value={priorityText(ctx)}
oninput={(e) => setPriority(ctx, e.currentTarget.value)}
placeholder="firefox, code, Development, ..."
class="priority"
/>
<button class="remove" onclick={() => removeContext(i)}>Remove</button>
</div>
{/each}
<button class="add" onclick={addContext}>Add context</button>
</Group>
<SaveButton onSave={save} />
{/if}
<ServiceControl unit="breadbox-sync.service" hasConfig />
</ViewScaffold>
<style>
.row {
display: flex;
gap: var(--space-sm, 8px);
align-items: center;
margin-bottom: var(--space-xs, 4px);
}
.name {
width: 14ch;
}
.priority {
flex: 1;
}
input {
background-color: var(--surface);
color: var(--on-surface);
border: 1px solid transparent;
border-radius: var(--radius-secondary, 6px);
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
input:focus {
outline: none;
border-color: var(--accent);
}
button {
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
.remove {
background-color: var(--red);
color: var(--on-red);
}
.add {
background-color: var(--surface);
color: var(--on-surface);
margin-top: var(--space-sm, 8px);
}
.empty {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
padding: var(--space-md, 12px) 0;
}
</style>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import ServiceControl from "$lib/components/ServiceControl.svelte";
function openHistory() {
invoke("open_breadclip");
}
</script>
<ViewScaffold title="Clipboard">
<Group
title="Clipboard history"
hint="Keeps a history of copied text/images and shows it as a popup. breadclipd (right) is the background daemon that watches the clipboard — breadclip itself is just the popup UI, launched on demand by the keybind or the button below."
>
<button class="open-btn" onclick={openHistory}>Open history (SUPER+V)</button>
</Group>
<ServiceControl unit="breadclipd.service" />
</ViewScaffold>
<style>
.open-btn {
align-self: flex-start;
background-color: var(--accent);
color: var(--on-accent);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
}
</style>

View file

@ -0,0 +1,276 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import ServiceControl from "$lib/components/ServiceControl.svelte";
import Group from "$lib/components/Group.svelte";
import SelectField from "$lib/components/SelectField.svelte";
import TextField from "$lib/components/TextField.svelte";
import NumberField from "$lib/components/NumberField.svelte";
import ChipPickerField from "$lib/components/ChipPickerField.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
import Switch from "$lib/components/Switch.svelte";
interface Settings {
default_profile: string;
dns: string;
exit_node: string;
ping_host: string;
connectivity_url: string;
nmcli_wait: number;
watch_interval: number;
}
interface Network {
ssid: string;
password: string;
hidden: boolean;
}
interface Profile {
name: string;
networks: string[];
detect_ssids: string[];
bootstrap: string;
exit_node: string;
tailscale: boolean;
include_all_known: boolean;
}
interface BreadcrumbsConfig {
settings: Settings;
networks: Network[];
profiles: Profile[];
}
let cfg = $state<BreadcrumbsConfig | null>(null);
// Profile names are free-text (set via "Add profile" below), not a fixed
// enum — populate the default-profile options from whatever profiles
// actually exist, rather than a hardcoded guess that can't match a
// user's real profile names.
let profileNames = $derived(cfg?.profiles.map((p) => p.name) ?? []);
// Saved network SSIDs feed the per-profile network pickers below — no
// typing an SSID by hand and hoping it matches one saved above.
let savedSsids = $derived(cfg?.networks.map((n) => n.ssid).filter((s) => s.trim().length > 0) ?? []);
onMount(async () => {
cfg = await invoke<BreadcrumbsConfig>("get_breadcrumbs_config");
});
function addNetwork() {
cfg!.networks = [...cfg!.networks, { ssid: "", password: "", hidden: false }];
}
function removeNetwork(i: number) {
cfg!.networks = cfg!.networks.filter((_, idx) => idx !== i);
}
function addProfile() {
cfg!.profiles = [
...cfg!.profiles,
{ name: "new", networks: [], detect_ssids: [], bootstrap: "", exit_node: "", tailscale: false, include_all_known: false },
];
}
function removeProfile(i: number) {
cfg!.profiles = cfg!.profiles.filter((_, idx) => idx !== i);
}
async function save() {
await invoke("save_breadcrumbs_config", { input: cfg });
}
</script>
<ViewScaffold title="Wi-Fi Profiles">
<ServiceControl unit="breadcrumbs.service" hasConfig />
{#if cfg}
<Group title="Settings">
{#if profileNames.length > 0}
<SelectField label="Default profile" bind:value={cfg.settings.default_profile} options={profileNames} />
{:else}
<TextField label="Default profile" bind:value={cfg.settings.default_profile} placeholder="add a profile below first" />
{/if}
<TextField label="DNS" bind:value={cfg.settings.dns} placeholder="1.1.1.1" />
<TextField label="Exit node" bind:value={cfg.settings.exit_node} placeholder="tailscale exit node" />
<TextField label="Ping host" bind:value={cfg.settings.ping_host} placeholder="1.1.1.1" />
<TextField label="Connectivity check URL" bind:value={cfg.settings.connectivity_url} />
<NumberField label="Connection timeout (s)" bind:value={cfg.settings.nmcli_wait} min={1} max={120} />
<NumberField label="Check connectivity every (s)" bind:value={cfg.settings.watch_interval} min={1} max={600} />
</Group>
<Group title="Saved networks" wide>
<div class="list">
{#each cfg.networks as net, i (i)}
<div class="net-row">
<input type="text" bind:value={net.ssid} placeholder="Network name (SSID)" class="ssid" />
<input type="password" bind:value={net.password} placeholder="Password" class="pass" />
<div class="hidden-label">
<Switch bind:value={net.hidden} ariaLabel="Hidden network" />
<button type="button" class="label-text" onclick={() => (net.hidden = !net.hidden)}>Hidden network</button>
</div>
<button class="remove" onclick={() => removeNetwork(i)}>Remove</button>
</div>
{/each}
</div>
<button class="add" onclick={addNetwork}>Add network</button>
</Group>
<Group title="Location profiles" hint="Each profile switches DNS/exit-node/routing when you're on one of its networks." wide>
{#each cfg.profiles as profile, i (i)}
<div class="profile-card">
<div class="profile-header">
<input type="text" bind:value={profile.name} placeholder="Profile name (e.g. Home)" class="profile-name" />
<button class="remove" onclick={() => removeProfile(i)}>Remove</button>
</div>
<ChipPickerField
label="Networks in this profile"
bind:value={profile.networks}
options={savedSsids}
emptyOptionsHint="Add a saved network above first."
/>
<ChipPickerField
label="Auto-switch here when nearby"
bind:value={profile.detect_ssids}
options={savedSsids}
emptyOptionsHint="Add a saved network above first."
/>
<label class="field-label">
Exit node
<input type="text" bind:value={profile.exit_node} placeholder="Tailscale exit node (optional)" />
</label>
<label class="field-label">
Run on connect
<input type="text" bind:value={profile.bootstrap} placeholder="Optional command (optional)" />
</label>
<div class="check-label">
<Switch bind:value={profile.tailscale} ariaLabel="Enable Tailscale" />
<button type="button" class="label-text" onclick={() => (profile.tailscale = !profile.tailscale)}>Enable Tailscale</button>
</div>
<div class="check-label">
<Switch bind:value={profile.include_all_known} ariaLabel="Also allow any other saved network" />
<button
type="button"
class="label-text"
onclick={() => (profile.include_all_known = !profile.include_all_known)}
>
Also allow any other saved network, not just the ones listed above
</button>
</div>
</div>
{/each}
<button class="add" onclick={addProfile}>Add profile</button>
</Group>
<SaveButton onSave={save} />
{/if}
</ViewScaffold>
<style>
.list {
display: flex;
flex-direction: column;
gap: var(--space-xs, 4px);
margin-bottom: var(--space-sm, 8px);
}
.net-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm, 8px);
align-items: center;
background-color: var(--surface);
border-radius: var(--radius-secondary, 6px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
}
.ssid {
width: 20ch;
}
.pass {
width: 14ch;
}
.hidden-label,
.check-label {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--font-size-secondary, 12px);
opacity: 0.85;
}
.label-text {
background: transparent;
border: none;
padding: 0;
margin: 0;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
input[type="text"],
input[type="password"] {
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);
}
input:focus {
outline: none;
border-color: var(--accent);
}
button {
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
.remove {
background-color: var(--red);
color: var(--on-red);
}
.add {
background-color: var(--surface);
color: var(--on-surface);
margin-top: var(--space-sm, 8px);
align-self: flex-start;
}
.profile-card {
background-color: var(--surface);
color: var(--on-surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px);
margin-bottom: var(--space-sm, 8px);
display: flex;
flex-direction: column;
gap: var(--space-xs, 4px);
}
.profile-header {
display: flex;
gap: var(--space-sm, 8px);
margin-bottom: 4px;
}
.profile-name {
flex: 1;
font-weight: 500;
}
.field-label {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
font-size: var(--font-size-secondary, 12px);
}
.field-label input[type="text"] {
flex: 1;
}
</style>

View file

@ -0,0 +1,138 @@
<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 SwitchField from "$lib/components/SwitchField.svelte";
import TextField from "$lib/components/TextField.svelte";
import FileField from "$lib/components/FileField.svelte";
import PasswordField from "$lib/components/PasswordField.svelte";
import SelectField from "$lib/components/SelectField.svelte";
import NumberField from "$lib/components/NumberField.svelte";
import TagsField from "$lib/components/TagsField.svelte";
import Row from "$lib/components/Row.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
interface BreadpadConfig {
default_type: string;
workspace_tag: boolean;
snooze_options: string[];
archive_after_days: number;
model_path: string;
tokenizer_path: string;
ollama_enabled: boolean;
ollama_endpoint: string;
ollama_model: string;
ollama_confidence_threshold: number;
reminders_default_morning: string;
reminders_missed_grace_minutes: number;
calendar_enabled: boolean;
calendar_url: string;
calendar_username: string;
calendar_password: string;
}
let cfg = $state<BreadpadConfig | null>(null);
// The confidence threshold is an f64 (0-1, step 0.05); binding a plain
// number input directly to it shows raw float noise (e.g. "0.6000000")
// once the value has been nudged by the spinner. Instead we mirror it
// into a formatted display string, and only parse it back into `cfg` as
// a value rounded to 2 decimals.
let confidenceText = $state("0.6");
let confidenceLoaded = false;
function formatConfidence(n: number): string {
return (Math.round(n * 100) / 100).toString();
}
function onConfidenceInput(e: Event) {
confidenceText = (e.currentTarget as HTMLInputElement).value;
const parsed = parseFloat(confidenceText);
if (!Number.isNaN(parsed) && cfg) {
cfg.ollama_confidence_threshold = Math.round(parsed * 100) / 100;
}
}
function onConfidenceBlur() {
if (cfg) confidenceText = formatConfidence(cfg.ollama_confidence_threshold);
}
$effect(() => {
if (cfg && !confidenceLoaded) {
confidenceText = formatConfidence(cfg.ollama_confidence_threshold);
confidenceLoaded = true;
}
});
onMount(async () => {
cfg = await invoke<BreadpadConfig>("get_breadpad_config");
});
async function save() {
await invoke("save_breadpad_config", { cfg });
}
</script>
<ViewScaffold title="Notes">
{#if cfg}
<Group title="Capture">
<SelectField label="Default note type" bind:value={cfg.default_type} options={["note", "reminder", "task"]} />
<SwitchField label="Tag with active workspace" bind:value={cfg.workspace_tag} />
<TagsField label="Snooze options" bind:value={cfg.snooze_options} />
<NumberField label="Archive after (days)" bind:value={cfg.archive_after_days} min={0} max={3650} />
</Group>
<Group title="Classifier model" hint="The local model breadpad uses to guess note vs. reminder vs. task — no network needed.">
<FileField label="ONNX model" bind:value={cfg.model_path} placeholder="~/.local/share/breadpad/model/classifier.onnx" extensions={["onnx"]} />
<FileField label="Tokenizer" bind:value={cfg.tokenizer_path} placeholder="~/.local/share/breadpad/model/tokenizer.json" extensions={["json"]} />
</Group>
<Group title="Ollama (LLM classifier)" hint="Optional: ask a locally-running LLM (via Ollama) to classify notes instead of, or alongside, the ONNX model above.">
<SwitchField label="Use Ollama" bind:value={cfg.ollama_enabled} />
<TextField label="Endpoint" bind:value={cfg.ollama_endpoint} placeholder="http://localhost:11434" />
<TextField label="Model" bind:value={cfg.ollama_model} placeholder="e.g. fastflowlm" />
<Row label="Confidence threshold">
<input
type="number"
min="0"
max="1"
step="0.05"
value={confidenceText}
oninput={onConfidenceInput}
onblur={onConfidenceBlur}
/>
</Row>
</Group>
<Group title="Reminders">
<TextField label="Default morning time" bind:value={cfg.reminders_default_morning} placeholder="7:00" />
<NumberField label="Missed grace (minutes)" bind:value={cfg.reminders_missed_grace_minutes} min={0} max={1440} step={5} />
</Group>
<Group title="Calendar (CalDAV)">
<SwitchField label="Sync to calendar" bind:value={cfg.calendar_enabled} />
<TextField label="CalDAV URL" bind:value={cfg.calendar_url} placeholder="https://host/remote.php/dav/calendars/..." />
<TextField label="Username" bind:value={cfg.calendar_username} />
<PasswordField label="Password" bind:value={cfg.calendar_password} />
</Group>
<SaveButton onSave={save} />
{/if}
</ViewScaffold>
<style>
input[type="number"] {
width: 10ch;
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);
}
input[type="number"]:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

@ -0,0 +1,200 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke, convertFileSrc } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
interface LibraryEntry {
path: string;
name: string;
}
let currentPath = $state<string | null>(null);
let status = $state("");
let libraryDir = $state("");
let library = $state<LibraryEntry[] | null>(null);
let scanning = $state(false);
async function refreshCurrent() {
currentPath = await invoke<string | null>("get_current_wallpaper");
}
onMount(async () => {
libraryDir = await invoke<string>("wallpaper_library_dir_display");
await refreshCurrent();
});
async function apply(path: string) {
status = "Setting…";
try {
await invoke("set_wallpaper", { path });
await refreshCurrent();
status = "Wallpaper set";
} catch (e) {
status = `${e}`;
} finally {
setTimeout(() => (status = ""), 3000);
}
}
async function chooseImage() {
const path = await open({
title: "Choose a wallpaper",
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "bmp"] }],
});
if (typeof path === "string") {
await apply(path);
}
}
async function browseLibrary() {
scanning = true;
library = await invoke<LibraryEntry[]>("list_wallpaper_library");
scanning = false;
}
</script>
<ViewScaffold title="Wallpaper">
<Group
title="Current wallpaper"
hint="Sets the desktop wallpaper, generates a matching pywal palette, and reloads the shared bread-theme stylesheet — the wallpaper drives the whole desktop's accent colors."
>
<div class="preview-card">
{#if currentPath}
<img class="preview" src={convertFileSrc(currentPath)} alt="Current wallpaper" />
<span class="path">{currentPath.split("/").pop()}</span>
{:else}
<div class="preview placeholder">No wallpaper set</div>
{/if}
</div>
<div class="btn-row">
<button class="choose" onclick={chooseImage}>Choose image…</button>
<span class="status">{status}</span>
</div>
</Group>
<Group title="Library" wide>
{#if library === null}
<button class="browse" disabled={scanning} onclick={browseLibrary}>
{scanning ? "Scanning…" : `Browse ${libraryDir}`}
</button>
{:else if library.length === 0}
<div class="empty">Nothing under {libraryDir} — use Choose image… above instead.</div>
{:else}
<div class="grid">
{#each library as item (item.path)}
<button class="thumb" onclick={() => apply(item.path)} title={item.path}>
<img src={convertFileSrc(item.path)} alt={item.name} loading="lazy" />
<span class="name">{item.name}</span>
</button>
{/each}
</div>
{/if}
</Group>
</ViewScaffold>
<style>
.preview-card {
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm, 8px);
}
.preview {
display: block;
width: 320px;
height: 180px;
object-fit: cover;
border-radius: var(--radius-secondary, 6px);
}
.preview.placeholder {
display: flex;
align-items: center;
justify-content: center;
opacity: 0.5;
}
.path {
display: block;
margin-top: var(--space-xs, 4px);
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
.btn-row {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
justify-content: center;
margin-top: var(--space-sm, 8px);
}
.choose,
.browse {
background-color: var(--accent);
color: var(--on-accent);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
}
.browse {
background-color: var(--surface);
color: var(--on-surface);
}
.browse:disabled {
opacity: 0.6;
cursor: default;
}
.status {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
.empty {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-sm, 8px);
}
.thumb {
background: transparent;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px;
padding: 0;
}
.thumb img {
display: block;
width: 100%;
height: 88px;
object-fit: cover;
border-radius: var(--radius-tertiary, 4px);
}
.thumb .name {
font-size: 11px;
opacity: 0.6;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,105 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import ServiceControl from "$lib/components/ServiceControl.svelte";
import Group from "$lib/components/Group.svelte";
import SwitchField from "$lib/components/SwitchField.svelte";
import Row from "$lib/components/Row.svelte";
import NumberField from "$lib/components/NumberField.svelte";
import PathListField from "$lib/components/PathListField.svelte";
import TagsField from "$lib/components/TagsField.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
const BACKEND_LABELS: Record<string, string> = {
cpu: "CPU",
npu: "NPU (AMD Ryzen AI)",
rocm: "AMD GPU (ROCm)",
cuda: "NVIDIA GPU (CUDA)",
openvino: "Intel GPU (OpenVINO)",
};
interface BreadsearchConfig {
power_enabled: boolean;
run_on_battery: boolean;
backend: string;
index_roots: string[];
index_excludes: string[];
index_extensions: string[];
max_file_mb: number;
search_limit: number;
snippet_len: number;
}
let cfg = $state<BreadsearchConfig | null>(null);
onMount(async () => {
cfg = await invoke<BreadsearchConfig>("get_breadsearch_config");
});
async function save() {
await invoke("save_breadsearch_config", { cfg });
}
</script>
<ViewScaffold title="File Search">
<ServiceControl unit="breadmill.service" hasConfig />
{#if cfg}
<Group
title="Power"
hint="breadmill's embedding step is CPU/NPU/GPU-heavy. Turn it off entirely, or just pause it on battery — it resumes automatically on AC power."
>
<SwitchField label="Enabled" bind:value={cfg.power_enabled} />
<SwitchField label="Index while on battery" bind:value={cfg.run_on_battery} />
</Group>
<Group
title="Model"
hint="What hardware does the indexing. Check journalctl --user -u breadmill after restarting to confirm it registered."
>
<Row label="Search acceleration">
<select bind:value={cfg.backend}>
{#each Object.entries(BACKEND_LABELS) as [code, label] (code)}
<option value={code}>{label}</option>
{/each}
</select>
</Row>
<NumberField label="Max file size (MB)" bind:value={cfg.max_file_mb} min={0.1} max={500} step={0.5} />
</Group>
<Group title="Search">
<NumberField label="Result limit" bind:value={cfg.search_limit} min={1} max={100} />
<NumberField label="Snippet length" bind:value={cfg.snippet_len} min={20} max={2000} step={20} />
</Group>
<Group title="Index" wide>
<PathListField label="Folders to index" bind:value={cfg.index_roots} />
<PathListField label="Excluded folders" bind:value={cfg.index_excludes} hint="Skipped even if inside an indexed folder above." />
<TagsField label="File extensions" bind:value={cfg.index_extensions} />
</Group>
<SaveButton onSave={save} />
{/if}
</ViewScaffold>
<style>
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);
}
option {
background-color: var(--bg);
color: var(--on-surface);
}
select:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

@ -0,0 +1,121 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import InfoRow from "$lib/components/InfoRow.svelte";
import Group from "$lib/components/Group.svelte";
import Row from "$lib/components/Row.svelte";
import Hint from "$lib/components/Hint.svelte";
interface DateTimeInfo {
current_time: string;
timezones: string[];
current_tz: string;
ntp_enabled: boolean;
ntp_synced: boolean;
}
let info = $state<DateTimeInfo | null>(null);
let tzStatus = $state("");
let ntpEnabled = $state(false);
onMount(async () => {
info = await invoke<DateTimeInfo>("get_datetime_info");
ntpEnabled = info.ntp_enabled;
});
async function applyTimezone(tz: string) {
tzStatus = "Applying…";
try {
await invoke("set_timezone", { tz });
tzStatus = "Applied";
} catch (e) {
tzStatus = `${e}`;
}
}
async function toggleNtp(enabled: boolean) {
ntpEnabled = enabled;
await invoke("set_ntp_enabled", { enabled });
}
</script>
<ViewScaffold title="Date & Time">
{#if info}
<Group title="Current time">
<InfoRow label="Right now" value={info.current_time} />
</Group>
<Group title="Timezone">
{#if info.timezones.length === 0}
<Hint text="Couldn't list timezones (is timedatectl available?)." />
{:else}
<Row label="Timezone">
<select value={info.current_tz} onchange={(e) => applyTimezone(e.currentTarget.value)}>
{#each info.timezones as tz (tz)}
<option value={tz}>{tz}</option>
{/each}
</select>
</Row>
{#if tzStatus}
<span class="status">{tzStatus}</span>
{/if}
{/if}
</Group>
<Group title="Network time" hint={info.ntp_synced ? "Synchronized." : "Not synchronized yet (needs network)."}>
<Row label="Synchronize automatically">
<button
class="switch"
class:on={ntpEnabled}
role="switch"
aria-checked={ntpEnabled}
aria-label="Synchronize automatically"
onclick={() => toggleNtp(!ntpEnabled)}
>
<span class="knob"></span>
</button>
</Row>
</Group>
{/if}
</ViewScaffold>
<style>
select {
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);
}
.status {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
.switch {
width: 40px;
height: 22px;
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;
}
</style>

View file

@ -0,0 +1,152 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import InfoRow from "$lib/components/InfoRow.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
interface LiveMonitor {
name: string;
mode: string;
}
interface MonitorRule {
output: string;
mode: string;
position: string;
scale: string;
}
let liveMonitors = $state<LiveMonitor[] | null>(null);
let rules = $state<MonitorRule[] | null>(null);
onMount(async () => {
liveMonitors = await invoke<LiveMonitor[]>("get_live_monitors");
rules = await invoke<MonitorRule[]>("get_monitor_rules");
});
function addRule() {
rules = [...(rules ?? []), { output: "", mode: "preferred", position: "auto", scale: "auto" }];
}
function removeRule(i: number) {
rules = rules!.filter((_, idx) => idx !== i);
if (rules.length === 0) {
rules = [{ output: "", mode: "preferred", position: "auto", scale: "auto" }];
}
}
async function save() {
await invoke("save_monitor_rules", { rules });
}
</script>
<ViewScaffold title="Display">
<Group title="Connected monitors">
{#if liveMonitors && liveMonitors.length > 0}
{#each liveMonitors as m (m.name)}
<InfoRow label={m.name} value={m.mode} />
{/each}
{:else}
<Hint text="No monitors detected (is Hyprland running?)" />
{/if}
</Group>
<Group title="Advanced">
<button class="secondary" onclick={() => invoke("open_hyprland_conf")}>Open hyprland.lua in editor</button>
<button class="secondary" onclick={() => invoke("open_keybinds_viewer")}>View keybinds (breadhelp)</button>
</Group>
{#if rules}
<Group
title="Layout"
hint="One row per monitor rule. Leave Output blank to match any monitor (the default — works on any hardware). Applies on next login/reload."
wide
>
{#each rules as rule, i (i)}
<div class="rule-row">
<label>Output <input type="text" bind:value={rule.output} placeholder="any (blank = all)" class="w-output" /></label>
<label>Mode <input type="text" bind:value={rule.mode} placeholder="preferred / 1920x1080@60" class="w-mode" /></label>
<label>Position <input type="text" bind:value={rule.position} placeholder="auto / 0x0" class="w-position" /></label>
<label>Scale <input type="text" bind:value={rule.scale} placeholder="auto / 1" class="w-scale" /></label>
<button class="remove" onclick={() => removeRule(i)}>Remove</button>
</div>
{/each}
<button class="add" onclick={addRule}>Add monitor rule</button>
<SaveButton onSave={save} />
</Group>
{/if}
</ViewScaffold>
<style>
.rule-row {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
margin-bottom: var(--space-xs, 4px);
flex-wrap: wrap;
}
.rule-row label {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--font-size-secondary, 12px);
}
.w-output {
width: 12ch;
}
.w-mode {
width: 18ch;
}
.w-position {
width: 10ch;
}
.w-scale {
width: 8ch;
}
input[type="text"] {
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);
}
input:focus {
outline: none;
border-color: var(--accent);
}
button {
border: none;
border-radius: var(--radius-primary, 8px);
cursor: pointer;
}
.remove {
background-color: var(--red);
color: var(--on-red);
padding: var(--space-xs, 4px) var(--space-md, 12px);
}
.add {
background-color: var(--surface);
color: var(--on-surface);
margin-top: var(--space-sm, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
align-self: flex-start;
}
.secondary {
background-color: var(--bg);
color: var(--on-surface);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
align-self: flex-start;
margin-bottom: var(--space-xs, 4px);
}
</style>

View file

@ -0,0 +1,205 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Row from "$lib/components/Row.svelte";
import Group from "$lib/components/Group.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import LogView from "$lib/components/LogView.svelte";
import Shield from "@lucide/svelte/icons/shield";
import ShieldAlert from "@lucide/svelte/icons/shield-alert";
interface FirewallRule {
number: string;
text: string;
}
interface FirewallStatus {
active: boolean;
rules: FirewallRule[];
}
let status = $state<FirewallStatus | "unloaded" | { error: string }>("unloaded");
let enabledSensitive = $state(false);
let newRule = $state("");
let log = $state<string[]>([]);
async function refresh() {
try {
status = await invoke<FirewallStatus>("get_firewall_status");
enabledSensitive = true;
} catch (e) {
status = { error: `${e}` };
}
}
async function toggleEnabled(enabled: boolean) {
if (typeof status !== "object" || !("active" in status)) return;
log = [];
try {
await invoke("set_firewall_enabled", { enabled });
} catch (e) {
log = [...log, `${e}`];
}
await refresh();
}
async function removeRule(number: string) {
log = [];
try {
await invoke("remove_firewall_rule", { number });
} catch (e) {
log = [...log, `${e}`];
}
await refresh();
}
async function addRule() {
if (!newRule.trim()) return;
log = [];
try {
await invoke("add_firewall_rule", { rule: newRule.trim() });
newRule = "";
} catch (e) {
log = [...log, `${e}`];
}
await refresh();
}
</script>
<ViewScaffold title="Firewall">
<Group
title="Firewall"
hint="Reading and changing firewall state needs your password (polkit) — ufw requires root even just to check status."
>
<Row label="Firewall enabled">
<button
class="switch"
class:on={typeof status === "object" && "active" in status && status.active}
disabled={!enabledSensitive}
role="switch"
aria-checked={typeof status === "object" && "active" in status && status.active}
aria-label="Firewall enabled"
onclick={() => toggleEnabled(!(typeof status === "object" && "active" in status && status.active))}
>
<span class="knob"></span>
</button>
</Row>
</Group>
<Group title="Add rule" hint={'e.g. "8080/tcp", "22/tcp", or a service name like "OpenSSH".'}>
<div class="add-row">
<input type="text" bind:value={newRule} placeholder="port/proto or service name" />
<button class="allow" onclick={addRule}>Allow</button>
</div>
</Group>
<Group title="Rules" wide>
<div class="list">
{#if status === "unloaded"}
<EmptyState icon={Shield} title="Status not loaded" hint="Click Refresh below to check the firewall's current state." />
{:else if "error" in status}
<EmptyState icon={ShieldAlert} title="Couldn't read firewall status" hint={status.error} />
{:else if status.rules.length === 0}
<EmptyState icon={Shield} title="No rules" hint="Only the default policy (deny incoming, allow outgoing) applies." />
{:else}
{#each status.rules as rule (rule.number)}
<div class="rule-row">
<span class="text">{rule.text}</span>
<button class="remove" onclick={() => removeRule(rule.number)}>Remove</button>
</div>
{/each}
{/if}
</div>
<button class="refresh" onclick={refresh}>Refresh status</button>
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.switch {
width: 40px;
height: 22px;
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 {
opacity: 0.5;
cursor: default;
}
.knob {
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--on-surface);
}
.list {
min-height: 60px;
max-height: 260px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.rule-row {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
background-color: var(--surface);
border-radius: var(--radius-secondary, 6px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
}
.text {
flex: 1;
}
button {
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
.remove {
background-color: var(--red);
color: var(--on-red);
}
.refresh {
background-color: var(--surface);
color: var(--on-surface);
align-self: flex-start;
margin-top: var(--space-sm, 8px);
}
.add-row {
display: flex;
gap: var(--space-sm, 8px);
}
.add-row input {
flex: 1;
background-color: var(--surface);
color: var(--on-surface);
border: 1px solid transparent;
border-radius: var(--radius-secondary, 6px);
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
.allow {
background-color: var(--accent);
color: var(--on-accent);
}
</style>

View file

@ -0,0 +1,140 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamingCommand } 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 RefreshCw from "@lucide/svelte/icons/refresh-cw";
interface FwDevice {
name: string;
version: string;
}
let devices = $state<FwDevice[] | null>(null);
let log = $state<string[]>([]);
let busy = $state(false);
async function refresh() {
devices = await invoke<FwDevice[]>("get_updatable_firmware");
}
onMount(refresh);
function appendLine(line: string) {
log = [...log, line];
}
async function checkForUpdates() {
log = [];
busy = true;
await runStreamingCommand("fwupdmgr", ["refresh"], appendLine);
busy = false;
await refresh();
}
async function updateAll() {
log = [];
busy = true;
await runStreamingCommand("fwupdmgr", ["update", "-y"], appendLine);
busy = false;
await refresh();
}
</script>
<ViewScaffold title="Firmware">
<Group
title="Updatable devices"
hint="Firmware updates for hardware that supports them (UEFI, some peripherals). fwupd-refresh.timer already keeps update metadata current in the background."
wide
>
<div class="list">
{#if devices === null}
<Hint text="Loading…" />
{:else if devices.length === 0}
<EmptyState icon={RefreshCw} title="No updatable firmware devices found" hint="Not every device supports firmware updates through fwupd." />
{:else}
{#each devices as dev (dev.name)}
<div class="row">
<span class="name" title={dev.name}>{dev.name}</span>
<span class="version" title={dev.version}>{dev.version}</span>
</div>
{/each}
{/if}
</div>
<div class="btn-row">
<button disabled={busy} onclick={checkForUpdates}>Check for updates</button>
<button disabled={busy} class="primary" onclick={updateAll}>Update all</button>
<button disabled={busy} onclick={refresh}>Refresh list</button>
</div>
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.list {
max-height: 320px;
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;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-row {
display: flex;
gap: var(--space-sm, 8px);
margin-top: var(--space-sm, 8px);
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,653 @@
<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 TextField from "$lib/components/TextField.svelte";
import SelectField from "$lib/components/SelectField.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
import Switch from "$lib/components/Switch.svelte";
// Mirrors src-tauri/src/commands/keybinds.rs's `Bind` — `action`/`key`/
// `mods` are the only fields every bind has; everything else (`command`,
// `direction`, `workspace`, `options`, breadhelp's `label`/`category`/
// `demo_cmd`, ...) round-trips through serde's `#[serde(flatten)]` as
// arbitrary sibling keys on the same JSON object.
interface Bind {
action: string;
key?: string;
mods?: string[];
[extra: string]: unknown;
}
interface BindsFile {
active_layout: string;
default_mods: string[];
globals: Bind[];
common: Bind[];
layouts: Record<string, Bind[]>;
bindings: Bind[];
}
type SchemaKind = "flat" | "multi_layout" | "unknown";
interface BindsPayload {
kind: SchemaKind;
file: BindsFile;
}
// Every action actually seen in real binds.json files (BOS's shipped
// flat schema and this app's own dev MultiLayout config) — an `action`
// dropdown beats free-typing a dispatcher name from memory. "Custom…"
// keeps anything not in this list reachable without blocking on it.
const KNOWN_ACTIONS = [
"exec",
"close",
"fullscreen",
"float",
"pseudo",
"resize",
"focus",
"focus_last",
"move",
"move_dir",
"resize_dir",
"drag",
"layout",
"exit",
] as const;
// The editable, per-row UI state a bind gets flattened into on load and
// reconstructed back into a `Bind` on save — same trade-off the GTK
// version made with individual `Entry` widgets per field.
interface EditRow {
action: string;
key: string;
mods: string;
// Whether `mods` has been explicitly set (present in the loaded JSON,
// or touched by the user since). `None`/absent means "fall back to
// default_mods"; `Some([])` — an explicitly *empty* mods list — means
// "use no modifiers at all, even though default_mods exists" (real
// BOS binds rely on this for e.g. bare media keys). Collapsing both
// cases to "omit when empty" would silently turn an explicit
// no-mods override back into "use the default" on next save.
modsTouched: boolean;
// The single source of truth for everything beyond action/key/mods
// (command, direction, workspace, x/y, layout, options, breadhelp's
// label/category/demo_cmd, ...). Dedicated per-action fields below
// read/write specific keys of this object directly, so any sibling
// key they don't know about (label/category/demo_cmd, or an action
// shape this editor has no dedicated fields for) survives untouched.
extraValue: Record<string, unknown>;
// Raw-JSON view of `extraValue`, kept in sync both directions —
// only actually shown when `advancedOpen` is true, or for an action
// not in `KNOWN_ACTIONS` (nothing dedicated to show instead).
extraText: string;
extraError: boolean;
advancedOpen: boolean;
}
function syncExtraText(row: EditRow) {
row.extraText = Object.keys(row.extraValue).length ? JSON.stringify(row.extraValue) : "";
row.extraError = false;
}
/** Mutates one top-level key of `row.extraValue` (deleting it when `value` is empty/undefined) and re-syncs the raw-JSON view. */
function setExtra(row: EditRow, key: string, value: unknown) {
if (value === undefined || value === "") {
delete row.extraValue[key];
} else {
row.extraValue[key] = value;
}
syncExtraText(row);
}
function getOption(row: EditRow, key: "locked" | "repeating"): boolean {
const options = row.extraValue.options as Record<string, unknown> | undefined;
return Boolean(options?.[key]);
}
function setOption(row: EditRow, key: "locked" | "repeating", value: boolean) {
const options = { ...((row.extraValue.options as Record<string, unknown>) ?? {}) };
if (value) options[key] = true;
else delete options[key];
if (Object.keys(options).length) row.extraValue.options = options;
else delete row.extraValue.options;
syncExtraText(row);
}
// Hyprland workspace refs mix bare integers ("1") and relative tokens
// ("e+1", "e-1") in the same field — keep whichever shape the user typed
// instead of forcing everything through one type.
function parseWorkspaceValue(s: string): string | number | undefined {
const trimmed = s.trim();
if (!trimmed) return undefined;
return /^-?\d+$/.test(trimmed) ? Number(trimmed) : trimmed;
}
function modsToText(mods?: string[]): string {
return (mods ?? []).join(", ");
}
function textToMods(s: string): string[] {
return s
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
function bindToRow(b: Bind): EditRow {
const { action, key, mods, ...extra } = b;
const row: EditRow = {
action: action ?? "",
key: (key as string | undefined) ?? "",
mods: modsToText(mods),
modsTouched: mods !== undefined,
extraValue: extra,
extraText: "",
extraError: false,
advancedOpen: false,
};
syncExtraText(row);
return row;
}
function rowToBind(r: EditRow): Bind {
// `drag` binds are only meaningful as a mouse bind — there's no
// dedicated field for `options.mouse` (nothing to configure, it's
// always true), so pin it here rather than exposing a checkbox
// whose only correct state is "on".
const extra = r.action === "drag" ? { ...r.extraValue, options: { ...(r.extraValue.options as object), mouse: true } } : r.extraValue;
return {
action: r.action,
...(r.key.trim() ? { key: r.key.trim() } : {}),
...(r.modsTouched ? { mods: textToMods(r.mods) } : {}),
...extra,
};
}
function onExtraInput(row: EditRow, text: string) {
row.extraText = text;
const trimmed = text.trim();
if (!trimmed) {
row.extraValue = {};
row.extraError = false;
return;
}
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
row.extraValue = parsed;
row.extraError = false;
} else {
row.extraError = true;
}
} catch {
row.extraError = true;
}
}
function emptyRow(): EditRow {
return {
action: "exec",
key: "",
mods: "",
modsTouched: false,
extraValue: {},
extraText: "",
extraError: false,
advancedOpen: false,
};
}
let loaded = $state(false);
let kind = $state<SchemaKind>("unknown");
let activeLayout = $state("");
let defaultMods = $state("");
let globalsRows = $state<EditRow[]>([]);
let commonRows = $state<EditRow[]>([]);
let bindingsRows = $state<EditRow[]>([]);
let layoutRows = $state<Record<string, EditRow[]>>({});
let layoutOrder = $state<string[]>([]);
let newLayoutName = $state("");
let loadError = $state("");
onMount(async () => {
try {
const p = await invoke<BindsPayload>("get_keybinds");
kind = p.kind;
// Rust's `#[serde(skip_serializing_if = ...)]` on every one of
// these fields means an empty one is OMITTED from the JSON
// entirely, not sent as `[]`/`{}`/`""` — every field here needs
// a `??` fallback, not just the ones that are "usually" empty.
activeLayout = p.file.active_layout ?? "";
defaultMods = modsToText(p.file.default_mods ?? []);
globalsRows = (p.file.globals ?? []).map(bindToRow);
commonRows = (p.file.common ?? []).map(bindToRow);
bindingsRows = (p.file.bindings ?? []).map(bindToRow);
const layouts: Record<string, EditRow[]> = {};
for (const [name, binds] of Object.entries(p.file.layouts ?? {})) layouts[name] = binds.map(bindToRow);
layoutRows = layouts;
layoutOrder = Object.keys(layouts);
loaded = true;
} catch (e) {
loadError = String(e);
}
});
function addLayout() {
const name = newLayoutName.trim();
if (!name || layoutOrder.includes(name)) return;
layoutRows[name] = [];
layoutOrder = [...layoutOrder, name];
newLayoutName = "";
}
function removeLayout(name: string) {
if (!confirm(`Remove layout "${name}"? Deletes every bind defined under this layout. This can't be undone here.`)) return;
delete layoutRows[name];
layoutOrder = layoutOrder.filter((n) => n !== name);
if (activeLayout === name) activeLayout = layoutOrder[0] ?? "";
}
// A row whose "extra" column currently holds text that doesn't parse as
// JSON keeps its *last successfully parsed* value in `extraValue` (see
// `onExtraInput`) rather than losing it on every keystroke while the
// user is mid-edit — but that means saving while such a row is still
// showing invalid/incomplete text would silently write that stale (or,
// for a brand new row, empty) value instead of what's actually on
// screen. Block save entirely until every row's extra JSON is valid, so
// an in-progress edit can never be the thing that quietly drops a
// bind's `command`/`direction`/`workspace`/etc.
function hasInvalidExtraJson(): boolean {
const all = [...globalsRows, ...commonRows, ...bindingsRows, ...Object.values(layoutRows).flat()];
return all.some((r) => r.extraError);
}
async function save() {
if (hasInvalidExtraJson()) {
throw new Error("Fix the invalid JSON in the highlighted field(s) before saving.");
}
const file: BindsFile = {
active_layout: activeLayout,
default_mods: textToMods(defaultMods),
globals: globalsRows.map(rowToBind),
common: commonRows.map(rowToBind),
layouts: Object.fromEntries(layoutOrder.map((name) => [name, (layoutRows[name] ?? []).map(rowToBind)])),
bindings: bindingsRows.map(rowToBind),
};
await invoke("save_keybinds", { file, kind });
}
</script>
{#snippet extraFields(row: EditRow)}
{#if row.action === "exec"}
<input
class="extra-wide"
type="text"
placeholder="Command to run"
value={(row.extraValue.command as string) ?? ""}
oninput={(e) => setExtra(row, "command", e.currentTarget.value)}
/>
<label class="mini-switch">
<Switch
ariaLabel="Repeat while held"
bind:value={() => getOption(row, "repeating"), (v) => setOption(row, "repeating", v)}
/>
Repeat while held
</label>
<label class="mini-switch">
<Switch
ariaLabel="Ignore keyboard lock"
bind:value={() => getOption(row, "locked"), (v) => setOption(row, "locked", v)}
/>
Works when locked
</label>
{:else if row.action === "focus" || row.action === "move"}
<input
class="extra-narrow"
type="text"
placeholder="Workspace (e.g. 3, e+1)"
value={String(row.extraValue.workspace ?? "")}
oninput={(e) => setExtra(row, "workspace", parseWorkspaceValue(e.currentTarget.value))}
/>
{:else if row.action === "move_dir"}
<select
class="extra-narrow"
value={(row.extraValue.direction as string) ?? ""}
onchange={(e) => setExtra(row, "direction", e.currentTarget.value || undefined)}
>
<option value="" disabled>Direction…</option>
<option value="left">Left</option>
<option value="right">Right</option>
<option value="up">Up</option>
<option value="down">Down</option>
</select>
{:else if row.action === "resize_dir"}
<input
class="extra-tiny"
type="number"
placeholder="x"
value={row.extraValue.x !== undefined ? String(row.extraValue.x) : ""}
oninput={(e) => setExtra(row, "x", e.currentTarget.value === "" ? undefined : Number(e.currentTarget.value))}
/>
<input
class="extra-tiny"
type="number"
placeholder="y"
value={row.extraValue.y !== undefined ? String(row.extraValue.y) : ""}
oninput={(e) => setExtra(row, "y", e.currentTarget.value === "" ? undefined : Number(e.currentTarget.value))}
/>
<label class="mini-switch">
<Switch
ariaLabel="Repeat while held"
bind:value={() => getOption(row, "repeating"), (v) => setOption(row, "repeating", v)}
/>
Repeat while held
</label>
{:else if row.action === "layout"}
<input
class="extra-narrow"
type="text"
placeholder="Layout command (e.g. togglesplit)"
value={(row.extraValue.layout as string) ?? ""}
oninput={(e) => setExtra(row, "layout", e.currentTarget.value)}
/>
{:else if row.action === "drag"}
<span class="extra-note">Mouse-drag bind — nothing else to set.</span>
{:else if row.action === "close" || row.action === "fullscreen" || row.action === "float" || row.action === "pseudo" || row.action === "resize" || row.action === "focus_last" || row.action === "exit"}
<span class="extra-note">No extra options for this action.</span>
{:else}
<input
class="extra-wide"
class:error={row.extraError}
type="text"
placeholder={'{"command": "..."}'}
value={row.extraText}
oninput={(e) => onExtraInput(row, e.currentTarget.value)}
/>
{/if}
{#if KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number]) && row.action !== "drag"}
<button type="button" class="advanced-toggle" onclick={() => (row.advancedOpen = !row.advancedOpen)}>
{row.advancedOpen ? "Hide raw JSON" : "Advanced"}
</button>
{/if}
{/snippet}
{#snippet section(rows: EditRow[], onAdd: () => void, onRemove: (i: number) => void)}
<div class="rows">
{#each rows as row, i (i)}
<div class="bind-card">
<div class="bind-top">
<input
class="mods"
type="text"
placeholder="SUPER, SHIFT"
bind:value={row.mods}
oninput={() => (row.modsTouched = true)}
/>
<input class="key" type="text" placeholder="key" bind:value={row.key} />
<select
class="action-select"
value={KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number]) ? row.action : "__custom__"}
onchange={(e) => {
const v = e.currentTarget.value;
row.action = v === "__custom__" ? "" : v;
}}
>
{#each KNOWN_ACTIONS as a (a)}
<option value={a}>{a}</option>
{/each}
<option value="__custom__">Custom…</option>
</select>
{#if !KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number])}
<input class="action-custom" type="text" placeholder="action name" bind:value={row.action} />
{/if}
<button type="button" class="remove" onclick={() => onRemove(i)}>Remove</button>
</div>
<div class="bind-bottom">
{@render extraFields(row)}
</div>
{#if row.advancedOpen && KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number]) && row.action !== "drag"}
<input
class="extra-wide"
class:error={row.extraError}
type="text"
placeholder={'{"command": "..."}'}
value={row.extraText}
oninput={(e) => onExtraInput(row, e.currentTarget.value)}
/>
{/if}
</div>
{/each}
</div>
<button type="button" class="add" onclick={onAdd}>Add bind</button>
{/snippet}
<ViewScaffold title="Keybinds">
{#if loadError}
<Group title="Failed to load" wide>
<Hint text={loadError} />
</Group>
{/if}
{#if loaded}
{#if kind === "unknown"}
<Group title="Keybinds" wide>
<Hint
text={`binds.json's schema wasn't recognized (expected a "bindings" key, or one of "globals"/"common"/"layouts"). Nothing below is editable, and Save is disabled, so the file on disk isn't at risk of being silently overwritten with the wrong shape. Fix or remove the file by hand, then reopen this panel.`}
/>
</Group>
{:else if kind === "flat"}
<Group
title="Defaults"
hint={`Mods/Key pick the shortcut; Action picks what it does. Choosing a known action (exec, move_dir, focus, ...) shows the fields it actually needs — e.g. a Command box for exec — instead of raw JSON. "Advanced" reveals the underlying JSON per bind for anything not covered (or breadhelp's label/category metadata). This machine's binds.json uses BOS's flat schema (no keyboard-layout switching), so that's all there is. Applies on next login/reload.`}
>
<TextField label="Default mods" bind:value={defaultMods} placeholder="SUPER" />
</Group>
<Group title="Bindings" wide>
{@render section(
bindingsRows,
() => bindingsRows.push(emptyRow()),
(i) => bindingsRows.splice(i, 1)
)}
</Group>
<SaveButton onSave={save} />
{:else}
<Group
title="Layout"
hint={`Mods/Key pick the shortcut; Action picks what it does. Choosing a known action (exec, move_dir, focus, ...) shows the fields it actually needs — e.g. a Command box for exec — instead of raw JSON. "Advanced" reveals the underlying JSON per bind for anything not covered. Applies on next login/reload.`}
>
{#if layoutOrder.length > 0}
<SelectField label="Active layout" bind:value={activeLayout} options={layoutOrder} />
{/if}
<TextField label="Default mods" bind:value={defaultMods} placeholder="SUPER" />
</Group>
<Group title="Media & function keys (globals)" wide>
{@render section(
globalsRows,
() => globalsRows.push(emptyRow()),
(i) => globalsRows.splice(i, 1)
)}
</Group>
<Group title="Common (every layout)" wide>
{@render section(
commonRows,
() => commonRows.push(emptyRow()),
(i) => commonRows.splice(i, 1)
)}
</Group>
{#each layoutOrder as name (name)}
<Group title={`Layout: ${name}`} wide>
<button type="button" class="remove-layout" onclick={() => removeLayout(name)}>Remove layout</button>
{@render section(
layoutRows[name] ?? [],
() => (layoutRows[name] ?? (layoutRows[name] = [])).push(emptyRow()),
(i) => layoutRows[name]?.splice(i, 1)
)}
</Group>
{/each}
<Group title="Add layout">
<div class="add-layout-row">
<input type="text" bind:value={newLayoutName} placeholder="new layout name" />
<button type="button" onclick={addLayout}>Add layout</button>
</div>
</Group>
<SaveButton onSave={save} />
{/if}
{/if}
</ViewScaffold>
<style>
.rows {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: var(--space-sm, 8px);
}
.bind-card {
display: flex;
flex-direction: column;
gap: var(--space-xs, 4px);
background-color: var(--surface);
border-radius: var(--radius-secondary, 6px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
}
.bind-top,
.bind-bottom {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
flex-wrap: wrap;
}
.bind-card input,
.bind-card select {
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);
}
.bind-card input:focus,
.bind-card select:focus {
outline: none;
border-color: var(--accent);
}
.bind-card input.error {
border-color: var(--red);
}
.mods {
width: 14ch;
flex-shrink: 0;
}
.key {
width: 9ch;
flex-shrink: 0;
}
.action-select {
width: 11ch;
flex-shrink: 0;
}
.action-custom {
width: 11ch;
flex-shrink: 0;
}
.extra-wide {
flex: 1;
min-width: 16ch;
font-family: monospace;
font-size: var(--font-size-secondary, 12px);
}
.extra-narrow {
width: 20ch;
}
.extra-tiny {
width: 7ch;
}
.extra-note {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
.mini-switch {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--font-size-secondary, 12px);
opacity: 0.85;
}
.advanced-toggle {
background: transparent;
border: none;
color: var(--on-surface);
opacity: 0.6;
cursor: pointer;
font-size: var(--font-size-secondary, 12px);
padding: var(--space-xs, 4px) var(--space-sm, 8px);
margin-left: auto;
}
.advanced-toggle:hover {
opacity: 1;
}
button {
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
.remove {
background-color: var(--red);
color: var(--on-red);
flex-shrink: 0;
}
.add {
background-color: var(--surface);
color: var(--on-surface);
align-self: flex-start;
}
.remove-layout {
background-color: var(--red);
color: var(--on-red);
margin-bottom: var(--space-sm, 8px);
align-self: flex-start;
}
.add-layout-row {
display: flex;
gap: var(--space-sm, 8px);
}
.add-layout-row input {
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);
}
.add-layout-row button {
background-color: var(--accent);
color: var(--on-accent);
}
</style>

View file

@ -0,0 +1,276 @@
<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 Row from "$lib/components/Row.svelte";
import InfoRow from "$lib/components/InfoRow.svelte";
import Hint from "$lib/components/Hint.svelte";
import Wifi from "@lucide/svelte/icons/wifi";
import WifiOff from "@lucide/svelte/icons/wifi-off";
import Lock from "@lucide/svelte/icons/lock";
interface WifiNetwork {
ssid: string;
signal: number;
secured: boolean;
active: boolean;
known: boolean;
}
let radioEnabled = $state(false);
let ethernet = $state<string | null>(null);
let networks = $state<WifiNetwork[] | null>(null);
let scanning = $state(false);
let status = $state("");
let pendingSsid = $state<string | null>(null);
let password = $state("");
// The backend reports the first `nmcli`-visible device of TYPE=ethernet,
// which on a machine running containers/VMs can be a virtual interface
// (docker/podman veth pairs, libvirt bridges, VPN tuns) rather than a
// real NIC — nmcli doesn't distinguish these from physical ethernet.
// Names like "vethYCF3ZA: unmanaged" are meaningless to a non-technical
// user, so hide the card rather than show raw interface jargon.
const VIRTUAL_IFACE_PREFIXES = ["veth", "docker", "br-", "virbr", "vnet", "tun", "tap", "vmnet", "podman"];
const ETHERNET_STATE_LABELS: Record<string, string> = {
connected: "Connected",
connecting: "Connecting…",
disconnected: "Not connected",
disconnecting: "Disconnecting…",
unavailable: "Not connected",
unmanaged: "Not connected",
};
let ethernetLabel = $derived.by(() => {
if (!ethernet) return null;
const [iface, rawState] = ethernet.split(": ");
if (!iface || VIRTUAL_IFACE_PREFIXES.some((prefix) => iface.startsWith(prefix))) return null;
return ETHERNET_STATE_LABELS[rawState ?? ""] ?? "Not connected";
});
onMount(async () => {
const info = await invoke<{ radio_enabled: boolean; ethernet: string | null }>("get_network_info");
radioEnabled = info.radio_enabled;
ethernet = info.ethernet;
});
async function toggleRadio(enabled: boolean) {
radioEnabled = enabled;
await invoke("set_wifi_radio", { enabled });
}
async function scan() {
scanning = true;
status = "Scanning…";
networks = await invoke<WifiNetwork[]>("scan_wifi");
status = `Found ${networks.length} network(s)`;
scanning = false;
}
async function connect(ssid: string, secured: boolean, known: boolean) {
if (secured && !known) {
pendingSsid = ssid;
return;
}
status = `Connecting to ${ssid}…`;
try {
await invoke("connect_wifi", { ssid, password: null });
status = `Connected to ${ssid}`;
} catch (e) {
status = `Failed to connect to ${ssid}: ${e}`;
}
}
async function connectWithPassword() {
const ssid = pendingSsid!;
const pw = password;
pendingSsid = null;
password = "";
status = `Connecting to ${ssid}…`;
try {
await invoke("connect_wifi", { ssid, password: pw });
status = `Connected to ${ssid}`;
} catch {
status = `Failed to connect to ${ssid}: wrong password?`;
}
}
function signalLabel(signal: number): string {
return `${Math.min(100, Math.max(0, signal))}%`;
}
</script>
<ViewScaffold title="Network">
<Group title="Wi-Fi">
<Row label="Wi-Fi radio">
<button class="switch" class:on={radioEnabled} role="switch" aria-checked={radioEnabled} aria-label="Wi-Fi radio" onclick={() => toggleRadio(!radioEnabled)}>
<span class="knob"></span>
</button>
</Row>
</Group>
{#if ethernetLabel}
<Group title="Ethernet">
<InfoRow label="Status" value={ethernetLabel} />
</Group>
{/if}
<Group title="Available networks" wide>
<div class="list">
{#if networks === null}
<div class="empty">
<Wifi size={40} />
<span>Not scanned yet</span>
<span class="hint">Press Scan to see nearby networks.</span>
</div>
{:else if networks.length === 0}
<div class="empty">
<WifiOff size={40} />
<span>No networks found</span>
<span class="hint">Try Scan again, or check Wi-Fi radio is on.</span>
</div>
{:else}
{#each networks as net (net.ssid)}
<div class="net-row">
<span class="ssid" class:active={net.active}>{net.ssid}{net.active ? " (connected)" : ""}</span>
{#if net.secured}<Lock size={14} />{/if}
<span class="signal">{signalLabel(net.signal)}</span>
{#if !net.active}
<button class="connect" onclick={() => connect(net.ssid, net.secured, net.known)}>Connect</button>
{/if}
</div>
{/each}
{/if}
</div>
{#if pendingSsid}
<div class="pw-row">
<input type="password" bind:value={password} placeholder="Password" />
<button class="connect" onclick={connectWithPassword}>Connect</button>
</div>
{/if}
{#if status}
<span class="status">{status}</span>
{/if}
<button class="scan" disabled={scanning} onclick={scan}>{scanning ? "Scanning…" : "Scan"}</button>
</Group>
<Group title="Advanced" hint="VPN, 802.1x, and static IP configuration aren't covered here.">
<button class="secondary" onclick={() => invoke("open_connection_editor")}>Open connection editor</button>
</Group>
</ViewScaffold>
<style>
.switch {
width: 40px;
height: 22px;
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);
}
.list {
min-height: 100px;
max-height: 260px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: var(--space-xl, 20px) 0;
opacity: 0.6;
}
.hint {
font-size: var(--font-size-secondary, 12px);
}
.net-row {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
background-color: var(--surface);
border-radius: var(--radius-secondary, 6px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
}
.ssid {
flex: 1;
}
.ssid.active {
font-weight: bold;
}
.signal {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
.pw-row {
display: flex;
gap: var(--space-sm, 8px);
}
.pw-row input {
flex: 1;
background-color: var(--surface);
color: var(--on-surface);
border: 1px solid transparent;
border-radius: var(--radius-secondary, 6px);
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
.status {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
button.connect,
button.scan,
button.secondary {
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
background-color: var(--accent);
color: var(--on-accent);
}
button.secondary {
background-color: var(--bg);
color: var(--on-surface);
align-self: flex-start;
padding: var(--space-sm, 8px) var(--space-lg, 16px);
}
button.scan {
align-self: flex-start;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,152 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamingCommand } 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 Package from "@lucide/svelte/icons/package";
interface InstalledPackage {
name: string;
version: string;
}
let packages = $state<InstalledPackage[] | null>(null);
let log = $state<string[]>([]);
let busy = $state(false);
async function refresh() {
packages = await invoke<InstalledPackage[]>("get_installed_packages");
}
onMount(refresh);
function appendLine(line: string) {
log = [...log, line];
}
async function updatePackage(name: string) {
log = [];
busy = true;
await runStreamingCommand("bakery", ["update", name], appendLine);
busy = false;
await refresh();
}
async function listInstalled() {
log = [];
busy = true;
await runStreamingCommand("bakery", ["list"], appendLine);
busy = false;
}
async function updateAll() {
log = [];
busy = true;
await runStreamingCommand("bakery", ["update", "--all"], appendLine);
busy = false;
await refresh();
}
async function updateSystem() {
log = [];
busy = true;
await runStreamingCommand("pkexec", ["pacman", "-Syu", "--noconfirm"], appendLine);
busy = false;
}
</script>
<ViewScaffold title="Packages">
<Group title="Bread ecosystem (bakery)" wide>
<div class="list">
{#if packages === null}
<Hint text="Loading…" />
{:else if packages.length === 0}
<EmptyState icon={Package} title="No bakery packages found" hint="~/.local/state/bakery/installed.json is missing or empty." />
{:else}
{#each packages as pkg (pkg.name)}
<div class="row">
<span class="name" title={pkg.name}>{pkg.name}</span>
<span class="version" title={pkg.version}>{pkg.version}</span>
<button disabled={busy} onclick={() => updatePackage(pkg.name)}>Update</button>
</div>
{/each}
{/if}
</div>
<div class="btn-row">
<button disabled={busy} onclick={listInstalled}>List installed</button>
<button disabled={busy} onclick={updateAll}>Update all</button>
</div>
</Group>
<Group
title="System packages (pacman)"
hint="Base system, kernel, bos-settings, and republished AUR packages — the other half of what bos-update covers. Needs your password (polkit) since pacman requires root."
>
<button disabled={busy} onclick={updateSystem}>Update system (pacman -Syu)</button>
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.list {
max-height: 320px;
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;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,105 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import InfoRow from "$lib/components/InfoRow.svelte";
import Row from "$lib/components/Row.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
interface PowerInfo {
battery: [string, string][];
power_source: string;
brightness_pct: number | null;
charge_start: number | null;
charge_end: number | null;
tlp_profile: string | null;
}
let info = $state<PowerInfo | null>(null);
let brightness = $state(0);
let chargeStart = $state(0);
let chargeEnd = $state(100);
onMount(async () => {
info = await invoke<PowerInfo>("get_power_info");
brightness = info.brightness_pct ?? 0;
chargeStart = info.charge_start ?? 0;
chargeEnd = info.charge_end ?? 100;
});
async function setBrightness(percent: number) {
brightness = percent;
await invoke("set_brightness", { percent });
}
async function setChargeThreshold(which: "start" | "end", percent: number) {
if (which === "start") chargeStart = percent;
else chargeEnd = percent;
await invoke("set_charge_threshold", { which, percent });
}
</script>
<ViewScaffold title="Power">
{#if info}
<Group title="Battery">
{#each info.battery as [label, value] (label)}
<InfoRow {label} {value} />
{/each}
<InfoRow label="Power source" value={info.power_source} />
</Group>
<Group title="Brightness">
{#if info.brightness_pct !== null}
<Row label="Screen brightness">
<input type="range" min="1" max="100" bind:value={brightness} onchange={() => setBrightness(brightness)} />
<span class="pct">{brightness}%</span>
</Row>
{:else}
<Hint text="No controllable backlight found." />
{/if}
</Group>
{#if info.charge_start !== null && info.charge_end !== null}
<Group
title="Charge limits"
hint="Some laptops let you cap charging below 100% to slow battery wear on a machine that's mostly plugged in."
>
<Row label="Start charging below (%)">
<input type="number" min="0" max="100" bind:value={chargeStart} onchange={() => setChargeThreshold("start", chargeStart)} />
</Row>
<Row label="Stop charging at (%)">
<input type="number" min="1" max="100" bind:value={chargeEnd} onchange={() => setChargeThreshold("end", chargeEnd)} />
</Row>
</Group>
{/if}
<Group
title="TLP"
hint="TLP automatically applies a power-saving profile on battery and a performance profile on AC — there's no manual switch by design."
>
<InfoRow label="Current profile" value={info.tlp_profile ?? "unknown"} />
</Group>
{/if}
</ViewScaffold>
<style>
input[type="range"] {
width: 180px;
}
input[type="number"] {
width: 8ch;
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);
}
.pct {
margin-left: var(--space-sm, 8px);
font-size: var(--font-size-secondary, 12px);
opacity: 0.7;
}
</style>

View file

@ -0,0 +1,176 @@
<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 History from "@lucide/svelte/icons/history";
import CircleAlert from "@lucide/svelte/icons/circle-alert";
interface SnapshotRow {
number: string;
date: string;
description: string;
}
let snapshots = $state<SnapshotRow[] | "loading">("loading");
let errorHint = $state<string | null>(null);
let selected = $state<string | null>(null);
async function refresh() {
errorHint = null;
try {
snapshots = await invoke<SnapshotRow[]>("get_snapshots");
} catch (e) {
const msg = `${e}`.toLowerCase();
if (msg.includes("no permission")) {
errorHint = "This user isn't allowed to run snapper. Check ALLOW_USERS in /etc/snapper/configs/root — it should list your username.";
} else if (msg.includes("unknown config") || msg.includes("no such file")) {
errorHint = "No snapper config exists for root yet, so nothing is being snapshotted. This should be set up automatically at install.";
} else {
errorHint = `${e}`;
}
snapshots = [];
}
selected = null;
}
onMount(refresh);
function bootIntoSelected() {
if (!selected) return;
if (
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.`,
)
) {
invoke("reboot_system");
}
}
async function deleteSelected() {
if (!selected) return;
if (!confirm(`Delete snapshot #${selected}? This cannot be undone.`)) return;
try {
await invoke("delete_snapshot", { number: selected });
await refresh();
} catch (e) {
alert(`${e}`);
}
}
</script>
<ViewScaffold title="Snapshots">
<Group
title="System snapshots"
hint="Created automatically by snap-pac on each pacman transaction. Boot into one from the GRUB menu to recover; delete old ones here."
wide
>
<div class="list">
{#if snapshots === "loading"}
<Hint text="Loading…" />
{:else if errorHint}
<EmptyState icon={CircleAlert} title="Couldn't read snapshots" hint={errorHint} />
{:else if snapshots.length === 0}
<EmptyState icon={History} title="No snapshots yet" hint="Snapshots are created automatically on every pacman transaction." />
{:else}
{#each snapshots as snap (snap.number)}
<button
class="row"
class:selected={selected === snap.number}
onclick={() => (selected = snap.number)}
>
<span class="number">{snap.number}</span>
<span class="date">{snap.date}</span>
<span class="desc" title={snap.description}>{snap.description}</span>
</button>
{/each}
{/if}
</div>
<div class="btn-row">
<button onclick={refresh}>Refresh</button>
<button disabled={!selected} onclick={bootIntoSelected}>Boot into selected…</button>
<button class="destructive" disabled={!selected} onclick={deleteSelected}>Delete selected</button>
</div>
</Group>
</ViewScaffold>
<style>
.list {
max-height: 320px;
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: 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);
min-width: 0;
}
.row:hover {
background-color: color-mix(in srgb, var(--surface), var(--on-surface) 8%);
}
.row.selected {
background-color: var(--accent);
color: var(--on-accent);
}
.number {
width: 4ch;
flex-shrink: 0;
}
.date {
width: 22ch;
flex-shrink: 0;
opacity: 0.85;
}
.desc {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-row {
display: flex;
gap: var(--space-sm, 8px);
margin-top: var(--space-sm, 8px);
}
button {
background-color: var(--bg);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
button.destructive {
background-color: var(--red);
color: var(--on-red);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,131 @@
<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 Row from "$lib/components/Row.svelte";
import Hint from "$lib/components/Hint.svelte";
import SwitchField from "$lib/components/SwitchField.svelte";
interface SoundDevice {
name: string;
description: string;
mute: boolean;
percent: number;
}
interface DeviceSection {
kind: "sinks" | "sources";
title: string;
devices: SoundDevice[];
selected: number;
}
let output = $state<DeviceSection | null>(null);
let input = $state<DeviceSection | null>(null);
async function loadSection(kind: "sinks" | "sources", title: string): Promise<DeviceSection> {
const section = await invoke<{ devices: SoundDevice[]; default_name: string | null }>("get_sound_section", { kind });
const selected = Math.max(0, section.devices.findIndex((d) => d.name === section.default_name));
return { kind, title, devices: section.devices, selected };
}
onMount(async () => {
output = await loadSection("sinks", "Output");
input = await loadSection("sources", "Input");
});
async function selectDevice(section: DeviceSection, index: number) {
section.selected = index;
await invoke("set_default_sound_device", { kind: section.kind, name: section.devices[index].name });
}
async function setVolume(section: DeviceSection, percent: number) {
const device = section.devices[section.selected];
device.percent = percent;
await invoke("set_sound_volume", { kind: section.kind, name: device.name, percent });
}
async function setMute(section: DeviceSection, mute: boolean) {
const device = section.devices[section.selected];
device.mute = mute;
await invoke("set_sound_mute", { kind: section.kind, name: device.name, mute });
}
</script>
{#snippet deviceSection(section: DeviceSection | null)}
{#if section}
<Group title={section.title}>
{#if section.devices.length === 0}
<Hint text="No devices found." />
{:else}
<Row label="Device">
<select
value={section.selected}
onchange={(e) => selectDevice(section, Number(e.currentTarget.value))}
>
{#each section.devices as d, i (d.name)}
<option value={i}>{d.description}</option>
{/each}
</select>
</Row>
<Row label="Volume">
<input
type="range"
min="0"
max="150"
value={section.devices[section.selected].percent}
oninput={(e) => setVolume(section, Number(e.currentTarget.value))}
/>
<span class="pct">{section.devices[section.selected].percent}%</span>
</Row>
<SwitchField
label="Mute"
bind:value={
() => section.devices[section.selected].mute,
(v) => setMute(section, v)
}
/>
{/if}
</Group>
{/if}
{/snippet}
<ViewScaffold title="Sound">
{@render deviceSection(output)}
{@render deviceSection(input)}
<Group title="Advanced" hint="Per-app volume, port selection, and profile switching aren't covered here.">
<button class="secondary" onclick={() => invoke("open_mixer")}>Open advanced mixer (pavucontrol)</button>
</Group>
</ViewScaffold>
<style>
select {
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);
}
input[type="range"] {
width: 180px;
}
.pct {
margin-left: var(--space-sm, 8px);
font-size: var(--font-size-secondary, 12px);
opacity: 0.7;
}
.secondary {
background-color: var(--bg);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
align-self: flex-start;
}
</style>

View file

@ -0,0 +1,196 @@
<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 Row from "$lib/components/Row.svelte";
interface Account {
username: string;
full_name: string;
}
let accounts = $state<Account[] | null>(null);
let currentUser = $state("");
let openPasswordFor = $state<string | null>(null);
let passwordInput = $state("");
let rowStatus = $state<Record<string, string>>({});
let newUsername = $state("");
let newFullName = $state("");
let newPassword = $state("");
let addStatus = $state("");
async function refresh() {
const info = await invoke<{ accounts: Account[]; current_user: string }>("get_users_info");
accounts = info.accounts;
currentUser = info.current_user;
}
onMount(refresh);
async function applyPassword(username: string) {
if (!passwordInput) return;
rowStatus = { ...rowStatus, [username]: "Applying…" };
try {
await invoke("change_password", { username, password: passwordInput });
rowStatus = { ...rowStatus, [username]: "Password changed" };
openPasswordFor = null;
passwordInput = "";
} catch {
rowStatus = { ...rowStatus, [username]: "Failed to change password" };
}
}
async function removeAccount(username: string) {
if (!confirm(`Remove user ${username}? Deletes the account and its home directory. This cannot be undone.`)) return;
await invoke("remove_user", { username });
await refresh();
}
async function addUser() {
if (!newUsername.trim() || !newPassword) {
addStatus = "Username and password are required.";
return;
}
addStatus = "Adding…";
try {
await invoke("add_user", { username: newUsername.trim(), fullName: newFullName.trim(), password: newPassword });
addStatus = "User added.";
newUsername = "";
newFullName = "";
newPassword = "";
await refresh();
} catch (e) {
addStatus = `${e}`;
await refresh();
}
}
</script>
<ViewScaffold title="Users">
<Group title="Accounts" hint="Real login accounts on this machine (system/service accounts aren't shown). Your own account can't be removed from here." wide>
<div class="list">
{#if accounts}
{#each accounts as acc (acc.username)}
<div class="card">
<div class="top">
<span class="name">{acc.username}{acc.full_name ? ` (${acc.full_name})` : ""}</span>
<button class="action" onclick={() => (openPasswordFor = openPasswordFor === acc.username ? null : acc.username)}>
Change password
</button>
<button class="remove" disabled={acc.username === currentUser} onclick={() => removeAccount(acc.username)}>Remove</button>
</div>
{#if openPasswordFor === acc.username}
<div class="pw-row">
<input type="password" bind:value={passwordInput} placeholder="New password" />
<button class="action" onclick={() => applyPassword(acc.username)}>Apply</button>
</div>
{/if}
{#if rowStatus[acc.username]}
<span class="status">{rowStatus[acc.username]}</span>
{/if}
</div>
{/each}
{/if}
</div>
</Group>
<Group title="Add user">
<Row label="Username">
<input type="text" bind:value={newUsername} placeholder="username" />
</Row>
<Row label="Full name">
<input type="text" bind:value={newFullName} placeholder="Full name (optional)" />
</Row>
<Row label="Password">
<input type="password" bind:value={newPassword} placeholder="password" />
</Row>
<button class="add" onclick={addUser}>Add user</button>
{#if addStatus}
<span class="status">{addStatus}</span>
{/if}
</Group>
</ViewScaffold>
<style>
.list {
max-height: 320px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-xs, 4px);
}
.card {
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
display: flex;
flex-direction: column;
gap: 6px;
}
.top {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
}
.name {
flex: 1;
}
.pw-row {
display: flex;
gap: var(--space-sm, 8px);
}
input[type="text"],
input[type="password"] {
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);
}
.pw-row input {
flex: 1;
}
button {
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
.action {
background-color: var(--bg);
color: var(--on-surface);
}
.remove {
background-color: var(--red);
color: var(--on-red);
}
.remove:disabled {
opacity: 0.4;
cursor: default;
}
.add {
background-color: var(--accent);
color: var(--on-accent);
align-self: flex-start;
margin-top: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
}
.status {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
</style>

View file

@ -0,0 +1,56 @@
// Maps a sidebar page id to its view component. Pages not yet migrated
// fall back to Placeholder (see +page.svelte) — this map only lists pages
// that actually have a real Tauri-backed view.
import type { Component } from "svelte";
import About from "./About.svelte";
import Breadclip from "./Breadclip.svelte";
import Bread from "./Bread.svelte";
import Breadbar from "./Breadbar.svelte";
import Breadbox from "./Breadbox.svelte";
import Breadpad from "./Breadpad.svelte";
import Breadpaper from "./Breadpaper.svelte";
import Breadsearch from "./Breadsearch.svelte";
import Breadcrumbs from "./Breadcrumbs.svelte";
import Appearance from "./Appearance.svelte";
import Autostart from "./Autostart.svelte";
import Display from "./Display.svelte";
import Keybinds from "./Keybinds.svelte";
import Sound from "./Sound.svelte";
import DateTime from "./DateTime.svelte";
import Power from "./Power.svelte";
import Network from "./Network.svelte";
import Bluetooth from "./Bluetooth.svelte";
import Firewall from "./Firewall.svelte";
import Users from "./Users.svelte";
import Packages from "./Packages.svelte";
import Aur from "./Aur.svelte";
import Firmware from "./Firmware.svelte";
import Snapshots from "./Snapshots.svelte";
export const VIEWS: Record<string, Component> = {
about: About,
breadclip: Breadclip,
bread: Bread,
breadbar: Breadbar,
breadbox: Breadbox,
breadpad: Breadpad,
breadpaper: Breadpaper,
breadsearch: Breadsearch,
breadcrumbs: Breadcrumbs,
appearance: Appearance,
autostart: Autostart,
hyprland: Display,
keybinds: Keybinds,
sound: Sound,
datetime: DateTime,
power: Power,
network: Network,
bluetooth: Bluetooth,
firewall: Firewall,
users: Users,
packages: Packages,
aur: Aur,
firmware: Firmware,
snapshots: Snapshots,
};

View file

@ -0,0 +1,5 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const ssr = false;

View file

@ -0,0 +1,61 @@
<script lang="ts">
import { onMount } from "svelte";
import Sidebar from "$lib/components/Sidebar.svelte";
import Placeholder from "$lib/components/Placeholder.svelte";
import { DEFAULT_PAGE } from "$lib/sidebar";
import { initTheme } from "$lib/theme";
import { VIEWS } from "$lib/views/registry";
let activePage = $state(DEFAULT_PAGE);
let ActiveView = $derived(VIEWS[activePage]);
onMount(() => {
initTheme();
});
</script>
<div class="shell">
<Sidebar bind:activePage />
<main class="content">
{#if ActiveView}
<ActiveView />
{:else}
<Placeholder page={activePage} />
{/if}
</main>
</div>
<style>
:global(*) {
box-sizing: border-box;
}
:global(html, body) {
margin: 0;
border: none;
outline: none;
height: 100%;
color-scheme: dark;
background-color: var(--bg, #0c0c0c);
color: var(--fg);
font-family: var(--font-family, sans-serif);
font-size: var(--font-size-base, 14px);
}
:global(#svelte) {
height: 100%;
}
.shell {
display: flex;
height: 100vh;
background-color: var(--bg, #0c0c0c);
}
.content {
flex: 1;
min-width: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
</style>