Compare commits

..

No commits in common. "main" and "v0.8.1" have entirely different histories.
main ... v0.8.1

105 changed files with 1409 additions and 3570 deletions

5
.gitignore vendored
View file

@ -32,8 +32,3 @@ logs/
# Claude Code local agent state # Claude Code local agent state
.claude/ .claude/
# .freebuff local tool state (not for commit)
.freebuff/
# graphify knowledge-graph output (local tool cache, not for commit)
graphify-out/

View file

@ -19,7 +19,6 @@
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/node": "^26.4.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"typescript": "~5.6.2", "typescript": "~5.6.2",
@ -1309,16 +1308,6 @@
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "26.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
"integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@ -1848,13 +1837,6 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.3", "version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "bos-settings-frontend", "name": "bos-settings-frontend",
"version": "0.8.2", "version": "0.8.0",
"description": "Frontend for BOS Settings", "description": "Frontend for BOS Settings",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -23,7 +23,6 @@
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/node": "^26.4.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"typescript": "~5.6.2", "typescript": "~5.6.2",

View file

@ -1,10 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { setContext } from "svelte";
import { EMBEDDED_KEY } from "$lib/embed";
let { children }: { children: Snippet } = $props();
setContext(EMBEDDED_KEY, true);
</script>
{@render children()}

View file

@ -16,8 +16,8 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 28px 8px; padding: var(--space-xl, 20px) 0;
color: var(--muted); opacity: 0.7;
text-align: center; text-align: center;
} }

View file

@ -22,35 +22,33 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
background: var(--surface);
border: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent));
border-radius: var(--radius, 14px);
padding: 16px 18px;
} }
.group.wide { .group.wide {
width: 100%; grid-column: 1 / -1;
} }
.title { .title {
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 1.05em;
letter-spacing: 0.04em; margin: 0 0 var(--space-sm, 8px);
text-transform: uppercase;
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
margin: 0 0 12px;
} }
.hint { .hint {
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent)); opacity: 0.75;
font-size: var(--font-size-secondary, 12px); font-size: var(--font-size-secondary, 12px);
line-height: 1.4; line-height: 1.4;
margin: -6px 0 12px; margin: 0 0 var(--space-sm, 8px);
} }
.rows { .rows {
display: flex; display: flex;
flex-direction: column; 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; min-width: 0;
} }

View file

@ -6,9 +6,13 @@
<style> <style>
.hint { .hint {
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent)); opacity: 0.6;
font-size: var(--font-size-secondary, 12px); font-size: var(--font-size-secondary, 12px);
margin: 0; margin: 0 0 var(--space-xs, 4px);
line-height: 1.4; /* 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> </style>

View file

@ -1,51 +0,0 @@
<script lang="ts">
import type { Component } from "svelte";
import ViewScaffold from "./ViewScaffold.svelte";
import Subnav from "./Subnav.svelte";
import Embed from "./Embed.svelte";
import { nav } from "$lib/nav.svelte";
interface Tab {
id: string;
label: string;
component: Component;
}
let { title, lede, tabs }: { title: string; lede: string; tabs: Tab[] } = $props();
function takeTab(): string {
const wanted = nav.tab;
nav.tab = null;
if (wanted && tabs.some((t) => t.id === wanted)) return wanted;
return tabs[0]?.id ?? "";
}
let tab = $state(takeTab());
$effect(() => {
const wanted = nav.tab;
if (wanted === null) return;
if (wanted === "") {
tab = tabs[0]?.id ?? "";
nav.tab = null;
} else if (tabs.some((t) => t.id === wanted)) {
tab = wanted;
nav.tab = null;
}
});
let Active = $derived(tabs.find((t) => t.id === tab)?.component);
</script>
<ViewScaffold {title} {lede}>
{#if tabs.length > 1}
<Subnav items={tabs.map(({ id, label }) => ({ id, label }))} bind:value={tab} />
{/if}
<Embed>
{#key tab}
{#if Active}
<Active />
{/if}
{/key}
</Embed>
</ViewScaffold>

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import Row from "./Row.svelte"; import Row from "./Row.svelte";
// Hyprland's own color format: "rgba(RRGGBBAA)" - a plain hex color input // 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 // has no alpha channel, so this pairs one with an alpha slider and
// recombines them into that exact string on every change. // recombines them into that exact string on every change.
let { label, value = $bindable() }: { label: string; value: string } = $props(); let { label, value = $bindable() }: { label: string; value: string } = $props();

View file

@ -20,8 +20,8 @@
background-color: var(--bg); background-color: var(--bg);
color: var(--on-surface); color: var(--on-surface);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-sm, 10px); border-radius: var(--radius-secondary, 6px);
padding: 6px 10px; padding: var(--space-xs, 4px) var(--space-sm, 8px);
} }
input:focus { input:focus {

View file

@ -1,51 +1,57 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
let { label, hint, children }: { label: string; hint?: string; children: Snippet } = $props(); let { label, children }: { label: string; children: Snippet } = $props();
</script> </script>
<div class="field-row"> <div class="field-row">
<div class="meta">
<span class="label">{label}</span> <span class="label">{label}</span>
{#if hint}<span class="hint">{hint}</span>{/if}
</div>
<div class="control"> <div class="control">
{@render children()} {@render children()}
</div> </div>
</div> </div>
<style> <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 { .field-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: var(--space-lg, 16px);
padding: 11px 2px; 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; min-height: 24px;
} }
:global(.field-row + .field-row) { :global(.field-row + .field-row) {
border-top: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent)); margin-top: calc(var(--space-sm, 8px) * -1);
border-top: 1px solid var(--bg);
border-top-left-radius: 0;
border-top-right-radius: 0;
} }
.meta { :global(.field-row:has(+ .field-row)) {
flex: 1; border-bottom-left-radius: 0;
min-width: 0; border-bottom-right-radius: 0;
margin-bottom: 0;
} }
.label { .label {
display: block; flex: 1;
}
.hint {
display: block;
font-size: 12px;
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
margin-top: 2px;
} }
.control { .control {
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0;
} }
</style> </style>

View file

@ -19,7 +19,7 @@
</script> </script>
<div class="row"> <div class="row">
<button class="btn primary" disabled={saving} onclick={save}>Save</button> <button disabled={saving} onclick={save}>Save</button>
<span class="status">{status}</span> <span class="status">{status}</span>
</div> </div>
@ -28,11 +28,28 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-md, 12px); gap: var(--space-md, 12px);
margin-top: 4px; 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 { .status {
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent)); opacity: 0.6;
font-size: var(--font-size-secondary, 12px); font-size: var(--font-size-secondary, 12px);
} }
</style> </style>

View file

@ -1,25 +1,13 @@
<script lang="ts"> <script lang="ts">
import Row from "./Row.svelte"; import Row from "./Row.svelte";
let { let { label, value = $bindable(), options }: { label: string; value: string; options: string[] } = $props();
label,
hint,
value = $bindable(),
options,
labels = {},
}: {
label: string;
hint?: string;
value: string;
options: string[];
labels?: Record<string, string>;
} = $props();
</script> </script>
<Row {label} {hint}> <Row {label}>
<select bind:value> <select bind:value>
{#each options as opt (opt)} {#each options as opt (opt)}
<option value={opt}>{labels[opt] ?? opt}</option> <option value={opt}>{opt}</option>
{/each} {/each}
</select> </select>
</Row> </Row>
@ -30,8 +18,8 @@
background-color: var(--bg); background-color: var(--bg);
color: var(--on-surface); color: var(--on-surface);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-sm, 10px); border-radius: var(--radius-secondary, 6px);
padding: 6px 10px; padding: var(--space-xs, 4px) var(--space-sm, 8px);
} }
option { option {

View file

@ -30,7 +30,7 @@
function toggle() { function toggle() {
if (active && critical) { if (active && critical) {
if (!confirm(`Stop ${unit}? Other bread apps may stall until it is restarted.`)) { 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; return;
} }
} }
@ -57,7 +57,7 @@
<button onclick={openLogs}>View logs</button> <button onclick={openLogs}>View logs</button>
</div> </div>
{#if hasConfig} {#if hasConfig}
<Hint text="Save writes the file. Restart the service to apply." /> <Hint text="Save (below) only writes the config file — click Restart above for the running service to pick up the change." />
{/if} {/if}
</Group> </Group>
@ -74,16 +74,16 @@
} }
button { button {
background-color: var(--surface-2, var(--bg)); background-color: var(--surface);
color: var(--on-surface); color: var(--on-surface);
border: 1px solid var(--line, transparent); border: none;
border-radius: var(--radius-sm, 10px); border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px); padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer; cursor: pointer;
} }
button:hover { button:hover {
background-color: color-mix(in srgb, var(--on-surface) 10%, transparent); background-color: color-mix(in srgb, var(--on-surface) 14%, transparent);
} }
button:disabled { button:disabled {

View file

@ -1,130 +1,59 @@
<script lang="ts"> <script lang="ts">
import Search from "@lucide/svelte/icons/search";
import { SIDEBAR_SECTIONS } from "$lib/sidebar"; import { SIDEBAR_SECTIONS } from "$lib/sidebar";
import { searchSettings } from "$lib/search";
import { go, nav } from "$lib/nav.svelte";
let query = $state(""); let { activePage = $bindable() }: { activePage: string } = $props();
let hits = $derived(query.trim().length >= 1 ? searchSettings(query, 6) : []);
function jump(page: string, tab?: string) {
query = "";
go(page, tab);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Enter" && hits[0]) {
jump(hits[0].page, hits[0].tab);
}
}
</script> </script>
<nav class="sidebar"> <nav class="sidebar">
<div class="search-wrap">
<Search size={16} />
<input bind:value={query} placeholder="Search settings" onkeydown={onKey} />
</div>
{#if hits.length > 0}
<div class="hits">
{#each hits as hit (`${hit.page}:${hit.tab ?? ""}:${hit.label}`)}
<button type="button" class="hit" onclick={() => jump(hit.page, hit.tab)}>
<span>{hit.label}</span>
</button>
{/each}
</div>
{:else}
<div class="nav">
{#each SIDEBAR_SECTIONS as section (section.title ?? "untitled")} {#each SIDEBAR_SECTIONS as section (section.title ?? "untitled")}
{#if section.title} {#if section.title}
<div class="section-header">{section.title}</div> <div class="section-header">{section.title}</div>
{/if} {/if}
{#each section.items as item (item.id)} {#each section.items as item (item.id)}
<button <button
type="button"
class="row" class="row"
class:selected={nav.page === item.id} class:selected={activePage === item.id}
onclick={() => go(item.id)} onclick={() => (activePage = item.id)}
> >
<item.icon size={16} /> <item.icon size={16} />
<span class="label">{item.label}</span> <span class="label">{item.label}</span>
</button> </button>
{/each} {/each}
{/each} {/each}
</div>
{/if}
</nav> </nav>
<style> <style>
.sidebar { .sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 248px; width: 220px;
flex-shrink: 0; flex-shrink: 0;
min-height: 0; min-height: 0;
background: var(--bg-2, var(--surface)); background-color: var(--surface);
color: var(--on-surface); color: var(--on-surface);
border-right: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent));
overflow: hidden;
padding: 12px 10px 14px;
}
.search-wrap {
position: relative;
margin: 2px 6px 12px;
flex-shrink: 0;
}
.search-wrap :global(svg) {
position: absolute;
left: 11px;
top: 10px;
opacity: 0.45;
pointer-events: none;
}
.search-wrap input {
width: 100%;
background: var(--surface);
border: 1px solid transparent;
border-radius: 10px;
padding: 9px 10px 9px 34px;
color: var(--fg);
}
.search-wrap input:focus {
border-color: color-mix(in oklab, var(--accent) 55%, transparent);
}
.nav,
.hits {
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
display: flex; padding: var(--space-sm, 8px);
flex-direction: column; gap: 1px;
gap: 2px;
padding: 0 4px;
min-height: 0;
} }
.section-header { .section-header {
padding: 14px 10px 6px; padding: var(--space-lg, 16px) var(--space-sm, 8px) var(--space-xs, 4px);
font-size: 10px; font-size: 11px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.14em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
color: var(--faint, color-mix(in oklab, var(--fg) 38%, transparent)); opacity: 0.5;
} }
.section-header:first-child { .section-header:first-child {
padding-top: 4px; padding-top: var(--space-xs, 4px);
} }
.row, .row {
.hit {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
width: 100%; width: 100%;
border: none; border: none;
background: transparent; background: transparent;
@ -132,29 +61,24 @@
font: inherit; font: inherit;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
padding: 8px 10px; padding: 9px var(--space-md, 12px);
border-radius: 10px; border-radius: var(--radius-secondary, 6px);
transition: background-color 0.1s ease;
} }
.row:hover, .row:hover {
.hit:hover { background-color: color-mix(in srgb, var(--on-surface) 8%, transparent);
background: color-mix(in srgb, var(--fg) 6%, transparent);
} }
.row.selected { .row.selected {
background: var(--accent-soft, color-mix(in oklab, var(--accent) 18%, transparent)); background-color: var(--accent);
color: var(--fg); color: var(--on-accent);
font-weight: 500; font-weight: 500;
} }
.row.selected :global(svg) {
color: var(--accent);
}
.label { .label {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 13.5px;
} }
</style> </style>

View file

@ -1,44 +0,0 @@
<script lang="ts">
import Row from "./Row.svelte";
let {
label,
hint,
value = $bindable(),
min,
max,
step = 1,
suffix = "",
onChange,
}: {
label: string;
hint?: string;
value: number;
min: number;
max: number;
step?: number;
suffix?: string;
onChange?: () => void;
} = $props();
let shown = $derived(step < 1 ? Number(value).toFixed(2).replace(/0+$/, "").replace(/\.$/, "") : String(Math.round(value)));
</script>
<Row {label} {hint}>
<input type="range" {min} {max} {step} bind:value oninput={() => onChange?.()} />
<span class="val">{shown}{suffix}</span>
</Row>
<style>
input[type="range"] {
width: 160px;
accent-color: var(--accent);
}
.val {
min-width: 4.5ch;
text-align: right;
font-size: 12px;
color: var(--muted);
}
</style>

View file

@ -1,40 +0,0 @@
<script lang="ts">
let {
items,
value = $bindable(),
}: { items: { id: string; label: string }[]; value: string } = $props();
</script>
<div class="subnav">
{#each items as item (item.id)}
<button type="button" class:on={value === item.id} onclick={() => (value = item.id)}>
{item.label}
</button>
{/each}
</div>
<style>
.subnav {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: -8px 0 4px;
}
button {
padding: 6px 11px;
border-radius: 999px;
font-size: 12px;
background: color-mix(in srgb, var(--fg) 6%, transparent);
border: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent));
color: var(--fg);
cursor: pointer;
}
button.on {
background: var(--accent);
color: var(--on-accent);
border-color: transparent;
font-weight: 600;
}
</style>

View file

@ -11,40 +11,33 @@
aria-label={ariaLabel} aria-label={ariaLabel}
onclick={() => (value = !value)} onclick={() => (value = !value)}
> >
<i></i> <span class="knob"></span>
</button> </button>
<style> <style>
.switch { .switch {
width: 42px; width: 40px;
height: 24px; height: 22px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 999px; border-radius: 999px;
border: none; border: none;
background: color-mix(in srgb, var(--fg) 12%, transparent); background-color: var(--overlay);
padding: 2px; padding: 2px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
transition: background 0.18s ease;
} }
.switch.on { .switch.on {
background: var(--accent); background-color: var(--accent);
justify-content: flex-end;
} }
i { .knob {
width: 20px; width: 18px;
height: 20px; height: 18px;
border-radius: 50%; border-radius: 50%;
background: #efe8e6; background-color: var(--on-surface);
display: block; display: block;
transform: translateX(0);
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.switch.on i {
transform: translateX(18px);
background: var(--on-accent);
} }
</style> </style>

View file

@ -2,13 +2,9 @@
import Row from "./Row.svelte"; import Row from "./Row.svelte";
import Switch from "./Switch.svelte"; import Switch from "./Switch.svelte";
let { let { label, value = $bindable() }: { label: string; value: boolean } = $props();
label,
hint,
value = $bindable(),
}: { label: string; hint?: string; value: boolean } = $props();
</script> </script>
<Row {label} {hint}> <Row {label}>
<Switch bind:value ariaLabel={label} /> <Switch bind:value ariaLabel={label} />
</Row> </Row>

View file

@ -18,8 +18,8 @@
background-color: var(--bg); background-color: var(--bg);
color: var(--on-surface); color: var(--on-surface);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-sm, 10px); border-radius: var(--radius-secondary, 6px);
padding: 6px 10px; padding: var(--space-xs, 4px) var(--space-sm, 8px);
} }
input:focus { input:focus {

View file

@ -1,75 +0,0 @@
<script lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
function win() {
return getCurrentWindow();
}
function close() {
win().close();
}
function minimize() {
win().minimize();
}
function toggleMax() {
win().toggleMaximize();
}
</script>
<div class="titlebar" data-tauri-drag-region>
<div class="dots">
<button class="dot close" aria-label="Close" onclick={close}></button>
<button class="dot min" aria-label="Minimize" onclick={minimize}></button>
<button class="dot max" aria-label="Maximize" onclick={toggleMax}></button>
</div>
<div class="name">Settings</div>
<div class="grow"></div>
<span class="kbd">Ctrl K</span>
</div>
<style>
.titlebar {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 14px;
background: var(--bg-2, var(--bg));
border-bottom: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent));
flex-shrink: 0;
}
.dots {
display: flex;
gap: 7px;
}
.dot {
width: 11px;
height: 11px;
border-radius: 50%;
border: none;
padding: 0;
background: #2a313c;
cursor: pointer;
}
.dot.close:hover {
background: var(--red);
}
.dot.min:hover,
.dot.max:hover {
background: var(--accent);
}
.name {
font-size: 12px;
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
letter-spacing: 0.02em;
pointer-events: none;
}
.grow {
flex: 1;
}
</style>

View file

@ -1,64 +1,40 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { getContext } from "svelte";
import { EMBEDDED_KEY } from "$lib/embed";
let { title, lede, children }: { title: string; lede?: string; children: Snippet } = $props(); let { title, children }: { title: string; children: Snippet } = $props();
const embedded = getContext<boolean>(EMBEDDED_KEY) ?? false;
</script> </script>
{#if embedded}
<div class="embed">
{@render children()}
</div>
{:else}
<div class="view"> <div class="view">
<h1 class="title">{title}</h1> <h1 class="title">{title}</h1>
{#if lede}
<p class="lede">{lede}</p>
{/if}
<div class="content"> <div class="content">
{@render children()} {@render children()}
</div> </div>
</div> </div>
{/if}
<style> <style>
.view { .view {
padding: 28px 36px 56px; padding: var(--space-xl, 20px) var(--space-xl, 20px) 48px;
min-height: 100%; height: 100%;
overflow-y: auto;
} }
.title { .title {
font-size: 28px; font-size: 1.6em;
font-weight: 600; font-weight: bold;
letter-spacing: -0.04em; margin: 0 0 var(--space-xl, 20px);
margin: 0 auto;
max-width: 920px;
width: 100%;
} }
.lede { /* Groups lay out as a responsive grid rather than a single narrow
margin: 6px auto 0; column — on a wide (tiled/maximized) window that means multiple
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent)); independent setting groups sit side by side instead of one column
font-size: 14px; with a wall of empty space either side. `.group.wide` (device
line-height: 1.45; lists, thumbnail grids, log views — anything that reads badly
max-width: 920px; squeezed into a card) opts out via `grid-column: 1 / -1`. */
width: 100%; .content {
} display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
.content, gap: var(--space-xl, 20px) 32px;
.embed { align-items: start;
display: flex; max-width: 1400px;
flex-direction: column;
gap: 18px;
max-width: 920px;
width: 100%;
margin-top: 22px;
margin-inline: auto;
}
.embed {
margin-top: 0;
} }
</style> </style>

View file

@ -1,7 +0,0 @@
export function debounce(fn: () => void, ms = 280): () => void {
let t: ReturnType<typeof setTimeout> | undefined;
return () => {
clearTimeout(t);
t = setTimeout(fn, ms);
};
}

View file

@ -1,2 +0,0 @@
/** When set, ViewScaffold renders inner groups only - used by hub tab panes. */
export const EMBEDDED_KEY = "bos-settings-embedded";

View file

@ -1,17 +0,0 @@
import { DEFAULT_PAGE, resolvePage } from "$lib/sidebar";
/** Shared navigation - module `$state` so sidebar/search/hubs all see the same page. */
export const nav = $state({
page: DEFAULT_PAGE,
tab: null as string | null,
searchNonce: 0,
});
export function go(page: string, tab?: string) {
const resolved = resolvePage(page);
nav.tab = tab ?? resolved.tab ?? "";
nav.page = resolved.page;
}
export type Navigate = (page: string, tab?: string) => void;
export const NAVIGATE_KEY = "bos-settings-navigate";

3
frontend/src/lib/nav.ts Normal file
View file

@ -0,0 +1,3 @@
/** Sidebar page switch. Set from +page.svelte; views call it to jump. */
export type Navigate = (page: string) => void;
export const NAVIGATE_KEY = "bos-settings-navigate";

View file

@ -1,120 +0,0 @@
export interface SearchHit {
page: string;
tab?: string;
label: string;
keywords: string;
}
export const SEARCH_INDEX: SearchHit[] = [
{ page: "home", label: "Home", keywords: "search overview status" },
{
page: "network",
label: "Wi-Fi",
keywords: "wifi ssid password scan radio ethernet hotspot dns",
},
{
page: "network",
tab: "vpn",
label: "VPN / WireGuard",
keywords: "vpn wireguard tailscale tunnel import conf",
},
{
page: "network",
tab: "breadcrumbs",
label: "Saved networks",
keywords: "wifi profiles breadcrumbs known ssid tailscale",
},
{ page: "bluetooth", label: "Bluetooth", keywords: "bluetooth headphones pair scan mouse keyboard" },
{
page: "displays",
label: "Displays",
keywords: "monitor scale resolution arrange rotate vrr hdr hyprland",
},
{
page: "displays",
tab: "nightlight",
label: "Night light",
keywords: "night light hyprsunset temperature warmth sunset",
},
{
page: "displays",
tab: "breadmon",
label: "Live arrange",
keywords: "breadmon mirror profile arrange",
},
{
page: "appearance",
label: "Wallpaper",
keywords: "wallpaper theme palette pywal breadpaper accent",
},
{
page: "appearance",
tab: "appearance",
label: "Windows",
keywords: "gaps blur rounding border shadow tiling dwindle",
},
{ page: "sound", label: "Sound", keywords: "volume mute microphone output input speaker pavucontrol" },
{
page: "power",
label: "Power & battery",
keywords: "battery brightness charge tlp suspend idle lid",
},
{
page: "input",
label: "Keyboard & mouse",
keywords: "keybind shortcut layout touchpad tap natural scroll follow mouse",
},
{ page: "input", tab: "ime", label: "Input method", keywords: "fcitx5 ime cjk chinese japanese korean" },
{
page: "input",
tab: "accessibility",
label: "Accessibility",
keywords: "orca screen reader zoom magnifier sticky keys",
},
{
page: "desktop",
label: "Bar",
keywords: "breadbar modules clock tray workspaces",
},
{ page: "desktop", tab: "breadbox", label: "Launcher", keywords: "breadbox launcher apps" },
{ page: "desktop", tab: "breadlock", label: "Lock screen", keywords: "lock greet breadlock idle" },
{ page: "desktop", tab: "breadshot", label: "Screenshots", keywords: "screenshot grim slurp breadshot" },
{ page: "desktop", tab: "autostart", label: "Startup apps", keywords: "autostart login startup" },
{ page: "desktop", tab: "breadclip", label: "Clipboard", keywords: "clipboard history breadclip" },
{ page: "desktop", tab: "breadpad", label: "Notes", keywords: "breadpad notes calendar caldav" },
{ page: "desktop", tab: "breadsearch", label: "File search", keywords: "breadsearch breadmill index" },
{ page: "desktop", tab: "bread", label: "Daemon", keywords: "breadd daemon adapters lua" },
{ page: "apps", label: "Default apps", keywords: "browser terminal pdf mime default" },
{ page: "apps", tab: "optional", label: "Optional software", keywords: "steam flatpak libreoffice" },
{ page: "apps", tab: "printing", label: "Printing", keywords: "cups printer" },
{ page: "privacy", label: "Firewall", keywords: "firewall ufw port allow" },
{ page: "privacy", tab: "users", label: "Users", keywords: "users password account" },
{ page: "system", label: "Updates", keywords: "update bakery pacman firmware nvidia" },
{ page: "system", tab: "packages", label: "Packages", keywords: "packages bakery install" },
{ page: "system", tab: "aur", label: "AUR", keywords: "aur yay" },
{ page: "system", tab: "snapshots", label: "Snapshots", keywords: "snapper snapshot rollback grub" },
{ page: "system", tab: "backup", label: "Backup", keywords: "restic backup restore" },
{ page: "system", tab: "channel", label: "Bakery channel", keywords: "track stable beta dev bakery" },
{ page: "about", label: "About", keywords: "hostname kernel cpu gpu about machine" },
{ page: "about", tab: "datetime", label: "Date & time", keywords: "timezone ntp clock date time" },
{ page: "desktop", tab: "breadhelp", label: "Help", keywords: "help breadhelp onboarding" },
];
export function searchSettings(query: string, limit = 8): SearchHit[] {
const q = query.trim().toLowerCase();
if (q.length < 1) return [];
const scored = SEARCH_INDEX.map((hit) => {
const hay = `${hit.label} ${hit.keywords} ${hit.page}`.toLowerCase();
let score = 0;
if (hit.label.toLowerCase().startsWith(q)) score += 8;
if (hit.label.toLowerCase().includes(q)) score += 4;
if (hay.includes(q)) score += 2;
for (const part of q.split(/\s+/)) {
if (hay.includes(part)) score += 1;
}
return { hit, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score);
return scored.slice(0, limit).map((x) => x.hit);
}

View file

@ -1,112 +1,117 @@
// 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 type { Component } from "svelte";
import House from "@lucide/svelte/icons/house";
import Wifi from "@lucide/svelte/icons/wifi"; import Wifi from "@lucide/svelte/icons/wifi";
import Network from "@lucide/svelte/icons/network";
import Bluetooth from "@lucide/svelte/icons/bluetooth"; import Bluetooth from "@lucide/svelte/icons/bluetooth";
import Monitor from "@lucide/svelte/icons/monitor"; import Shield from "@lucide/svelte/icons/shield";
import Volume2 from "@lucide/svelte/icons/volume-2"; import Volume2 from "@lucide/svelte/icons/volume-2";
import BatteryFull from "@lucide/svelte/icons/battery-full"; import BatteryFull from "@lucide/svelte/icons/battery-full";
import Palette from "@lucide/svelte/icons/palette"; import Clock from "@lucide/svelte/icons/clock";
import AppWindow from "@lucide/svelte/icons/app-window"; import Monitor from "@lucide/svelte/icons/monitor";
import Keyboard from "@lucide/svelte/icons/keyboard"; 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 LayoutGrid from "@lucide/svelte/icons/layout-grid";
import Shield from "@lucide/svelte/icons/shield"; import Grid3x3 from "@lucide/svelte/icons/grid-3x3";
import Download from "@lucide/svelte/icons/download"; 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"; import Info from "@lucide/svelte/icons/info";
import Lock from "@lucide/svelte/icons/lock";
import Camera from "@lucide/svelte/icons/camera";
import AppWindow from "@lucide/svelte/icons/app-window";
import CircleHelp from "@lucide/svelte/icons/circle-help";
import Download from "@lucide/svelte/icons/download";
import Printer from "@lucide/svelte/icons/printer";
import ShieldEllipsis from "@lucide/svelte/icons/shield-ellipsis";
import Moon from "@lucide/svelte/icons/moon";
import Languages from "@lucide/svelte/icons/languages";
import Accessibility from "@lucide/svelte/icons/accessibility";
import AppWindowMac from "@lucide/svelte/icons/app-window-mac";
import GitBranch from "@lucide/svelte/icons/git-branch";
import Archive from "@lucide/svelte/icons/archive";
import Boxes from "@lucide/svelte/icons/boxes";
export interface SidebarItem { export interface SidebarItem {
/** Must match a key in the view component map (see routing in +page.svelte). */
id: string; id: string;
label: 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; icon: Component;
} }
export const SYSTEM_ITEMS: SidebarItem[] = [
{ id: "network", label: "Network", icon: Wifi },
{ id: "breadcrumbs", label: "Wi-Fi Profiles", sublabel: "breadcrumbs", icon: Network },
{ id: "vpn", label: "VPN / WireGuard", sublabel: "NetworkManager", icon: ShieldEllipsis },
{ id: "bluetooth", label: "Bluetooth", icon: Bluetooth },
{ id: "printing", label: "Printing", sublabel: "CUPS", icon: Printer },
{ id: "firewall", label: "Firewall", icon: Shield },
{ id: "sound", label: "Sound", icon: Volume2 },
{ id: "power", label: "Power", icon: BatteryFull },
{ id: "datetime", label: "Date & Time", icon: Clock },
{ id: "hyprland", label: "Display", sublabel: "monitors.json", icon: Monitor },
{ id: "nightlight", label: "Night light", sublabel: "hyprsunset", icon: Moon },
{ id: "breadmon", label: "Monitors", sublabel: "breadmon", icon: AppWindow },
{ id: "breadlock", label: "Lock & greet", sublabel: "breadlock", icon: Lock },
{ id: "keybinds", label: "Keybinds", sublabel: "binds.json", icon: Keyboard },
{ id: "ime", label: "Input method", sublabel: "fcitx5", icon: Languages },
{ id: "accessibility", label: "Accessibility", icon: Accessibility },
{ id: "breadshot", label: "Screenshots", sublabel: "breadshot", icon: Camera },
{ id: "autostart", label: "Startup Apps", sublabel: "autostart.json", icon: Rocket },
{ id: "users", label: "Users", icon: Users },
];
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: "defaults", label: "Default apps", sublabel: "mimeapps.list", icon: AppWindowMac },
{ id: "bread", label: "Daemon", sublabel: "breadd", icon: Cog },
];
export const MAINTENANCE_ITEMS: SidebarItem[] = [
{ id: "updates", label: "Updates", icon: Download },
{ id: "packages", label: "Packages", icon: Package },
{ id: "aur", label: "AUR", icon: Search },
{ id: "firmware", label: "Firmware", icon: RefreshCw },
{ id: "snapshots", label: "Snapshots", icon: History },
{ id: "channel", label: "Bakery channel", sublabel: "track", icon: GitBranch },
{ id: "backup", label: "Backup", sublabel: "restic", icon: Archive },
{ id: "optional", label: "Optional software", icon: Boxes },
];
export const ABOUT_ITEMS: SidebarItem[] = [
{ id: "breadhelp", label: "Help", sublabel: "breadhelp", icon: CircleHelp },
{ id: "about", label: "About", icon: Info },
];
export interface SidebarSection { export interface SidebarSection {
title: string | null; title: string | null;
items: SidebarItem[]; items: SidebarItem[];
} }
export const SIDEBAR_SECTIONS: SidebarSection[] = [ export const SIDEBAR_SECTIONS: SidebarSection[] = [
{ title: "Overview", items: [{ id: "home", label: "Home", icon: House }] }, { title: "System", items: SYSTEM_ITEMS },
{ { title: "Personalization", items: PERSONALIZATION_ITEMS },
title: "Devices", { title: "Maintenance", items: MAINTENANCE_ITEMS },
items: [ { title: null, items: ABOUT_ITEMS },
{ id: "network", label: "Wi-Fi & internet", icon: Wifi },
{ id: "bluetooth", label: "Bluetooth", icon: Bluetooth },
{ id: "displays", label: "Displays", icon: Monitor },
{ id: "sound", label: "Sound", icon: Volume2 },
{ id: "power", label: "Power & battery", icon: BatteryFull },
],
},
{
title: "Desktop",
items: [
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "desktop", label: "Bar & apps", icon: AppWindow },
{ id: "input", label: "Keyboard & mouse", icon: Keyboard },
{ id: "apps", label: "Default apps", icon: LayoutGrid },
],
},
{
title: "System",
items: [
{ id: "privacy", label: "Privacy & users", icon: Shield },
{ id: "system", label: "Updates & backup", icon: Download },
{ id: "about", label: "About", icon: Info },
],
},
]; ];
/** Old sidebar ids (screenshot CLI, in-app jumps) → hub page + optional tab. */ export const DEFAULT_PAGE = "about";
export const PAGE_ALIASES: Record<string, { page: string; tab?: string }> = {
home: { page: "home" },
network: { page: "network" },
vpn: { page: "network", tab: "vpn" },
breadcrumbs: { page: "network", tab: "breadcrumbs" },
bluetooth: { page: "bluetooth" },
displays: { page: "displays" },
hyprland: { page: "displays", tab: "hyprland" },
nightlight: { page: "displays", tab: "nightlight" },
breadmon: { page: "displays", tab: "breadmon" },
sound: { page: "sound" },
power: { page: "power" },
appearance: { page: "appearance" },
breadpaper: { page: "appearance", tab: "breadpaper" },
desktop: { page: "desktop" },
breadbar: { page: "desktop", tab: "breadbar" },
breadbox: { page: "desktop", tab: "breadbox" },
breadlock: { page: "desktop", tab: "breadlock" },
breadshot: { page: "desktop", tab: "breadshot" },
autostart: { page: "desktop", tab: "autostart" },
breadclip: { page: "desktop", tab: "more" },
breadpad: { page: "desktop", tab: "more" },
breadsearch: { page: "desktop", tab: "more" },
bread: { page: "desktop", tab: "more" },
breadhelp: { page: "desktop", tab: "more" },
more: { page: "desktop", tab: "more" },
input: { page: "input" },
keybinds: { page: "input", tab: "keybinds" },
ime: { page: "input", tab: "ime" },
accessibility: { page: "input", tab: "accessibility" },
apps: { page: "apps" },
defaults: { page: "apps" },
optional: { page: "apps", tab: "optional" },
printing: { page: "apps", tab: "printing" },
privacy: { page: "privacy" },
firewall: { page: "privacy" },
users: { page: "privacy", tab: "users" },
system: { page: "system" },
updates: { page: "system" },
packages: { page: "system", tab: "packages" },
aur: { page: "system", tab: "aur" },
firmware: { page: "system", tab: "firmware" },
snapshots: { page: "system", tab: "snapshots" },
backup: { page: "system", tab: "backup" },
channel: { page: "system", tab: "channel" },
about: { page: "about" },
datetime: { page: "about", tab: "datetime" },
};
export const DEFAULT_PAGE = "home";
export function resolvePage(id: string): { page: string; tab?: string } {
return PAGE_ALIASES[id] ?? { page: id };
}

View file

@ -1,5 +1,5 @@
// Frontend half of the event-streaming command pattern (see // Frontend half of the event-streaming command pattern (see
// src/src/commands/streaming.rs) - listens for `cmd-output` lines from a // src/src/commands/streaming.rs) listens for `cmd-output` lines from a
// typed Tauri command that runs a hardcoded program, then resolves once // typed Tauri command that runs a hardcoded program, then resolves once
// the process exits. // the process exits.

View file

@ -1,121 +0,0 @@
:root {
--bg-2: color-mix(in oklab, var(--bg) 82%, var(--surface));
--surface-2: color-mix(in oklab, var(--surface) 72%, var(--overlay));
--muted: color-mix(in oklab, var(--fg) 58%, transparent);
--faint: color-mix(in oklab, var(--fg) 38%, transparent);
--line: color-mix(in srgb, var(--fg) 8%, transparent);
--accent-soft: color-mix(in oklab, var(--accent) 18%, transparent);
--radius: 14px;
--radius-sm: 10px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
border: none;
outline: none;
height: 100%;
color-scheme: dark;
background-color: var(--bg, #12161c);
color: var(--fg);
font-family: var(--font-family, sans-serif);
font-size: var(--font-size-base, 14px);
}
#svelte {
height: 100%;
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid color-mix(in oklab, var(--accent) 70%, white);
outline-offset: 2px;
}
input[type="range"] {
accent-color: var(--accent);
}
.btn {
background: var(--surface-2, var(--surface));
border: 1px solid var(--line);
padding: 8px 14px;
border-radius: var(--radius-sm, 10px);
font-size: 13px;
cursor: pointer;
color: var(--fg);
}
.btn:hover {
background: color-mix(in oklab, var(--surface-2, var(--surface)) 80%, var(--fg));
}
.btn.primary {
background: var(--accent);
color: var(--on-accent);
border-color: transparent;
font-weight: 600;
}
.btn.primary:hover {
filter: brightness(1.06);
}
.btn.danger {
color: var(--red);
}
.btn:disabled {
opacity: 0.5;
cursor: default;
}
.kbd {
font-size: 11px;
letter-spacing: 0.04em;
background: color-mix(in srgb, var(--fg) 6%, transparent);
border: 1px solid var(--line);
padding: 2px 7px;
border-radius: 6px;
color: var(--muted);
}
.pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 6px 11px;
border-radius: 999px;
font-size: 12px;
background: color-mix(in srgb, var(--fg) 6%, transparent);
border: 1px solid var(--line);
color: var(--fg);
cursor: pointer;
}
.pill.on {
background: var(--accent);
color: var(--on-accent);
border-color: transparent;
font-weight: 600;
}

View file

@ -1,6 +1,6 @@
// Bridges bread-theme's pywal-derived palette into the webview. Mirrors // Bridges bread-theme's pywal-derived palette into the webview. Mirrors
// bread_theme::gtk::apply_shared()'s two-phase pattern: fetch once at // bread_theme::gtk::apply_shared()'s two-phase pattern: fetch once at
// startup, then keep it live via a backend-pushed event - see // startup, then keep it live via a backend-pushed event see
// src-tauri/src/commands/theme.rs for the file-watch side of this. // src-tauri/src/commands/theme.rs for the file-watch side of this.
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";

View file

@ -24,7 +24,7 @@
// The backend reports the GPU as a raw lspci device string, e.g. // 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)". // "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 // That's too technical for a general settings page, so extract just the
// marketing model name. Heuristic (not exhaustive - just needs to read // marketing model name. Heuristic (not exhaustive just needs to read
// well for common vendors): drop the trailing "(rev ..)", prefer the // well for common vendors): drop the trailing "(rev ..)", prefer the
// text inside the last [...] bracket group (usually the model name), // text inside the last [...] bracket group (usually the model name),
// collapse "840M / 860M" style multi-model lists to the last variant, // collapse "840M / 860M" style multi-model lists to the last variant,

View file

@ -1,14 +0,0 @@
<script lang="ts">
import Hub from "$lib/components/Hub.svelte";
import About from "./About.svelte";
import DateTime from "./DateTime.svelte";
</script>
<Hub
title="About"
lede="This machine and the clock."
tabs={[
{ id: "about", label: "Machine", component: About },
{ id: "datetime", label: "Date & time", component: DateTime },
]}
/>

View file

@ -94,7 +94,7 @@
{/if} {/if}
</Group> </Group>
<Group title="Magnifier" hint="Zooms around the cursor. This session only."> <Group title="Magnifier" hint="Hyprlands real magnifier is cursor:zoom_factor — pointer-centered zoom. Applies to this session.">
{#if st} {#if st}
<NumberField label="Zoom factor" bind:value={zoom} min={1} max={8} step={0.25} /> <NumberField label="Zoom factor" bind:value={zoom} min={1} max={8} step={0.25} />
<button class="primary" onclick={applyZoom}>Apply zoom</button> <button class="primary" onclick={applyZoom}>Apply zoom</button>

View file

@ -5,12 +5,13 @@
import Group from "$lib/components/Group.svelte"; import Group from "$lib/components/Group.svelte";
import SwitchField from "$lib/components/SwitchField.svelte"; import SwitchField from "$lib/components/SwitchField.svelte";
import SelectField from "$lib/components/SelectField.svelte"; import SelectField from "$lib/components/SelectField.svelte";
import SliderField from "$lib/components/SliderField.svelte"; import NumberField from "$lib/components/NumberField.svelte";
import HyprColorField from "$lib/components/HyprColorField.svelte"; import HyprColorField from "$lib/components/HyprColorField.svelte";
import Row from "$lib/components/Row.svelte";
import Hint from "$lib/components/Hint.svelte"; import Hint from "$lib/components/Hint.svelte";
import { debounce } from "$lib/debounce"; import SaveButton from "$lib/components/SaveButton.svelte";
// Common XKB layout codes. Not exhaustive - if the config holds // Common XKB layout codes. Not exhaustive if the config holds
// something else, it's merged in below so it's never dropped from the // something else, it's merged in below so it's never dropped from the
// dropdown. // dropdown.
const COMMON_KB_LAYOUTS = [ const COMMON_KB_LAYOUTS = [
@ -24,51 +25,13 @@
// window under the cursor; 2 = focus follows the cursor, but clicking a // window under the cursor; 2 = focus follows the cursor, but clicking a
// window keeps keyboard focus there until the mouse moves again; 3 = // window keeps keyboard focus there until the mouse moves again; 3 =
// cursor and keyboard focus are fully independent. // cursor and keyboard focus are fully independent.
const FOLLOW_MOUSE_MODES: { value: string; label: string }[] = [ const FOLLOW_MOUSE_MODES: { value: number; label: string }[] = [
{ value: "0", label: "Click to focus" }, { value: 0, label: "Off — click to focus" },
{ value: "1", label: "Follow mouse" }, { value: 1, label: "Follow mouse" },
{ value: "2", label: "Follow, keep focus on click" }, { value: 2, label: "Follow mouse, detach on click" },
{ value: "3", label: "Mouse and keys independent" }, { value: 3, label: "Fully detached from keyboard focus" },
]; ];
const KB_LABELS: Record<string, string> = {
us: "English (US)",
gb: "English (UK)",
de: "German",
fr: "French",
es: "Spanish",
it: "Italian",
pt: "Portuguese",
nl: "Dutch",
se: "Swedish",
no: "Norwegian",
dk: "Danish",
fi: "Finnish",
pl: "Polish",
cz: "Czech",
sk: "Slovak",
hu: "Hungarian",
ro: "Romanian",
gr: "Greek",
tr: "Turkish",
ru: "Russian",
ua: "Ukrainian",
jp: "Japanese",
kr: "Korean",
cn: "Chinese",
br: "Portuguese (Brazil)",
ca: "English (Canada)",
ch: "German (Switzerland)",
be: "Belgian",
at: "German (Austria)",
ie: "English (Ireland)",
};
const LAYOUT_LABELS: Record<string, string> = {
dwindle: "Tiling",
master: "Master stack",
};
interface Appearance { interface Appearance {
gaps_in: number; gaps_in: number;
gaps_out: number; gaps_out: number;
@ -90,73 +53,76 @@
} }
let cfg = $state<Appearance | null>(null); let cfg = $state<Appearance | null>(null);
let loaded = $state(false);
let followMouse = $state("1");
let kbLayoutOptions = $derived( let kbLayoutOptions = $derived(
cfg && !COMMON_KB_LAYOUTS.includes(cfg.kb_layout) ? [...COMMON_KB_LAYOUTS, cfg.kb_layout] : COMMON_KB_LAYOUTS, cfg && !COMMON_KB_LAYOUTS.includes(cfg.kb_layout) ? [...COMMON_KB_LAYOUTS, cfg.kb_layout] : COMMON_KB_LAYOUTS,
); );
const persist = debounce(() => {
if (!cfg) return;
invoke("save_appearance", { appearance: { ...cfg, follow_mouse: Number(followMouse) } });
}, 450);
onMount(async () => { onMount(async () => {
cfg = await invoke<Appearance>("get_appearance"); cfg = await invoke<Appearance>("get_appearance");
followMouse = String(cfg.follow_mouse);
loaded = true;
}); });
let primed = false; async function save() {
$effect(() => { await invoke("save_appearance", { appearance: cfg });
if (!loaded || !cfg) return;
JSON.stringify(cfg);
void followMouse;
if (!primed) {
primed = true;
return;
} }
persist();
});
</script> </script>
<ViewScaffold title="Appearance"> <ViewScaffold title="Appearance">
{#if cfg} {#if cfg}
<Group title="Windows"> <Group title="Windows & borders" hint="Gaps, borders, and tiling — the same settings.json Hyprland reads at login.">
<SliderField label="Gaps between windows" bind:value={cfg.gaps_in} min={0} max={50} /> <NumberField label="Gaps between windows" bind:value={cfg.gaps_in} min={0} max={50} />
<SliderField label="Gaps around the edge" bind:value={cfg.gaps_out} min={0} max={50} /> <NumberField label="Gaps around screen edge" bind:value={cfg.gaps_out} min={0} max={50} />
<SliderField label="Border width" bind:value={cfg.border_size} min={0} max={10} /> <NumberField label="Border width" bind:value={cfg.border_size} min={0} max={10} />
<HyprColorField label="Active border" bind:value={cfg.active_border} /> <HyprColorField label="Active border color" bind:value={cfg.active_border} />
<HyprColorField label="Inactive border" bind:value={cfg.inactive_border} /> <HyprColorField label="Inactive border color" bind:value={cfg.inactive_border} />
<SelectField label="Layout" bind:value={cfg.layout} options={["dwindle", "master"]} labels={LAYOUT_LABELS} /> <SelectField label="Tiling layout" bind:value={cfg.layout} options={["dwindle", "master"]} />
<SwitchField label="Resize by dragging the border" bind:value={cfg.resize_on_border} /> <SwitchField label="Resize by dragging borders" bind:value={cfg.resize_on_border} />
</Group> </Group>
<Group title="Effects"> <Group title="Effects">
<SliderField label="Corner rounding" bind:value={cfg.rounding} min={0} max={30} /> <NumberField label="Corner rounding" bind:value={cfg.rounding} min={0} max={30} />
<SwitchField label="Blur" bind:value={cfg.blur_enabled} /> <SwitchField label="Blur" bind:value={cfg.blur_enabled} />
{#if cfg.blur_enabled} <NumberField label="Blur size" bind:value={cfg.blur_size} min={0} max={20} />
<SliderField 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} />
<SliderField label="Blur quality" bind:value={cfg.blur_passes} min={1} max={5} /> <SwitchField label="Window shadows" bind:value={cfg.shadow_enabled} />
{/if} <NumberField label="Shadow range" bind:value={cfg.shadow_range} min={0} max={40} />
<SwitchField label="Shadows" bind:value={cfg.shadow_enabled} /> <NumberField label="Shadow render power" bind:value={cfg.shadow_render_power} min={1} max={4} />
{#if cfg.shadow_enabled}
<SliderField label="Shadow size" bind:value={cfg.shadow_range} min={0} max={40} />
{/if}
</Group> </Group>
<Group title="Keyboard and mouse"> <Group title="Input">
<SelectField label="Keyboard layout" bind:value={cfg.kb_layout} options={kbLayoutOptions} labels={KB_LABELS} /> <SelectField label="Keyboard layout" bind:value={cfg.kb_layout} options={kbLayoutOptions} />
<SelectField <Row label="Focus-follows-mouse mode">
label="Focus" <select bind:value={cfg.follow_mouse}>
bind:value={followMouse} {#each FOLLOW_MOUSE_MODES as mode (mode.value)}
options={FOLLOW_MOUSE_MODES.map((m) => m.value)} <option value={mode.value}>{mode.label}</option>
labels={Object.fromEntries(FOLLOW_MOUSE_MODES.map((m) => [m.value, m.label]))} {/each}
/> </select>
<SwitchField label="Natural scrolling" hint="Touchpad" bind:value={cfg.natural_scroll} /> </Row>
<SwitchField label="Natural scrolling (touchpad)" bind:value={cfg.natural_scroll} />
</Group> </Group>
<Hint text="Applies as you change it." /> <Hint text="Changes apply on next login or Hyprland reload — this saves settings.json, it doesn't reload Hyprland live." />
<SaveButton onSave={save} />
{/if} {/if}
</ViewScaffold> </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

@ -1,14 +0,0 @@
<script lang="ts">
import Hub from "$lib/components/Hub.svelte";
import Breadpaper from "./Breadpaper.svelte";
import Appearance from "./Appearance.svelte";
</script>
<Hub
title="Appearance"
lede="Wallpaper sets the colors. Windows follow."
tabs={[
{ id: "breadpaper", label: "Wallpaper", component: Breadpaper },
{ id: "appearance", label: "Windows", component: Appearance },
]}
/>

View file

@ -1,16 +0,0 @@
<script lang="ts">
import Hub from "$lib/components/Hub.svelte";
import Defaults from "./Defaults.svelte";
import Optional from "./Optional.svelte";
import Printing from "./Printing.svelte";
</script>
<Hub
title="Default apps"
lede="Browser, files, terminal, and extras."
tabs={[
{ id: "defaults", label: "Defaults", component: Defaults },
{ id: "optional", label: "Optional", component: Optional },
{ id: "printing", label: "Printing", component: Printing },
]}
/>

View file

@ -34,7 +34,7 @@
<ViewScaffold title="AUR"> <ViewScaffold title="AUR">
<Group <Group
title="Search" title="Search"
hint="Opens a terminal. Review the PKGBUILD before you confirm." 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 wide
> >
<div class="search-row"> <div class="search-row">

View file

@ -41,7 +41,7 @@
{#if entries} {#if entries}
<Group <Group
title="Extra autostart apps" title="Extra autostart apps"
hint="Extra apps after login. Bar and clipboard always start." hint="What launches after login, beyond the core desktop (bar, theme, clipboard, etc. always start regardless). Toggle off, edit, or add your own."
wide wide
> >
{#each entries as entry, i (i)} {#each entries as entry, i (i)}

View file

@ -84,7 +84,7 @@
log = [...log, line]; log = [...log, line];
}); });
busy = false; busy = false;
if (!ok) message = "Failed. See the log."; if (!ok) message = "Command failed — see the log.";
} }
async function listSnaps() { async function listSnaps() {
@ -122,7 +122,7 @@
<ViewScaffold title="Backup"> <ViewScaffold title="Backup">
<Group <Group
title="Repository" title="Repository"
hint="Local folder or sftp. Password is write-only." hint="restic to a local directory or sftp:user@host:/path. Path and password are stored in ~/.config/bos-settings/backup.toml (mode 0600). The password field is write-only."
wide wide
> >
{#if !st} {#if !st}
@ -140,7 +140,7 @@
<Group <Group
title="Actions" title="Actions"
hint="Home directory. Snapshots page is the root filesystem." hint="This backs up your @home life — documents, configs, the stuff snapper does not. Snapshots on the Snapshots page are root (@) only. Skips caches, Trash, Steam, containers, cargo/rustup, Flatpak, node_modules, target, and .git."
> >
<div class="btn-row"> <div class="btn-row">
<button disabled={busy} onclick={() => run("restic_init")}>Init repo</button> <button disabled={busy} onclick={() => run("restic_init")}>Init repo</button>

View file

@ -75,10 +75,7 @@
} }
</script> </script>
<ViewScaffold <ViewScaffold title="Bluetooth">
title="Bluetooth"
lede="Paired devices and nearby scan."
>
{#if powered === null} {#if powered === null}
<EmptyState <EmptyState
icon={BluetoothOff} icon={BluetoothOff}
@ -100,7 +97,7 @@
{#each paired as dev (dev.address)} {#each paired as dev (dev.address)}
<div class="row"> <div class="row">
<span class="name" class:active={dev.connected}>{dev.name}{dev.connected ? " (connected)" : ""}</span> <span class="name" class:active={dev.connected}>{dev.name}{dev.connected ? " (connected)" : ""}</span>
<button class="btn" onclick={() => toggleConnect(dev)}>{dev.connected ? "Disconnect" : "Connect"}</button> <button class="action" onclick={() => toggleConnect(dev)}>{dev.connected ? "Disconnect" : "Connect"}</button>
<button class="remove" onclick={() => forget(dev)}>Forget</button> <button class="remove" onclick={() => forget(dev)}>Forget</button>
</div> </div>
{/each} {/each}
@ -110,7 +107,7 @@
<Group <Group
title="Available devices" title="Available devices"
hint="PIN pairing is not supported." 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"> <div class="list">
{#if scanResults === null} {#if scanResults === null}
@ -147,13 +144,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-sm, 8px); gap: var(--space-sm, 8px);
padding: 10px 2px; background-color: var(--surface);
border-top: 1px solid var(--line); border-radius: var(--radius-secondary, 6px);
} padding: var(--space-sm, 8px) var(--space-md, 12px);
.row:first-child {
border-top: none;
padding-top: 0;
} }
.name { .name {

View file

@ -34,7 +34,7 @@
let cfg = $state<BreadConfig | null>(null); let cfg = $state<BreadConfig | null>(null);
// The daemon's 4 compiled-in modules plus every *.lua file actually // The daemon's 4 compiled-in modules plus every *.lua file actually
// sitting in the configured module directory - real installed modules, // sitting in the configured module directory real installed modules,
// not a guess, so picking one to disable is a click. // not a guess, so picking one to disable is a click.
let knownModules = $state<string[]>([]); let knownModules = $state<string[]>([]);

View file

@ -3,10 +3,10 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte"; import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte"; import Group from "$lib/components/Group.svelte";
import Row from "$lib/components/Row.svelte";
import TextField from "$lib/components/TextField.svelte"; import TextField from "$lib/components/TextField.svelte";
import SliderField from "$lib/components/SliderField.svelte"; import NumberField from "$lib/components/NumberField.svelte";
import Hint from "$lib/components/Hint.svelte"; import SaveButton from "$lib/components/SaveButton.svelte";
import { debounce } from "$lib/debounce";
import ChevronDown from "@lucide/svelte/icons/chevron-down"; import ChevronDown from "@lucide/svelte/icons/chevron-down";
import ChevronRight from "@lucide/svelte/icons/chevron-right"; import ChevronRight from "@lucide/svelte/icons/chevron-right";
@ -23,33 +23,20 @@
} }
let style = $state<BreadbarStyle | null>(null); let style = $state<BreadbarStyle | null>(null);
let loaded = $state(false);
let advancedOpen = $state(false); let advancedOpen = $state(false);
let css = $state(""); let css = $state("");
let cssStatus = $state(""); let cssStatus = $state("");
let cssSaving = $state(false); let cssSaving = $state(false);
const persist = debounce(() => {
if (!style) return;
invoke("save_breadbar_style", { style }).then(() => invoke<string>("get_breadbar_css").then((c) => (css = c)));
}, 320);
onMount(async () => { onMount(async () => {
style = await invoke<BreadbarStyle>("get_breadbar_style"); style = await invoke<BreadbarStyle>("get_breadbar_style");
css = await invoke<string>("get_breadbar_css"); css = await invoke<string>("get_breadbar_css");
loaded = true;
}); });
let primed = false; async function saveStyle() {
$effect(() => { await invoke("save_breadbar_style", { style });
if (!loaded || !style) return; css = await invoke<string>("get_breadbar_css");
JSON.stringify(style);
if (!primed) {
primed = true;
return;
} }
persist();
});
async function saveCss() { async function saveCss() {
cssSaving = true; cssSaving = true;
@ -68,37 +55,36 @@
<ViewScaffold title="Bar"> <ViewScaffold title="Bar">
{#if style} {#if style}
<Group title="Text" hint="Clock, workspace numbers, and stats."> <Group title="Text" hint="Applies to the clock, workspace numbers, and stat labels.">
<TextField label="Font" bind:value={style.font_family} placeholder="Varela Round" /> <TextField label="Font" bind:value={style.font_family} placeholder="Varela Round" />
<SliderField label="Font size" bind:value={style.font_size} min={8} max={32} /> <NumberField label="Font size" bind:value={style.font_size} min={8} max={32} />
</Group> </Group>
<Group title="Bar shape"> <Group title="Bar shape">
<SliderField label="Corner rounding" bind:value={style.bar_border_radius} min={0} max={40} /> <NumberField label="Corner rounding" bind:value={style.bar_border_radius} min={0} max={40} />
<SliderField label="Inner padding" bind:value={style.bar_padding} min={0} max={40} /> <NumberField label="Inner padding" bind:value={style.bar_padding} min={0} max={40} />
</Group> </Group>
<Group title="Workspaces"> <Group title="Workspace indicator">
<SliderField label="Number size" bind:value={style.workspace_font_size} min={8} max={40} /> <NumberField label="Size" bind:value={style.workspace_font_size} min={8} max={40} />
<SliderField <Row label="Inactive dimness">
label="Inactive dimness" <div class="opacity-row">
bind:value={style.workspace_inactive_opacity} <input type="range" min="0" max="1" step="0.05" bind:value={style.workspace_inactive_opacity} />
min={0} <span class="pct">{Math.round(style.workspace_inactive_opacity * 100)}%</span>
max={1} </div>
step={0.05} </Row>
/>
</Group> </Group>
<Group title="Spacing and icons"> <Group title="Spacing & icons">
<SliderField label="Gap between stats" bind:value={style.stat_gap} min={0} max={40} /> <NumberField label="Gap between stats" bind:value={style.stat_gap} min={0} max={40} />
<SliderField label="Tray icon size" bind:value={style.tray_icon_size} min={8} max={32} /> <NumberField label="Tray icon size" bind:value={style.tray_icon_size} min={8} max={32} />
<SliderField label="Notification rounding" bind:value={style.notification_border_radius} min={0} max={24} /> <NumberField label="Notification corner rounding" bind:value={style.notification_border_radius} min={0} max={24} />
</Group> </Group>
<Hint text="Applies as you change it." /> <SaveButton onSave={saveStyle} />
{/if} {/if}
<Group title="Advanced" hint="Raw CSS. Same file as the fields above." wide> <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)}> <button class="toggle" onclick={() => (advancedOpen = !advancedOpen)}>
{#if advancedOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if} {#if advancedOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
Edit raw CSS Edit raw CSS
@ -115,6 +101,22 @@
</ViewScaffold> </ViewScaffold>
<style> <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 { .toggle {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -45,7 +45,7 @@
{#if contexts} {#if contexts}
<Group <Group
title="Contexts" title="Contexts"
hint="Apps and categories shown first." hint="Launcher contexts — each lists, in priority order, the apps/categories surfaced first."
wide wide
> >
{#if contexts.length === 0} {#if contexts.length === 0}

View file

@ -12,7 +12,7 @@
<ViewScaffold title="Clipboard"> <ViewScaffold title="Clipboard">
<Group <Group
title="Clipboard history" title="Clipboard history"
hint="History popup. Super+V." 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> <button class="open-btn" onclick={openHistory}>Open history (SUPER+V)</button>
</Group> </Group>

View file

@ -44,11 +44,11 @@
let cfg = $state<BreadcrumbsConfig | null>(null); let cfg = $state<BreadcrumbsConfig | null>(null);
// Profile names are free-text (set via "Add profile" below), not a fixed // Profile names are free-text (set via "Add profile" below), not a fixed
// enum - populate the default-profile options from whatever profiles // enum populate the default-profile options from whatever profiles
// actually exist, rather than a hardcoded guess that can't match a // actually exist, rather than a hardcoded guess that can't match a
// user's real profile names. // user's real profile names.
let profileNames = $derived(cfg?.profiles.map((p) => p.name) ?? []); let profileNames = $derived(cfg?.profiles.map((p) => p.name) ?? []);
// Saved network SSIDs feed the per-profile network pickers below - no // Saved network SSIDs feed the per-profile network pickers below no
// typing an SSID by hand and hoping it matches one saved above. // 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) ?? []); let savedSsids = $derived(cfg?.networks.map((n) => n.ssid).filter((s) => s.trim().length > 0) ?? []);
@ -112,7 +112,7 @@
<Group <Group
title="Saved networks" title="Saved networks"
hint="Leave password blank to keep the saved one." hint="Password is write-only — leave it blank to keep an already-saved secret or let NetworkManager remember it after the first connect. Breadcrumbs never writes a PSK back into breadcrumbs.toml; new passwords go only to networks.toml (0600) and are cleared there after the first successful connect."
wide wide
> >
<div class="list"> <div class="list">

View file

@ -47,15 +47,15 @@
<ViewScaffold title="Help"> <ViewScaffold title="Help">
<Group <Group
title="BOS Help" title="BOS Help"
hint="Opens the help app." hint="breadhelp is the onboarding and help center — searchable guides, a keybind cheatsheet, a troubleshoot wizard, and a live tour. This panel just launches it; it doesn't copy the help app into Settings."
> >
<button class="primary" onclick={() => invoke("open_breadhelp")}>Open breadhelp</button> <button class="primary" onclick={() => invoke("open_breadhelp")}>Open breadhelp</button>
</Group> </Group>
<Group title="Show on login"> <Group title="First-run autostart" hint="The extra autostart entry in hypr/autostart.json (breadhelp --autostart). Core desktop launch is separate and always runs.">
<SwitchField label="Show help on login" bind:value={() => autostartOn, (v) => setAutostart(v)} /> <SwitchField label="Show help on login" bind:value={() => autostartOn, (v) => setAutostart(v)} />
{#if !helpEntry} {#if !helpEntry}
<Hint text="Turning this on adds the first-run command." /> <Hint text="No breadhelp entry in autostart.json yet — turning this on adds the default first-run command." />
{/if} {/if}
{#if status} {#if status}
<Hint text={status} /> <Hint text={status} />

View file

@ -33,23 +33,28 @@
} }
</script> </script>
<ViewScaffold title="Lock"> <ViewScaffold title="Lock & greet">
<Group <Group
title="How locking works" title="How locking works"
hint="Login and PAM are packaged. This only styles the lock screen." hint="This panel does not configure PAM. Authentication is the breadlock PAM service (/etc/pam.d/breadlock) plus greetd for login — both are packaged, not user-editable from Settings."
> >
<Hint text="Super+L locks. Login screen is packaged separately." /> <Hint
text="Super+L runs loginctl lock-session. hypridle's lock_cmd / idle listener then starts breadlock, which owns the already-running Hyprland session via ext-session-lock-v1."
/>
<Hint
text="breadgreet is the graphical greetd greeter (replacing tuigreet). Its live config is typically /etc/greetd/breadgreet.toml, owned by the greeter user — not written from here."
/>
<button class="primary" onclick={() => invoke("lock_session")}>Lock now</button> <button class="primary" onclick={() => invoke("lock_session")}>Lock now</button>
</Group> </Group>
{#if cfg} {#if cfg}
<Group title="Lock screen"> <Group title="Lock screen" hint="~/.config/breadlock/breadlock.toml — every field is optional; breadlock runs with these defaults if the file is missing.">
<SelectField label="Background" bind:value={cfg.background_mode} options={["color", "image"]} /> <SelectField label="Background" bind:value={cfg.background_mode} options={["color", "image"]} />
{#if cfg.background_mode === "image"} {#if cfg.background_mode === "image"}
<FileField label="Image" bind:value={cfg.background_path} placeholder="PNG, cover-fit" extensions={["png"]} /> <FileField label="Image" bind:value={cfg.background_path} placeholder="PNG, cover-fit" extensions={["png"]} />
{/if} {/if}
<SwitchField label="Blur background" bind:value={cfg.background_blur} /> <SwitchField label="Blur background" bind:value={cfg.background_blur} />
<Hint text="Blur is saved but not drawn yet." /> <Hint text="Blur is accepted in the file but not implemented yet (needs a wlr-screencopy capture). breadlock logs a warning and shows the background unblurred." />
<TextField label="Clock format" bind:value={cfg.clock_format} placeholder="%H:%M" /> <TextField label="Clock format" bind:value={cfg.clock_format} placeholder="%H:%M" />
<TextField label="Font" bind:value={cfg.font_family} placeholder="Varela Round" /> <TextField label="Font" bind:value={cfg.font_family} placeholder="Varela Round" />
<NumberField label="Wrong-password timeout (ms)" bind:value={cfg.fail_timeout_ms} min={0} max={10000} /> <NumberField label="Wrong-password timeout (ms)" bind:value={cfg.fail_timeout_ms} min={0} max={10000} />

View file

@ -8,9 +8,14 @@
<ViewScaffold title="Monitors"> <ViewScaffold title="Monitors">
<Group <Group
title="Two different jobs" title="Two different jobs"
hint="Live arrange, mirror, and named profiles." hint="This is not a second Display editor. Display (monitors.json) is the login-time layout Hyprland itself reads. breadmon is a TUI for live arrange / mirror / named profiles."
> >
<Hint text="Layout tab is the login config. This opens the live TUI." /> <Hint
text="Use Display when you want the persistent Hyprland rule set (output / mode / position / scale) that applies on next login or hyprctl reload."
/>
<Hint
text="Use breadmon when you want to drag monitors around live, pick a common mirror mode, or save/load a profile under ~/.config/breadmon/profiles/. Settings never writes those profiles."
/>
<button class="primary" onclick={() => invoke("open_breadmon")}>Open breadmon</button> <button class="primary" onclick={() => invoke("open_breadmon")}>Open breadmon</button>
</Group> </Group>
</ViewScaffold> </ViewScaffold>

View file

@ -83,7 +83,7 @@
<NumberField label="Archive after (days)" bind:value={cfg.archive_after_days} min={0} max={3650} /> <NumberField label="Archive after (days)" bind:value={cfg.archive_after_days} min={0} max={3650} />
</Group> </Group>
<Group title="Classifier model" hint="Local model. No network."> <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="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"]} /> <FileField label="Tokenizer" bind:value={cfg.tokenizer_path} placeholder="~/.local/share/breadpad/model/tokenizer.json" extensions={["json"]} />
</Group> </Group>

View file

@ -23,12 +23,6 @@
onMount(async () => { onMount(async () => {
libraryDir = await invoke<string>("wallpaper_library_dir_display"); libraryDir = await invoke<string>("wallpaper_library_dir_display");
await refreshCurrent(); await refreshCurrent();
scanning = true;
try {
library = await invoke<LibraryEntry[]>("list_wallpaper_library");
} finally {
scanning = false;
}
}); });
async function apply(path: string) { async function apply(path: string) {
@ -36,7 +30,7 @@
try { try {
await invoke("set_wallpaper", { path }); await invoke("set_wallpaper", { path });
await refreshCurrent(); await refreshCurrent();
status = "Set"; status = "Wallpaper set";
} catch (e) { } catch (e) {
status = `${e}`; status = `${e}`;
} finally { } finally {
@ -49,40 +43,51 @@
title: "Choose a wallpaper", title: "Choose a wallpaper",
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "bmp"] }], filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "bmp"] }],
}); });
if (typeof path === "string") await apply(path); if (typeof path === "string") {
await apply(path);
}
}
async function browseLibrary() {
scanning = true;
library = await invoke<LibraryEntry[]>("list_wallpaper_library");
scanning = false;
} }
</script> </script>
<ViewScaffold title="Wallpaper"> <ViewScaffold title="Wallpaper">
<Group title="Wallpaper" hint="This also updates desktop colors."> <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} {#if currentPath}
<div class="hero" style="background-image: url('{convertFileSrc(currentPath)}')"> <img class="preview" src={convertFileSrc(currentPath)} alt="Current wallpaper" />
<span class="cap">{currentPath.split("/").pop()}</span> <span class="path">{currentPath.split("/").pop()}</span>
</div>
{:else} {:else}
<div class="hero placeholder">No wallpaper</div> <div class="preview placeholder">No wallpaper set</div>
{/if} {/if}
</div>
<div class="btn-row"> <div class="btn-row">
<button class="btn primary" onclick={chooseImage}>Choose image</button> <button class="choose" onclick={chooseImage}>Choose image</button>
<span class="status">{status}</span> <span class="status">{status}</span>
</div> </div>
</Group> </Group>
<Group title="Library" wide> <Group title="Library" wide>
{#if scanning && library === null} {#if library === null}
<div class="empty">Loading {libraryDir}</div> <button class="browse" disabled={scanning} onclick={browseLibrary}>
{:else if library && library.length === 0} {scanning ? "Scanning…" : `Browse ${libraryDir}`}
<div class="empty">Nothing in {libraryDir}</div> </button>
{:else if library} {:else if library.length === 0}
<div class="empty">Nothing under {libraryDir} — use Choose image… above instead.</div>
{:else}
<div class="grid"> <div class="grid">
{#each library as item (item.path)} {#each library as item (item.path)}
<button <button class="thumb" onclick={() => apply(item.path)} title={item.path}>
class="thumb"
class:on={item.path === currentPath}
onclick={() => apply(item.path)}
title={item.path}
>
<img src={convertFileSrc(item.path)} alt={item.name} loading="lazy" /> <img src={convertFileSrc(item.path)} alt={item.name} loading="lazy" />
<span class="name">{item.name}</span>
</button> </button>
{/each} {/each}
</div> </div>
@ -91,74 +96,105 @@
</ViewScaffold> </ViewScaffold>
<style> <style>
.hero { .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; height: 180px;
border-radius: 12px; object-fit: cover;
background-size: cover; border-radius: var(--radius-secondary, 6px);
background-position: center;
position: relative;
overflow: hidden;
} }
.hero.placeholder { .preview.placeholder {
display: grid; display: flex;
place-items: center; align-items: center;
background: var(--bg); justify-content: center;
color: var(--muted); opacity: 0.5;
} }
.cap { .path {
position: absolute; display: block;
left: 12px; margin-top: var(--space-xs, 4px);
bottom: 10px; opacity: 0.6;
font-size: 12px; font-size: var(--font-size-secondary, 12px);
color: #fffc;
background: #0006;
padding: 4px 8px;
border-radius: 6px;
} }
.btn-row { .btn-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: var(--space-md, 12px);
margin-top: 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 { .status {
color: var(--muted); opacity: 0.6;
font-size: 12px; font-size: var(--font-size-secondary, 12px);
} }
.empty { .empty {
color: var(--muted); opacity: 0.6;
font-size: 12px; font-size: var(--font-size-secondary, 12px);
} }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px; gap: var(--space-sm, 8px);
} }
.thumb { .thumb {
padding: 0;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: transparent; background: transparent;
aspect-ratio: 16/10; border: none;
} cursor: pointer;
display: flex;
.thumb.on { flex-direction: column;
border-color: var(--accent); gap: 4px;
padding: 0;
} }
.thumb img { .thumb img {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 88px;
object-fit: cover; 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> </style>

View file

@ -48,7 +48,7 @@
{#if cfg} {#if cfg}
<Group <Group
title="Power" title="Power"
hint="Indexing is heavy. Pause it on battery if you want." 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="Enabled" bind:value={cfg.power_enabled} />
<SwitchField label="Index while on battery" bind:value={cfg.run_on_battery} /> <SwitchField label="Index while on battery" bind:value={cfg.run_on_battery} />
@ -56,7 +56,7 @@
<Group <Group
title="Model" title="Model"
hint="Hardware used for indexing." hint="What hardware does the indexing. Check journalctl --user -u breadmill after restarting to confirm it registered."
> >
<Row label="Search acceleration"> <Row label="Search acceleration">
<select bind:value={cfg.backend}> <select bind:value={cfg.backend}>

View file

@ -39,7 +39,7 @@
<ViewScaffold title="Screenshots"> <ViewScaffold title="Screenshots">
<Group <Group
title="Keybinds" title="Keybinds"
hint="Change shortcuts under Keyboard." hint="Read-only from binds.json. Super+Shift+S/C/P is the BOS default (region→file / region→clipboard / screen→file). Change them on the Keybinds panel."
> >
{#if binds && binds.length > 0} {#if binds && binds.length > 0}
{#each binds as b (`${b.shortcut}:${b.command}`)} {#each binds as b (`${b.shortcut}:${b.command}`)}
@ -52,7 +52,7 @@
</Group> </Group>
{#if cfg} {#if cfg}
<Group title="Capture"> <Group title="breadshot" hint="~/.config/breadshot/config.toml — grim + slurp + wl-copy, Hyprland-aware. All keys optional.">
<FileField label="Save directory" bind:value={cfg.save_dir} placeholder="~/Pictures/Screenshots" mode="folder" /> <FileField label="Save directory" bind:value={cfg.save_dir} placeholder="~/Pictures/Screenshots" mode="folder" />
<SwitchField label="Silent (no notifications)" bind:value={cfg.silent} /> <SwitchField label="Silent (no notifications)" bind:value={cfg.silent} />
<SwitchField label="Freeze screen during select" bind:value={cfg.freeze} /> <SwitchField label="Freeze screen during select" bind:value={cfg.freeze} />

View file

@ -56,7 +56,7 @@
<ViewScaffold title="Bakery channel"> <ViewScaffold title="Bakery channel">
<Group <Group
title="Track" title="Track"
hint="Nothing downloads until you update." hint="bakery track show / bakery track set. This only changes the preference — nothing is downloaded until you update."
> >
{#if !track} {#if !track}
<Hint text="Loading…" /> <Hint text="Loading…" />

View file

@ -4,7 +4,7 @@
import ViewScaffold from "$lib/components/ViewScaffold.svelte"; import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte"; import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte"; import Hint from "$lib/components/Hint.svelte";
import Row from "$lib/components/Row.svelte"; import SaveButton from "$lib/components/SaveButton.svelte";
interface DesktopApp { interface DesktopApp {
id: string; id: string;
@ -34,23 +34,25 @@
async function save() { async function save() {
if (!st) return; if (!st) return;
await invoke("save_default_apps", { input: { current: st.current } }); await invoke("save_default_apps", { input: { current: st.current } });
st = await invoke<DefaultsStatus>("get_default_apps");
} }
</script> </script>
<ViewScaffold title="Default apps"> <ViewScaffold title="Default apps">
<Group title="Apps" wide> <Group title="MIME associations" hint={st ? `Read and written at ${st.path}. Terminal also writes ~/.config/xdg-terminals.list.` : "Loading…"} wide>
{#if st} {#if st}
{#each CATEGORIES as cat (cat.id)} {#each CATEGORIES as cat (cat.id)}
<Row label={cat.label}> <div class="field-row">
<select bind:value={st.current[cat.id]} onchange={save}> <span class="label">{cat.label}</span>
<option value="">Not set</option> <select bind:value={st.current[cat.id]}>
<option value=""></option>
{#each st.options[cat.id] ?? [] as app (app.id)} {#each st.options[cat.id] ?? [] as app (app.id)}
<option value={app.id}>{app.name}</option> <option value={app.id}>{app.name}</option>
{/each} {/each}
</select> </select>
</Row> </div>
{/each} {/each}
<Hint text="Applies as you change it." /> <SaveButton onSave={save} />
{:else} {:else}
<Hint text="Loading…" /> <Hint text="Loading…" />
{/if} {/if}
@ -58,18 +60,45 @@
</ViewScaffold> </ViewScaffold>
<style> <style>
.field-row {
display: flex;
align-items: center;
gap: var(--space-lg, 16px);
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px) var(--space-lg, 16px);
margin-bottom: var(--space-sm, 8px);
}
:global(.field-row + .field-row) {
margin-top: calc(var(--space-sm, 8px) * -1);
border-top: 1px solid var(--bg);
border-top-left-radius: 0;
border-top-right-radius: 0;
}
:global(.field-row:has(+ .field-row)) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin-bottom: 0;
}
.label {
flex: 1;
}
select { select {
color-scheme: dark; color-scheme: dark;
background: var(--bg); background-color: var(--bg);
color: var(--on-surface); color: var(--on-surface);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 10px; border-radius: var(--radius-secondary, 6px);
padding: 6px 10px; padding: var(--space-xs, 4px) var(--space-sm, 8px);
min-width: 22ch; max-width: 28ch;
max-width: 36ch;
} }
select:focus { select:focus {
outline: none;
border-color: var(--accent); border-color: var(--accent);
} }
</style> </style>

View file

@ -1,22 +0,0 @@
<script lang="ts">
import Hub from "$lib/components/Hub.svelte";
import Breadbar from "./Breadbar.svelte";
import Breadbox from "./Breadbox.svelte";
import Breadlock from "./Breadlock.svelte";
import Breadshot from "./Breadshot.svelte";
import Autostart from "./Autostart.svelte";
import DesktopMore from "./DesktopMore.svelte";
</script>
<Hub
title="Bar & apps"
lede="Bar, lock screen, screenshots, and startup."
tabs={[
{ id: "breadbar", label: "Bar", component: Breadbar },
{ id: "breadlock", label: "Lock", component: Breadlock },
{ id: "breadshot", label: "Screenshots", component: Breadshot },
{ id: "autostart", label: "Startup", component: Autostart },
{ id: "breadbox", label: "Launcher", component: Breadbox },
{ id: "more", label: "More", component: DesktopMore },
]}
/>

View file

@ -1,29 +0,0 @@
<script lang="ts">
import Subnav from "$lib/components/Subnav.svelte";
import Embed from "$lib/components/Embed.svelte";
import Breadclip from "./Breadclip.svelte";
import Breadpad from "./Breadpad.svelte";
import Breadsearch from "./Breadsearch.svelte";
import Bread from "./Bread.svelte";
import Breadhelp from "./Breadhelp.svelte";
const tabs = [
{ id: "breadclip", label: "Clipboard", component: Breadclip },
{ id: "breadpad", label: "Notes", component: Breadpad },
{ id: "breadsearch", label: "Search", component: Breadsearch },
{ id: "bread", label: "Daemon", component: Bread },
{ id: "breadhelp", label: "Help", component: Breadhelp },
];
let tab = $state(tabs[0].id);
let Active = $derived(tabs.find((t) => t.id === tab)?.component);
</script>
<Subnav items={tabs.map(({ id, label }) => ({ id, label }))} bind:value={tab} />
<Embed>
{#key tab}
{#if Active}
<Active />
{/if}
{/key}
</Embed>

View file

@ -2,369 +2,151 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte"; import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import InfoRow from "$lib/components/InfoRow.svelte";
import Group from "$lib/components/Group.svelte"; import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte"; import Hint from "$lib/components/Hint.svelte";
import Row from "$lib/components/Row.svelte"; import SaveButton from "$lib/components/SaveButton.svelte";
interface LiveMonitor { interface LiveMonitor {
name: string; name: string;
mode: string; mode: string;
x: number; }
y: number; interface MonitorRule {
width: number; output: string;
height: number; mode: string;
refresh: number; position: string;
scale: number; scale: string;
transform: number;
available_modes: string[];
} }
const SCALES = [ let liveMonitors = $state<LiveMonitor[] | null>(null);
{ label: "100%", value: 1 }, let rules = $state<MonitorRule[] | null>(null);
{ label: "125%", value: 1.25 },
{ label: "150%", value: 1.5 },
{ label: "200%", value: 2 },
];
const TURNS = [ onMount(async () => {
{ label: "Landscape", value: 0 }, liveMonitors = await invoke<LiveMonitor[]>("get_live_monitors");
{ label: "Right", value: 1 }, rules = await invoke<MonitorRule[]>("get_monitor_rules");
{ label: "Upside down", value: 2 },
{ label: "Left", value: 3 },
];
const PAD = 28;
const STAGE_H = 300;
let monitors = $state<LiveMonitor[]>([]);
let selected = $state<string | null>(null);
let stageEl: HTMLDivElement | undefined = $state();
let stageW = $state(640);
let message = $state("");
let applying = $state(false);
let live = $derived(monitors.find((m) => m.name === selected) ?? null);
let view = $derived.by(() => {
if (monitors.length === 0) return { minX: 0, minY: 0, scale: 0.1 };
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const m of monitors) {
const w = boxW(m);
const h = boxH(m);
minX = Math.min(minX, m.x);
minY = Math.min(minY, m.y);
maxX = Math.max(maxX, m.x + w);
maxY = Math.max(maxY, m.y + h);
}
const bw = Math.max(maxX - minX, 1);
const bh = Math.max(maxY - minY, 1);
const scale = Math.min((stageW - PAD * 2) / bw, (STAGE_H - PAD * 2) / bh);
return { minX, minY, scale };
}); });
type Drag = { i: number; grabX: number; grabY: number; pointer: number }; function addRule() {
let drag = $state<Drag | null>(null); rules = [...(rules ?? []), { output: "", mode: "preferred", position: "auto", scale: "auto" }];
function rotated(m: LiveMonitor): boolean {
return m.transform === 1 || m.transform === 3 || m.transform === 5 || m.transform === 7;
}
function boxW(m: LiveMonitor): number {
return rotated(m) ? m.height : m.width;
}
function boxH(m: LiveMonitor): number {
return rotated(m) ? m.width : m.height;
} }
function screenX(m: LiveMonitor): number { function removeRule(i: number) {
return PAD + (m.x - view.minX) * view.scale; rules = rules!.filter((_, idx) => idx !== i);
} if (rules.length === 0) {
function screenY(m: LiveMonitor): number { rules = [{ output: "", mode: "preferred", position: "auto", scale: "auto" }];
return PAD + (m.y - view.minY) * view.scale;
}
function screenW(m: LiveMonitor): number {
return Math.max(48, boxW(m) * view.scale);
}
function screenH(m: LiveMonitor): number {
return Math.max(32, boxH(m) * view.scale);
}
function nearestScale(v: number): number {
return SCALES.reduce((best, s) => (Math.abs(s.value - v) < Math.abs(best - v) ? s.value : best), 1);
}
async function refresh() {
monitors = await invoke<LiveMonitor[]>("get_live_monitors");
if (!selected || !monitors.some((m) => m.name === selected)) {
selected = monitors[0]?.name ?? null;
} }
} }
onMount(refresh); async function save() {
await invoke("save_monitor_rules", { rules });
$effect(() => {
const el = stageEl;
if (!el) return;
stageW = el.clientWidth;
const ro = new ResizeObserver(() => {
stageW = el.clientWidth;
});
ro.observe(el);
return () => ro.disconnect();
});
function snap(idx: number, x: number, y: number): { x: number; y: number } {
const moving = monitors[idx];
if (!moving) return { x, y };
const mw = boxW(moving);
const mh = boxH(moving);
const thresh = Math.max(16, 18 / Math.max(view.scale, 0.01));
let bestX = x;
let bestY = y;
let bestXd = thresh + 1;
let bestYd = thresh + 1;
for (let i = 0; i < monitors.length; i++) {
if (i === idx) continue;
const o = monitors[i];
const ow = boxW(o);
const oh = boxH(o);
const xs = [x - o.x, x - (o.x + ow), x + mw - o.x, x + mw - (o.x + ow)];
for (const d of xs) {
const ad = Math.abs(d);
if (ad < bestXd) {
bestXd = ad;
bestX = x - d;
}
}
const ys = [y - o.y, y - (o.y + oh), y + mh - o.y, y + mh - (o.y + oh)];
for (const d of ys) {
const ad = Math.abs(d);
if (ad < bestYd) {
bestYd = ad;
bestY = y - d;
}
}
}
return { x: Math.round(bestX), y: Math.round(bestY) };
}
function onDown(e: PointerEvent, i: number) {
const m = monitors[i];
if (!m || !stageEl) return;
selected = m.name;
const rect = stageEl.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
drag = { i, grabX: px - screenX(m), grabY: py - screenY(m), pointer: e.pointerId };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
e.preventDefault();
}
function onMove(e: PointerEvent) {
if (!drag || !stageEl) return;
const rect = stageEl.getBoundingClientRect();
const px = e.clientX - rect.left - drag.grabX;
const py = e.clientY - rect.top - drag.grabY;
const wx = view.minX + (px - PAD) / view.scale;
const wy = view.minY + (py - PAD) / view.scale;
const snapped = snap(drag.i, wx, wy);
monitors[drag.i].x = snapped.x;
monitors[drag.i].y = snapped.y;
monitors = [...monitors];
}
async function onUp(e: PointerEvent) {
if (!drag) return;
if (e.pointerId !== drag.pointer && e.type !== "pointerleave") return;
drag = null;
await apply();
}
async function apply() {
if (monitors.length === 0) return;
applying = true;
message = "";
try {
await invoke("apply_monitor_layout", { monitors });
await refresh();
} catch (err) {
message = `${err}`;
} finally {
applying = false;
}
}
async function setScale(value: number) {
if (!live) return;
live.scale = value;
monitors = [...monitors];
await apply();
}
async function setTransform(value: number) {
if (!live) return;
live.transform = value;
monitors = [...monitors];
await apply();
}
async function setMode(modeStr: string) {
if (!live) return;
const cleaned = modeStr.replace(/Hz$/i, "");
const [res, hz] = cleaned.split("@");
const [w, h] = (res ?? "").split("x");
const width = Number(w);
const height = Number(h);
const refresh = Number(hz);
if (!width || !height) return;
live.width = width;
live.height = height;
if (refresh) live.refresh = refresh;
live.mode = `${width}x${height} @ ${Math.round(live.refresh)}Hz`;
monitors = [...monitors];
await apply();
} }
</script> </script>
<ViewScaffold title="Displays" lede="Drag to arrange. Edges snap. Changes apply now."> <ViewScaffold title="Display">
<Group title="Arrangement" wide> <Group title="Connected monitors">
{#if monitors.length === 0} {#if liveMonitors && liveMonitors.length > 0}
<Hint text="No monitors detected." /> {#each liveMonitors as m (m.name)}
{:else} <InfoRow label={m.name} value={m.mode} />
<div
class="mon-stage"
role="application"
aria-label="Monitor arrangement canvas. Drag to reorder monitors."
bind:this={stageEl}
onpointermove={onMove}
onpointerup={onUp}
onpointercancel={onUp}
>
{#each monitors as m, i (m.name)}
<button
type="button"
class="mon"
class:sel={selected === m.name}
class:dragging={drag?.i === i}
style="left:{screenX(m)}px; top:{screenY(m)}px; width:{screenW(m)}px; height:{screenH(m)}px;"
onpointerdown={(e) => onDown(e, i)}
>
<div class="screen"></div>
<div class="chin">{m.name}</div>
</button>
{/each} {/each}
</div> {:else}
<Hint text={applying ? "Applying…" : "Drag a panel. It sticks when you let go."} /> <Hint text="No monitors detected (is Hyprland running?)" />
{/if} {/if}
</Group> </Group>
{#if live} <Group title="Advanced">
<Group title={live.name}> <button class="secondary" onclick={() => invoke("open_hyprland_conf")}>Open hyprland.lua in editor</button>
<Row label="Resolution"> <button class="secondary" onclick={() => invoke("open_keybinds_viewer")}>View keybinds (breadhelp)</button>
{#if live.available_modes.length > 0} </Group>
<select value={`${live.width}x${live.height}@${live.refresh.toFixed(2)}Hz`} onchange={(e) => setMode(e.currentTarget.value)}>
{#each live.available_modes as mode (mode)} {#if rules}
<option value={mode}>{mode}</option> <Group
{/each} title="Layout"
</select> hint="One row per monitor rule. Leave Output blank to match any monitor (the default — works on any hardware). Applies on next login/reload."
{:else} wide
<span class="mode">{live.mode}</span> >
{/if} {#each rules as rule, i (i)}
</Row> <div class="rule-row">
<Row label="Scale"> <label>Output <input type="text" bind:value={rule.output} placeholder="any (blank = all)" class="w-output" /></label>
<div class="pills"> <label>Mode <input type="text" bind:value={rule.mode} placeholder="preferred / 1920x1080@60" class="w-mode" /></label>
{#each SCALES as s (s.value)} <label>Position <input type="text" bind:value={rule.position} placeholder="auto / 0x0" class="w-position" /></label>
<button type="button" class="pill" class:on={nearestScale(live.scale) === s.value} onclick={() => setScale(s.value)}> <label>Scale <input type="text" bind:value={rule.scale} placeholder="auto / 1" class="w-scale" /></label>
{s.label} <button class="remove" onclick={() => removeRule(i)}>Remove</button>
</button>
{/each}
</div> </div>
</Row>
<Row label="Rotation">
<div class="pills">
{#each TURNS as t (t.value)}
<button type="button" class="pill" class:on={live.transform % 4 === t.value} onclick={() => setTransform(t.value)}>
{t.label}
</button>
{/each} {/each}
</div> <button class="add" onclick={addRule}>Add monitor rule</button>
</Row>
<Row label="Position"> <SaveButton onSave={save} />
<span class="mode">{live.x}, {live.y}</span>
</Row>
{#if message}
<Hint text={message} />
{/if}
</Group> </Group>
{/if} {/if}
</ViewScaffold> </ViewScaffold>
<style> <style>
.mon-stage { .rule-row {
position: relative;
height: 300px;
background: var(--bg);
border-radius: 12px;
overflow: hidden;
touch-action: none;
user-select: none;
}
.mon {
position: absolute;
border-radius: 10px;
background: var(--surface-2, var(--surface));
border: 2px solid color-mix(in srgb, var(--fg) 10%, transparent);
box-shadow: 0 12px 28px #0005;
display: flex; display: flex;
flex-direction: column; align-items: center;
cursor: grab; gap: var(--space-sm, 8px);
padding: 0; margin-bottom: var(--space-xs, 4px);
color: inherit; flex-wrap: wrap;
overflow: hidden;
} }
.mon.sel { .rule-row label {
border-color: var(--accent); display: flex;
z-index: 2; align-items: center;
gap: 6px;
font-size: var(--font-size-secondary, 12px);
} }
.mon.dragging { .w-output {
cursor: grabbing; width: 12ch;
z-index: 3; }
.w-mode {
width: 18ch;
}
.w-position {
width: 10ch;
}
.w-scale {
width: 8ch;
} }
.screen { input[type="text"] {
flex: 1; background-color: var(--bg);
margin: 6px 6px 0;
border-radius: 5px;
background: linear-gradient(160deg, var(--surface) 10%, var(--accent) 140%);
pointer-events: none;
}
.chin {
height: 22px;
display: grid;
place-items: center;
font-size: 11px;
color: var(--muted);
pointer-events: none;
}
.mode {
color: var(--muted);
font-size: 13px;
}
select {
color-scheme: dark;
background: var(--bg);
color: var(--on-surface); color: var(--on-surface);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 10px; border-radius: var(--radius-secondary, 6px);
padding: 6px 10px; padding: var(--space-xs, 4px) var(--space-sm, 8px);
max-width: 28ch; }
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> </style>

View file

@ -1,16 +0,0 @@
<script lang="ts">
import Hub from "$lib/components/Hub.svelte";
import Display from "./Display.svelte";
import NightLight from "./NightLight.svelte";
import Breadmon from "./Breadmon.svelte";
</script>
<Hub
title="Displays"
lede="Drag to arrange. Scale and rotate apply now."
tabs={[
{ id: "hyprland", label: "Layout", component: Display },
{ id: "nightlight", label: "Night light", component: NightLight },
{ id: "breadmon", label: "Live arrange", component: Breadmon },
]}
/>

View file

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte"; import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Row from "$lib/components/Row.svelte"; import Row from "$lib/components/Row.svelte";
@ -23,28 +22,12 @@
let newRule = $state(""); let newRule = $state("");
let log = $state<string[]>([]); let log = $state<string[]>([]);
onMount(refresh);
function friendlyError(raw: string): string {
const s = raw.toLowerCase();
if (
s.includes("polkit") ||
s.includes("pkexec") ||
s.includes("password") ||
s.includes("authentication") ||
s.includes("controlling terminal")
) {
return "Need your password to read firewall rules.";
}
return raw;
}
async function refresh() { async function refresh() {
try { try {
status = await invoke<FirewallStatus>("get_firewall_status"); status = await invoke<FirewallStatus>("get_firewall_status");
enabledSensitive = true; enabledSensitive = true;
} catch (e) { } catch (e) {
status = { error: friendlyError(`${e}`) }; status = { error: `${e}` };
} }
} }
@ -83,7 +66,10 @@
</script> </script>
<ViewScaffold title="Firewall"> <ViewScaffold title="Firewall">
<Group title="Firewall" hint="Needs your password."> <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"> <Row label="Firewall enabled">
<button <button
class="switch" class="switch"
@ -109,7 +95,7 @@
<Group title="Rules" wide> <Group title="Rules" wide>
<div class="list"> <div class="list">
{#if status === "unloaded"} {#if status === "unloaded"}
<EmptyState icon={Shield} title="Loading…" hint="May ask for your password." /> <EmptyState icon={Shield} title="Status not loaded" hint="Click Refresh below to check the firewall's current state." />
{:else if "error" in status} {:else if "error" in status}
<EmptyState icon={ShieldAlert} title="Couldn't read firewall status" hint={status.error} /> <EmptyState icon={ShieldAlert} title="Couldn't read firewall status" hint={status.error} />
{:else if status.rules.length === 0} {:else if status.rules.length === 0}

View file

@ -48,7 +48,7 @@
<ViewScaffold title="Firmware"> <ViewScaffold title="Firmware">
<Group <Group
title="Updatable devices" title="Updatable devices"
hint="UEFI and devices that speak fwupd." hint="Firmware updates for hardware that supports them (UEFI, some peripherals). fwupd-refresh.timer already keeps update metadata current in the background."
wide wide
> >
<div class="list"> <div class="list">

View file

@ -1,315 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { go, nav } from "$lib/nav.svelte";
import { searchSettings, type SearchHit } from "$lib/search";
import Search from "@lucide/svelte/icons/search";
import Wifi from "@lucide/svelte/icons/wifi";
import BatteryFull from "@lucide/svelte/icons/battery-full";
import Download from "@lucide/svelte/icons/download";
import Monitor from "@lucide/svelte/icons/monitor";
import Palette from "@lucide/svelte/icons/palette";
import Volume2 from "@lucide/svelte/icons/volume-2";
import Keyboard from "@lucide/svelte/icons/keyboard";
import AppWindow from "@lucide/svelte/icons/app-window";
import Shield from "@lucide/svelte/icons/shield";
import Package from "@lucide/svelte/icons/package";
let query = $state("");
let searchEl: HTMLInputElement | undefined = $state();
let hits = $derived(query.trim() ? searchSettings(query) : []);
let wifiLabel = $state("Wi-Fi");
let wifiDetail = $state("Checking…");
let batteryLabel = $state("Battery");
let batteryDetail = $state("Checking…");
let updateLabel = $state("Updates");
let updateDetail = $state("Checking…");
const tiles: { id: string; tab?: string; title: string; desc: string; icon: typeof Wifi }[] = [
{ id: "network", title: "Wi-Fi & internet", desc: "Radio, saved networks, VPN.", icon: Wifi },
{ id: "displays", title: "Displays", desc: "Arrange, scale, night light.", icon: Monitor },
{ id: "appearance", title: "Appearance", desc: "Wallpaper drives the palette.", icon: Palette },
{ id: "sound", title: "Sound", desc: "Devices, volume, mute.", icon: Volume2 },
{ id: "power", title: "Power", desc: "Brightness, charge limit, battery.", icon: BatteryFull },
{ id: "input", title: "Keyboard & mouse", desc: "Layouts, keybinds, tap-to-click.", icon: Keyboard },
{ id: "desktop", title: "Bar & apps", desc: "Bar, launcher, lock, screenshots.", icon: AppWindow },
{ id: "privacy", title: "Privacy & users", desc: "Firewall and accounts.", icon: Shield },
{ id: "system", title: "Updates", desc: "Bakery, pacman, snapshots, restic.", icon: Package },
];
$effect(() => {
if (nav.searchNonce > 0) {
searchEl?.focus();
searchEl?.select();
}
});
onMount(async () => {
try {
const net = await invoke<{ radio_enabled: boolean; ethernet: string | null }>("get_network_info");
if (!net.radio_enabled) {
wifiLabel = "Wi-Fi off";
wifiDetail = "Radio disabled";
} else {
const networks = await invoke<{ ssid: string; active: boolean; signal: number }[]>("scan_wifi").catch(
() => [],
);
const active = networks.find((n) => n.active);
if (active) {
wifiLabel = active.ssid;
wifiDetail = `Wi-Fi · ${active.signal}%`;
} else {
wifiLabel = "Wi-Fi";
wifiDetail = net.ethernet ? "On · no network" : "On";
}
}
} catch {
wifiDetail = "Unavailable";
}
try {
const power = await invoke<{ battery: [string, string][]; power_source: string }>("get_power_info");
const charge = power.battery.find(([k]) => k === "Charge")?.[1] ?? "";
const remain = power.battery.find(([k]) => k === "Time remaining")?.[1];
batteryLabel = charge || power.power_source;
batteryDetail = remain ? `${remain}` : power.power_source;
} catch {
batteryDetail = "Unavailable";
}
try {
const st = await invoke<{
pacman: unknown[];
bakery: unknown[];
firmware: unknown[];
}>("get_updates_status");
const n = st.pacman.length + st.bakery.length + st.firmware.length;
updateLabel = n === 0 ? "Up to date" : `${n} update${n === 1 ? "" : "s"}`;
updateDetail = n === 0 ? "bakery + pacman" : "bakery + pacman + firmware";
} catch {
updateDetail = "Unavailable";
}
});
function goHit(hit: SearchHit) {
go(hit.page, hit.tab);
}
</script>
<div class="home">
<h1>Settings</h1>
<p class="lede">Wi-Fi, lock screen, updates, keybinds.</p>
<div class="search-wrap">
<Search size={18} />
<input
bind:this={searchEl}
class="bigsearch"
bind:value={query}
placeholder="Try “night light”, “keybinds”, “forget network”…"
/>
</div>
{#if hits.length > 0}
<div class="results">
{#each hits as hit (`${hit.page}:${hit.tab ?? ""}:${hit.label}`)}
<button class="hit" onclick={() => goHit(hit)}>
<span>{hit.label}</span>
<em>{hit.tab ?? hit.page}</em>
</button>
{/each}
</div>
{:else}
<div class="status">
<button class="stat" onclick={() => go("network")}>
<Wifi size={18} />
<div><b>{wifiLabel}</b><span>{wifiDetail}</span></div>
</button>
<button class="stat" onclick={() => go("power")}>
<BatteryFull size={18} />
<div><b>{batteryLabel}</b><span>{batteryDetail}</span></div>
</button>
<button class="stat" onclick={() => go("system")}>
<Download size={18} />
<div><b>{updateLabel}</b><span>{updateDetail}</span></div>
</button>
</div>
<div class="tiles">
{#each tiles as tile (tile.id + (tile.tab ?? ""))}
<button class="tile" onclick={() => go(tile.id, tile.tab)}>
<div class="ico"><tile.icon size={18} /></div>
<div class="t">{tile.title}</div>
<div class="d">{tile.desc}</div>
</button>
{/each}
</div>
{/if}
</div>
<style>
.home {
padding: 28px 36px 56px;
max-width: 1080px;
margin-inline: auto;
}
h1 {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.04em;
margin: 0;
}
.lede {
margin: 6px 0 0;
color: var(--muted);
font-size: 14px;
}
.search-wrap {
position: relative;
margin-top: 22px;
}
.search-wrap :global(svg) {
position: absolute;
left: 16px;
top: 16px;
opacity: 0.45;
pointer-events: none;
}
.bigsearch {
width: 100%;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px 16px 14px 44px;
font-size: 16px;
}
.status {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-top: 18px;
}
.stat {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px 16px;
display: flex;
gap: 12px;
align-items: center;
text-align: left;
cursor: pointer;
color: inherit;
}
.stat :global(svg) {
color: var(--accent);
flex-shrink: 0;
}
.stat b {
display: block;
font-size: 14px;
font-weight: 600;
}
.stat span {
font-size: 12px;
color: var(--muted);
}
.tiles {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-top: 18px;
}
.tile {
text-align: left;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px 16px 14px;
min-height: 108px;
display: flex;
flex-direction: column;
gap: 8px;
cursor: pointer;
color: inherit;
transition:
transform 0.2s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.15s;
}
.tile:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--fg) 18%, transparent);
}
.ico {
width: 34px;
height: 34px;
border-radius: 10px;
display: grid;
place-items: center;
background: color-mix(in oklab, var(--fg) 9%, transparent);
color: var(--fg);
}
.t {
font-size: 14.5px;
font-weight: 600;
}
.d {
font-size: 12px;
color: var(--muted);
line-height: 1.35;
}
.results {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.hit {
text-align: left;
padding: 10px 12px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--line);
display: flex;
justify-content: space-between;
gap: 12px;
cursor: pointer;
color: inherit;
}
.hit:hover {
background: var(--surface-2, var(--surface));
}
.hit em {
font-style: normal;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 800px) {
.status,
.tiles {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -1,16 +0,0 @@
<script lang="ts">
import Hub from "$lib/components/Hub.svelte";
import Keybinds from "./Keybinds.svelte";
import InputMethod from "./InputMethod.svelte";
import Accessibility from "./Accessibility.svelte";
</script>
<Hub
title="Keyboard & mouse"
lede="Shortcuts, input method, and accessibility."
tabs={[
{ id: "keybinds", label: "Shortcuts", component: Keybinds },
{ id: "ime", label: "Input method", component: InputMethod },
{ id: "accessibility", label: "Accessibility", component: Accessibility },
]}
/>

View file

@ -59,7 +59,7 @@
<ViewScaffold title="Input method"> <ViewScaffold title="Input method">
<Group <Group
title="fcitx5" title="fcitx5"
hint="Starts fcitx5. Running apps need a logout." hint="Writes ~/.config/environment.d/90-fcitx5.conf and a Hyprland source fragment, then starts fcitx5 for this user. Already-running apps keep their old IM until you log out."
> >
{#if !st} {#if !st}
<Hint text="Loading…" /> <Hint text="Loading…" />
@ -87,7 +87,7 @@
{#if st} {#if st}
<ul> <ul>
{#each st.packages as p (p.name)} {#each st.packages as p (p.name)}
<li class:missing={!p.installed}>{p.name}{p.installed ? "" : " (missing)"}</li> <li class:missing={!p.installed}>{p.name}{p.installed ? "" : " — not installed"}</li>
{/each} {/each}
</ul> </ul>
{/if} {/if}

View file

@ -9,7 +9,7 @@
import SaveButton from "$lib/components/SaveButton.svelte"; import SaveButton from "$lib/components/SaveButton.svelte";
import Switch from "$lib/components/Switch.svelte"; import Switch from "$lib/components/Switch.svelte";
// Mirrors src-tauri/src/commands/keybinds.rs's `Bind` - `action`/`key`/ // Mirrors src-tauri/src/commands/keybinds.rs's `Bind` `action`/`key`/
// `mods` are the only fields every bind has; everything else (`command`, // `mods` are the only fields every bind has; everything else (`command`,
// `direction`, `workspace`, `options`, breadhelp's `label`/`category`/ // `direction`, `workspace`, `options`, breadhelp's `label`/`category`/
// `demo_cmd`, ...) round-trips through serde's `#[serde(flatten)]` as // `demo_cmd`, ...) round-trips through serde's `#[serde(flatten)]` as
@ -35,7 +35,7 @@
} }
// Every action actually seen in real binds.json files (BOS's shipped // Every action actually seen in real binds.json files (BOS's shipped
// flat schema and this app's own dev MultiLayout config) - an `action` // flat schema and this app's own dev MultiLayout config) an `action`
// dropdown beats free-typing a dispatcher name from memory. "Custom…" // dropdown beats free-typing a dispatcher name from memory. "Custom…"
// keeps anything not in this list reachable without blocking on it. // keeps anything not in this list reachable without blocking on it.
const KNOWN_ACTIONS = [ const KNOWN_ACTIONS = [
@ -56,7 +56,7 @@
] as const; ] as const;
// The editable, per-row UI state a bind gets flattened into on load and // 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 // reconstructed back into a `Bind` on save same trade-off the GTK
// version made with individual `Entry` widgets per field. // version made with individual `Entry` widgets per field.
interface EditRow { interface EditRow {
action: string; action: string;
@ -64,7 +64,7 @@
mods: string; mods: string;
// Whether `mods` has been explicitly set (present in the loaded JSON, // Whether `mods` has been explicitly set (present in the loaded JSON,
// or touched by the user since). `None`/absent means "fall back to // or touched by the user since). `None`/absent means "fall back to
// default_mods"; `Some([])` - an explicitly *empty* mods list - means // default_mods"; `Some([])` — an explicitly *empty* mods list — means
// "use no modifiers at all, even though default_mods exists" (real // "use no modifiers at all, even though default_mods exists" (real
// BOS binds rely on this for e.g. bare media keys). Collapsing both // BOS binds rely on this for e.g. bare media keys). Collapsing both
// cases to "omit when empty" would silently turn an explicit // cases to "omit when empty" would silently turn an explicit
@ -77,123 +77,12 @@
// key they don't know about (label/category/demo_cmd, or an action // key they don't know about (label/category/demo_cmd, or an action
// shape this editor has no dedicated fields for) survives untouched. // shape this editor has no dedicated fields for) survives untouched.
extraValue: Record<string, unknown>; extraValue: Record<string, unknown>;
// Raw-JSON view of `extraValue`, kept in sync both directions - // Raw-JSON view of `extraValue`, kept in sync both directions
// only actually shown when `advancedOpen` is true, or for an action // only actually shown when `advancedOpen` is true, or for an action
// not in `KNOWN_ACTIONS` (nothing dedicated to show instead). // not in `KNOWN_ACTIONS` (nothing dedicated to show instead).
extraText: string; extraText: string;
extraError: boolean; extraError: boolean;
advancedOpen: boolean; advancedOpen: boolean;
capturing: boolean;
}
const ACTION_LABELS: Record<string, string> = {
exec: "Run command",
close: "Close window",
fullscreen: "Fullscreen",
float: "Float window",
pseudo: "Fake fullscreen",
resize: "Resize",
focus: "Go to workspace",
focus_last: "Last workspace",
move: "Move window to workspace",
move_dir: "Move window",
resize_dir: "Resize window",
layout: "Layout",
drag: "Mouse drag",
exit: "Exit Hyprland",
};
const KEY_LABELS: Record<string, string> = {
RETURN: "Enter",
SPACE: "Space",
ESCAPE: "Esc",
BACKSPACE: "Backspace",
TAB: "Tab",
SUPER: "Super",
CTRL: "Ctrl",
ALT: "Alt",
SHIFT: "Shift",
XF86AudioRaiseVolume: "Vol +",
XF86AudioLowerVolume: "Vol -",
XF86AudioMute: "Mute",
XF86AudioMicMute: "Mic mute",
XF86MonBrightnessUp: "Bright +",
XF86MonBrightnessDown: "Bright -",
XF86AudioNext: "Next",
XF86AudioPrev: "Prev",
XF86AudioPlay: "Play",
Print: "Print",
};
const CODE_TO_HYPR: Record<string, string> = {
Space: "SPACE",
Enter: "RETURN",
Escape: "ESCAPE",
Backspace: "BACKSPACE",
Tab: "TAB",
AudioVolumeUp: "XF86AudioRaiseVolume",
AudioVolumeDown: "XF86AudioLowerVolume",
AudioVolumeMute: "XF86AudioMute",
AudioMicMute: "XF86AudioMicMute",
BrightnessUp: "XF86MonBrightnessUp",
BrightnessDown: "XF86MonBrightnessDown",
MediaTrackNext: "XF86AudioNext",
MediaTrackPrevious: "XF86AudioPrev",
MediaPlayPause: "XF86AudioPlay",
PrintScreen: "Print",
};
function keyLabel(k: string): string {
return KEY_LABELS[k] ?? k;
}
function bindTitle(row: EditRow): string {
if (row.action === "exec") {
const cmd = String(row.extraValue.command ?? "");
if (cmd.includes("set-volume") && cmd.includes("+")) return "Volume up";
if (cmd.includes("set-volume") && cmd.includes("-")) return "Volume down";
if (cmd.includes("set-mute") && cmd.includes("SINK")) return "Mute";
if (cmd.includes("set-mute") && cmd.includes("SOURCE")) return "Mute mic";
if (cmd.includes("brightnessctl") && cmd.includes("+")) return "Brightness up";
if (cmd.includes("brightnessctl") && cmd.includes("-")) return "Brightness down";
if (cmd.includes("playerctl next")) return "Next track";
if (cmd.includes("playerctl previous")) return "Previous track";
if (cmd.includes("playerctl play-pause")) return "Play/pause";
if (cmd) return cmd.split(/\s+/)[0].split("/").pop() ?? "Command";
}
return ACTION_LABELS[row.action] ?? row.action;
}
function keyChips(row: EditRow): string[] {
const mods = textToMods(row.mods);
const key = row.key.trim();
return [...mods, key].filter(Boolean).map(keyLabel);
}
function hyprFromEvent(e: KeyboardEvent): { mods: string[]; key: string } | null {
if (["Shift", "Control", "Alt", "Meta"].includes(e.key)) return null;
const mods: string[] = [];
if (e.metaKey) mods.push("SUPER");
if (e.ctrlKey) mods.push("CTRL");
if (e.altKey) mods.push("ALT");
if (e.shiftKey) mods.push("SHIFT");
let key = CODE_TO_HYPR[e.code] ?? CODE_TO_HYPR[e.key];
if (!key) {
if (e.code.startsWith("Key") && e.code.length === 4) key = e.code.slice(3);
else if (e.code.startsWith("Digit")) key = e.code.slice(5);
else if (e.code.startsWith("F") && /^F\d+$/.test(e.code)) key = e.code;
else key = e.key.length === 1 ? e.key.toUpperCase() : e.key;
}
return { mods, key };
}
function allRows(): EditRow[] {
return [...globalsRows, ...commonRows, ...bindingsRows, ...Object.values(layoutRows).flat()];
}
function beginCapture(row: EditRow) {
for (const r of allRows()) r.capturing = false;
row.capturing = true;
} }
function syncExtraText(row: EditRow) { function syncExtraText(row: EditRow) {
@ -225,7 +114,7 @@
} }
// Hyprland workspace refs mix bare integers ("1") and relative tokens // Hyprland workspace refs mix bare integers ("1") and relative tokens
// ("e+1", "e-1") in the same field - keep whichever shape the user typed // ("e+1", "e-1") in the same field keep whichever shape the user typed
// instead of forcing everything through one type. // instead of forcing everything through one type.
function parseWorkspaceValue(s: string): string | number | undefined { function parseWorkspaceValue(s: string): string | number | undefined {
const trimmed = s.trim(); const trimmed = s.trim();
@ -254,14 +143,13 @@
extraText: "", extraText: "",
extraError: false, extraError: false,
advancedOpen: false, advancedOpen: false,
capturing: false,
}; };
syncExtraText(row); syncExtraText(row);
return row; return row;
} }
function rowToBind(r: EditRow): Bind { function rowToBind(r: EditRow): Bind {
// `drag` binds are only meaningful as a mouse bind - there's no // `drag` binds are only meaningful as a mouse bind there's no
// dedicated field for `options.mouse` (nothing to configure, it's // dedicated field for `options.mouse` (nothing to configure, it's
// always true), so pin it here rather than exposing a checkbox // always true), so pin it here rather than exposing a checkbox
// whose only correct state is "on". // whose only correct state is "on".
@ -305,7 +193,6 @@
extraText: "", extraText: "",
extraError: false, extraError: false,
advancedOpen: false, advancedOpen: false,
capturing: false,
}; };
} }
@ -321,32 +208,13 @@
let newLayoutName = $state(""); let newLayoutName = $state("");
let loadError = $state(""); let loadError = $state("");
onMount(() => { onMount(async () => {
const onKey = (e: KeyboardEvent) => {
const row = allRows().find((r) => r.capturing);
if (!row) return;
const mapped = hyprFromEvent(e);
if (!mapped) return;
e.preventDefault();
row.mods = mapped.mods.join(", ");
row.modsTouched = true;
row.key = mapped.key;
row.capturing = false;
};
window.addEventListener("keydown", onKey);
void loadKeybinds();
// Register cleanup synchronously so Svelte types the onMount
// callback as returning a function (not a Promise-of-function).
return () => window.removeEventListener("keydown", onKey);
});
async function loadKeybinds() {
try { try {
const p = await invoke<BindsPayload>("get_keybinds"); const p = await invoke<BindsPayload>("get_keybinds");
kind = p.kind; kind = p.kind;
// Rust's `#[serde(skip_serializing_if = ...)]` on every one of // Rust's `#[serde(skip_serializing_if = ...)]` on every one of
// these fields means an empty one is OMITTED from the JSON // these fields means an empty one is OMITTED from the JSON
// entirely, not sent as `[]`/`{}`/`""` - every field here needs // entirely, not sent as `[]`/`{}`/`""` — every field here needs
// a `??` fallback, not just the ones that are "usually" empty. // a `??` fallback, not just the ones that are "usually" empty.
activeLayout = p.file.active_layout ?? ""; activeLayout = p.file.active_layout ?? "";
defaultMods = modsToText(p.file.default_mods ?? []); defaultMods = modsToText(p.file.default_mods ?? []);
@ -361,7 +229,7 @@
} catch (e) { } catch (e) {
loadError = String(e); loadError = String(e);
} }
} });
function addLayout() { function addLayout() {
const name = newLayoutName.trim(); const name = newLayoutName.trim();
@ -380,7 +248,7 @@
// A row whose "extra" column currently holds text that doesn't parse as // A row whose "extra" column currently holds text that doesn't parse as
// JSON keeps its *last successfully parsed* value in `extraValue` (see // JSON keeps its *last successfully parsed* value in `extraValue` (see
// `onExtraInput`) rather than losing it on every keystroke while the // `onExtraInput`) rather than losing it on every keystroke while the
// user is mid-edit - but that means saving while such a row is still // user is mid-edit but that means saving while such a row is still
// showing invalid/incomplete text would silently write that stale (or, // showing invalid/incomplete text would silently write that stale (or,
// for a brand new row, empty) value instead of what's actually on // 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 // screen. Block save entirely until every row's extra JSON is valid, so
@ -481,7 +349,7 @@
oninput={(e) => setExtra(row, "layout", e.currentTarget.value)} oninput={(e) => setExtra(row, "layout", e.currentTarget.value)}
/> />
{:else if row.action === "drag"} {:else if row.action === "drag"}
<span class="extra-note">Mouse-drag bind.</span> <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"} {: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> <span class="extra-note">No extra options for this action.</span>
{:else} {:else}
@ -507,19 +375,30 @@
{#each rows as row, i (i)} {#each rows as row, i (i)}
<div class="bind-card"> <div class="bind-card">
<div class="bind-top"> <div class="bind-top">
<span class="what">{bindTitle(row)}</span> <input
<button type="button" class="capture" class:listening={row.capturing} onclick={() => beginCapture(row)}> class="mods"
{#if row.capturing} type="text"
Press a key placeholder="SUPER, SHIFT"
{:else if keyChips(row).length} bind:value={row.mods}
{#each keyChips(row) as k, ki (`${k}-${ki}`)} oninput={() => (row.modsTouched = true)}
{#if ki > 0}<span class="plus">+</span>{/if} />
<span class="kbd">{k}</span> <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} {/each}
{:else} <option value="__custom__">Custom…</option>
Set shortcut </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} {/if}
</button>
<button type="button" class="remove" onclick={() => onRemove(i)}>Remove</button> <button type="button" class="remove" onclick={() => onRemove(i)}>Remove</button>
</div> </div>
<div class="bind-bottom"> <div class="bind-bottom">
@ -557,7 +436,7 @@
{:else if kind === "flat"} {:else if kind === "flat"}
<Group <Group
title="Defaults" title="Defaults"
hint="Click a shortcut, then press the keys. Save when you are done." 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" /> <TextField label="Default mods" bind:value={defaultMods} placeholder="SUPER" />
</Group> </Group>
@ -574,7 +453,7 @@
{:else} {:else}
<Group <Group
title="Layout" title="Layout"
hint="Click a shortcut, then press the keys. Save when you are done." 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} {#if layoutOrder.length > 0}
<SelectField label="Active layout" bind:value={activeLayout} options={layoutOrder} /> <SelectField label="Active layout" bind:value={activeLayout} options={layoutOrder} />
@ -633,13 +512,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-xs, 4px); gap: var(--space-xs, 4px);
padding: 10px 2px; background-color: var(--surface);
border-top: 1px solid var(--line); border-radius: var(--radius-secondary, 6px);
} padding: var(--space-sm, 8px) var(--space-md, 12px);
.bind-card:first-child {
border-top: none;
padding-top: 0;
} }
.bind-top, .bind-top,
@ -650,34 +525,6 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.what {
flex: 1;
min-width: 12ch;
}
.capture {
display: flex;
align-items: center;
gap: 4px;
background: color-mix(in srgb, var(--fg) 6%, transparent);
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px 10px;
color: inherit;
cursor: pointer;
min-height: 32px;
}
.capture.listening {
border-color: var(--accent);
color: var(--accent);
}
.plus {
opacity: 0.4;
padding: 0 1px;
}
.bind-card input, .bind-card input,
.bind-card select { .bind-card select {
background-color: var(--bg); background-color: var(--bg);
@ -697,6 +544,26 @@
border-color: var(--red); 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 { .extra-wide {
flex: 1; flex: 1;
min-width: 16ch; min-width: 16ch;

View file

@ -3,8 +3,9 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte"; import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte"; import Group from "$lib/components/Group.svelte";
import SwitchField from "$lib/components/SwitchField.svelte"; import Row from "$lib/components/Row.svelte";
import InfoRow from "$lib/components/InfoRow.svelte"; import InfoRow from "$lib/components/InfoRow.svelte";
import Hint from "$lib/components/Hint.svelte";
import Wifi from "@lucide/svelte/icons/wifi"; import Wifi from "@lucide/svelte/icons/wifi";
import WifiOff from "@lucide/svelte/icons/wifi-off"; import WifiOff from "@lucide/svelte/icons/wifi-off";
import Lock from "@lucide/svelte/icons/lock"; import Lock from "@lucide/svelte/icons/lock";
@ -25,6 +26,12 @@
let pendingSsid = $state<string | null>(null); let pendingSsid = $state<string | null>(null);
let password = $state(""); 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 VIRTUAL_IFACE_PREFIXES = ["veth", "docker", "br-", "virbr", "vnet", "tun", "tap", "vmnet", "podman"];
const ETHERNET_STATE_LABELS: Record<string, string> = { const ETHERNET_STATE_LABELS: Record<string, string> = {
connected: "Connected", connected: "Connected",
@ -40,14 +47,11 @@
if (!iface || VIRTUAL_IFACE_PREFIXES.some((prefix) => iface.startsWith(prefix))) return null; if (!iface || VIRTUAL_IFACE_PREFIXES.some((prefix) => iface.startsWith(prefix))) return null;
return ETHERNET_STATE_LABELS[rawState ?? ""] ?? "Not connected"; return ETHERNET_STATE_LABELS[rawState ?? ""] ?? "Not connected";
}); });
let activeNet = $derived(networks?.find((n) => n.active) ?? null);
let nearby = $derived(networks?.filter((n) => !n.active) ?? []);
onMount(async () => { onMount(async () => {
const info = await invoke<{ radio_enabled: boolean; ethernet: string | null }>("get_network_info"); const info = await invoke<{ radio_enabled: boolean; ethernet: string | null }>("get_network_info");
radioEnabled = info.radio_enabled; radioEnabled = info.radio_enabled;
ethernet = info.ethernet; ethernet = info.ethernet;
if (info.radio_enabled) await scan();
}); });
async function toggleRadio(enabled: boolean) { async function toggleRadio(enabled: boolean) {
@ -57,12 +61,10 @@
async function scan() { async function scan() {
scanning = true; scanning = true;
try { status = "Scanning…";
networks = await invoke<WifiNetwork[]>("scan_wifi"); networks = await invoke<WifiNetwork[]>("scan_wifi");
} finally { status = `Found ${networks.length} network(s)`;
scanning = false; scanning = false;
status = "";
}
} }
async function connect(ssid: string, secured: boolean, known: boolean) { async function connect(ssid: string, secured: boolean, known: boolean) {
@ -74,9 +76,8 @@
try { try {
await invoke("connect_wifi", { ssid, password: null }); await invoke("connect_wifi", { ssid, password: null });
status = `Connected to ${ssid}`; status = `Connected to ${ssid}`;
await scan();
} catch (e) { } catch (e) {
status = `Failed: ${e}`; status = `Failed to connect to ${ssid}: ${e}`;
} }
} }
@ -89,76 +90,54 @@
try { try {
await invoke("connect_wifi", { ssid, password: pw }); await invoke("connect_wifi", { ssid, password: pw });
status = `Connected to ${ssid}`; status = `Connected to ${ssid}`;
await scan();
} catch { } catch {
status = "Wrong password?"; status = `Failed to connect to ${ssid}: wrong password?`;
} }
} }
function bars(signal: number): number { function signalLabel(signal: number): string {
if (signal >= 75) return 4; return `${Math.min(100, Math.max(0, signal))}%`;
if (signal >= 50) return 3;
if (signal >= 25) return 2;
return 1;
} }
</script> </script>
<ViewScaffold title="Wi-Fi"> <ViewScaffold title="Network">
<Group title="Radio"> <Group title="Wi-Fi">
<SwitchField label="Wi-Fi" hint={radioEnabled ? "On" : "Off"} bind:value={() => radioEnabled, (v) => toggleRadio(v)} /> <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} {#if ethernetLabel}
<InfoRow label="Ethernet" value={ethernetLabel} /> <Group title="Ethernet">
{/if} <InfoRow label="Status" value={ethernetLabel} />
</Group>
{#if activeNet}
<Group title="This network">
<div class="wifi">
<div class="bars on">
{#each [1, 2, 3, 4] as n (n)}
<b style="height: {3 + n * 3}px" class:lit={bars(activeNet.signal) >= n}></b>
{/each}
</div>
<div class="ssid">
<strong>{activeNet.ssid}</strong>
<small>{activeNet.secured ? "Secured" : "Open"} · {activeNet.signal}%</small>
</div>
<span class="pill on">Connected</span>
</div>
</Group> </Group>
{/if} {/if}
<Group title="Nearby" wide> <Group title="Available networks" wide>
<div class="list"> <div class="list">
{#if scanning && networks === null} {#if networks === null}
<div class="empty"> <div class="empty">
<Wifi size={36} /> <Wifi size={40} />
<span>Scanning…</span> <span>Not scanned yet</span>
<span class="hint">Press Scan to see nearby networks.</span>
</div> </div>
{:else if networks === null} {:else if networks.length === 0}
<div class="empty"> <div class="empty">
<Wifi size={36} /> <WifiOff size={40} />
<span>No scan yet</span>
</div>
{:else if nearby.length === 0 && !activeNet}
<div class="empty">
<WifiOff size={36} />
<span>No networks found</span> <span>No networks found</span>
<span class="hint">Try Scan again, or check Wi-Fi radio is on.</span>
</div> </div>
{:else} {:else}
{#each nearby as net (net.ssid)} {#each networks as net (net.ssid)}
<div class="wifi"> <div class="net-row">
<div class="bars"> <span class="ssid" class:active={net.active}>{net.ssid}{net.active ? " (connected)" : ""}</span>
{#each [1, 2, 3, 4] as n (n)}
<b style="height: {3 + n * 3}px" class:lit={bars(net.signal) >= n}></b>
{/each}
</div>
<div class="ssid">
{net.ssid}
<small>{net.known ? "Saved" : net.secured ? "Secured" : "Open"}</small>
</div>
{#if net.secured}<Lock size={14} />{/if} {#if net.secured}<Lock size={14} />{/if}
<button class="btn" onclick={() => connect(net.ssid, net.secured, net.known)}>Connect</button> <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> </div>
{/each} {/each}
{/if} {/if}
@ -166,28 +145,52 @@
{#if pendingSsid} {#if pendingSsid}
<div class="pw-row"> <div class="pw-row">
<input type="password" bind:value={password} placeholder="Password for {pendingSsid}" /> <input type="password" bind:value={password} placeholder="Password" />
<button class="btn primary" onclick={connectWithPassword}>Connect</button> <button class="connect" onclick={connectWithPassword}>Connect</button>
</div> </div>
{/if} {/if}
{#if status} {#if status}
<span class="status">{status}</span> <span class="status">{status}</span>
{/if} {/if}
<button class="btn" disabled={scanning} onclick={scan}>{scanning ? "Scanning…" : "Scan"}</button>
<button class="scan" disabled={scanning} onclick={scan}>{scanning ? "Scanning…" : "Scan"}</button>
</Group> </Group>
<Group title="Advanced"> <Group title="Advanced" hint="VPN, 802.1x, and static IP configuration aren't covered here.">
<button class="btn" onclick={() => invoke("open_connection_editor")}>Connection editor</button> <button class="secondary" onclick={() => invoke("open_connection_editor")}>Open connection editor</button>
</Group> </Group>
</ViewScaffold> </ViewScaffold>
<style> <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 { .list {
min-height: 72px; min-height: 100px;
max-height: 280px; max-height: 260px;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px;
} }
.empty { .empty {
@ -195,80 +198,79 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 20px 0; padding: var(--space-xl, 20px) 0;
opacity: 0.6; opacity: 0.6;
} }
.wifi { .hint {
font-size: var(--font-size-secondary, 12px);
}
.net-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: var(--space-sm, 8px);
padding: 10px 2px; background-color: var(--surface);
} border-radius: var(--radius-secondary, 6px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
.wifi + .wifi {
border-top: 1px solid var(--line);
}
.bars {
display: flex;
align-items: flex-end;
gap: 2px;
height: 14px;
width: 16px;
flex-shrink: 0;
}
.bars b {
width: 3px;
background: color-mix(in srgb, var(--fg) 22%, transparent);
border-radius: 1px;
display: block;
}
.bars b.lit,
.bars.on b.lit {
background: var(--accent);
} }
.ssid { .ssid {
flex: 1; flex: 1;
min-width: 0;
} }
.ssid small { .ssid.active {
display: block; font-weight: bold;
color: var(--muted); }
font-size: 11px;
.signal {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
} }
.pw-row { .pw-row {
display: flex; display: flex;
gap: 8px; gap: var(--space-sm, 8px);
margin-top: 8px;
} }
.pw-row input { .pw-row input {
flex: 1; flex: 1;
background: var(--bg); background-color: var(--surface);
color: var(--on-surface);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 10px; border-radius: var(--radius-secondary, 6px);
padding: 6px 10px; padding: var(--space-xs, 4px) var(--space-sm, 8px);
} }
.status { .status {
display: block; opacity: 0.6;
margin: 8px 0; font-size: var(--font-size-secondary, 12px);
color: var(--muted);
font-size: 12px;
} }
.btn { button.connect,
margin-top: 10px; 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; align-self: flex-start;
} }
.wifi .btn { button:disabled {
margin-top: 0; opacity: 0.5;
cursor: default;
} }
</style> </style>

View file

@ -1,16 +0,0 @@
<script lang="ts">
import Hub from "$lib/components/Hub.svelte";
import Network from "./Network.svelte";
import Vpn from "./Vpn.svelte";
import Breadcrumbs from "./Breadcrumbs.svelte";
</script>
<Hub
title="Wi-Fi & internet"
lede="Wi-Fi, saved networks, and VPN."
tabs={[
{ id: "network", label: "Wi-Fi", component: Network },
{ id: "vpn", label: "VPN", component: Vpn },
{ id: "breadcrumbs", label: "Saved networks", component: Breadcrumbs },
]}
/>

View file

@ -5,10 +5,8 @@
import ViewScaffold from "$lib/components/ViewScaffold.svelte"; import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte"; import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte"; import Hint from "$lib/components/Hint.svelte";
import SwitchField from "$lib/components/SwitchField.svelte"; import NumberField from "$lib/components/NumberField.svelte";
import SliderField from "$lib/components/SliderField.svelte";
import LogView from "$lib/components/LogView.svelte"; import LogView from "$lib/components/LogView.svelte";
import { debounce } from "$lib/debounce";
interface NightlightStatus { interface NightlightStatus {
installed: boolean; installed: boolean;
@ -22,18 +20,19 @@
let log = $state<string[]>([]); let log = $state<string[]>([]);
let busy = $state(false); let busy = $state(false);
let message = $state(""); let message = $state("");
async function refresh() { async function refresh() {
st = await invoke<NightlightStatus>("get_nightlight"); st = await invoke<NightlightStatus>("get_nightlight");
} }
onMount(refresh); onMount(refresh);
async function applyNow() { async function apply(enabled: boolean) {
if (!st) return; if (!st) return;
message = ""; message = "";
try { try {
st = await invoke<NightlightStatus>("set_nightlight", { st = await invoke<NightlightStatus>("set_nightlight", {
enabled: st.enabled, enabled,
temperature: st.temperature, temperature: st.temperature,
}); });
if (st.error) message = st.error; if (st.error) message = st.error;
@ -42,8 +41,6 @@
} }
} }
const persistTemp = debounce(applyNow, 200);
async function install() { async function install() {
log = []; log = [];
busy = true; busy = true;
@ -56,30 +53,93 @@
</script> </script>
<ViewScaffold title="Night light"> <ViewScaffold title="Night light">
<Group title="Night light" hint="Warmer screen after dark."> <Group
title="Hyprland twilight"
hint="hyprsunset owns the compositor color filter. hyprctl hyprsunset talks to its socket — the binary has to be installed and running."
>
{#if !st} {#if !st}
<Hint text="Loading…" /> <Hint text="Loading…" />
{:else if !st.installed} {:else if !st.installed}
<Hint text="hyprsunset is not installed." /> <Hint text="hyprsunset is not installed. Night light cannot run without it." />
<button class="btn primary" disabled={busy} onclick={install}>Install</button> <button class="primary" disabled={busy} onclick={install}>Install hyprsunset</button>
{:else} {:else}
<SwitchField <div class="row-switch">
label="Night light" <span>Night light</span>
bind:value={() => st!.enabled, (v) => { st!.enabled = v; applyNow(); }} <button
/> class="switch"
{#if st.enabled} class:on={st.enabled}
<SliderField role="switch"
label="Warmth" aria-checked={st.enabled}
bind:value={st.temperature} aria-label="Night light"
min={2000} onclick={() => apply(!st!.enabled)}
max={6500} >
step={100} <span class="knob"></span>
suffix="K" </button>
onChange={persistTemp} </div>
/> <NumberField label="Temperature (K)" bind:value={st.temperature} min={2000} max={6500} step={100} />
{/if} <button disabled={!st.enabled} onclick={() => apply(true)}>Apply temperature</button>
<Hint text={st.running ? "hyprsunset is running." : "hyprsunset will start when you enable night light."} />
{/if} {/if}
{#if message}<Hint text={message} />{/if} {#if message}<Hint text={message} />{/if}
</Group> </Group>
<LogView lines={log} /> <LogView lines={log} />
</ViewScaffold> </ViewScaffold>
<style>
.row-switch {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px) var(--space-lg, 16px);
margin-bottom: var(--space-sm, 8px);
}
.switch {
width: 40px;
height: 22px;
flex-shrink: 0;
border-radius: 999px;
border: none;
background-color: var(--overlay);
padding: 2px;
cursor: pointer;
display: flex;
align-items: center;
}
.switch.on {
background-color: var(--accent);
justify-content: flex-end;
}
.knob {
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--on-surface);
display: block;
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
align-self: flex-start;
margin-top: var(--space-sm, 8px);
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -38,7 +38,7 @@
log = [...log, line]; log = [...log, line];
}); });
busy = false; busy = false;
if (!ok) message = "Install failed. See the log."; if (!ok) message = "Install failed — see the log.";
await refresh(); await refresh();
return ok; return ok;
} }

View file

@ -85,7 +85,7 @@
<Group <Group
title="System packages (pacman)" title="System packages (pacman)"
hint="Official repos. Needs your password." 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> <button disabled={busy} onclick={updateSystem}>Update system (pacman -Syu)</button>
</Group> </Group>

View file

@ -40,10 +40,7 @@
} }
</script> </script>
<ViewScaffold <ViewScaffold title="Power">
title="Power & battery"
lede="Brightness, battery, and charge limits."
>
{#if info} {#if info}
<Group title="Battery"> <Group title="Battery">
{#each info.battery as [label, value] (label)} {#each info.battery as [label, value] (label)}
@ -66,22 +63,20 @@
{#if info.charge_start !== null && info.charge_end !== null} {#if info.charge_start !== null && info.charge_end !== null}
<Group <Group
title="Charge limits" title="Charge limits"
hint="Keeps the battery off 100% when this machine stays plugged in." 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"> <Row label="Start charging below (%)">
<input type="range" min="0" max="100" bind:value={chargeStart} onchange={() => setChargeThreshold("start", chargeStart)} /> <input type="number" min="0" max="100" bind:value={chargeStart} onchange={() => setChargeThreshold("start", chargeStart)} />
<span class="pct">{chargeStart}%</span>
</Row> </Row>
<Row label="Stop charging at"> <Row label="Stop charging at (%)">
<input type="range" min="1" max="100" bind:value={chargeEnd} onchange={() => setChargeThreshold("end", chargeEnd)} /> <input type="number" min="1" max="100" bind:value={chargeEnd} onchange={() => setChargeThreshold("end", chargeEnd)} />
<span class="pct">{chargeEnd}%</span>
</Row> </Row>
</Group> </Group>
{/if} {/if}
<Group <Group
title="TLP" title="TLP"
hint="TLP picks battery vs AC on its own." 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"} /> <InfoRow label="Current profile" value={info.tlp_profile ?? "unknown"} />
</Group> </Group>
@ -93,6 +88,15 @@
width: 180px; 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 { .pct {
margin-left: var(--space-sm, 8px); margin-left: var(--space-sm, 8px);
font-size: var(--font-size-secondary, 12px); font-size: var(--font-size-secondary, 12px);

View file

@ -1,14 +0,0 @@
<script lang="ts">
import Hub from "$lib/components/Hub.svelte";
import Firewall from "./Firewall.svelte";
import Users from "./Users.svelte";
</script>
<Hub
title="Privacy & users"
lede="Firewall and accounts."
tabs={[
{ id: "firewall", label: "Firewall", component: Firewall },
{ id: "users", label: "Users", component: Users },
]}
/>

View file

@ -25,7 +25,7 @@
} catch (e) { } catch (e) {
const msg = `${e}`.toLowerCase(); const msg = `${e}`.toLowerCase();
if (msg.includes("no permission")) { if (msg.includes("no permission")) {
errorHint = "This user is not in ALLOW_USERS for snapper."; 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")) { } 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."; errorHint = "No snapper config exists for root yet, so nothing is being snapshotted. This should be set up automatically at install.";
} else { } else {
@ -62,10 +62,10 @@
</script> </script>
<ViewScaffold title="Snapshots"> <ViewScaffold title="Snapshots">
<Hint text="Reboot and pick a snapshot in GRUB to undo an update." /> <Hint text="This is how you undo a bad update: reboot and pick the snapshot in GRUB (BOS snapshots)." />
<Group <Group
title="What to pick in GRUB" title="What to pick in GRUB"
hint="Reboot and choose that entry in GRUB." hint="Number, date, and description match the GRUB “BOS snapshots” submenu. Reboot, then choose that entry. This page does not run snapper rollback."
wide wide
> >
<div class="list"> <div class="list">

View file

@ -24,154 +24,107 @@
let output = $state<DeviceSection | null>(null); let output = $state<DeviceSection | null>(null);
let input = $state<DeviceSection | null>(null); let input = $state<DeviceSection | null>(null);
function pick(devices: SoundDevice[], defaultName: string | null): number {
const i = devices.findIndex((d) => d.name === defaultName);
return i >= 0 ? i : 0;
}
async function loadSection(kind: "sinks" | "sources", title: string): Promise<DeviceSection> { async function loadSection(kind: "sinks" | "sources", title: string): Promise<DeviceSection> {
const section = await invoke<{ devices: SoundDevice[]; default_name: string | null }>("get_sound_section", { const section = await invoke<{ devices: SoundDevice[]; default_name: string | null }>("get_sound_section", { kind });
kind, const selected = Math.max(0, section.devices.findIndex((d) => d.name === section.default_name));
}); return { kind, title, devices: section.devices, selected };
return { kind, title, devices: section.devices, selected: pick(section.devices, section.default_name) };
} }
onMount(async () => { onMount(async () => {
try {
output = await loadSection("sinks", "Output"); output = await loadSection("sinks", "Output");
input = await loadSection("sources", "Input"); input = await loadSection("sources", "Input");
} catch (e) {
console.error(e);
output = { kind: "sinks", title: "Output", devices: [], selected: 0 };
input = { kind: "sources", title: "Input", devices: [], selected: 0 };
}
}); });
function current(section: DeviceSection): SoundDevice | null {
return section.devices[section.selected] ?? section.devices[0] ?? null;
}
async function selectDevice(section: DeviceSection, index: number) { async function selectDevice(section: DeviceSection, index: number) {
const device = section.devices[index];
if (!device) return;
section.selected = index; section.selected = index;
await invoke("set_default_sound_device", { kind: section.kind, name: device.name }); await invoke("set_default_sound_device", { kind: section.kind, name: section.devices[index].name });
} }
async function setVolume(section: DeviceSection, percent: number) { async function setVolume(section: DeviceSection, percent: number) {
const device = current(section); const device = section.devices[section.selected];
if (!device) return;
device.percent = percent; device.percent = percent;
await invoke("set_sound_volume", { kind: section.kind, name: device.name, percent: Math.round(percent) }); await invoke("set_sound_volume", { kind: section.kind, name: device.name, percent });
} }
async function setMute(section: DeviceSection, mute: boolean) { async function setMute(section: DeviceSection, mute: boolean) {
const device = current(section); const device = section.devices[section.selected];
if (!device) return;
device.mute = mute; device.mute = mute;
await invoke("set_sound_mute", { kind: section.kind, name: device.name, mute }); await invoke("set_sound_mute", { kind: section.kind, name: device.name, mute });
} }
</script> </script>
{#snippet sectionCard(section: DeviceSection)} {#snippet deviceSection(section: DeviceSection | null)}
{#if section}
<Group title={section.title}> <Group title={section.title}>
{#if section.devices.length === 0} {#if section.devices.length === 0}
<Hint text="No devices found." /> <Hint text="No devices found." />
{:else} {:else}
{#each section.devices as d, i (d.name + i)} <Row label="Device">
<button type="button" class="dev" class:on={section.selected === i} onclick={() => selectDevice(section, i)}> <select
<span class="dev-name">{d.description || d.name}</span> value={section.selected}
{#if section.selected === i}<span class="mark">In use</span>{/if} onchange={(e) => selectDevice(section, Number(e.currentTarget.value))}
</button> >
{#each section.devices as d, i (d.name)}
<option value={i}>{d.description}</option>
{/each} {/each}
{#if current(section)} </select>
</Row>
<Row label="Volume"> <Row label="Volume">
<input <input
type="range" type="range"
min="0" min="0"
max="150" max="150"
value={Math.round(current(section)!.percent)} value={section.devices[section.selected].percent}
oninput={(e) => setVolume(section, Number(e.currentTarget.value))} oninput={(e) => setVolume(section, Number(e.currentTarget.value))}
/> />
<span class="pct">{Math.round(current(section)!.percent)}%</span> <span class="pct">{section.devices[section.selected].percent}%</span>
</Row> </Row>
<SwitchField <SwitchField
label="Mute" label="Mute"
bind:value={() => current(section)!.mute, (v) => setMute(section, v)} bind:value={
() => section.devices[section.selected].mute,
(v) => setMute(section, v)
}
/> />
{/if} {/if}
{/if}
</Group> </Group>
{/if}
{/snippet} {/snippet}
<ViewScaffold title="Sound" lede="Output, input, and volume."> <ViewScaffold title="Sound">
{#if output} {@render deviceSection(output)}
{@render sectionCard(output)} {@render deviceSection(input)}
{/if}
{#if input}
{@render sectionCard(input)}
{/if}
<Group title="Advanced"> <Group title="Advanced" hint="Per-app volume, port selection, and profile switching aren't covered here.">
<button class="secondary" onclick={() => invoke("open_mixer")}>Per-app volume</button> <button class="secondary" onclick={() => invoke("open_mixer")}>Open advanced mixer (pavucontrol)</button>
</Group> </Group>
</ViewScaffold> </ViewScaffold>
<style> <style>
.dev { select {
display: flex; background-color: var(--bg);
align-items: center; color: var(--on-surface);
gap: 10px; border: 1px solid transparent;
width: 100%; border-radius: var(--radius-secondary, 6px);
text-align: left; padding: var(--space-xs, 4px) var(--space-sm, 8px);
background: transparent;
border: none;
border-top: 1px solid var(--line, #ffffff12);
padding: 10px 2px;
color: inherit;
cursor: pointer;
}
.dev:first-child {
border-top: none;
padding-top: 0;
}
.dev.on .dev-name {
font-weight: 600;
}
.dev-name {
flex: 1;
}
.mark {
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
} }
input[type="range"] { input[type="range"] {
width: 180px; width: 180px;
accent-color: var(--accent);
} }
.pct { .pct {
margin-left: 8px; margin-left: var(--space-sm, 8px);
font-size: 12px; font-size: var(--font-size-secondary, 12px);
opacity: 0.7; opacity: 0.7;
min-width: 4ch;
} }
.secondary { .secondary {
background-color: var(--bg); background-color: var(--bg);
color: var(--on-surface); color: var(--on-surface);
border: none; border: none;
border-radius: 10px; border-radius: var(--radius-primary, 8px);
padding: 8px 16px; padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer; cursor: pointer;
align-self: flex-start; align-self: flex-start;
} }

View file

@ -1,24 +0,0 @@
<script lang="ts">
import Hub from "$lib/components/Hub.svelte";
import Updates from "./Updates.svelte";
import Packages from "./Packages.svelte";
import Aur from "./Aur.svelte";
import Firmware from "./Firmware.svelte";
import Snapshots from "./Snapshots.svelte";
import Backup from "./Backup.svelte";
import Channel from "./Channel.svelte";
</script>
<Hub
title="Updates & backup"
lede="Updates, snapshots, and backups."
tabs={[
{ id: "updates", label: "Updates", component: Updates },
{ id: "packages", label: "Packages", component: Packages },
{ id: "aur", label: "AUR", component: Aur },
{ id: "firmware", label: "Firmware", component: Firmware },
{ id: "snapshots", label: "Snapshots", component: Snapshots },
{ id: "backup", label: "Backup", component: Backup },
{ id: "channel", label: "Channel", component: Channel },
]}
/>

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { getContext, onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming"; import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte"; import ViewScaffold from "$lib/components/ViewScaffold.svelte";
@ -7,10 +7,12 @@
import Hint from "$lib/components/Hint.svelte"; import Hint from "$lib/components/Hint.svelte";
import EmptyState from "$lib/components/EmptyState.svelte"; import EmptyState from "$lib/components/EmptyState.svelte";
import LogView from "$lib/components/LogView.svelte"; import LogView from "$lib/components/LogView.svelte";
import { go } from "$lib/nav.svelte"; import { NAVIGATE_KEY, type Navigate } from "$lib/nav";
import Download from "@lucide/svelte/icons/download"; import Download from "@lucide/svelte/icons/download";
import Cpu from "@lucide/svelte/icons/cpu"; import Cpu from "@lucide/svelte/icons/cpu";
const navigate = getContext<Navigate | undefined>(NAVIGATE_KEY);
interface PendingUpdate { interface PendingUpdate {
name: string; name: string;
current: string; current: string;
@ -112,7 +114,7 @@
<Group <Group
title="Bread ecosystem (bakery)" title="Bread ecosystem (bakery)"
hint="Bakery packages waiting to update." hint="bakery --dry-run update --all — bakery has no separate outdated command. Per-package install still lives on Packages."
wide wide
> >
{#if !status} {#if !status}
@ -162,9 +164,10 @@
title="Rollback" title="Rollback"
hint="Boot a snapshot from GRUB (BOS snapshots)." hint="Boot a snapshot from GRUB (BOS snapshots)."
> >
<Hint text="Pick the snapshot in GRUB. bakery rollback undoes one package." /> <Hint text="Snapshots lists number, date, and description so you know which GRUB entry to pick. snapper rollback will not change what GRUB boots (rootflags=subvol=@)." />
<Hint text="Bakery also has bakery rollback <pkg> for a single ecosystem binary — that is not a system rollback." />
<div class="btn-row"> <div class="btn-row">
<button onclick={() => go("snapshots")}>Open Snapshots</button> <button disabled={!navigate} onclick={() => navigate?.("snapshots")}>Open Snapshots</button>
</div> </div>
</Group> </Group>

View file

@ -69,7 +69,7 @@
</script> </script>
<ViewScaffold title="Users"> <ViewScaffold title="Users">
<Group title="Accounts" hint="Login accounts. You cannot delete yourself." wide> <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"> <div class="list">
{#if accounts} {#if accounts}
{#each accounts as acc (acc.username)} {#each accounts as acc (acc.username)}

View file

@ -62,7 +62,7 @@
<ViewScaffold title="VPN / WireGuard"> <ViewScaffold title="VPN / WireGuard">
<Group <Group
title="NetworkManager tunnels" title="NetworkManager tunnels"
hint="WireGuard and OpenVPN. Tailscale is under Saved networks." hint="Lists connection type vpn and wireguard only. Wi-Fi profiles stay on breadcrumbs; Tailscale is not managed here."
wide wide
> >
{#if !status} {#if !status}

View file

@ -1,30 +1,83 @@
// Maps a sidebar page id to its view component. Every sidebar id has a
// real view — +page.svelte's Placeholder is only a safety net for typos.
import type { Component } from "svelte"; import type { Component } from "svelte";
import AboutHub from "./AboutHub.svelte"; import About from "./About.svelte";
import AppearanceHub from "./AppearanceHub.svelte"; import Breadclip from "./Breadclip.svelte";
import AppsHub from "./AppsHub.svelte"; import Bread from "./Bread.svelte";
import Bluetooth from "./Bluetooth.svelte"; import Breadbar from "./Breadbar.svelte";
import DesktopHub from "./DesktopHub.svelte"; import Breadbox from "./Breadbox.svelte";
import DisplaysHub from "./DisplaysHub.svelte"; import Breadpad from "./Breadpad.svelte";
import Home from "./Home.svelte"; import Breadpaper from "./Breadpaper.svelte";
import InputHub from "./InputHub.svelte"; import Breadsearch from "./Breadsearch.svelte";
import NetworkHub from "./NetworkHub.svelte"; import Breadcrumbs from "./Breadcrumbs.svelte";
import Power from "./Power.svelte"; import Appearance from "./Appearance.svelte";
import PrivacyHub from "./PrivacyHub.svelte"; import Autostart from "./Autostart.svelte";
import Display from "./Display.svelte";
import Keybinds from "./Keybinds.svelte";
import Sound from "./Sound.svelte"; import Sound from "./Sound.svelte";
import SystemHub from "./SystemHub.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";
import Breadlock from "./Breadlock.svelte";
import Breadshot from "./Breadshot.svelte";
import Breadmon from "./Breadmon.svelte";
import Breadhelp from "./Breadhelp.svelte";
import Updates from "./Updates.svelte";
import Printing from "./Printing.svelte";
import Vpn from "./Vpn.svelte";
import NightLight from "./NightLight.svelte";
import InputMethod from "./InputMethod.svelte";
import Accessibility from "./Accessibility.svelte";
import Defaults from "./Defaults.svelte";
import Channel from "./Channel.svelte";
import Backup from "./Backup.svelte";
import Optional from "./Optional.svelte";
export const VIEWS: Record<string, Component> = { export const VIEWS: Record<string, Component> = {
home: Home, about: About,
network: NetworkHub, breadclip: Breadclip,
bluetooth: Bluetooth, bread: Bread,
displays: DisplaysHub, breadbar: Breadbar,
breadbox: Breadbox,
breadpad: Breadpad,
breadpaper: Breadpaper,
breadsearch: Breadsearch,
breadcrumbs: Breadcrumbs,
appearance: Appearance,
autostart: Autostart,
hyprland: Display,
keybinds: Keybinds,
sound: Sound, sound: Sound,
datetime: DateTime,
power: Power, power: Power,
appearance: AppearanceHub, network: Network,
desktop: DesktopHub, bluetooth: Bluetooth,
input: InputHub, firewall: Firewall,
apps: AppsHub, users: Users,
privacy: PrivacyHub, packages: Packages,
system: SystemHub, aur: Aur,
about: AboutHub, firmware: Firmware,
snapshots: Snapshots,
breadlock: Breadlock,
breadshot: Breadshot,
breadmon: Breadmon,
breadhelp: Breadhelp,
updates: Updates,
printing: Printing,
vpn: Vpn,
nightlight: NightLight,
ime: InputMethod,
accessibility: Accessibility,
defaults: Defaults,
channel: Channel,
backup: Backup,
optional: Optional,
}; };

View file

@ -2,65 +2,68 @@
import { onMount, setContext } from "svelte"; import { onMount, setContext } from "svelte";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import Sidebar from "$lib/components/Sidebar.svelte"; import Sidebar from "$lib/components/Sidebar.svelte";
import Titlebar from "$lib/components/Titlebar.svelte";
import Placeholder from "$lib/components/Placeholder.svelte"; import Placeholder from "$lib/components/Placeholder.svelte";
import { go, nav, NAVIGATE_KEY, type Navigate } from "$lib/nav.svelte"; import { DEFAULT_PAGE } from "$lib/sidebar";
import { NAVIGATE_KEY, type Navigate } from "$lib/nav";
import { initTheme } from "$lib/theme"; import { initTheme } from "$lib/theme";
import { VIEWS } from "$lib/views/registry"; import { VIEWS } from "$lib/views/registry";
import "$lib/styles/app.css";
let ActiveView = $derived(VIEWS[nav.page]); let activePage = $state(DEFAULT_PAGE);
let ActiveView = $derived(VIEWS[activePage]);
setContext<Navigate>(NAVIGATE_KEY, go); setContext<Navigate>(NAVIGATE_KEY, (page) => {
activePage = page;
});
onMount(() => { onMount(() => {
initTheme(); initTheme();
// Screenshot mode only (src-tauri's screenshot.rs) — lets the Rust
// core drive which sidebar section is showing for a capture without
// a real user ever clicking the sidebar.
const unlisten = listen<string>("screenshot-set-view", (event) => { const unlisten = listen<string>("screenshot-set-view", (event) => {
go(event.payload); activePage = event.payload;
}); });
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
nav.searchNonce += 1;
go("home");
}
};
window.addEventListener("keydown", onKey);
return () => { return () => {
unlisten.then((f) => f()); unlisten.then((f) => f());
window.removeEventListener("keydown", onKey);
}; };
}); });
</script> </script>
<div class="app">
<Titlebar />
<div class="shell"> <div class="shell">
<Sidebar /> <Sidebar bind:activePage />
<main class="content"> <main class="content">
{#key nav.page}
{#if ActiveView} {#if ActiveView}
<ActiveView /> <ActiveView />
{:else} {:else}
<Placeholder page={nav.page} /> <Placeholder page={activePage} />
{/if} {/if}
{/key}
</main> </main>
</div> </div>
</div>
<style> <style>
.app { :global(*) {
display: flex; box-sizing: border-box;
flex-direction: column; }
height: 100vh;
background-color: var(--bg, #12161c); :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 { .shell {
display: flex; display: flex;
flex: 1; height: 100vh;
min-height: 0; background-color: var(--bg, #0c0c0c);
} }
.content { .content {

View file

@ -3,6 +3,7 @@ import path from "node:path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from "@sveltejs/kit/vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
// The Rust/Tauri project dir (../src relative to this file) — an absolute // The Rust/Tauri project dir (../src relative to this file) — an absolute

454
src/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "bos-settings" name = "bos-settings"
version = "0.8.2" version = "0.8.0"
description = "System settings app for BOS (Bread Operating System)" description = "System settings app for BOS (Bread Operating System)"
authors = ["Breadway"] authors = ["Breadway"]
edition = "2021" edition = "2021"
@ -36,7 +36,7 @@ toml_edit = "0.22"
tokio = { version = "1", features = ["process", "io-util", "time", "macros"] } tokio = { version = "1", features = ["process", "io-util", "time", "macros"] }
notify = "7" notify = "7"
regex = "1" regex = "1"
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml"] } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml"] }
anyhow = "1" anyhow = "1"

View file

@ -5,10 +5,7 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-close", "opener:default",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-start-dragging",
"dialog:default" "dialog:default"
] ]
} }

View file

@ -23,10 +23,8 @@ fn os_pretty_name() -> String {
fs::read_to_string("/etc/os-release") fs::read_to_string("/etc/os-release")
.ok() .ok()
.and_then(|s| { .and_then(|s| {
s.lines().find_map(|l| { s.lines()
l.strip_prefix("PRETTY_NAME=") .find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string()))
.map(|v| v.trim_matches('"').to_string())
})
}) })
.unwrap_or_else(|| "BOS".to_string()) .unwrap_or_else(|| "BOS".to_string())
} }
@ -51,15 +49,11 @@ fn cpu() -> String {
let model = fs::read_to_string("/proc/cpuinfo") let model = fs::read_to_string("/proc/cpuinfo")
.ok() .ok()
.and_then(|s| { .and_then(|s| {
s.lines().find_map(|l| { s.lines()
l.strip_prefix("model name") .find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string()))
.map(|v| v.trim_start_matches([':', ' ', '\t']).to_string())
})
}) })
.unwrap_or_else(|| "unknown".to_string()); .unwrap_or_else(|| "unknown".to_string());
let cores = std::thread::available_parallelism() let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
.map(|n| n.get())
.unwrap_or(0);
if cores > 0 { if cores > 0 {
format!("{model} ({cores} threads)") format!("{model} ({cores} threads)")
} else { } else {
@ -68,7 +62,9 @@ fn cpu() -> String {
} }
fn memory() -> String { fn memory() -> String {
let kb = fs::read_to_string("/proc/meminfo").ok().and_then(|s| { let kb = fs::read_to_string("/proc/meminfo")
.ok()
.and_then(|s| {
s.lines() s.lines()
.find(|l| l.starts_with("MemTotal:")) .find(|l| l.starts_with("MemTotal:"))
.and_then(|l| l.split_whitespace().nth(1)) .and_then(|l| l.split_whitespace().nth(1))
@ -100,11 +96,7 @@ async fn gpu() -> String {
} }
async fn disk_usage() -> String { async fn disk_usage() -> String {
let Ok(output) = Command::new("df") let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output().await else {
.args(["-h", "--output=used,size,pcent", "/"])
.output()
.await
else {
return "unknown".to_string(); return "unknown".to_string();
}; };
let text = String::from_utf8_lossy(&output.stdout); let text = String::from_utf8_lossy(&output.stdout);
@ -144,35 +136,11 @@ pub async fn get_system_info() -> SystemInfo {
} }
} }
/// RFC 1123 labels (digit start allowed), no leading `-`. Linux static
/// hostnames are also capped at `HOST_NAME_MAX` (64).
fn valid_hostname(name: &str) -> bool {
let name = name.trim();
if name.is_empty() || name.len() > 64 || name.starts_with('-') {
return false;
}
if name.contains('\n') || name.contains('\r') || name.contains('\0') {
return false;
}
name.split('.').all(valid_dns_label)
}
fn valid_dns_label(label: &str) -> bool {
let b = label.as_bytes();
if b.is_empty() || b.len() > 63 {
return false;
}
if !b[0].is_ascii_alphanumeric() || !b[b.len() - 1].is_ascii_alphanumeric() {
return false;
}
b.iter().all(|c| c.is_ascii_alphanumeric() || *c == b'-')
}
#[tauri::command] #[tauri::command]
pub async fn set_hostname(name: String) -> Result<(), String> { pub async fn set_hostname(name: String) -> Result<(), String> {
let name = name.trim(); let name = name.trim();
if !valid_hostname(name) { if name.is_empty() {
return Err("invalid hostname".into()); return Err("Hostname can't be empty".into());
} }
let output = Command::new("pkexec") let output = Command::new("pkexec")
.args(["hostnamectl", "set-hostname", name]) .args(["hostnamectl", "set-hostname", name])
@ -185,24 +153,3 @@ pub async fn set_hostname(name: String) -> Result<(), String> {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostname_rfc1123() {
assert!(valid_hostname("bos"));
assert!(valid_hostname("bos.local"));
assert!(valid_hostname("a1-b"));
assert!(valid_hostname("1host"));
assert!(!valid_hostname(""));
assert!(!valid_hostname("-bos"));
assert!(!valid_hostname("bos-"));
assert!(!valid_hostname("-foo.bar"));
assert!(!valid_hostname("foo_bar"));
assert!(!valid_hostname("bos\n-set-hostname evil"));
assert!(!valid_hostname("--help"));
assert!(!valid_hostname(&"a".repeat(65)));
}
}

View file

@ -69,7 +69,5 @@ pub fn get_appearance() -> Appearance {
#[tauri::command] #[tauri::command]
pub fn save_appearance(appearance: Appearance) -> Result<(), String> { pub fn save_appearance(appearance: Appearance) -> Result<(), String> {
let json = serde_json::to_string_pretty(&appearance).map_err(|e| e.to_string())?; let json = serde_json::to_string_pretty(&appearance).map_err(|e| e.to_string())?;
config::atomic_write(&config_path(), &json).map_err(|e| e.to_string())?; config::atomic_write(&config_path(), &json).map_err(|e| e.to_string())
super::util::hypr_reload();
Ok(())
} }

View file

@ -10,8 +10,6 @@
use serde::Serialize; use serde::Serialize;
use super::util;
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
pub struct AurResult { pub struct AurResult {
name: String, name: String,
@ -21,11 +19,7 @@ pub struct AurResult {
#[tauri::command] #[tauri::command]
pub async fn search_aur(query: String) -> Vec<AurResult> { pub async fn search_aur(query: String) -> Vec<AurResult> {
let Ok(output) = tokio::process::Command::new("yay") let Ok(output) = tokio::process::Command::new("yay").args(["-Ss", "--aur", &query]).output().await else {
.args(["-Ss", "--aur", &query])
.output()
.await
else {
return Vec::new(); return Vec::new();
}; };
let text = String::from_utf8_lossy(&output.stdout); let text = String::from_utf8_lossy(&output.stdout);
@ -34,18 +28,12 @@ pub async fn search_aur(query: String) -> Vec<AurResult> {
while let Some(header) = lines.next() { while let Some(header) = lines.next() {
// "aur/name version (+votes score) [Orphaned]" — name/version are // "aur/name version (+votes score) [Orphaned]" — name/version are
// always the first two whitespace-separated fields after "aur/". // always the first two whitespace-separated fields after "aur/".
let Some(rest) = header.strip_prefix("aur/") else { let Some(rest) = header.strip_prefix("aur/") else { continue };
continue;
};
let mut parts = rest.split_whitespace(); let mut parts = rest.split_whitespace();
let Some(name) = parts.next() else { continue }; let Some(name) = parts.next() else { continue };
let version = parts.next().unwrap_or("").to_string(); let version = parts.next().unwrap_or("").to_string();
let description = lines.next().unwrap_or("").trim().to_string(); let description = lines.next().unwrap_or("").trim().to_string();
results.push(AurResult { results.push(AurResult { name: name.to_string(), version, description });
name: name.to_string(),
version,
description,
});
if results.len() >= 50 { if results.len() >= 50 {
break; break;
} }
@ -54,13 +42,6 @@ pub async fn search_aur(query: String) -> Vec<AurResult> {
} }
#[tauri::command] #[tauri::command]
pub fn install_aur_package(pkg: String) -> Result<(), String> { pub fn install_aur_package(pkg: String) {
if !util::valid_pkg_name(&pkg) { let _ = std::process::Command::new("kitty").args(["-e", "yay", "-S", &pkg]).spawn();
return Err(format!("refusing to install '{pkg}'"));
}
std::process::Command::new("kitty")
.args(["-e", "yay", "-S", &pkg])
.spawn()
.map_err(|e| e.to_string())?;
Ok(())
} }

View file

@ -239,7 +239,7 @@ pub fn valid_restore_target(path: &Path) -> bool {
return false; return false;
} }
let normalized = normalize_abs(path); let normalized = normalize_abs(path);
if normalized == *"/" { if normalized == PathBuf::from("/") {
return false; return false;
} }
normalized != normalize_abs(&home_dir()) normalized != normalize_abs(&home_dir())

View file

@ -35,35 +35,22 @@ pub struct BreadpadConfig {
pub fn get_breadpad_config() -> BreadpadConfig { pub fn get_breadpad_config() -> BreadpadConfig {
let doc = config::load_doc(&config_path()); let doc = config::load_doc(&config_path());
BreadpadConfig { BreadpadConfig {
default_type: config::get_str(&doc, &["settings", "default_type"]) default_type: config::get_str(&doc, &["settings", "default_type"]).unwrap_or_else(|| "note".into()),
.unwrap_or_else(|| "note".into()),
workspace_tag: config::get_bool(&doc, &["settings", "workspace_tag"]).unwrap_or(true), workspace_tag: config::get_bool(&doc, &["settings", "workspace_tag"]).unwrap_or(true),
snooze_options: config::get_str_list(&doc, &["settings", "snooze_options"]), snooze_options: config::get_str_list(&doc, &["settings", "snooze_options"]),
archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"]) archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"]).unwrap_or(30),
.unwrap_or(30),
model_path: config::get_str(&doc, &["model", "path"]).unwrap_or_default(), model_path: config::get_str(&doc, &["model", "path"]).unwrap_or_default(),
tokenizer_path: config::get_str(&doc, &["model", "tokenizer"]).unwrap_or_default(), tokenizer_path: config::get_str(&doc, &["model", "tokenizer"]).unwrap_or_default(),
ollama_enabled: config::get_bool(&doc, &["model", "ollama", "enabled"]).unwrap_or(true), ollama_enabled: config::get_bool(&doc, &["model", "ollama", "enabled"]).unwrap_or(true),
ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"]) ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"]).unwrap_or_default(),
.unwrap_or_default(),
ollama_model: config::get_str(&doc, &["model", "ollama", "model"]).unwrap_or_default(), ollama_model: config::get_str(&doc, &["model", "ollama", "model"]).unwrap_or_default(),
ollama_confidence_threshold: config::get_f64( ollama_confidence_threshold: config::get_f64(&doc, &["model", "ollama", "confidence_threshold"]).unwrap_or(0.6),
&doc, reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"]).unwrap_or_else(|| "7:00".into()),
&["model", "ollama", "confidence_threshold"], reminders_missed_grace_minutes: config::get_i64(&doc, &["reminders", "missed_grace_minutes"]).unwrap_or(60),
)
.unwrap_or(0.6),
reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"])
.unwrap_or_else(|| "7:00".into()),
reminders_missed_grace_minutes: config::get_i64(
&doc,
&["reminders", "missed_grace_minutes"],
)
.unwrap_or(60),
calendar_enabled: config::get_bool(&doc, &["calendar", "enabled"]).unwrap_or(false), calendar_enabled: config::get_bool(&doc, &["calendar", "enabled"]).unwrap_or(false),
calendar_url: config::get_str(&doc, &["calendar", "url"]).unwrap_or_default(), calendar_url: config::get_str(&doc, &["calendar", "url"]).unwrap_or_default(),
calendar_username: config::get_str(&doc, &["calendar", "username"]).unwrap_or_default(), calendar_username: config::get_str(&doc, &["calendar", "username"]).unwrap_or_default(),
// Write-only to the webview, same as restic — never round-trip the secret. calendar_password: config::get_str(&doc, &["calendar", "password"]).unwrap_or_default(),
calendar_password: String::new(),
} }
} }
@ -73,76 +60,19 @@ pub fn save_breadpad_config(cfg: BreadpadConfig) -> Result<(), String> {
let mut doc = config::load_doc(&path); let mut doc = config::load_doc(&path);
config::set_str(&mut doc, &["settings", "default_type"], &cfg.default_type); config::set_str(&mut doc, &["settings", "default_type"], &cfg.default_type);
config::set_bool(&mut doc, &["settings", "workspace_tag"], cfg.workspace_tag); config::set_bool(&mut doc, &["settings", "workspace_tag"], cfg.workspace_tag);
config::set_str_list( config::set_str_list(&mut doc, &["settings", "snooze_options"], &cfg.snooze_options);
&mut doc, config::set_i64(&mut doc, &["settings", "archive_after_days"], cfg.archive_after_days);
&["settings", "snooze_options"],
&cfg.snooze_options,
);
config::set_i64(
&mut doc,
&["settings", "archive_after_days"],
cfg.archive_after_days,
);
config::set_str_or_remove(&mut doc, &["model", "path"], &cfg.model_path); config::set_str_or_remove(&mut doc, &["model", "path"], &cfg.model_path);
config::set_str_or_remove(&mut doc, &["model", "tokenizer"], &cfg.tokenizer_path); config::set_str_or_remove(&mut doc, &["model", "tokenizer"], &cfg.tokenizer_path);
config::set_bool( config::set_bool(&mut doc, &["model", "ollama", "enabled"], cfg.ollama_enabled);
&mut doc, config::set_str_or_remove(&mut doc, &["model", "ollama", "endpoint"], &cfg.ollama_endpoint);
&["model", "ollama", "enabled"],
cfg.ollama_enabled,
);
config::set_str_or_remove(
&mut doc,
&["model", "ollama", "endpoint"],
&cfg.ollama_endpoint,
);
config::set_str_or_remove(&mut doc, &["model", "ollama", "model"], &cfg.ollama_model); config::set_str_or_remove(&mut doc, &["model", "ollama", "model"], &cfg.ollama_model);
config::set_f64( config::set_f64(&mut doc, &["model", "ollama", "confidence_threshold"], cfg.ollama_confidence_threshold);
&mut doc, config::set_str_or_remove(&mut doc, &["reminders", "default_morning"], &cfg.reminders_default_morning);
&["model", "ollama", "confidence_threshold"], config::set_i64(&mut doc, &["reminders", "missed_grace_minutes"], cfg.reminders_missed_grace_minutes);
cfg.ollama_confidence_threshold,
);
config::set_str_or_remove(
&mut doc,
&["reminders", "default_morning"],
&cfg.reminders_default_morning,
);
config::set_i64(
&mut doc,
&["reminders", "missed_grace_minutes"],
cfg.reminders_missed_grace_minutes,
);
config::set_bool(&mut doc, &["calendar", "enabled"], cfg.calendar_enabled); config::set_bool(&mut doc, &["calendar", "enabled"], cfg.calendar_enabled);
config::set_str_or_remove(&mut doc, &["calendar", "url"], &cfg.calendar_url); config::set_str_or_remove(&mut doc, &["calendar", "url"], &cfg.calendar_url);
config::set_str_or_remove(&mut doc, &["calendar", "username"], &cfg.calendar_username); config::set_str_or_remove(&mut doc, &["calendar", "username"], &cfg.calendar_username);
apply_calendar_password(&mut doc, &cfg.calendar_password); config::set_str_or_remove(&mut doc, &["calendar", "password"], &cfg.calendar_password);
config::save_doc(&path, &doc).map_err(|e| e.to_string()) config::save_doc(&path, &doc).map_err(|e| e.to_string())
} }
/// Empty incoming password keeps the existing secret (PasswordField is write-only).
fn apply_calendar_password(doc: &mut toml_edit::DocumentMut, incoming: &str) {
if incoming.is_empty() {
return;
}
config::set_str(doc, &["calendar", "password"], incoming);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_password_keeps_existing_secret() {
let mut doc: toml_edit::DocumentMut =
"[calendar]\npassword = \"secret\"\n".parse().unwrap();
apply_calendar_password(&mut doc, "");
assert_eq!(
config::get_str(&doc, &["calendar", "password"]).as_deref(),
Some("secret")
);
apply_calendar_password(&mut doc, "newpass");
assert_eq!(
config::get_str(&doc, &["calendar", "password"]).as_deref(),
Some("newpass")
);
}
}

View file

@ -26,12 +26,7 @@ async fn list_timezones() -> Vec<String> {
.output() .output()
.await .await
.ok() .ok()
.map(|o| { .map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect())
String::from_utf8_lossy(&o.stdout)
.lines()
.map(str::to_string)
.collect()
})
.unwrap_or_default() .unwrap_or_default()
} }
@ -65,32 +60,10 @@ pub async fn get_datetime_info() -> DateTimeInfo {
} }
} }
/// Reject flags, path traversal, and newlines before we ever exec. Charset
/// matches IANA names (`Area/City`, `UTC`, `Etc/GMT+6`).
fn timezone_looks_safe(tz: &str) -> bool {
let tz = tz.trim();
if tz.is_empty() || tz.len() > 64 || tz.starts_with('-') {
return false;
}
if tz.contains('\n') || tz.contains('\r') || tz.contains('\0') || tz.contains("..") {
return false;
}
tz.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-'))
}
#[tauri::command] #[tauri::command]
pub async fn set_timezone(tz: String) -> Result<(), String> { pub async fn set_timezone(tz: String) -> Result<(), String> {
let tz = tz.trim();
if !timezone_looks_safe(tz) {
return Err("invalid timezone".into());
}
let listed = list_timezones().await;
if !listed.is_empty() && !listed.iter().any(|t| t == tz) {
return Err("unknown timezone".into());
}
let output = Command::new("pkexec") let output = Command::new("pkexec")
.args(["timedatectl", "set-timezone", tz]) .args(["timedatectl", "set-timezone", &tz])
.output() .output()
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@ -104,28 +77,6 @@ pub async fn set_timezone(tz: String) -> Result<(), String> {
#[tauri::command] #[tauri::command]
pub async fn set_ntp_enabled(enabled: bool) -> Result<(), String> { pub async fn set_ntp_enabled(enabled: bool) -> Result<(), String> {
let val = if enabled { "true" } else { "false" }; let val = if enabled { "true" } else { "false" };
Command::new("pkexec") Command::new("pkexec").args(["timedatectl", "set-ntp", val]).status().await.map_err(|e| e.to_string())?;
.args(["timedatectl", "set-ntp", val])
.status()
.await
.map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timezone_rejects_flags_and_traversal() {
assert!(timezone_looks_safe("UTC"));
assert!(timezone_looks_safe("America/New_York"));
assert!(timezone_looks_safe("Etc/GMT+6"));
assert!(!timezone_looks_safe(""));
assert!(!timezone_looks_safe("-UTC"));
assert!(!timezone_looks_safe("--help"));
assert!(!timezone_looks_safe("America/../UTC"));
assert!(!timezone_looks_safe("UTC\n--adjust"));
assert!(!timezone_looks_safe("UTC;reboot"));
}
}

View file

@ -32,9 +32,7 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if stderr.is_empty() { return Err(if stderr.is_empty() {
match output.status.code() { match output.status.code() {
Some(127) => { Some(127) => "no polkit authentication agent is available in this session".to_string(),
"no polkit authentication agent is available in this session".to_string()
}
Some(code) => format!("pkexec exited with status {code}"), Some(code) => format!("pkexec exited with status {code}"),
None => "pkexec was terminated by a signal".to_string(), None => "pkexec was terminated by a signal".to_string(),
} }
@ -43,10 +41,7 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
}); });
} }
let text = String::from_utf8_lossy(&output.stdout); let text = String::from_utf8_lossy(&output.stdout);
let active = text let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active");
.lines()
.next()
.is_some_and(|l| l.trim() == "Status: active");
let rules = text let rules = text
.lines() .lines()
.filter_map(|l| { .filter_map(|l| {
@ -56,10 +51,7 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
} }
let (num, rest) = l.split_once(']')?; let (num, rest) = l.split_once(']')?;
let number = num.trim_start_matches('[').trim().to_string(); let number = num.trim_start_matches('[').trim().to_string();
Some(FirewallRule { Some(FirewallRule { number, text: rest.trim().to_string() })
number,
text: rest.trim().to_string(),
})
}) })
.collect(); .collect();
Ok(FirewallStatus { active, rules }) Ok(FirewallStatus { active, rules })
@ -68,11 +60,7 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
#[tauri::command] #[tauri::command]
pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> { pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> {
let verb = if enabled { "enable" } else { "disable" }; let verb = if enabled { "enable" } else { "disable" };
let output = Command::new("pkexec") let output = Command::new("pkexec").args(["ufw", "--force", verb]).output().await.map_err(|e| e.to_string())?;
.args(["ufw", "--force", verb])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() { if output.status.success() {
Ok(()) Ok(())
} else { } else {
@ -80,54 +68,9 @@ pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> {
} }
} }
/// Port, optional `/tcp`/`/udp`, or optional space-separated proto. No
/// service names, IPs, or flags — those become extra `ufw allow` operands.
fn valid_firewall_rule(rule: &str) -> bool {
let rule = rule.trim();
if rule.is_empty() || rule.len() > 16 || rule.starts_with('-') {
return false;
}
if rule.contains('\n') || rule.contains('\r') || rule.contains('\0') {
return false;
}
let (port, proto) = if let Some((p, rest)) = rule.split_once('/') {
(p, Some(rest))
} else if let Some((p, rest)) = rule.split_once(' ') {
(p, Some(rest.trim()))
} else {
(rule, None)
};
let Ok(n) = port.parse::<u16>() else {
return false;
};
if n == 0 {
return false;
}
match proto {
None => true,
Some(p) => p == "tcp" || p == "udp",
}
}
fn valid_rule_number(number: &str) -> bool {
let t = number.trim();
!t.is_empty()
&& t.len() <= 8
&& t.bytes().all(|b| b.is_ascii_digit())
&& t.parse::<u32>().is_ok_and(|n| n > 0)
}
#[tauri::command] #[tauri::command]
pub async fn add_firewall_rule(rule: String) -> Result<(), String> { pub async fn add_firewall_rule(rule: String) -> Result<(), String> {
let rule = rule.trim(); let output = Command::new("pkexec").args(["ufw", "allow", rule.trim()]).output().await.map_err(|e| e.to_string())?;
if !valid_firewall_rule(rule) {
return Err("invalid firewall rule".into());
}
let output = Command::new("pkexec")
.args(["ufw", "allow", rule])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() { if output.status.success() {
Ok(()) Ok(())
} else { } else {
@ -137,48 +80,10 @@ pub async fn add_firewall_rule(rule: String) -> Result<(), String> {
#[tauri::command] #[tauri::command]
pub async fn remove_firewall_rule(number: String) -> Result<(), String> { pub async fn remove_firewall_rule(number: String) -> Result<(), String> {
if !valid_rule_number(&number) { let output = Command::new("pkexec").args(["ufw", "--force", "delete", &number]).output().await.map_err(|e| e.to_string())?;
return Err("invalid rule number".into());
}
let output = Command::new("pkexec")
.args(["ufw", "--force", "delete", number.trim()])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() { if output.status.success() {
Ok(()) Ok(())
} else { } else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn firewall_rule_is_port_and_optional_proto() {
assert!(valid_firewall_rule("22"));
assert!(valid_firewall_rule("8080/tcp"));
assert!(valid_firewall_rule("53/udp"));
assert!(valid_firewall_rule("80 tcp"));
assert!(!valid_firewall_rule("OpenSSH"));
assert!(!valid_firewall_rule("-f"));
assert!(!valid_firewall_rule("22;id"));
assert!(!valid_firewall_rule("22/tcp\nallow 23"));
assert!(!valid_firewall_rule("0"));
assert!(!valid_firewall_rule("65536"));
assert!(!valid_firewall_rule("22/all"));
}
#[test]
fn firewall_delete_is_positive_int() {
assert!(valid_rule_number("1"));
assert!(valid_rule_number("12"));
assert!(!valid_rule_number("0"));
assert!(!valid_rule_number("-1"));
assert!(!valid_rule_number("1;2"));
assert!(!valid_rule_number("1\n2"));
assert!(!valid_rule_number(""));
}
}

View file

@ -51,30 +51,10 @@ fn hypr_path(name: &str) -> std::path::PathBuf {
config::config_dir().join("hypr").join(name) config::config_dir().join("hypr").join(name)
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize)]
pub struct LiveMonitor { pub struct LiveMonitor {
pub name: String, name: String,
pub mode: String, mode: String,
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
pub refresh: f64,
pub scale: f64,
pub transform: u32,
pub available_modes: Vec<String>,
}
fn json_i32(v: &serde_json::Value, key: &str) -> Option<i32> {
v.get(key)?.as_i64().map(|n| n as i32).or_else(|| v.get(key)?.as_f64().map(|n| n.round() as i32))
}
fn json_u32(v: &serde_json::Value, key: &str) -> Option<u32> {
v.get(key)?.as_u64().map(|n| n as u32).or_else(|| v.get(key)?.as_f64().map(|n| n.round() as u32))
}
fn json_f64(v: &serde_json::Value, key: &str) -> Option<f64> {
v.get(key)?.as_f64().or_else(|| v.get(key)?.as_u64().map(|n| n as f64))
} }
#[tauri::command] #[tauri::command]
@ -89,103 +69,15 @@ pub fn get_live_monitors() -> Vec<LiveMonitor> {
monitors monitors
.iter() .iter()
.filter_map(|m| { .filter_map(|m| {
let name = m.get("name")?.as_str()?.to_string(); let name = m.get("name")?.as_str()?;
let width = json_u32(m, "width")?; let w = m.get("width")?.as_u64()?;
let height = json_u32(m, "height")?; let h = m.get("height")?.as_u64()?;
let refresh = json_f64(m, "refreshRate").unwrap_or(60.0); let refresh = m.get("refreshRate")?.as_f64()?;
let x = json_i32(m, "x").unwrap_or(0); Some(LiveMonitor { name: name.to_string(), mode: format!("{w}x{h} @ {refresh:.0}Hz") })
let y = json_i32(m, "y").unwrap_or(0);
let scale = json_f64(m, "scale").unwrap_or(1.0).max(0.1);
let transform = json_u32(m, "transform").unwrap_or(0);
let available_modes = m
.get("availableModes")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
Some(LiveMonitor {
name,
mode: format!("{width}x{height} @ {refresh:.0}Hz"),
x,
y,
width,
height,
refresh,
scale,
transform,
available_modes,
})
}) })
.collect() .collect()
} }
fn write_monitor_rules(rules: &[MonitorRule]) -> Result<(), String> {
let file = MonitorsFile { monitors: rules.to_vec() };
let json = serde_json::to_string_pretty(&file).map_err(|e| e.to_string())?;
config::atomic_write(&config_path(), &json).map_err(|e| e.to_string())
}
fn lua_ident(name: &str) -> Result<(), String> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(format!("bad monitor name '{name}'"));
}
Ok(())
}
/// Live-apply a layout via BOS Hyprland `hl.monitor()`, then persist monitors.json.
#[tauri::command]
pub fn apply_monitor_layout(monitors: Vec<LiveMonitor>) -> Result<(), String> {
if monitors.is_empty() {
return Err("no monitors".into());
}
let mut stmts = Vec::new();
let mut rules = Vec::new();
for m in &monitors {
lua_ident(&m.name)?;
let refresh = if m.refresh > 1.0 { m.refresh.round() as u32 } else { 60 };
let mode = format!("{}x{}@{refresh}", m.width.max(1), m.height.max(1));
let position = format!("{}x{}", m.x, m.y);
let scale = if (m.scale - 1.0).abs() < 0.001 {
"1".to_string()
} else {
format!("{:.2}", m.scale)
};
let transform = m.transform.min(7);
stmts.push(format!(
"hl.monitor({{ output = \"{}\", mode = \"{mode}\", position = \"{position}\", scale = \"{scale}\", transform = {transform}, vrr = false }})",
m.name
));
rules.push(MonitorRule {
output: m.name.clone(),
mode,
position,
scale,
});
}
let lua = stmts.join("; ");
let output = std::process::Command::new("hyprctl")
.args(["eval", &lua])
.output()
.map_err(|e| e.to_string())?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout != "ok" {
let low = stdout.to_lowercase();
if low.contains("unknown request") || low.contains("unknown command") || stdout.is_empty() {
return Err("Live layout needs BOS Hyprland (hyprctl eval / hl.monitor).".into());
}
return Err(format!("hyprctl: {stdout}"));
}
write_monitor_rules(&rules)?;
Ok(())
}
#[tauri::command] #[tauri::command]
pub fn get_monitor_rules() -> Vec<MonitorRule> { pub fn get_monitor_rules() -> Vec<MonitorRule> {
std::fs::read_to_string(config_path()) std::fs::read_to_string(config_path())
@ -198,9 +90,9 @@ pub fn get_monitor_rules() -> Vec<MonitorRule> {
#[tauri::command] #[tauri::command]
pub fn save_monitor_rules(rules: Vec<MonitorRule>) -> Result<(), String> { pub fn save_monitor_rules(rules: Vec<MonitorRule>) -> Result<(), String> {
write_monitor_rules(&rules)?; let file = MonitorsFile { monitors: rules };
super::util::hypr_reload(); let json = serde_json::to_string_pretty(&file).map_err(|e| e.to_string())?;
Ok(()) config::atomic_write(&config_path(), &json).map_err(|e| e.to_string())
} }
/// Opens `hyprland.lua` in `$EDITOR` (nano if unset) inside a terminal — /// Opens `hyprland.lua` in `$EDITOR` (nano if unset) inside a terminal —

View file

@ -254,9 +254,7 @@ pub fn get_keybinds() -> BindsPayload {
#[tauri::command] #[tauri::command]
pub fn save_keybinds(file: BindsFile, kind: SchemaKind) -> Result<(), String> { pub fn save_keybinds(file: BindsFile, kind: SchemaKind) -> Result<(), String> {
save(&file, kind).map_err(|e| e.to_string())?; save(&file, kind).map_err(|e| e.to_string())
super::util::hypr_reload();
Ok(())
} }
#[cfg(test)] #[cfg(test)]

View file

@ -7,8 +7,6 @@ use serde::Serialize;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use tokio::process::Command; use tokio::process::Command;
use super::util;
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
pub struct WifiNetwork { pub struct WifiNetwork {
ssid: String, ssid: String,
@ -97,9 +95,6 @@ pub async fn scan_wifi() -> Vec<WifiNetwork> {
#[tauri::command] #[tauri::command]
pub async fn connect_wifi(ssid: String, password: Option<String>) -> Result<(), String> { pub async fn connect_wifi(ssid: String, password: Option<String>) -> Result<(), String> {
if !util::valid_nm_id(&ssid) {
return Err(format!("invalid SSID '{ssid}'"));
}
let known = known_connection_names().await; let known = known_connection_names().await;
let output = if let Some(password) = password { let output = if let Some(password) = password {
Command::new("nmcli").args(["dev", "wifi", "connect", &ssid, "password", &password]).output().await Command::new("nmcli").args(["dev", "wifi", "connect", &ssid, "password", &password]).output().await

View file

@ -97,7 +97,7 @@ pub fn read_nvidia_offer() -> Option<NvidiaOffer> {
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
.filter(|p| !p.is_empty()) .filter(|p| !p.is_empty())
.unwrap_or_else(default_packages); .unwrap_or_else(|| default_packages());
return Some(NvidiaOffer { return Some(NvidiaOffer {
gpu, gpu,
reason, reason,

View file

@ -7,22 +7,13 @@
use serde::Serialize; use serde::Serialize;
use tokio::process::Command; use tokio::process::Command;
use super::util;
async fn upower_device(kind: &str) -> Option<String> { async fn upower_device(kind: &str) -> Option<String> {
let out = Command::new("upower").arg("-e").output().await.ok()?; let out = Command::new("upower").arg("-e").output().await.ok()?;
String::from_utf8_lossy(&out.stdout) String::from_utf8_lossy(&out.stdout).lines().find(|l| l.to_lowercase().contains(kind)).map(str::to_string)
.lines()
.find(|l| l.to_lowercase().contains(kind))
.map(str::to_string)
} }
async fn upower_field(device: &str, field: &str) -> Option<String> { async fn upower_field(device: &str, field: &str) -> Option<String> {
let out = Command::new("upower") let out = Command::new("upower").args(["-i", device]).output().await.ok()?;
.args(["-i", device])
.output()
.await
.ok()?;
let text = String::from_utf8_lossy(&out.stdout); let text = String::from_utf8_lossy(&out.stdout);
text.lines() text.lines()
.find(|l| l.trim_start().starts_with(field)) .find(|l| l.trim_start().starts_with(field))
@ -48,18 +39,12 @@ async fn battery_summary() -> Vec<(String, String)> {
if let Some(t) = t { if let Some(t) = t {
rows.push(("Time remaining".to_string(), t)); rows.push(("Time remaining".to_string(), t));
} }
let full: Option<f64> = upower_field(&bat, "energy-full") let full: Option<f64> = upower_field(&bat, "energy-full").await.and_then(|v| v.split_whitespace().next()?.parse().ok());
.await let design: Option<f64> =
.and_then(|v| v.split_whitespace().next()?.parse().ok()); upower_field(&bat, "energy-full-design").await.and_then(|v| v.split_whitespace().next()?.parse().ok());
let design: Option<f64> = upower_field(&bat, "energy-full-design")
.await
.and_then(|v| v.split_whitespace().next()?.parse().ok());
if let (Some(full), Some(design)) = (full, design) { if let (Some(full), Some(design)) = (full, design) {
if design > 0.0 { if design > 0.0 {
rows.push(( rows.push(("Battery health".to_string(), format!("{:.0}% of design capacity", full / design * 100.0)));
"Battery health".to_string(),
format!("{:.0}% of design capacity", full / design * 100.0),
));
} }
} }
rows rows
@ -79,19 +64,12 @@ async fn power_source() -> String {
async fn tlp_profile() -> Option<String> { async fn tlp_profile() -> Option<String> {
let out = Command::new("tlp-stat").arg("-s").output().await.ok()?; let out = Command::new("tlp-stat").arg("-s").output().await.ok()?;
let text = String::from_utf8_lossy(&out.stdout); let text = String::from_utf8_lossy(&out.stdout);
text.lines() text.lines().find(|l| l.trim_start().starts_with("TLP profile")).and_then(|l| l.split('=').nth(1)).map(|v| v.trim().to_string())
.find(|l| l.trim_start().starts_with("TLP profile"))
.and_then(|l| l.split('=').nth(1))
.map(|v| v.trim().to_string())
} }
async fn brightness_device() -> Option<String> { async fn brightness_device() -> Option<String> {
let out = Command::new("brightnessctl").output().await.ok()?; let out = Command::new("brightnessctl").output().await.ok()?;
String::from_utf8_lossy(&out.stdout) String::from_utf8_lossy(&out.stdout).lines().find(|l| l.starts_with("Device")).and_then(|l| l.split('\'').nth(1)).map(str::to_string)
.lines()
.find(|l| l.starts_with("Device"))
.and_then(|l| l.split('\'').nth(1))
.map(str::to_string)
} }
async fn brightness_pct() -> Option<u32> { async fn brightness_pct() -> Option<u32> {
@ -120,10 +98,7 @@ fn charge_threshold_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)>
} }
fn read_threshold(path: &std::path::Path) -> i64 { fn read_threshold(path: &std::path::Path) -> i64 {
std::fs::read_to_string(path) std::fs::read_to_string(path).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(100)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(100)
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -154,59 +129,17 @@ pub async fn set_brightness(percent: i64) -> Result<(), String> {
let Some(device) = brightness_device().await else { let Some(device) = brightness_device().await else {
return Err("No controllable backlight found".into()); return Err("No controllable backlight found".into());
}; };
// Clamp before handing the value to brightnessctl. let pct = format!("{percent}%");
let pct = format!("{}%", percent.clamp(0, 100)); Command::new("brightnessctl").args(["--device", &device, "set", &pct]).status().await.map_err(|e| e.to_string())?;
Command::new("brightnessctl")
.args(["--device", &device, "set", &pct])
.status()
.await
.map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
fn charge_threshold_write(which: &str, percent: i64) -> Result<(String, i64), String> {
if which != "start" && which != "end" {
return Err("threshold must be start or end".into());
}
Ok((which.to_string(), percent.clamp(0, 100)))
}
#[tauri::command] #[tauri::command]
pub async fn set_charge_threshold(which: String, percent: i64) -> Result<(), String> { pub async fn set_charge_threshold(which: String, percent: i64) -> Result<(), String> {
let (which, percent) = charge_threshold_write(&which, percent)?;
let Some((start, end)) = charge_threshold_paths() else { let Some((start, end)) = charge_threshold_paths() else {
return Err("No charge threshold support on this hardware".into()); return Err("No charge threshold support on this hardware".into());
}; };
let path = if which == "start" { start } else { end }; let path = if which == "start" { start } else { end };
// GNU tee writes stdin to its path operands — the percent must be piped, Command::new("pkexec").args(["tee", &path.display().to_string()]).arg(percent.to_string()).output().await.map_err(|e| e.to_string())?;
// not passed as a second path argument.
let input = format!("{percent}\n");
if util::run_with_stdin(&["pkexec", "tee", &path.display().to_string()], &input).await {
Ok(()) Ok(())
} else {
Err("Failed to set charge threshold".into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn charge_threshold_clamps_and_restricts_which() {
assert_eq!(
charge_threshold_write("start", 80).unwrap(),
("start".into(), 80)
);
assert_eq!(
charge_threshold_write("end", 150).unwrap(),
("end".into(), 100)
);
assert_eq!(
charge_threshold_write("start", -5).unwrap(),
("start".into(), 0)
);
assert!(charge_threshold_write("both", 50).is_err());
assert!(charge_threshold_write("-start", 50).is_err());
}
} }

View file

@ -21,19 +21,6 @@ pub enum ServiceAction {
Restart, Restart,
} }
/// Units the frontend already hardcodes in ServiceControl call sites.
const ALLOWED_UNITS: &[&str] = &[
"breadd.service",
"breadclipd.service",
"breadcrumbs.service",
"breadmill.service",
"breadbox-sync.service",
];
fn allowed_unit(unit: &str) -> bool {
ALLOWED_UNITS.contains(&unit)
}
async fn systemctl_active(unit: &str) -> bool { async fn systemctl_active(unit: &str) -> bool {
Command::new("systemctl") Command::new("systemctl")
.args(["--user", "is-active", "--quiet", unit]) .args(["--user", "is-active", "--quiet", unit])
@ -53,21 +40,15 @@ async fn systemctl_enabled(unit: &str) -> bool {
} }
#[tauri::command] #[tauri::command]
pub async fn get_service_status(unit: String) -> Result<ServiceStatus, String> { pub async fn get_service_status(unit: String) -> ServiceStatus {
if !allowed_unit(&unit) { ServiceStatus {
return Err("unknown service".into());
}
Ok(ServiceStatus {
active: systemctl_active(&unit).await, active: systemctl_active(&unit).await,
enabled: systemctl_enabled(&unit).await, enabled: systemctl_enabled(&unit).await,
}) }
} }
#[tauri::command] #[tauri::command]
pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), String> { pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), String> {
if !allowed_unit(&unit) {
return Err("unknown service".into());
}
let verb = match action { let verb = match action {
ServiceAction::Start => "start", ServiceAction::Start => "start",
ServiceAction::Stop => "stop", ServiceAction::Stop => "stop",
@ -89,31 +70,8 @@ pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), S
/// panel, no reason to pull an open-ended `journalctl -f` tail into the /// panel, no reason to pull an open-ended `journalctl -f` tail into the
/// webview. /// webview.
#[tauri::command] #[tauri::command]
pub fn open_logs(unit: String) -> Result<(), String> { pub fn open_logs(unit: String) {
if !allowed_unit(&unit) { let _ = std::process::Command::new("kitty")
return Err("unknown service".into());
}
std::process::Command::new("kitty")
.args(["-e", "journalctl", "--user", "-u", &unit, "-f"]) .args(["-e", "journalctl", "--user", "-u", &unit, "-f"])
.spawn() .spawn();
.map_err(|e| e.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn units_match_frontend_hardcoded_list() {
assert!(allowed_unit("breadd.service"));
assert!(allowed_unit("breadclipd.service"));
assert!(allowed_unit("breadcrumbs.service"));
assert!(allowed_unit("breadmill.service"));
assert!(allowed_unit("breadbox-sync.service"));
assert!(!allowed_unit("sshd.service"));
assert!(!allowed_unit("breadd.service;reboot"));
assert!(!allowed_unit("../sshd.service"));
assert!(!allowed_unit("-u sshd"));
}
} }

View file

@ -1,8 +1,6 @@
use serde::Serialize; use serde::Serialize;
use tokio::process::Command; use tokio::process::Command;
use super::util;
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
pub struct SnapshotRow { pub struct SnapshotRow {
number: String, number: String,
@ -46,9 +44,6 @@ pub async fn get_snapshots() -> Result<Vec<SnapshotRow>, String> {
#[tauri::command] #[tauri::command]
pub async fn delete_snapshot(number: String) -> Result<(), String> { pub async fn delete_snapshot(number: String) -> Result<(), String> {
if !util::valid_number_id(&number) {
return Err("invalid snapshot number".into());
}
let output = Command::new("snapper").args(["delete", &number]).status().await.map_err(|e| e.to_string())?; let output = Command::new("snapper").args(["delete", &number]).status().await.map_err(|e| e.to_string())?;
if output.success() { if output.success() {
Ok(()) Ok(())

View file

@ -51,12 +51,6 @@ pub(crate) async fn run_hardcoded_env(
) -> bool { ) -> bool {
let mut cmd = Command::new(program); let mut cmd = Command::new(program);
cmd.args(args) cmd.args(args)
// Null stdin, never inherit: a child reading the app's inherited
// stdin (pkexec falling back to a tty password prompt, pacman's
// interactive confirm) would block forever and hang the install
// command. These commands are all non-interactive (--noconfirm/-y);
// authenticating goes through the polkit agent as a GUI dialog.
.stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.kill_on_drop(true); .kill_on_drop(true);

View file

@ -11,26 +11,17 @@ use tauri::{AppHandle, Emitter, Manager};
/// Initial theme fetch — called once by the frontend at startup. /// Initial theme fetch — called once by the frontend at startup.
#[tauri::command] #[tauri::command]
pub fn get_theme_css(window: tauri::WebviewWindow) -> String { pub fn get_theme_css() -> String {
render_theme_css(&palette_for_window(&window)) render_theme_css()
} }
fn palette_for_window(window: &tauri::WebviewWindow) -> bread_theme::Palette { fn render_theme_css() -> String {
window let palette = bread_theme::load_palette();
.current_monitor()
.ok()
.flatten()
.and_then(|m| m.name().map(|s| s.to_string()))
.map(|name| bread_theme::load_palette_for(&name))
.unwrap_or_else(bread_theme::load_palette)
}
fn render_theme_css(palette: &bread_theme::Palette) -> String {
// bread-theme v0.7.1 exposes Palette + ink_on + tokens, but not the // bread-theme v0.7.1 exposes Palette + ink_on + tokens, but not the
// later css_custom_properties / css_tokens helpers (those landed after // later css_custom_properties / css_tokens helpers (those landed after
// the tag). Emit the same :root custom-property names the Svelte app // the tag). Emit the same :root custom-property names the Svelte app
// already uses so a tag pin doesn't require a web-side rename. // already uses so a tag pin doesn't require a web-side rename.
format!("{}\n{}", css_custom_properties(palette), css_tokens()) format!("{}\n{}", css_custom_properties(&palette), css_tokens())
} }
fn css_custom_properties(p: &bread_theme::Palette) -> String { fn css_custom_properties(p: &bread_theme::Palette) -> String {
@ -39,7 +30,7 @@ fn css_custom_properties(p: &bread_theme::Palette) -> String {
("fg", p.foreground.as_str()), ("fg", p.foreground.as_str()),
("surface", p.color0.as_str()), ("surface", p.color0.as_str()),
("overlay", p.color7.as_str()), ("overlay", p.color7.as_str()),
("accent", p.color1.as_str()), ("accent", p.color4.as_str()),
("red", p.color1.as_str()), ("red", p.color1.as_str()),
("green", p.color2.as_str()), ("green", p.color2.as_str()),
("yellow", p.color3.as_str()), ("yellow", p.color3.as_str()),
@ -48,7 +39,7 @@ fn css_custom_properties(p: &bread_theme::Palette) -> String {
("teal", p.color6.as_str()), ("teal", p.color6.as_str()),
("on-bg", bread_theme::ink_on(&p.background)), ("on-bg", bread_theme::ink_on(&p.background)),
("on-surface", bread_theme::ink_on(&p.color0)), ("on-surface", bread_theme::ink_on(&p.color0)),
("on-accent", bread_theme::ink_on(&p.color1)), ("on-accent", bread_theme::ink_on(&p.color4)),
("on-red", bread_theme::ink_on(&p.color1)), ("on-red", bread_theme::ink_on(&p.color1)),
("on-overlay", bread_theme::ink_on(&p.color7)), ("on-overlay", bread_theme::ink_on(&p.color7)),
]; ];
@ -112,11 +103,7 @@ pub fn watch_and_emit(app: &AppHandle) {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
) && event.paths.iter().any(|p| p == &target_for_watcher); ) && event.paths.iter().any(|p| p == &target_for_watcher);
if touches_target { if touches_target {
let css = app_for_watcher let _ = app_for_watcher.emit("theme-changed", render_theme_css());
.get_webview_window("main")
.map(|w| render_theme_css(&palette_for_window(&w)))
.unwrap_or_else(|| render_theme_css(&bread_theme::load_palette()));
let _ = app_for_watcher.emit("theme-changed", css);
} }
}, },
notify::Config::default(), notify::Config::default(),

Some files were not shown because too many files have changed in this diff Show more