Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b34fd6c807 | |||
|
|
3dfc9d56f3 | ||
|
|
57898400d3 | ||
|
|
dce2031743 | ||
|
|
f7b114f778 | ||
|
|
77b5a223d8 | ||
|
|
a6cb245ae4 | ||
|
|
11c4ecb3cc | ||
|
|
73589ae36d | ||
|
|
3aad09c9ef | ||
|
|
0798fad697 | ||
|
|
60a473fff0 |
116 changed files with 7991 additions and 1242 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -32,3 +32,8 @@ logs/
|
|||
|
||||
# Claude Code local agent state
|
||||
.claude/
|
||||
|
||||
# .freebuff local tool state (not for commit)
|
||||
.freebuff/
|
||||
# graphify knowledge-graph output (local tool cache, not for commit)
|
||||
graphify-out/
|
||||
|
|
|
|||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
|
|
@ -19,6 +19,7 @@
|
|||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^26.4.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
|
|
@ -1308,6 +1309,16 @@
|
|||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"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": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
|
|
@ -1837,6 +1848,13 @@
|
|||
"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": {
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bos-settings-frontend",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.2",
|
||||
"description": "Frontend for BOS Settings",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^26.4.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
|
|
|
|||
10
frontend/src/lib/components/Embed.svelte
Normal file
10
frontend/src/lib/components/Embed.svelte
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<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()}
|
||||
|
|
@ -16,8 +16,8 @@
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--space-xl, 20px) 0;
|
||||
opacity: 0.7;
|
||||
padding: 28px 8px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,33 +22,35 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 1.05em;
|
||||
margin: 0 0 var(--space-sm, 8px);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
opacity: 0.75;
|
||||
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
line-height: 1.4;
|
||||
margin: 0 0 var(--space-sm, 8px);
|
||||
margin: -6px 0 12px;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Flex children default to refusing to shrink below their content's
|
||||
natural width (min-width: auto) — without this, a button/input
|
||||
with enough text overflows past the grid column's actual pixel
|
||||
width instead of wrapping, visually spilling into the next
|
||||
column. */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,9 @@
|
|||
|
||||
<style>
|
||||
.hint {
|
||||
opacity: 0.6;
|
||||
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
margin: 0 0 var(--space-xs, 4px);
|
||||
/* No-op unless this Hint is a direct child of ViewScaffold's CSS
|
||||
grid (i.e. used standalone between Groups, not nested inside
|
||||
one) — in that case it spans full width instead of getting
|
||||
squeezed into whichever grid column it happened to land in. */
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
51
frontend/src/lib/components/Hub.svelte
Normal file
51
frontend/src/lib/components/Hub.svelte
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<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>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
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
|
||||
// recombines them into that exact string on every change.
|
||||
let { label, value = $bindable() }: { label: string; value: string } = $props();
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@
|
|||
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);
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
|
|
|
|||
|
|
@ -1,57 +1,51 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { label, children }: { label: string; children: Snippet } = $props();
|
||||
let { label, hint, children }: { label: string; hint?: string; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="field-row">
|
||||
<span class="label">{label}</span>
|
||||
<div class="meta">
|
||||
<span class="label">{label}</span>
|
||||
{#if hint}<span class="hint">{hint}</span>{/if}
|
||||
</div>
|
||||
<div class="control">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* GNOME Settings' "boxed list" look: consecutive rows merge into one
|
||||
card with a thin divider between them, rounding only the outer
|
||||
corners of the run — not each row individually. `.field-row` is a
|
||||
distinctive name (not just `.row`) so the :global() adjacency rules
|
||||
below can't collide with an unrelated `.row` class elsewhere (e.g.
|
||||
Sidebar's own nav rows) — every Row instance (and everything that
|
||||
wraps it — InfoRow, the field components) renders this same element,
|
||||
so a run of them is just plain DOM adjacency; no parent wrapper
|
||||
component needed. */
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg, 16px);
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
gap: 14px;
|
||||
padding: 11px 2px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
:global(.field-row + .field-row) {
|
||||
margin-top: calc(var(--space-sm, 8px) * -1);
|
||||
border-top: 1px solid var(--bg);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent));
|
||||
}
|
||||
|
||||
:global(.field-row:has(+ .field-row)) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-bottom: 0;
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
</script>
|
||||
|
||||
<div class="row">
|
||||
<button disabled={saving} onclick={save}>Save</button>
|
||||
<button class="btn primary" disabled={saving} onclick={save}>Save</button>
|
||||
<span class="status">{status}</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -28,28 +28,11 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
margin-top: var(--space-lg, 16px);
|
||||
/* Always spans the full grid width, regardless of how many
|
||||
Group columns the rest of the page laid out above it. */
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
|
||||
let { label, value = $bindable(), options }: { label: string; value: string; options: string[] } = $props();
|
||||
let {
|
||||
label,
|
||||
hint,
|
||||
value = $bindable(),
|
||||
options,
|
||||
labels = {},
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
labels?: Record<string, string>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<Row {label} {hint}>
|
||||
<select bind:value>
|
||||
{#each options as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
<option value={opt}>{labels[opt] ?? opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
|
|
@ -18,8 +30,8 @@
|
|||
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);
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
option {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
|
||||
function toggle() {
|
||||
if (active && critical) {
|
||||
if (!confirm(`Stop ${unit}? This is a core part of the desktop's event handling — stopping it may affect other bread apps until it's restarted.`)) {
|
||||
if (!confirm(`Stop ${unit}? Other bread apps may stall until it is restarted.`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
<button onclick={openLogs}>View logs</button>
|
||||
</div>
|
||||
{#if hasConfig}
|
||||
<Hint text="Save (below) only writes the config file — click Restart above for the running service to pick up the change." />
|
||||
<Hint text="Save writes the file. Restart the service to apply." />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
|
|
@ -74,16 +74,16 @@
|
|||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
background-color: var(--surface-2, var(--bg));
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
border: 1px solid var(--line, transparent);
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: color-mix(in srgb, var(--on-surface) 14%, transparent);
|
||||
background-color: color-mix(in srgb, var(--on-surface) 10%, transparent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
|
|
|
|||
|
|
@ -1,59 +1,130 @@
|
|||
<script lang="ts">
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
import { SIDEBAR_SECTIONS } from "$lib/sidebar";
|
||||
import { searchSettings } from "$lib/search";
|
||||
import { go, nav } from "$lib/nav.svelte";
|
||||
|
||||
let { activePage = $bindable() }: { activePage: string } = $props();
|
||||
let query = $state("");
|
||||
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>
|
||||
|
||||
<nav class="sidebar">
|
||||
{#each SIDEBAR_SECTIONS as section (section.title ?? "untitled")}
|
||||
{#if section.title}
|
||||
<div class="section-header">{section.title}</div>
|
||||
{/if}
|
||||
{#each section.items as item (item.id)}
|
||||
<button
|
||||
class="row"
|
||||
class:selected={activePage === item.id}
|
||||
onclick={() => (activePage = item.id)}
|
||||
>
|
||||
<item.icon size={16} />
|
||||
<span class="label">{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
<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")}
|
||||
{#if section.title}
|
||||
<div class="section-header">{section.title}</div>
|
||||
{/if}
|
||||
{#each section.items as item (item.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="row"
|
||||
class:selected={nav.page === item.id}
|
||||
onclick={() => go(item.id)}
|
||||
>
|
||||
<item.icon size={16} />
|
||||
<span class="label">{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 220px;
|
||||
width: 248px;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
background-color: var(--surface);
|
||||
background: var(--bg-2, var(--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;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--space-sm, 8px);
|
||||
gap: 1px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 0 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: var(--space-lg, 16px) var(--space-sm, 8px) var(--space-xs, 4px);
|
||||
font-size: 11px;
|
||||
padding: 14px 10px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.5;
|
||||
color: var(--faint, color-mix(in oklab, var(--fg) 38%, transparent));
|
||||
}
|
||||
|
||||
.section-header:first-child {
|
||||
padding-top: var(--space-xs, 4px);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.row {
|
||||
.row,
|
||||
.hit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
|
@ -61,24 +132,29 @@
|
|||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 9px var(--space-md, 12px);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
transition: background-color 0.1s ease;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background-color: color-mix(in srgb, var(--on-surface) 8%, transparent);
|
||||
.row:hover,
|
||||
.hit:hover {
|
||||
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
||||
}
|
||||
|
||||
.row.selected {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
background: var(--accent-soft, color-mix(in oklab, var(--accent) 18%, transparent));
|
||||
color: var(--fg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.row.selected :global(svg) {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
44
frontend/src/lib/components/SliderField.svelte
Normal file
44
frontend/src/lib/components/SliderField.svelte
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<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>
|
||||
40
frontend/src/lib/components/Subnav.svelte
Normal file
40
frontend/src/lib/components/Subnav.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<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>
|
||||
|
|
@ -11,33 +11,40 @@
|
|||
aria-label={ariaLabel}
|
||||
onclick={() => (value = !value)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
<i></i>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
background: color-mix(in srgb, var(--fg) 12%, transparent);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
background: #efe8e6;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@
|
|||
import Row from "./Row.svelte";
|
||||
import Switch from "./Switch.svelte";
|
||||
|
||||
let { label, value = $bindable() }: { label: string; value: boolean } = $props();
|
||||
let {
|
||||
label,
|
||||
hint,
|
||||
value = $bindable(),
|
||||
}: { label: string; hint?: string; value: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<Row {label} {hint}>
|
||||
<Switch bind:value ariaLabel={label} />
|
||||
</Row>
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@
|
|||
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);
|
||||
border-radius: var(--radius-sm, 10px);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
|
|
|
|||
75
frontend/src/lib/components/Titlebar.svelte
Normal file
75
frontend/src/lib/components/Titlebar.svelte
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<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>
|
||||
|
|
@ -1,40 +1,64 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { EMBEDDED_KEY } from "$lib/embed";
|
||||
|
||||
let { title, children }: { title: string; children: Snippet } = $props();
|
||||
let { title, lede, children }: { title: string; lede?: string; children: Snippet } = $props();
|
||||
const embedded = getContext<boolean>(EMBEDDED_KEY) ?? false;
|
||||
</script>
|
||||
|
||||
<div class="view">
|
||||
<h1 class="title">{title}</h1>
|
||||
<div class="content">
|
||||
{#if embedded}
|
||||
<div class="embed">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="view">
|
||||
<h1 class="title">{title}</h1>
|
||||
{#if lede}
|
||||
<p class="lede">{lede}</p>
|
||||
{/if}
|
||||
<div class="content">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.view {
|
||||
padding: var(--space-xl, 20px) var(--space-xl, 20px) 48px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 28px 36px 56px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.6em;
|
||||
font-weight: bold;
|
||||
margin: 0 0 var(--space-xl, 20px);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.04em;
|
||||
margin: 0 auto;
|
||||
max-width: 920px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Groups lay out as a responsive grid rather than a single narrow
|
||||
column — on a wide (tiled/maximized) window that means multiple
|
||||
independent setting groups sit side by side instead of one column
|
||||
with a wall of empty space either side. `.group.wide` (device
|
||||
lists, thumbnail grids, log views — anything that reads badly
|
||||
squeezed into a card) opts out via `grid-column: 1 / -1`. */
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: var(--space-xl, 20px) 32px;
|
||||
align-items: start;
|
||||
max-width: 1400px;
|
||||
.lede {
|
||||
margin: 6px auto 0;
|
||||
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
max-width: 920px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content,
|
||||
.embed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
max-width: 920px;
|
||||
width: 100%;
|
||||
margin-top: 22px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.embed {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
7
frontend/src/lib/debounce.ts
Normal file
7
frontend/src/lib/debounce.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function debounce(fn: () => void, ms = 280): () => void {
|
||||
let t: ReturnType<typeof setTimeout> | undefined;
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(fn, ms);
|
||||
};
|
||||
}
|
||||
2
frontend/src/lib/embed.ts
Normal file
2
frontend/src/lib/embed.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/** When set, ViewScaffold renders inner groups only - used by hub tab panes. */
|
||||
export const EMBEDDED_KEY = "bos-settings-embedded";
|
||||
17
frontend/src/lib/nav.svelte.ts
Normal file
17
frontend/src/lib/nav.svelte.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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";
|
||||
120
frontend/src/lib/search.ts
Normal file
120
frontend/src/lib/search.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,97 +1,112 @@
|
|||
// 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 House from "@lucide/svelte/icons/house";
|
||||
import Wifi from "@lucide/svelte/icons/wifi";
|
||||
import Network from "@lucide/svelte/icons/network";
|
||||
import Bluetooth from "@lucide/svelte/icons/bluetooth";
|
||||
import Shield from "@lucide/svelte/icons/shield";
|
||||
import Monitor from "@lucide/svelte/icons/monitor";
|
||||
import Volume2 from "@lucide/svelte/icons/volume-2";
|
||||
import BatteryFull from "@lucide/svelte/icons/battery-full";
|
||||
import Clock from "@lucide/svelte/icons/clock";
|
||||
import Monitor from "@lucide/svelte/icons/monitor";
|
||||
import Keyboard from "@lucide/svelte/icons/keyboard";
|
||||
import Rocket from "@lucide/svelte/icons/rocket";
|
||||
import Users from "@lucide/svelte/icons/users";
|
||||
import Palette from "@lucide/svelte/icons/palette";
|
||||
import Image from "@lucide/svelte/icons/image";
|
||||
import LayoutGrid from "@lucide/svelte/icons/layout-grid";
|
||||
import Grid3x3 from "@lucide/svelte/icons/grid-3x3";
|
||||
import Clipboard from "@lucide/svelte/icons/clipboard";
|
||||
import NotebookPen from "@lucide/svelte/icons/notebook-pen";
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
import Cog from "@lucide/svelte/icons/cog";
|
||||
import Package from "@lucide/svelte/icons/package";
|
||||
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
||||
import History from "@lucide/svelte/icons/history";
|
||||
import Info from "@lucide/svelte/icons/info";
|
||||
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 Keyboard from "@lucide/svelte/icons/keyboard";
|
||||
import LayoutGrid from "@lucide/svelte/icons/layout-grid";
|
||||
import Shield from "@lucide/svelte/icons/shield";
|
||||
import Download from "@lucide/svelte/icons/download";
|
||||
import Info from "@lucide/svelte/icons/info";
|
||||
|
||||
export interface SidebarItem {
|
||||
/** Must match a key in the view component map (see routing in +page.svelte). */
|
||||
id: string;
|
||||
label: string;
|
||||
/** Dim second line — the underlying binary/config name, for items whose
|
||||
* human label doesn't already make that obvious. */
|
||||
sublabel?: string;
|
||||
icon: Component;
|
||||
}
|
||||
|
||||
export const SYSTEM_ITEMS: SidebarItem[] = [
|
||||
{ id: "network", label: "Network", icon: Wifi },
|
||||
{ id: "breadcrumbs", label: "Wi-Fi Profiles", sublabel: "breadcrumbs", icon: Network },
|
||||
{ id: "bluetooth", label: "Bluetooth", icon: Bluetooth },
|
||||
{ id: "firewall", label: "Firewall", icon: Shield },
|
||||
{ id: "sound", label: "Sound", icon: Volume2 },
|
||||
{ id: "power", label: "Power", icon: BatteryFull },
|
||||
{ id: "datetime", label: "Date & Time", icon: Clock },
|
||||
{ id: "hyprland", label: "Display", sublabel: "monitors.json", icon: Monitor },
|
||||
{ id: "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: "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: "bread", label: "Daemon", sublabel: "breadd", icon: Cog },
|
||||
];
|
||||
|
||||
export const MAINTENANCE_ITEMS: SidebarItem[] = [
|
||||
{ id: "packages", label: "Packages", icon: Package },
|
||||
{ id: "aur", label: "AUR", icon: Search },
|
||||
{ id: "firmware", label: "Firmware", icon: RefreshCw },
|
||||
{ id: "snapshots", label: "Snapshots", icon: History },
|
||||
];
|
||||
|
||||
export const ABOUT_ITEMS: SidebarItem[] = [
|
||||
{ id: "breadhelp", label: "Help", sublabel: "breadhelp", icon: CircleHelp },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
];
|
||||
|
||||
export interface SidebarSection {
|
||||
title: string | null;
|
||||
items: SidebarItem[];
|
||||
}
|
||||
|
||||
export const SIDEBAR_SECTIONS: SidebarSection[] = [
|
||||
{ title: "System", items: SYSTEM_ITEMS },
|
||||
{ title: "Personalization", items: PERSONALIZATION_ITEMS },
|
||||
{ title: "Maintenance", items: MAINTENANCE_ITEMS },
|
||||
{ title: null, items: ABOUT_ITEMS },
|
||||
{ title: "Overview", items: [{ id: "home", label: "Home", icon: House }] },
|
||||
{
|
||||
title: "Devices",
|
||||
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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_PAGE = "about";
|
||||
/** Old sidebar ids (screenshot CLI, in-app jumps) → hub page + optional tab. */
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// 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
|
||||
// the process exits.
|
||||
|
||||
|
|
|
|||
121
frontend/src/lib/styles/app.css
Normal file
121
frontend/src/lib/styles/app.css
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
: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;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Bridges bread-theme's pywal-derived palette into the webview. Mirrors
|
||||
// bread_theme::gtk::apply_shared()'s two-phase pattern: fetch once at
|
||||
// startup, then keep it live via a backend-pushed event — see
|
||||
// startup, then keep it live via a backend-pushed event - see
|
||||
// src-tauri/src/commands/theme.rs for the file-watch side of this.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
// The backend reports the GPU as a raw lspci device string, e.g.
|
||||
// "Advanced Micro Devices, Inc. [AMD/ATI] Krackan [Radeon 840M / 860M Graphics] (rev c2)".
|
||||
// That's too technical for a general settings page, so extract just the
|
||||
// marketing model name. Heuristic (not exhaustive — just needs to read
|
||||
// marketing model name. Heuristic (not exhaustive - just needs to read
|
||||
// well for common vendors): drop the trailing "(rev ..)", prefer the
|
||||
// text inside the last [...] bracket group (usually the model name),
|
||||
// collapse "840M / 860M" style multi-model lists to the last variant,
|
||||
|
|
|
|||
14
frontend/src/lib/views/AboutHub.svelte
Normal file
14
frontend/src/lib/views/AboutHub.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<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 },
|
||||
]}
|
||||
/>
|
||||
197
frontend/src/lib/views/Accessibility.svelte
Normal file
197
frontend/src/lib/views/Accessibility.svelte
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
|
||||
interface A11yStatus {
|
||||
orca_installed: boolean;
|
||||
orca_running: boolean;
|
||||
zoom_factor: number;
|
||||
sticky_keys_supported: boolean;
|
||||
slow_keys_supported: boolean;
|
||||
kmag_installed: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
let st = $state<A11yStatus | null>(null);
|
||||
let zoom = $state(1);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
let message = $state("");
|
||||
|
||||
async function refresh() {
|
||||
st = await invoke<A11yStatus>("get_a11y_status");
|
||||
zoom = st.zoom_factor;
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function install(packages: string[]) {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("pacman_install", { packages }, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function toggleOrca(running: boolean) {
|
||||
message = "";
|
||||
try {
|
||||
await invoke("set_orca_running", { running });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyZoom() {
|
||||
message = "";
|
||||
try {
|
||||
zoom = await invoke<number>("set_cursor_zoom", { factor: zoom });
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function openKmag() {
|
||||
message = "";
|
||||
try {
|
||||
await invoke("open_kmag");
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Accessibility">
|
||||
<Group title="Screen reader" hint="Orca. Works on Wayland; start/stop is process-level, not a compositor setting.">
|
||||
{#if !st}
|
||||
<Hint text="Loading…" />
|
||||
{:else if !st.orca_installed}
|
||||
<Hint text="orca is not installed." />
|
||||
<button class="primary" disabled={busy} onclick={() => install(["orca"])}>Install orca</button>
|
||||
{:else}
|
||||
<div class="row-switch">
|
||||
<span>Orca</span>
|
||||
<button
|
||||
class="switch"
|
||||
class:on={st.orca_running}
|
||||
role="switch"
|
||||
aria-checked={st.orca_running}
|
||||
aria-label="Orca"
|
||||
onclick={() => toggleOrca(!st!.orca_running)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Magnifier" hint="Zooms around the cursor. This session only.">
|
||||
{#if st}
|
||||
<NumberField label="Zoom factor" bind:value={zoom} min={1} max={8} step={0.25} />
|
||||
<button class="primary" onclick={applyZoom}>Apply zoom</button>
|
||||
<Hint text="1.0 is off. Values above 1 enlarge around the cursor." />
|
||||
{#if !st.kmag_installed}
|
||||
<Hint text="kmag is a separate KDE magnifier and is not wired into Hyprland. Optional install only." />
|
||||
<button disabled={busy} onclick={() => install(["kmag"])}>Install kmag</button>
|
||||
{:else}
|
||||
<button onclick={openKmag}>Open kmag</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Sticky keys / Slow keys">
|
||||
{#if st}
|
||||
<div class="row-switch disabled">
|
||||
<span>Sticky keys</span>
|
||||
<button class="switch" disabled role="switch" aria-checked="false" aria-label="Sticky keys">
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row-switch disabled">
|
||||
<span>Slow keys</span>
|
||||
<button class="switch" disabled role="switch" aria-checked="false" aria-label="Slow keys">
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
<Hint text={st.note} />
|
||||
{/if}
|
||||
</Group>
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.row-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.row-switch.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.switch:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
display: block;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,13 +5,12 @@
|
|||
import Group from "$lib/components/Group.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import SliderField from "$lib/components/SliderField.svelte";
|
||||
import HyprColorField from "$lib/components/HyprColorField.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import { debounce } from "$lib/debounce";
|
||||
|
||||
// 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
|
||||
// dropdown.
|
||||
const COMMON_KB_LAYOUTS = [
|
||||
|
|
@ -25,13 +24,51 @@
|
|||
// window under the cursor; 2 = focus follows the cursor, but clicking a
|
||||
// window keeps keyboard focus there until the mouse moves again; 3 =
|
||||
// cursor and keyboard focus are fully independent.
|
||||
const FOLLOW_MOUSE_MODES: { value: number; label: string }[] = [
|
||||
{ value: 0, label: "Off — click to focus" },
|
||||
{ value: 1, label: "Follow mouse" },
|
||||
{ value: 2, label: "Follow mouse, detach on click" },
|
||||
{ value: 3, label: "Fully detached from keyboard focus" },
|
||||
const FOLLOW_MOUSE_MODES: { value: string; label: string }[] = [
|
||||
{ value: "0", label: "Click to focus" },
|
||||
{ value: "1", label: "Follow mouse" },
|
||||
{ value: "2", label: "Follow, keep focus on click" },
|
||||
{ value: "3", label: "Mouse and keys independent" },
|
||||
];
|
||||
|
||||
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 {
|
||||
gaps_in: number;
|
||||
gaps_out: number;
|
||||
|
|
@ -53,76 +90,73 @@
|
|||
}
|
||||
|
||||
let cfg = $state<Appearance | null>(null);
|
||||
let loaded = $state(false);
|
||||
let followMouse = $state("1");
|
||||
|
||||
let kbLayoutOptions = $derived(
|
||||
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 () => {
|
||||
cfg = await invoke<Appearance>("get_appearance");
|
||||
followMouse = String(cfg.follow_mouse);
|
||||
loaded = true;
|
||||
});
|
||||
|
||||
async function save() {
|
||||
await invoke("save_appearance", { appearance: cfg });
|
||||
}
|
||||
let primed = false;
|
||||
$effect(() => {
|
||||
if (!loaded || !cfg) return;
|
||||
JSON.stringify(cfg);
|
||||
void followMouse;
|
||||
if (!primed) {
|
||||
primed = true;
|
||||
return;
|
||||
}
|
||||
persist();
|
||||
});
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Appearance">
|
||||
{#if cfg}
|
||||
<Group title="Windows & borders" hint="Gaps, borders, and tiling — the same settings.json Hyprland reads at login.">
|
||||
<NumberField label="Gaps between windows" bind:value={cfg.gaps_in} min={0} max={50} />
|
||||
<NumberField label="Gaps around screen edge" bind:value={cfg.gaps_out} min={0} max={50} />
|
||||
<NumberField label="Border width" bind:value={cfg.border_size} min={0} max={10} />
|
||||
<HyprColorField label="Active border color" bind:value={cfg.active_border} />
|
||||
<HyprColorField label="Inactive border color" bind:value={cfg.inactive_border} />
|
||||
<SelectField label="Tiling layout" bind:value={cfg.layout} options={["dwindle", "master"]} />
|
||||
<SwitchField label="Resize by dragging borders" bind:value={cfg.resize_on_border} />
|
||||
<Group title="Windows">
|
||||
<SliderField 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} />
|
||||
<SliderField label="Border width" bind:value={cfg.border_size} min={0} max={10} />
|
||||
<HyprColorField label="Active border" bind:value={cfg.active_border} />
|
||||
<HyprColorField label="Inactive border" bind:value={cfg.inactive_border} />
|
||||
<SelectField label="Layout" bind:value={cfg.layout} options={["dwindle", "master"]} labels={LAYOUT_LABELS} />
|
||||
<SwitchField label="Resize by dragging the border" bind:value={cfg.resize_on_border} />
|
||||
</Group>
|
||||
|
||||
<Group title="Effects">
|
||||
<NumberField label="Corner rounding" bind:value={cfg.rounding} min={0} max={30} />
|
||||
<SliderField label="Corner rounding" bind:value={cfg.rounding} min={0} max={30} />
|
||||
<SwitchField label="Blur" bind:value={cfg.blur_enabled} />
|
||||
<NumberField label="Blur size" bind:value={cfg.blur_size} min={0} max={20} />
|
||||
<NumberField label="Blur passes" bind:value={cfg.blur_passes} min={1} max={5} />
|
||||
<SwitchField label="Window shadows" bind:value={cfg.shadow_enabled} />
|
||||
<NumberField label="Shadow range" bind:value={cfg.shadow_range} min={0} max={40} />
|
||||
<NumberField label="Shadow render power" bind:value={cfg.shadow_render_power} min={1} max={4} />
|
||||
{#if cfg.blur_enabled}
|
||||
<SliderField label="Blur size" bind:value={cfg.blur_size} min={0} max={20} />
|
||||
<SliderField label="Blur quality" bind:value={cfg.blur_passes} min={1} max={5} />
|
||||
{/if}
|
||||
<SwitchField label="Shadows" bind:value={cfg.shadow_enabled} />
|
||||
{#if cfg.shadow_enabled}
|
||||
<SliderField label="Shadow size" bind:value={cfg.shadow_range} min={0} max={40} />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Input">
|
||||
<SelectField label="Keyboard layout" bind:value={cfg.kb_layout} options={kbLayoutOptions} />
|
||||
<Row label="Focus-follows-mouse mode">
|
||||
<select bind:value={cfg.follow_mouse}>
|
||||
{#each FOLLOW_MOUSE_MODES as mode (mode.value)}
|
||||
<option value={mode.value}>{mode.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
<SwitchField label="Natural scrolling (touchpad)" bind:value={cfg.natural_scroll} />
|
||||
<Group title="Keyboard and mouse">
|
||||
<SelectField label="Keyboard layout" bind:value={cfg.kb_layout} options={kbLayoutOptions} labels={KB_LABELS} />
|
||||
<SelectField
|
||||
label="Focus"
|
||||
bind:value={followMouse}
|
||||
options={FOLLOW_MOUSE_MODES.map((m) => m.value)}
|
||||
labels={Object.fromEntries(FOLLOW_MOUSE_MODES.map((m) => [m.value, m.label]))}
|
||||
/>
|
||||
<SwitchField label="Natural scrolling" hint="Touchpad" bind:value={cfg.natural_scroll} />
|
||||
</Group>
|
||||
|
||||
<Hint text="Changes apply on next login or Hyprland reload — this saves settings.json, it doesn't reload Hyprland live." />
|
||||
<SaveButton onSave={save} />
|
||||
<Hint text="Applies as you change it." />
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
select {
|
||||
color-scheme: dark;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
14
frontend/src/lib/views/AppearanceHub.svelte
Normal file
14
frontend/src/lib/views/AppearanceHub.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<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 },
|
||||
]}
|
||||
/>
|
||||
16
frontend/src/lib/views/AppsHub.svelte
Normal file
16
frontend/src/lib/views/AppsHub.svelte
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<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 },
|
||||
]}
|
||||
/>
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
<ViewScaffold title="AUR">
|
||||
<Group
|
||||
title="Search"
|
||||
hint="Search the Arch User Repository via yay. Installing opens a terminal — AUR packages run arbitrary build scripts, and reviewing what yay is about to do (and entering your password) is a real safety step."
|
||||
hint="Opens a terminal. Review the PKGBUILD before you confirm."
|
||||
wide
|
||||
>
|
||||
<div class="search-row">
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
{#if entries}
|
||||
<Group
|
||||
title="Extra autostart apps"
|
||||
hint="What launches after login, beyond the core desktop (bar, theme, clipboard, etc. always start regardless). Toggle off, edit, or add your own."
|
||||
hint="Extra apps after login. Bar and clipboard always start."
|
||||
wide
|
||||
>
|
||||
{#each entries as entry, i (i)}
|
||||
|
|
|
|||
254
frontend/src/lib/views/Backup.svelte
Normal file
254
frontend/src/lib/views/Backup.svelte
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import FileField from "$lib/components/FileField.svelte";
|
||||
import PasswordField from "$lib/components/PasswordField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import Archive from "@lucide/svelte/icons/archive";
|
||||
|
||||
interface ResticSnapshot {
|
||||
id: string;
|
||||
time: string;
|
||||
paths: string[];
|
||||
}
|
||||
interface BackupStatus {
|
||||
restic_installed: boolean;
|
||||
repo: string;
|
||||
has_password: boolean;
|
||||
snapshots: ResticSnapshot[];
|
||||
error: string | null;
|
||||
home: string;
|
||||
}
|
||||
|
||||
let st = $state<BackupStatus | null>(null);
|
||||
let repo = $state("");
|
||||
let password = $state("");
|
||||
let snapshots = $state<ResticSnapshot[]>([]);
|
||||
let selected = $state("latest");
|
||||
let restoreTarget = $state("");
|
||||
let lastAutoTarget = $state("");
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
let message = $state("");
|
||||
|
||||
function defaultTarget(id: string): string {
|
||||
const home = st?.home ?? "";
|
||||
if (!home) return "";
|
||||
return `${home}/bos-restore-${id || "latest"}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!st?.home) return;
|
||||
const auto = defaultTarget(selected || "latest");
|
||||
if (restoreTarget === "" || restoreTarget === lastAutoTarget) {
|
||||
if (restoreTarget !== auto) restoreTarget = auto;
|
||||
if (lastAutoTarget !== auto) lastAutoTarget = auto;
|
||||
}
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
st = await invoke<BackupStatus>("get_backup_config");
|
||||
repo = st.repo;
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function save() {
|
||||
await invoke("save_backup_config", { input: { repo, password: password || null } });
|
||||
password = "";
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function installRestic() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("pacman_install", { packages: ["restic"] }, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function run(command: string, args: Record<string, unknown> = {}) {
|
||||
log = [];
|
||||
busy = true;
|
||||
message = "";
|
||||
const ok = await runStreamed(command, args, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
if (!ok) message = "Failed. See the log.";
|
||||
}
|
||||
|
||||
async function listSnaps() {
|
||||
message = "";
|
||||
try {
|
||||
snapshots = await invoke<ResticSnapshot[]>("list_restic_snapshots");
|
||||
if (snapshots.length === 0) message = "No snapshots in this repo yet.";
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
snapshots = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(dryRun: boolean) {
|
||||
const snap = selected || "latest";
|
||||
const target = restoreTarget.trim() || defaultTarget(snap);
|
||||
const home = st?.home ?? "";
|
||||
if (!dryRun && home && (target === home || target === `${home}/`)) {
|
||||
message = "Refusing to restore onto $HOME. Leave the default ~/bos-restore-<id> or pick another folder.";
|
||||
return;
|
||||
}
|
||||
if (!dryRun) {
|
||||
const ok = confirm(
|
||||
`Restore snapshot ${snap} into ${target}?\n\nFiles go into that directory. Your live home is not overwritten.`,
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
await run(dryRun ? "restic_restore_dry_run" : "restic_restore", {
|
||||
snapshot: snap,
|
||||
target,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Backup">
|
||||
<Group
|
||||
title="Repository"
|
||||
hint="Local folder or sftp. Password is write-only."
|
||||
wide
|
||||
>
|
||||
{#if !st}
|
||||
<Hint text="Loading…" />
|
||||
{:else if !st.restic_installed}
|
||||
<Hint text="restic is not installed." />
|
||||
<button class="primary" disabled={busy} onclick={installRestic}>Install restic</button>
|
||||
{:else}
|
||||
<FileField label="Local path" bind:value={repo} mode="folder" placeholder="/mnt/backup/bos" />
|
||||
<TextField label="Or SFTP" bind:value={repo} placeholder="sftp:user@host:/backups/bos" />
|
||||
<PasswordField label={st.has_password ? "Password (leave empty to keep)" : "Password"} bind:value={password} />
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Actions"
|
||||
hint="Home directory. Snapshots page is the root filesystem."
|
||||
>
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} onclick={() => run("restic_init")}>Init repo</button>
|
||||
<button class="primary" disabled={busy} onclick={() => run("restic_backup")}>Backup home</button>
|
||||
<button disabled={busy} onclick={listSnaps}>List snapshots</button>
|
||||
</div>
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Restore"
|
||||
hint="Writes into a new folder (default ~/bos-restore-<id>). Does not overwrite $HOME. Dry-run previews the same target."
|
||||
>
|
||||
<FileField label="Restore into" bind:value={restoreTarget} mode="folder" placeholder="/home/you/bos-restore-latest" />
|
||||
<div class="btn-row">
|
||||
<button disabled={busy || !st?.restic_installed} onclick={() => restore(true)}>Restore dry-run</button>
|
||||
<button class="primary" disabled={busy || !st?.restic_installed} onclick={() => restore(false)}>Restore</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Snapshots" wide>
|
||||
{#if snapshots.length === 0}
|
||||
<EmptyState icon={Archive} title="No snapshots loaded" hint="Init, backup, then list." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each snapshots as s (s.id)}
|
||||
<button class="row" class:selected={selected === s.id} onclick={() => (selected = s.id)}>
|
||||
<span class="id">{s.id}</span>
|
||||
<span class="time">{s.time}</span>
|
||||
<span class="paths">{s.paths.join(", ")}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Group>
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
}
|
||||
|
||||
.row.selected {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.id {
|
||||
width: 10ch;
|
||||
flex-shrink: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.time {
|
||||
width: 22ch;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.paths {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -75,7 +75,10 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Bluetooth">
|
||||
<ViewScaffold
|
||||
title="Bluetooth"
|
||||
lede="Paired devices and nearby scan."
|
||||
>
|
||||
{#if powered === null}
|
||||
<EmptyState
|
||||
icon={BluetoothOff}
|
||||
|
|
@ -97,7 +100,7 @@
|
|||
{#each paired as dev (dev.address)}
|
||||
<div class="row">
|
||||
<span class="name" class:active={dev.connected}>{dev.name}{dev.connected ? " (connected)" : ""}</span>
|
||||
<button class="action" onclick={() => toggleConnect(dev)}>{dev.connected ? "Disconnect" : "Connect"}</button>
|
||||
<button class="btn" onclick={() => toggleConnect(dev)}>{dev.connected ? "Disconnect" : "Connect"}</button>
|
||||
<button class="remove" onclick={() => forget(dev)}>Forget</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
@ -107,7 +110,7 @@
|
|||
|
||||
<Group
|
||||
title="Available devices"
|
||||
hint="Scanning takes a few seconds. Devices needing a PIN aren't supported — only "just works" pairing (most headphones, speakers, keyboards, and mice)."
|
||||
hint="PIN pairing is not supported."
|
||||
>
|
||||
<div class="list">
|
||||
{#if scanResults === null}
|
||||
|
|
@ -144,9 +147,13 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
padding: 10px 2px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.row:first-child {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
|
||||
let cfg = $state<BreadConfig | null>(null);
|
||||
// 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.
|
||||
let knownModules = $state<string[]>([]);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import SliderField from "$lib/components/SliderField.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import { debounce } from "$lib/debounce";
|
||||
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
|
||||
|
|
@ -23,20 +23,33 @@
|
|||
}
|
||||
|
||||
let style = $state<BreadbarStyle | null>(null);
|
||||
let loaded = $state(false);
|
||||
let advancedOpen = $state(false);
|
||||
let css = $state("");
|
||||
let cssStatus = $state("");
|
||||
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 () => {
|
||||
style = await invoke<BreadbarStyle>("get_breadbar_style");
|
||||
css = await invoke<string>("get_breadbar_css");
|
||||
loaded = true;
|
||||
});
|
||||
|
||||
async function saveStyle() {
|
||||
await invoke("save_breadbar_style", { style });
|
||||
css = await invoke<string>("get_breadbar_css");
|
||||
}
|
||||
let primed = false;
|
||||
$effect(() => {
|
||||
if (!loaded || !style) return;
|
||||
JSON.stringify(style);
|
||||
if (!primed) {
|
||||
primed = true;
|
||||
return;
|
||||
}
|
||||
persist();
|
||||
});
|
||||
|
||||
async function saveCss() {
|
||||
cssSaving = true;
|
||||
|
|
@ -55,36 +68,37 @@
|
|||
|
||||
<ViewScaffold title="Bar">
|
||||
{#if style}
|
||||
<Group title="Text" hint="Applies to the clock, workspace numbers, and stat labels.">
|
||||
<Group title="Text" hint="Clock, workspace numbers, and stats.">
|
||||
<TextField label="Font" bind:value={style.font_family} placeholder="Varela Round" />
|
||||
<NumberField label="Font size" bind:value={style.font_size} min={8} max={32} />
|
||||
<SliderField label="Font size" bind:value={style.font_size} min={8} max={32} />
|
||||
</Group>
|
||||
|
||||
<Group title="Bar shape">
|
||||
<NumberField label="Corner rounding" bind:value={style.bar_border_radius} min={0} max={40} />
|
||||
<NumberField label="Inner padding" bind:value={style.bar_padding} min={0} max={40} />
|
||||
<SliderField 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} />
|
||||
</Group>
|
||||
|
||||
<Group title="Workspace indicator">
|
||||
<NumberField label="Size" bind:value={style.workspace_font_size} min={8} max={40} />
|
||||
<Row label="Inactive dimness">
|
||||
<div class="opacity-row">
|
||||
<input type="range" min="0" max="1" step="0.05" bind:value={style.workspace_inactive_opacity} />
|
||||
<span class="pct">{Math.round(style.workspace_inactive_opacity * 100)}%</span>
|
||||
</div>
|
||||
</Row>
|
||||
<Group title="Workspaces">
|
||||
<SliderField label="Number size" bind:value={style.workspace_font_size} min={8} max={40} />
|
||||
<SliderField
|
||||
label="Inactive dimness"
|
||||
bind:value={style.workspace_inactive_opacity}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group title="Spacing & icons">
|
||||
<NumberField label="Gap between stats" bind:value={style.stat_gap} min={0} max={40} />
|
||||
<NumberField label="Tray icon size" bind:value={style.tray_icon_size} min={8} max={32} />
|
||||
<NumberField label="Notification corner rounding" bind:value={style.notification_border_radius} min={0} max={24} />
|
||||
<Group title="Spacing and icons">
|
||||
<SliderField 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} />
|
||||
<SliderField label="Notification rounding" bind:value={style.notification_border_radius} min={0} max={24} />
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={saveStyle} />
|
||||
<Hint text="Applies as you change it." />
|
||||
{/if}
|
||||
|
||||
<Group title="Advanced" hint="Raw stylesheet. Anything set here can also be changed above — those fields edit this same file." wide>
|
||||
<Group title="Advanced" hint="Raw CSS. Same file as the fields above." wide>
|
||||
<button class="toggle" onclick={() => (advancedOpen = !advancedOpen)}>
|
||||
{#if advancedOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
|
||||
Edit raw CSS
|
||||
|
|
@ -101,22 +115,6 @@
|
|||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.opacity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.opacity-row input[type="range"] {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.pct {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
width: 4ch;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
{#if contexts}
|
||||
<Group
|
||||
title="Contexts"
|
||||
hint="Launcher contexts — each lists, in priority order, the apps/categories surfaced first."
|
||||
hint="Apps and categories shown first."
|
||||
wide
|
||||
>
|
||||
{#if contexts.length === 0}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
<ViewScaffold title="Clipboard">
|
||||
<Group
|
||||
title="Clipboard history"
|
||||
hint="Keeps a history of copied text/images and shows it as a popup. breadclipd (right) is the background daemon that watches the clipboard — breadclip itself is just the popup UI, launched on demand by the keybind or the button below."
|
||||
hint="History popup. Super+V."
|
||||
>
|
||||
<button class="open-btn" onclick={openHistory}>Open history (SUPER+V)</button>
|
||||
</Group>
|
||||
|
|
|
|||
|
|
@ -44,11 +44,11 @@
|
|||
|
||||
let cfg = $state<BreadcrumbsConfig | null>(null);
|
||||
// Profile names are free-text (set via "Add profile" below), not a fixed
|
||||
// enum — populate the default-profile options from whatever profiles
|
||||
// enum - populate the default-profile options from whatever profiles
|
||||
// actually exist, rather than a hardcoded guess that can't match a
|
||||
// user's real profile names.
|
||||
let profileNames = $derived(cfg?.profiles.map((p) => p.name) ?? []);
|
||||
// Saved network SSIDs feed the per-profile network pickers below — no
|
||||
// Saved network SSIDs feed the per-profile network pickers below - no
|
||||
// typing an SSID by hand and hoping it matches one saved above.
|
||||
let savedSsids = $derived(cfg?.networks.map((n) => n.ssid).filter((s) => s.trim().length > 0) ?? []);
|
||||
|
||||
|
|
@ -112,7 +112,7 @@
|
|||
|
||||
<Group
|
||||
title="Saved networks"
|
||||
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."
|
||||
hint="Leave password blank to keep the saved one."
|
||||
wide
|
||||
>
|
||||
<div class="list">
|
||||
|
|
|
|||
|
|
@ -47,15 +47,15 @@
|
|||
<ViewScaffold title="Help">
|
||||
<Group
|
||||
title="BOS Help"
|
||||
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."
|
||||
hint="Opens the help app."
|
||||
>
|
||||
<button class="primary" onclick={() => invoke("open_breadhelp")}>Open breadhelp</button>
|
||||
</Group>
|
||||
|
||||
<Group title="First-run autostart" hint="The extra autostart entry in hypr/autostart.json (breadhelp --autostart). Core desktop launch is separate and always runs.">
|
||||
<Group title="Show on login">
|
||||
<SwitchField label="Show help on login" bind:value={() => autostartOn, (v) => setAutostart(v)} />
|
||||
{#if !helpEntry}
|
||||
<Hint text="No breadhelp entry in autostart.json yet — turning this on adds the default first-run command." />
|
||||
<Hint text="Turning this on adds the first-run command." />
|
||||
{/if}
|
||||
{#if status}
|
||||
<Hint text={status} />
|
||||
|
|
|
|||
|
|
@ -33,28 +33,23 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Lock & greet">
|
||||
<ViewScaffold title="Lock">
|
||||
<Group
|
||||
title="How locking works"
|
||||
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="Login and PAM are packaged. This only styles the lock screen."
|
||||
>
|
||||
<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."
|
||||
/>
|
||||
<Hint text="Super+L locks. Login screen is packaged separately." />
|
||||
<button class="primary" onclick={() => invoke("lock_session")}>Lock now</button>
|
||||
</Group>
|
||||
|
||||
{#if cfg}
|
||||
<Group title="Lock screen" hint="~/.config/breadlock/breadlock.toml — every field is optional; breadlock runs with these defaults if the file is missing.">
|
||||
<Group title="Lock screen">
|
||||
<SelectField label="Background" bind:value={cfg.background_mode} options={["color", "image"]} />
|
||||
{#if cfg.background_mode === "image"}
|
||||
<FileField label="Image" bind:value={cfg.background_path} placeholder="PNG, cover-fit" extensions={["png"]} />
|
||||
{/if}
|
||||
<SwitchField label="Blur background" bind:value={cfg.background_blur} />
|
||||
<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." />
|
||||
<Hint text="Blur is saved but not drawn yet." />
|
||||
<TextField label="Clock format" bind:value={cfg.clock_format} placeholder="%H:%M" />
|
||||
<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} />
|
||||
|
|
|
|||
|
|
@ -8,14 +8,9 @@
|
|||
<ViewScaffold title="Monitors">
|
||||
<Group
|
||||
title="Two different jobs"
|
||||
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="Live arrange, mirror, and named profiles."
|
||||
>
|
||||
<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."
|
||||
/>
|
||||
<Hint text="Layout tab is the login config. This opens the live TUI." />
|
||||
<button class="primary" onclick={() => invoke("open_breadmon")}>Open breadmon</button>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@
|
|||
<NumberField label="Archive after (days)" bind:value={cfg.archive_after_days} min={0} max={3650} />
|
||||
</Group>
|
||||
|
||||
<Group title="Classifier model" hint="The local model breadpad uses to guess note vs. reminder vs. task — no network needed.">
|
||||
<Group title="Classifier model" hint="Local model. No network.">
|
||||
<FileField label="ONNX model" bind:value={cfg.model_path} placeholder="~/.local/share/breadpad/model/classifier.onnx" extensions={["onnx"]} />
|
||||
<FileField label="Tokenizer" bind:value={cfg.tokenizer_path} placeholder="~/.local/share/breadpad/model/tokenizer.json" extensions={["json"]} />
|
||||
</Group>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@
|
|||
onMount(async () => {
|
||||
libraryDir = await invoke<string>("wallpaper_library_dir_display");
|
||||
await refreshCurrent();
|
||||
scanning = true;
|
||||
try {
|
||||
library = await invoke<LibraryEntry[]>("list_wallpaper_library");
|
||||
} finally {
|
||||
scanning = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function apply(path: string) {
|
||||
|
|
@ -30,7 +36,7 @@
|
|||
try {
|
||||
await invoke("set_wallpaper", { path });
|
||||
await refreshCurrent();
|
||||
status = "Wallpaper set";
|
||||
status = "Set";
|
||||
} catch (e) {
|
||||
status = `${e}`;
|
||||
} finally {
|
||||
|
|
@ -43,51 +49,40 @@
|
|||
title: "Choose a wallpaper",
|
||||
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "bmp"] }],
|
||||
});
|
||||
if (typeof path === "string") {
|
||||
await apply(path);
|
||||
}
|
||||
}
|
||||
|
||||
async function browseLibrary() {
|
||||
scanning = true;
|
||||
library = await invoke<LibraryEntry[]>("list_wallpaper_library");
|
||||
scanning = false;
|
||||
if (typeof path === "string") await apply(path);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Wallpaper">
|
||||
<Group
|
||||
title="Current wallpaper"
|
||||
hint="Sets the desktop wallpaper, generates a matching pywal palette, and reloads the shared bread-theme stylesheet — the wallpaper drives the whole desktop's accent colors."
|
||||
>
|
||||
<div class="preview-card">
|
||||
{#if currentPath}
|
||||
<img class="preview" src={convertFileSrc(currentPath)} alt="Current wallpaper" />
|
||||
<span class="path">{currentPath.split("/").pop()}</span>
|
||||
{:else}
|
||||
<div class="preview placeholder">No wallpaper set</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Group title="Wallpaper" hint="This also updates desktop colors.">
|
||||
{#if currentPath}
|
||||
<div class="hero" style="background-image: url('{convertFileSrc(currentPath)}')">
|
||||
<span class="cap">{currentPath.split("/").pop()}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="hero placeholder">No wallpaper</div>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button class="choose" onclick={chooseImage}>Choose image…</button>
|
||||
<button class="btn primary" onclick={chooseImage}>Choose image</button>
|
||||
<span class="status">{status}</span>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Library" wide>
|
||||
{#if library === null}
|
||||
<button class="browse" disabled={scanning} onclick={browseLibrary}>
|
||||
{scanning ? "Scanning…" : `Browse ${libraryDir}`}
|
||||
</button>
|
||||
{:else if library.length === 0}
|
||||
<div class="empty">Nothing under {libraryDir} — use Choose image… above instead.</div>
|
||||
{:else}
|
||||
{#if scanning && library === null}
|
||||
<div class="empty">Loading {libraryDir}…</div>
|
||||
{:else if library && library.length === 0}
|
||||
<div class="empty">Nothing in {libraryDir}</div>
|
||||
{:else if library}
|
||||
<div class="grid">
|
||||
{#each library as item (item.path)}
|
||||
<button class="thumb" onclick={() => apply(item.path)} title={item.path}>
|
||||
<button
|
||||
class="thumb"
|
||||
class:on={item.path === currentPath}
|
||||
onclick={() => apply(item.path)}
|
||||
title={item.path}
|
||||
>
|
||||
<img src={convertFileSrc(item.path)} alt={item.name} loading="lazy" />
|
||||
<span class="name">{item.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -96,105 +91,74 @@
|
|||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.preview-card {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: block;
|
||||
width: 320px;
|
||||
.hero {
|
||||
height: 180px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
border-radius: 12px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
.hero.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.path {
|
||||
display: block;
|
||||
margin-top: var(--space-xs, 4px);
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
.cap {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: #fffc;
|
||||
background: #0006;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
justify-content: center;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.choose,
|
||||
.browse {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browse {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.browse:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--space-sm, 8px);
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
aspect-ratio: 16/10;
|
||||
}
|
||||
|
||||
.thumb.on {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 88px;
|
||||
height: 100%;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
{#if cfg}
|
||||
<Group
|
||||
title="Power"
|
||||
hint="breadmill's embedding step is CPU/NPU/GPU-heavy. Turn it off entirely, or just pause it on battery — it resumes automatically on AC power."
|
||||
hint="Indexing is heavy. Pause it on battery if you want."
|
||||
>
|
||||
<SwitchField label="Enabled" bind:value={cfg.power_enabled} />
|
||||
<SwitchField label="Index while on battery" bind:value={cfg.run_on_battery} />
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
|
||||
<Group
|
||||
title="Model"
|
||||
hint="What hardware does the indexing. Check journalctl --user -u breadmill after restarting to confirm it registered."
|
||||
hint="Hardware used for indexing."
|
||||
>
|
||||
<Row label="Search acceleration">
|
||||
<select bind:value={cfg.backend}>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
<ViewScaffold title="Screenshots">
|
||||
<Group
|
||||
title="Keybinds"
|
||||
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."
|
||||
hint="Change shortcuts under Keyboard."
|
||||
>
|
||||
{#if binds && binds.length > 0}
|
||||
{#each binds as b (`${b.shortcut}:${b.command}`)}
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
</Group>
|
||||
|
||||
{#if cfg}
|
||||
<Group title="breadshot" hint="~/.config/breadshot/config.toml — grim + slurp + wl-copy, Hyprland-aware. All keys optional.">
|
||||
<Group title="Capture">
|
||||
<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="Freeze screen during select" bind:value={cfg.freeze} />
|
||||
|
|
|
|||
127
frontend/src/lib/views/Channel.svelte
Normal file
127
frontend/src/lib/views/Channel.svelte
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
|
||||
interface BakeryTrack {
|
||||
current: string;
|
||||
tracks: string[];
|
||||
}
|
||||
|
||||
const BLURBS: Record<string, string> = {
|
||||
stable: "Last tagged release.",
|
||||
beta: "Latest release candidate (vX.Y.Z-rc.N).",
|
||||
dev: "Every push to main.",
|
||||
};
|
||||
|
||||
let track = $state<BakeryTrack | null>(null);
|
||||
let message = $state("");
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
track = await invoke<BakeryTrack>("get_bakery_track");
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function setTrack(name: string) {
|
||||
message = "";
|
||||
try {
|
||||
track = await invoke<BakeryTrack>("set_bakery_track", { track: name });
|
||||
message = `Now on ${name}. Run bakery update --all to install this track’s builds.`;
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("bakery_update_all", {}, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Bakery channel">
|
||||
<Group
|
||||
title="Track"
|
||||
hint="Nothing downloads until you update."
|
||||
>
|
||||
{#if !track}
|
||||
<Hint text="Loading…" />
|
||||
{:else}
|
||||
<div class="tracks">
|
||||
{#each track.tracks as name (name)}
|
||||
<button class="track" class:on={track.current === name} onclick={() => setTrack(name)}>
|
||||
<strong>{name}</strong>
|
||||
<span>{BLURBS[name] ?? ""}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<Hint text={`Current track: ${track.current}`} />
|
||||
{/if}
|
||||
<button class="primary" disabled={busy} onclick={updateAll}>Update all on this track</button>
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
background-color: var(--surface);
|
||||
color: inherit;
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.track span {
|
||||
opacity: 0.7;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.track.on {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
75
frontend/src/lib/views/Defaults.svelte
Normal file
75
frontend/src/lib/views/Defaults.svelte
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
|
||||
interface DesktopApp {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
interface DefaultsStatus {
|
||||
path: string;
|
||||
current: Record<string, string>;
|
||||
options: Record<string, DesktopApp[]>;
|
||||
}
|
||||
|
||||
const CATEGORIES: { id: string; label: string }[] = [
|
||||
{ id: "browser", label: "Browser" },
|
||||
{ id: "files", label: "File manager" },
|
||||
{ id: "terminal", label: "Terminal" },
|
||||
{ id: "image", label: "Images" },
|
||||
{ id: "pdf", label: "PDF" },
|
||||
{ id: "editor", label: "Editor" },
|
||||
];
|
||||
|
||||
let st = $state<DefaultsStatus | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
st = await invoke<DefaultsStatus>("get_default_apps");
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!st) return;
|
||||
await invoke("save_default_apps", { input: { current: st.current } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Default apps">
|
||||
<Group title="Apps" wide>
|
||||
{#if st}
|
||||
{#each CATEGORIES as cat (cat.id)}
|
||||
<Row label={cat.label}>
|
||||
<select bind:value={st.current[cat.id]} onchange={save}>
|
||||
<option value="">Not set</option>
|
||||
{#each st.options[cat.id] ?? [] as app (app.id)}
|
||||
<option value={app.id}>{app.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
{/each}
|
||||
<Hint text="Applies as you change it." />
|
||||
{:else}
|
||||
<Hint text="Loading…" />
|
||||
{/if}
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
select {
|
||||
color-scheme: dark;
|
||||
background: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
min-width: 22ch;
|
||||
max-width: 36ch;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
22
frontend/src/lib/views/DesktopHub.svelte
Normal file
22
frontend/src/lib/views/DesktopHub.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<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 },
|
||||
]}
|
||||
/>
|
||||
29
frontend/src/lib/views/DesktopMore.svelte
Normal file
29
frontend/src/lib/views/DesktopMore.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<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>
|
||||
|
|
@ -2,151 +2,369 @@
|
|||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import InfoRow from "$lib/components/InfoRow.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
|
||||
interface LiveMonitor {
|
||||
name: string;
|
||||
mode: string;
|
||||
}
|
||||
interface MonitorRule {
|
||||
output: string;
|
||||
mode: string;
|
||||
position: string;
|
||||
scale: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
refresh: number;
|
||||
scale: number;
|
||||
transform: number;
|
||||
available_modes: string[];
|
||||
}
|
||||
|
||||
let liveMonitors = $state<LiveMonitor[] | null>(null);
|
||||
let rules = $state<MonitorRule[] | null>(null);
|
||||
const SCALES = [
|
||||
{ label: "100%", value: 1 },
|
||||
{ label: "125%", value: 1.25 },
|
||||
{ label: "150%", value: 1.5 },
|
||||
{ label: "200%", value: 2 },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
liveMonitors = await invoke<LiveMonitor[]>("get_live_monitors");
|
||||
rules = await invoke<MonitorRule[]>("get_monitor_rules");
|
||||
const TURNS = [
|
||||
{ label: "Landscape", value: 0 },
|
||||
{ label: "Right", value: 1 },
|
||||
{ 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 };
|
||||
});
|
||||
|
||||
function addRule() {
|
||||
rules = [...(rules ?? []), { output: "", mode: "preferred", position: "auto", scale: "auto" }];
|
||||
type Drag = { i: number; grabX: number; grabY: number; pointer: number };
|
||||
let drag = $state<Drag | null>(null);
|
||||
|
||||
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 removeRule(i: number) {
|
||||
rules = rules!.filter((_, idx) => idx !== i);
|
||||
if (rules.length === 0) {
|
||||
rules = [{ output: "", mode: "preferred", position: "auto", scale: "auto" }];
|
||||
function screenX(m: LiveMonitor): number {
|
||||
return PAD + (m.x - view.minX) * view.scale;
|
||||
}
|
||||
function screenY(m: LiveMonitor): number {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await invoke("save_monitor_rules", { rules });
|
||||
onMount(refresh);
|
||||
|
||||
$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>
|
||||
|
||||
<ViewScaffold title="Display">
|
||||
<Group title="Connected monitors">
|
||||
{#if liveMonitors && liveMonitors.length > 0}
|
||||
{#each liveMonitors as m (m.name)}
|
||||
<InfoRow label={m.name} value={m.mode} />
|
||||
{/each}
|
||||
<ViewScaffold title="Displays" lede="Drag to arrange. Edges snap. Changes apply now.">
|
||||
<Group title="Arrangement" wide>
|
||||
{#if monitors.length === 0}
|
||||
<Hint text="No monitors detected." />
|
||||
{:else}
|
||||
<Hint text="No monitors detected (is Hyprland running?)" />
|
||||
<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}
|
||||
</div>
|
||||
<Hint text={applying ? "Applying…" : "Drag a panel. It sticks when you let go."} />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Advanced">
|
||||
<button class="secondary" onclick={() => invoke("open_hyprland_conf")}>Open hyprland.lua in editor</button>
|
||||
<button class="secondary" onclick={() => invoke("open_keybinds_viewer")}>View keybinds (breadhelp)</button>
|
||||
</Group>
|
||||
|
||||
{#if rules}
|
||||
<Group
|
||||
title="Layout"
|
||||
hint="One row per monitor rule. Leave Output blank to match any monitor (the default — works on any hardware). Applies on next login/reload."
|
||||
wide
|
||||
>
|
||||
{#each rules as rule, i (i)}
|
||||
<div class="rule-row">
|
||||
<label>Output <input type="text" bind:value={rule.output} placeholder="any (blank = all)" class="w-output" /></label>
|
||||
<label>Mode <input type="text" bind:value={rule.mode} placeholder="preferred / 1920x1080@60" class="w-mode" /></label>
|
||||
<label>Position <input type="text" bind:value={rule.position} placeholder="auto / 0x0" class="w-position" /></label>
|
||||
<label>Scale <input type="text" bind:value={rule.scale} placeholder="auto / 1" class="w-scale" /></label>
|
||||
<button class="remove" onclick={() => removeRule(i)}>Remove</button>
|
||||
{#if live}
|
||||
<Group title={live.name}>
|
||||
<Row label="Resolution">
|
||||
{#if live.available_modes.length > 0}
|
||||
<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)}
|
||||
<option value={mode}>{mode}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<span class="mode">{live.mode}</span>
|
||||
{/if}
|
||||
</Row>
|
||||
<Row label="Scale">
|
||||
<div class="pills">
|
||||
{#each SCALES as s (s.value)}
|
||||
<button type="button" class="pill" class:on={nearestScale(live.scale) === s.value} onclick={() => setScale(s.value)}>
|
||||
{s.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<button class="add" onclick={addRule}>Add monitor rule</button>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
</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}
|
||||
</div>
|
||||
</Row>
|
||||
<Row label="Position">
|
||||
<span class="mode">{live.x}, {live.y}</span>
|
||||
</Row>
|
||||
{#if message}
|
||||
<Hint text={message} />
|
||||
{/if}
|
||||
</Group>
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.rule-row {
|
||||
.mon-stage {
|
||||
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;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
cursor: grab;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rule-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
.mon.sel {
|
||||
border-color: var(--accent);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.w-output {
|
||||
width: 12ch;
|
||||
}
|
||||
.w-mode {
|
||||
width: 18ch;
|
||||
}
|
||||
.w-position {
|
||||
width: 10ch;
|
||||
}
|
||||
.w-scale {
|
||||
width: 8ch;
|
||||
.mon.dragging {
|
||||
cursor: grabbing;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
background-color: var(--bg);
|
||||
.screen {
|
||||
flex: 1;
|
||||
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);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.add {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
align-self: flex-start;
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
max-width: 28ch;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
16
frontend/src/lib/views/DisplaysHub.svelte
Normal file
16
frontend/src/lib/views/DisplaysHub.svelte
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<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 },
|
||||
]}
|
||||
/>
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
|
|
@ -22,12 +23,28 @@
|
|||
let newRule = $state("");
|
||||
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() {
|
||||
try {
|
||||
status = await invoke<FirewallStatus>("get_firewall_status");
|
||||
enabledSensitive = true;
|
||||
} catch (e) {
|
||||
status = { error: `${e}` };
|
||||
status = { error: friendlyError(`${e}`) };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,10 +83,7 @@
|
|||
</script>
|
||||
|
||||
<ViewScaffold title="Firewall">
|
||||
<Group
|
||||
title="Firewall"
|
||||
hint="Reading and changing firewall state needs your password (polkit) — ufw requires root even just to check status."
|
||||
>
|
||||
<Group title="Firewall" hint="Needs your password.">
|
||||
<Row label="Firewall enabled">
|
||||
<button
|
||||
class="switch"
|
||||
|
|
@ -95,7 +109,7 @@
|
|||
<Group title="Rules" wide>
|
||||
<div class="list">
|
||||
{#if status === "unloaded"}
|
||||
<EmptyState icon={Shield} title="Status not loaded" hint="Click Refresh below to check the firewall's current state." />
|
||||
<EmptyState icon={Shield} title="Loading…" hint="May ask for your password." />
|
||||
{:else if "error" in status}
|
||||
<EmptyState icon={ShieldAlert} title="Couldn't read firewall status" hint={status.error} />
|
||||
{:else if status.rules.length === 0}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
<ViewScaffold title="Firmware">
|
||||
<Group
|
||||
title="Updatable devices"
|
||||
hint="Firmware updates for hardware that supports them (UEFI, some peripherals). fwupd-refresh.timer already keeps update metadata current in the background."
|
||||
hint="UEFI and devices that speak fwupd."
|
||||
wide
|
||||
>
|
||||
<div class="list">
|
||||
|
|
|
|||
315
frontend/src/lib/views/Home.svelte
Normal file
315
frontend/src/lib/views/Home.svelte
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
<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>
|
||||
16
frontend/src/lib/views/InputHub.svelte
Normal file
16
frontend/src/lib/views/InputHub.svelte
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<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 },
|
||||
]}
|
||||
/>
|
||||
166
frontend/src/lib/views/InputMethod.svelte
Normal file
166
frontend/src/lib/views/InputMethod.svelte
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
|
||||
interface ImePackage {
|
||||
name: string;
|
||||
installed: boolean;
|
||||
}
|
||||
interface ImeStatus {
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
packages: ImePackage[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
let st = $state<ImeStatus | null>(null);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
let message = $state("");
|
||||
|
||||
const installSet = ["fcitx5-im", "fcitx5-gtk", "fcitx5-qt", "fcitx5-chinese-addons"];
|
||||
|
||||
async function refresh() {
|
||||
st = await invoke<ImeStatus>("get_ime_status");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function toggle(enabled: boolean) {
|
||||
message = "";
|
||||
try {
|
||||
st = await invoke<ImeStatus>("set_ime_enabled", { enabled });
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function installMissing() {
|
||||
const missing = st?.packages.filter((p) => !p.installed).map((p) => p.name) ?? [];
|
||||
const packages = ["fcitx5-im", ...missing.filter((n) => n !== "fcitx5")];
|
||||
const unique = [...new Set(packages.length ? packages : installSet)];
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("pacman_install", { packages: unique }, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
let missing = $derived(st?.packages.filter((p) => !p.installed) ?? []);
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Input method">
|
||||
<Group
|
||||
title="fcitx5"
|
||||
hint="Starts fcitx5. Running apps need a logout."
|
||||
>
|
||||
{#if !st}
|
||||
<Hint text="Loading…" />
|
||||
{:else}
|
||||
<div class="row-switch">
|
||||
<span>Enable fcitx5 for this session</span>
|
||||
<button
|
||||
class="switch"
|
||||
class:on={st.enabled}
|
||||
role="switch"
|
||||
aria-checked={st.enabled}
|
||||
aria-label="Enable fcitx5"
|
||||
onclick={() => toggle(!st!.enabled)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
<Hint text={st.running ? "fcitx5 is running." : "fcitx5 is not running."} />
|
||||
<button onclick={() => invoke("open_fcitx_config")}>Open fcitx5 config</button>
|
||||
{/if}
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Packages" hint="fcitx5-im (group) plus GTK/Qt modules and a CJK table (fcitx5-chinese-addons).">
|
||||
{#if st}
|
||||
<ul>
|
||||
{#each st.packages as p (p.name)}
|
||||
<li class:missing={!p.installed}>{p.name}{p.installed ? "" : " (missing)"}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if missing.length}
|
||||
<button class="primary" disabled={busy} onclick={installMissing}>Install missing</button>
|
||||
{/if}
|
||||
</Group>
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.row-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
display: block;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0 0 var(--space-sm, 8px);
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
.missing {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import Switch from "$lib/components/Switch.svelte";
|
||||
|
||||
// Mirrors src-tauri/src/commands/keybinds.rs's `Bind` — `action`/`key`/
|
||||
// Mirrors src-tauri/src/commands/keybinds.rs's `Bind` - `action`/`key`/
|
||||
// `mods` are the only fields every bind has; everything else (`command`,
|
||||
// `direction`, `workspace`, `options`, breadhelp's `label`/`category`/
|
||||
// `demo_cmd`, ...) round-trips through serde's `#[serde(flatten)]` as
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
}
|
||||
|
||||
// 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…"
|
||||
// keeps anything not in this list reachable without blocking on it.
|
||||
const KNOWN_ACTIONS = [
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
] as const;
|
||||
|
||||
// The editable, per-row UI state a bind gets flattened into on load and
|
||||
// reconstructed back into a `Bind` on save — same trade-off the GTK
|
||||
// reconstructed back into a `Bind` on save - same trade-off the GTK
|
||||
// version made with individual `Entry` widgets per field.
|
||||
interface EditRow {
|
||||
action: string;
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
mods: string;
|
||||
// Whether `mods` has been explicitly set (present in the loaded JSON,
|
||||
// or touched by the user since). `None`/absent means "fall back to
|
||||
// default_mods"; `Some([])` — an explicitly *empty* mods list — means
|
||||
// default_mods"; `Some([])` - an explicitly *empty* mods list - means
|
||||
// "use no modifiers at all, even though default_mods exists" (real
|
||||
// BOS binds rely on this for e.g. bare media keys). Collapsing both
|
||||
// cases to "omit when empty" would silently turn an explicit
|
||||
|
|
@ -77,12 +77,123 @@
|
|||
// key they don't know about (label/category/demo_cmd, or an action
|
||||
// shape this editor has no dedicated fields for) survives untouched.
|
||||
extraValue: Record<string, unknown>;
|
||||
// Raw-JSON view of `extraValue`, kept in sync both directions —
|
||||
// Raw-JSON view of `extraValue`, kept in sync both directions -
|
||||
// only actually shown when `advancedOpen` is true, or for an action
|
||||
// not in `KNOWN_ACTIONS` (nothing dedicated to show instead).
|
||||
extraText: string;
|
||||
extraError: boolean;
|
||||
advancedOpen: boolean;
|
||||
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) {
|
||||
|
|
@ -114,7 +225,7 @@
|
|||
}
|
||||
|
||||
// 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.
|
||||
function parseWorkspaceValue(s: string): string | number | undefined {
|
||||
const trimmed = s.trim();
|
||||
|
|
@ -143,13 +254,14 @@
|
|||
extraText: "",
|
||||
extraError: false,
|
||||
advancedOpen: false,
|
||||
capturing: false,
|
||||
};
|
||||
syncExtraText(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
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
|
||||
// always true), so pin it here rather than exposing a checkbox
|
||||
// whose only correct state is "on".
|
||||
|
|
@ -193,6 +305,7 @@
|
|||
extraText: "",
|
||||
extraError: false,
|
||||
advancedOpen: false,
|
||||
capturing: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -208,13 +321,32 @@
|
|||
let newLayoutName = $state("");
|
||||
let loadError = $state("");
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
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 {
|
||||
const p = await invoke<BindsPayload>("get_keybinds");
|
||||
kind = p.kind;
|
||||
// Rust's `#[serde(skip_serializing_if = ...)]` on every one of
|
||||
// these fields means an empty one is OMITTED from the JSON
|
||||
// entirely, not sent as `[]`/`{}`/`""` — every field here needs
|
||||
// entirely, not sent as `[]`/`{}`/`""` - every field here needs
|
||||
// a `??` fallback, not just the ones that are "usually" empty.
|
||||
activeLayout = p.file.active_layout ?? "";
|
||||
defaultMods = modsToText(p.file.default_mods ?? []);
|
||||
|
|
@ -229,7 +361,7 @@
|
|||
} catch (e) {
|
||||
loadError = String(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addLayout() {
|
||||
const name = newLayoutName.trim();
|
||||
|
|
@ -248,7 +380,7 @@
|
|||
// A row whose "extra" column currently holds text that doesn't parse as
|
||||
// JSON keeps its *last successfully parsed* value in `extraValue` (see
|
||||
// `onExtraInput`) rather than losing it on every keystroke while the
|
||||
// user is mid-edit — but that means saving while such a row is still
|
||||
// user is mid-edit - but that means saving while such a row is still
|
||||
// showing invalid/incomplete text would silently write that stale (or,
|
||||
// for a brand new row, empty) value instead of what's actually on
|
||||
// screen. Block save entirely until every row's extra JSON is valid, so
|
||||
|
|
@ -349,7 +481,7 @@
|
|||
oninput={(e) => setExtra(row, "layout", e.currentTarget.value)}
|
||||
/>
|
||||
{:else if row.action === "drag"}
|
||||
<span class="extra-note">Mouse-drag bind — nothing else to set.</span>
|
||||
<span class="extra-note">Mouse-drag bind.</span>
|
||||
{:else if row.action === "close" || row.action === "fullscreen" || row.action === "float" || row.action === "pseudo" || row.action === "resize" || row.action === "focus_last" || row.action === "exit"}
|
||||
<span class="extra-note">No extra options for this action.</span>
|
||||
{:else}
|
||||
|
|
@ -375,30 +507,19 @@
|
|||
{#each rows as row, i (i)}
|
||||
<div class="bind-card">
|
||||
<div class="bind-top">
|
||||
<input
|
||||
class="mods"
|
||||
type="text"
|
||||
placeholder="SUPER, SHIFT"
|
||||
bind:value={row.mods}
|
||||
oninput={() => (row.modsTouched = true)}
|
||||
/>
|
||||
<input class="key" type="text" placeholder="key" bind:value={row.key} />
|
||||
<select
|
||||
class="action-select"
|
||||
value={KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number]) ? row.action : "__custom__"}
|
||||
onchange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
row.action = v === "__custom__" ? "" : v;
|
||||
}}
|
||||
>
|
||||
{#each KNOWN_ACTIONS as a (a)}
|
||||
<option value={a}>{a}</option>
|
||||
{/each}
|
||||
<option value="__custom__">Custom…</option>
|
||||
</select>
|
||||
{#if !KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number])}
|
||||
<input class="action-custom" type="text" placeholder="action name" bind:value={row.action} />
|
||||
{/if}
|
||||
<span class="what">{bindTitle(row)}</span>
|
||||
<button type="button" class="capture" class:listening={row.capturing} onclick={() => beginCapture(row)}>
|
||||
{#if row.capturing}
|
||||
Press a key
|
||||
{:else if keyChips(row).length}
|
||||
{#each keyChips(row) as k, ki (`${k}-${ki}`)}
|
||||
{#if ki > 0}<span class="plus">+</span>{/if}
|
||||
<span class="kbd">{k}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
Set shortcut
|
||||
{/if}
|
||||
</button>
|
||||
<button type="button" class="remove" onclick={() => onRemove(i)}>Remove</button>
|
||||
</div>
|
||||
<div class="bind-bottom">
|
||||
|
|
@ -436,7 +557,7 @@
|
|||
{:else if kind === "flat"}
|
||||
<Group
|
||||
title="Defaults"
|
||||
hint={`Mods/Key pick the shortcut; Action picks what it does. Choosing a known action (exec, move_dir, focus, ...) shows the fields it actually needs — e.g. a Command box for exec — instead of raw JSON. "Advanced" reveals the underlying JSON per bind for anything not covered (or breadhelp's label/category metadata). This machine's binds.json uses BOS's flat schema (no keyboard-layout switching), so that's all there is. Applies on next login/reload.`}
|
||||
hint="Click a shortcut, then press the keys. Save when you are done."
|
||||
>
|
||||
<TextField label="Default mods" bind:value={defaultMods} placeholder="SUPER" />
|
||||
</Group>
|
||||
|
|
@ -453,7 +574,7 @@
|
|||
{:else}
|
||||
<Group
|
||||
title="Layout"
|
||||
hint={`Mods/Key pick the shortcut; Action picks what it does. Choosing a known action (exec, move_dir, focus, ...) shows the fields it actually needs — e.g. a Command box for exec — instead of raw JSON. "Advanced" reveals the underlying JSON per bind for anything not covered. Applies on next login/reload.`}
|
||||
hint="Click a shortcut, then press the keys. Save when you are done."
|
||||
>
|
||||
{#if layoutOrder.length > 0}
|
||||
<SelectField label="Active layout" bind:value={activeLayout} options={layoutOrder} />
|
||||
|
|
@ -512,9 +633,13 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs, 4px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
padding: 10px 2px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.bind-card:first-child {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.bind-top,
|
||||
|
|
@ -525,6 +650,34 @@
|
|||
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 select {
|
||||
background-color: var(--bg);
|
||||
|
|
@ -544,26 +697,6 @@
|
|||
border-color: var(--red);
|
||||
}
|
||||
|
||||
.mods {
|
||||
width: 14ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.key {
|
||||
width: 9ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-select {
|
||||
width: 11ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-custom {
|
||||
width: 11ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extra-wide {
|
||||
flex: 1;
|
||||
min-width: 16ch;
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@
|
|||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import InfoRow from "$lib/components/InfoRow.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import Wifi from "@lucide/svelte/icons/wifi";
|
||||
import WifiOff from "@lucide/svelte/icons/wifi-off";
|
||||
import Lock from "@lucide/svelte/icons/lock";
|
||||
|
|
@ -26,12 +25,6 @@
|
|||
let pendingSsid = $state<string | null>(null);
|
||||
let password = $state("");
|
||||
|
||||
// The backend reports the first `nmcli`-visible device of TYPE=ethernet,
|
||||
// which on a machine running containers/VMs can be a virtual interface
|
||||
// (docker/podman veth pairs, libvirt bridges, VPN tuns) rather than a
|
||||
// real NIC — nmcli doesn't distinguish these from physical ethernet.
|
||||
// Names like "vethYCF3ZA: unmanaged" are meaningless to a non-technical
|
||||
// user, so hide the card rather than show raw interface jargon.
|
||||
const VIRTUAL_IFACE_PREFIXES = ["veth", "docker", "br-", "virbr", "vnet", "tun", "tap", "vmnet", "podman"];
|
||||
const ETHERNET_STATE_LABELS: Record<string, string> = {
|
||||
connected: "Connected",
|
||||
|
|
@ -47,11 +40,14 @@
|
|||
if (!iface || VIRTUAL_IFACE_PREFIXES.some((prefix) => iface.startsWith(prefix))) return null;
|
||||
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 () => {
|
||||
const info = await invoke<{ radio_enabled: boolean; ethernet: string | null }>("get_network_info");
|
||||
radioEnabled = info.radio_enabled;
|
||||
ethernet = info.ethernet;
|
||||
if (info.radio_enabled) await scan();
|
||||
});
|
||||
|
||||
async function toggleRadio(enabled: boolean) {
|
||||
|
|
@ -61,10 +57,12 @@
|
|||
|
||||
async function scan() {
|
||||
scanning = true;
|
||||
status = "Scanning…";
|
||||
networks = await invoke<WifiNetwork[]>("scan_wifi");
|
||||
status = `Found ${networks.length} network(s)`;
|
||||
scanning = false;
|
||||
try {
|
||||
networks = await invoke<WifiNetwork[]>("scan_wifi");
|
||||
} finally {
|
||||
scanning = false;
|
||||
status = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(ssid: string, secured: boolean, known: boolean) {
|
||||
|
|
@ -76,8 +74,9 @@
|
|||
try {
|
||||
await invoke("connect_wifi", { ssid, password: null });
|
||||
status = `Connected to ${ssid}`;
|
||||
await scan();
|
||||
} catch (e) {
|
||||
status = `Failed to connect to ${ssid}: ${e}`;
|
||||
status = `Failed: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,54 +89,76 @@
|
|||
try {
|
||||
await invoke("connect_wifi", { ssid, password: pw });
|
||||
status = `Connected to ${ssid}`;
|
||||
await scan();
|
||||
} catch {
|
||||
status = `Failed to connect to ${ssid}: wrong password?`;
|
||||
status = "Wrong password?";
|
||||
}
|
||||
}
|
||||
|
||||
function signalLabel(signal: number): string {
|
||||
return `${Math.min(100, Math.max(0, signal))}%`;
|
||||
function bars(signal: number): number {
|
||||
if (signal >= 75) return 4;
|
||||
if (signal >= 50) return 3;
|
||||
if (signal >= 25) return 2;
|
||||
return 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Network">
|
||||
<Group title="Wi-Fi">
|
||||
<Row label="Wi-Fi radio">
|
||||
<button class="switch" class:on={radioEnabled} role="switch" aria-checked={radioEnabled} aria-label="Wi-Fi radio" onclick={() => toggleRadio(!radioEnabled)}>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</Row>
|
||||
<ViewScaffold title="Wi-Fi">
|
||||
<Group title="Radio">
|
||||
<SwitchField label="Wi-Fi" hint={radioEnabled ? "On" : "Off"} bind:value={() => radioEnabled, (v) => toggleRadio(v)} />
|
||||
{#if ethernetLabel}
|
||||
<InfoRow label="Ethernet" value={ethernetLabel} />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
{#if ethernetLabel}
|
||||
<Group title="Ethernet">
|
||||
<InfoRow label="Status" value={ethernetLabel} />
|
||||
{#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>
|
||||
{/if}
|
||||
|
||||
<Group title="Available networks" wide>
|
||||
<Group title="Nearby" wide>
|
||||
<div class="list">
|
||||
{#if networks === null}
|
||||
{#if scanning && networks === null}
|
||||
<div class="empty">
|
||||
<Wifi size={40} />
|
||||
<span>Not scanned yet</span>
|
||||
<span class="hint">Press Scan to see nearby networks.</span>
|
||||
<Wifi size={36} />
|
||||
<span>Scanning…</span>
|
||||
</div>
|
||||
{:else if networks.length === 0}
|
||||
{:else if networks === null}
|
||||
<div class="empty">
|
||||
<WifiOff size={40} />
|
||||
<Wifi size={36} />
|
||||
<span>No scan yet</span>
|
||||
</div>
|
||||
{:else if nearby.length === 0 && !activeNet}
|
||||
<div class="empty">
|
||||
<WifiOff size={36} />
|
||||
<span>No networks found</span>
|
||||
<span class="hint">Try Scan again, or check Wi-Fi radio is on.</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#each networks as net (net.ssid)}
|
||||
<div class="net-row">
|
||||
<span class="ssid" class:active={net.active}>{net.ssid}{net.active ? " (connected)" : ""}</span>
|
||||
{#each nearby as net (net.ssid)}
|
||||
<div class="wifi">
|
||||
<div class="bars">
|
||||
{#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}
|
||||
<span class="signal">{signalLabel(net.signal)}</span>
|
||||
{#if !net.active}
|
||||
<button class="connect" onclick={() => connect(net.ssid, net.secured, net.known)}>Connect</button>
|
||||
{/if}
|
||||
<button class="btn" onclick={() => connect(net.ssid, net.secured, net.known)}>Connect</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
|
@ -145,52 +166,28 @@
|
|||
|
||||
{#if pendingSsid}
|
||||
<div class="pw-row">
|
||||
<input type="password" bind:value={password} placeholder="Password" />
|
||||
<button class="connect" onclick={connectWithPassword}>Connect</button>
|
||||
<input type="password" bind:value={password} placeholder="Password for {pendingSsid}" />
|
||||
<button class="btn primary" onclick={connectWithPassword}>Connect</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if status}
|
||||
<span class="status">{status}</span>
|
||||
{/if}
|
||||
|
||||
<button class="scan" disabled={scanning} onclick={scan}>{scanning ? "Scanning…" : "Scan"}</button>
|
||||
<button class="btn" disabled={scanning} onclick={scan}>{scanning ? "Scanning…" : "Scan"}</button>
|
||||
</Group>
|
||||
|
||||
<Group title="Advanced" hint="VPN, 802.1x, and static IP configuration aren't covered here.">
|
||||
<button class="secondary" onclick={() => invoke("open_connection_editor")}>Open connection editor</button>
|
||||
<Group title="Advanced">
|
||||
<button class="btn" onclick={() => invoke("open_connection_editor")}>Connection editor</button>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
}
|
||||
|
||||
.list {
|
||||
min-height: 100px;
|
||||
max-height: 260px;
|
||||
min-height: 72px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
|
@ -198,79 +195,80 @@
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--space-xl, 20px) 0;
|
||||
padding: 20px 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.net-row {
|
||||
.wifi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
gap: 10px;
|
||||
padding: 10px 2px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ssid.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.signal {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
.ssid small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pw-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.pw-row input {
|
||||
flex: 1;
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
background: var(--bg);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button.connect,
|
||||
button.scan,
|
||||
button.secondary {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
align-self: flex-start;
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
}
|
||||
|
||||
button.scan {
|
||||
.btn {
|
||||
margin-top: 10px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
.wifi .btn {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
16
frontend/src/lib/views/NetworkHub.svelte
Normal file
16
frontend/src/lib/views/NetworkHub.svelte
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<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 },
|
||||
]}
|
||||
/>
|
||||
85
frontend/src/lib/views/NightLight.svelte
Normal file
85
frontend/src/lib/views/NightLight.svelte
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import SliderField from "$lib/components/SliderField.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import { debounce } from "$lib/debounce";
|
||||
|
||||
interface NightlightStatus {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
enabled: boolean;
|
||||
temperature: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
let st = $state<NightlightStatus | null>(null);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
let message = $state("");
|
||||
async function refresh() {
|
||||
st = await invoke<NightlightStatus>("get_nightlight");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function applyNow() {
|
||||
if (!st) return;
|
||||
message = "";
|
||||
try {
|
||||
st = await invoke<NightlightStatus>("set_nightlight", {
|
||||
enabled: st.enabled,
|
||||
temperature: st.temperature,
|
||||
});
|
||||
if (st.error) message = st.error;
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
const persistTemp = debounce(applyNow, 200);
|
||||
|
||||
async function install() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("pacman_install", { packages: ["hyprsunset"] }, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Night light">
|
||||
<Group title="Night light" hint="Warmer screen after dark.">
|
||||
{#if !st}
|
||||
<Hint text="Loading…" />
|
||||
{:else if !st.installed}
|
||||
<Hint text="hyprsunset is not installed." />
|
||||
<button class="btn primary" disabled={busy} onclick={install}>Install</button>
|
||||
{:else}
|
||||
<SwitchField
|
||||
label="Night light"
|
||||
bind:value={() => st!.enabled, (v) => { st!.enabled = v; applyNow(); }}
|
||||
/>
|
||||
{#if st.enabled}
|
||||
<SliderField
|
||||
label="Warmth"
|
||||
bind:value={st.temperature}
|
||||
min={2000}
|
||||
max={6500}
|
||||
step={100}
|
||||
suffix="K"
|
||||
onChange={persistTemp}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
149
frontend/src/lib/views/Optional.svelte
Normal file
149
frontend/src/lib/views/Optional.svelte
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
|
||||
interface OptionalItem {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
installed: boolean;
|
||||
via: string;
|
||||
}
|
||||
interface OptionalStatus {
|
||||
items: OptionalItem[];
|
||||
flathub: boolean;
|
||||
}
|
||||
|
||||
let st = $state<OptionalStatus | null>(null);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
let message = $state("");
|
||||
|
||||
async function refresh() {
|
||||
st = await invoke<OptionalStatus>("get_optional_software");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function stream(command: string, args: Record<string, unknown> = {}) {
|
||||
log = [];
|
||||
busy = true;
|
||||
message = "";
|
||||
const ok = await runStreamed(command, args, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
if (!ok) message = "Install failed. See the log.";
|
||||
await refresh();
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function install(id: string) {
|
||||
if (id === "breadcast") {
|
||||
await stream("bakery_install", { name: "breadcast" });
|
||||
return;
|
||||
}
|
||||
if (id === "flatpak") {
|
||||
const ok = await stream("pacman_install", { packages: ["flatpak"] });
|
||||
if (ok) {
|
||||
try {
|
||||
await invoke("enable_flathub");
|
||||
message = "Flathub user remote added.";
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id === "office") {
|
||||
await stream("pacman_install", { packages: ["libreoffice-fresh", "papers"] });
|
||||
return;
|
||||
}
|
||||
if (id === "steam") {
|
||||
await stream("pacman_install", { packages: ["steam"] });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Optional software">
|
||||
<Group
|
||||
title="Curated extras"
|
||||
hint="Not an AUR browser. breadcast is bakery-only and is not on the ISO. Pacman items use the allowlisted installer."
|
||||
wide
|
||||
>
|
||||
{#if !st}
|
||||
<Hint text="Loading…" />
|
||||
{:else}
|
||||
{#each st.items as item (item.id)}
|
||||
<div class="card">
|
||||
<div class="meta">
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.detail}</p>
|
||||
<span class="via">{item.via}{item.installed ? " · installed" : ""}</span>
|
||||
</div>
|
||||
{#if item.installed}
|
||||
<span class="done">Installed</span>
|
||||
{:else}
|
||||
<button class="primary" disabled={busy} onclick={() => install(item.id)}>Install</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta p {
|
||||
margin: 4px 0;
|
||||
opacity: 0.8;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.via,
|
||||
.done {
|
||||
opacity: 0.65;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -85,7 +85,7 @@
|
|||
|
||||
<Group
|
||||
title="System packages (pacman)"
|
||||
hint="Base system, kernel, bos-settings, and republished AUR packages — the other half of what bos-update covers. Needs your password (polkit) since pacman requires root."
|
||||
hint="Official repos. Needs your password."
|
||||
>
|
||||
<button disabled={busy} onclick={updateSystem}>Update system (pacman -Syu)</button>
|
||||
</Group>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,10 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Power">
|
||||
<ViewScaffold
|
||||
title="Power & battery"
|
||||
lede="Brightness, battery, and charge limits."
|
||||
>
|
||||
{#if info}
|
||||
<Group title="Battery">
|
||||
{#each info.battery as [label, value] (label)}
|
||||
|
|
@ -63,20 +66,22 @@
|
|||
{#if info.charge_start !== null && info.charge_end !== null}
|
||||
<Group
|
||||
title="Charge limits"
|
||||
hint="Some laptops let you cap charging below 100% to slow battery wear on a machine that's mostly plugged in."
|
||||
hint="Keeps the battery off 100% when this machine stays plugged in."
|
||||
>
|
||||
<Row label="Start charging below (%)">
|
||||
<input type="number" min="0" max="100" bind:value={chargeStart} onchange={() => setChargeThreshold("start", chargeStart)} />
|
||||
<Row label="Start charging below">
|
||||
<input type="range" min="0" max="100" bind:value={chargeStart} onchange={() => setChargeThreshold("start", chargeStart)} />
|
||||
<span class="pct">{chargeStart}%</span>
|
||||
</Row>
|
||||
<Row label="Stop charging at (%)">
|
||||
<input type="number" min="1" max="100" bind:value={chargeEnd} onchange={() => setChargeThreshold("end", chargeEnd)} />
|
||||
<Row label="Stop charging at">
|
||||
<input type="range" min="1" max="100" bind:value={chargeEnd} onchange={() => setChargeThreshold("end", chargeEnd)} />
|
||||
<span class="pct">{chargeEnd}%</span>
|
||||
</Row>
|
||||
</Group>
|
||||
{/if}
|
||||
|
||||
<Group
|
||||
title="TLP"
|
||||
hint="TLP automatically applies a power-saving profile on battery and a performance profile on AC — there's no manual switch by design."
|
||||
hint="TLP picks battery vs AC on its own."
|
||||
>
|
||||
<InfoRow label="Current profile" value={info.tlp_profile ?? "unknown"} />
|
||||
</Group>
|
||||
|
|
@ -88,15 +93,6 @@
|
|||
width: 180px;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 8ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.pct {
|
||||
margin-left: var(--space-sm, 8px);
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
|
|
|
|||
147
frontend/src/lib/views/Printing.svelte
Normal file
147
frontend/src/lib/views/Printing.svelte
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import Printer from "@lucide/svelte/icons/printer";
|
||||
|
||||
interface PrinterRow {
|
||||
name: string;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
is_default: boolean;
|
||||
}
|
||||
interface PrintingStatus {
|
||||
printers: PrinterRow[];
|
||||
default: string | null;
|
||||
cups_ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
let status = $state<PrintingStatus | null>(null);
|
||||
let message = $state("");
|
||||
let newName = $state("");
|
||||
let newUri = $state("");
|
||||
|
||||
async function refresh() {
|
||||
status = await invoke<PrintingStatus>("get_printers");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function setDefault(name: string) {
|
||||
message = "";
|
||||
try {
|
||||
await invoke("set_default_printer", { name });
|
||||
message = `${name} is the default printer.`;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function addIpp() {
|
||||
message = "";
|
||||
try {
|
||||
await invoke("add_ipp_printer", { name: newName.trim(), uri: newUri.trim() });
|
||||
message = `Added ${newName.trim()}.`;
|
||||
newName = "";
|
||||
newUri = "";
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Printing">
|
||||
<Group title="Printers" hint="CUPS + Avahi ship on the ISO. Discovery and drivers for odd hardware live in system-config-printer." wide>
|
||||
{#if !status}
|
||||
<Hint text="Loading…" />
|
||||
{:else if status.error}
|
||||
<EmptyState icon={Printer} title="Couldn't talk to CUPS" hint={status.error} />
|
||||
{:else if status.printers.length === 0}
|
||||
<EmptyState icon={Printer} title="No printers yet" hint="Add one below, or open the CUPS printer wizard." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each status.printers as p (p.name)}
|
||||
<div class="row">
|
||||
<div class="meta">
|
||||
<span class="name">{p.name}{p.is_default ? " (default)" : ""}</span>
|
||||
<span class="status">{p.enabled ? p.status : "disabled"}</span>
|
||||
</div>
|
||||
<button disabled={p.is_default} onclick={() => setDefault(p.name)}>Set default</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button onclick={refresh}>Refresh</button>
|
||||
<button class="primary" onclick={() => invoke("open_printer_settings")}>Add printer…</button>
|
||||
</div>
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="IPP Everywhere" hint="For a printer that already speaks IPP. Name must be letters, digits, dash, underscore.">
|
||||
<TextField label="Name" bind:value={newName} placeholder="Office" />
|
||||
<TextField label="URI" bind:value={newUri} placeholder="ipp://192.168.1.20/ipp/print" />
|
||||
<button disabled={!newName.trim() || !newUri.trim()} onclick={addIpp}>Add IPP printer</button>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.7;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
14
frontend/src/lib/views/PrivacyHub.svelte
Normal file
14
frontend/src/lib/views/PrivacyHub.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<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 },
|
||||
]}
|
||||
/>
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
} catch (e) {
|
||||
const msg = `${e}`.toLowerCase();
|
||||
if (msg.includes("no permission")) {
|
||||
errorHint = "This user isn't allowed to run snapper. Check ALLOW_USERS in /etc/snapper/configs/root — it should list your username.";
|
||||
errorHint = "This user is not in ALLOW_USERS for snapper.";
|
||||
} else if (msg.includes("unknown config") || msg.includes("no such file")) {
|
||||
errorHint = "No snapper config exists for root yet, so nothing is being snapshotted. This should be set up automatically at install.";
|
||||
} else {
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
if (!selected) return;
|
||||
if (
|
||||
confirm(
|
||||
`Boot into snapshot #${selected}? Snapshots on BOS are booted directly from the GRUB menu (under "BOS snapshots"), not rolled back in place. Reboot now and pick this snapshot there.`,
|
||||
`Reboot to pick snapshot #${selected} in GRUB? BOS boots snapshots from the GRUB “BOS snapshots” submenu. This does not run snapper rollback.`,
|
||||
)
|
||||
) {
|
||||
invoke("reboot_system");
|
||||
|
|
@ -62,9 +62,10 @@
|
|||
</script>
|
||||
|
||||
<ViewScaffold title="Snapshots">
|
||||
<Hint text="Reboot and pick a snapshot in GRUB to undo an update." />
|
||||
<Group
|
||||
title="System snapshots"
|
||||
hint="Created automatically by snap-pac on each pacman transaction. Boot into one from the GRUB menu to recover; delete old ones here."
|
||||
title="What to pick in GRUB"
|
||||
hint="Reboot and choose that entry in GRUB."
|
||||
wide
|
||||
>
|
||||
<div class="list">
|
||||
|
|
@ -75,6 +76,11 @@
|
|||
{:else if snapshots.length === 0}
|
||||
<EmptyState icon={History} title="No snapshots yet" hint="Snapshots are created automatically on every pacman transaction." />
|
||||
{:else}
|
||||
<div class="row header" aria-hidden="true">
|
||||
<span class="number">#</span>
|
||||
<span class="date">Date</span>
|
||||
<span class="desc">Description</span>
|
||||
</div>
|
||||
{#each snapshots as snap (snap.number)}
|
||||
<button
|
||||
class="row"
|
||||
|
|
@ -91,7 +97,7 @@
|
|||
|
||||
<div class="btn-row">
|
||||
<button onclick={refresh}>Refresh</button>
|
||||
<button disabled={!selected} onclick={bootIntoSelected}>Boot into selected…</button>
|
||||
<button disabled={!selected} onclick={bootIntoSelected}>Reboot to pick in GRUB</button>
|
||||
<button class="destructive" disabled={!selected} onclick={deleteSelected}>Delete selected</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
|
@ -121,7 +127,15 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
.row.header {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.row:hover:not(.header) {
|
||||
background-color: color-mix(in srgb, var(--surface), var(--on-surface) 8%);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,107 +24,154 @@
|
|||
let output = $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> {
|
||||
const section = await invoke<{ devices: SoundDevice[]; default_name: string | null }>("get_sound_section", { kind });
|
||||
const selected = Math.max(0, section.devices.findIndex((d) => d.name === section.default_name));
|
||||
return { kind, title, devices: section.devices, selected };
|
||||
const section = await invoke<{ devices: SoundDevice[]; default_name: string | null }>("get_sound_section", {
|
||||
kind,
|
||||
});
|
||||
return { kind, title, devices: section.devices, selected: pick(section.devices, section.default_name) };
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
output = await loadSection("sinks", "Output");
|
||||
input = await loadSection("sources", "Input");
|
||||
try {
|
||||
output = await loadSection("sinks", "Output");
|
||||
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) {
|
||||
const device = section.devices[index];
|
||||
if (!device) return;
|
||||
section.selected = index;
|
||||
await invoke("set_default_sound_device", { kind: section.kind, name: section.devices[index].name });
|
||||
await invoke("set_default_sound_device", { kind: section.kind, name: device.name });
|
||||
}
|
||||
|
||||
async function setVolume(section: DeviceSection, percent: number) {
|
||||
const device = section.devices[section.selected];
|
||||
const device = current(section);
|
||||
if (!device) return;
|
||||
device.percent = percent;
|
||||
await invoke("set_sound_volume", { kind: section.kind, name: device.name, percent });
|
||||
await invoke("set_sound_volume", { kind: section.kind, name: device.name, percent: Math.round(percent) });
|
||||
}
|
||||
|
||||
async function setMute(section: DeviceSection, mute: boolean) {
|
||||
const device = section.devices[section.selected];
|
||||
const device = current(section);
|
||||
if (!device) return;
|
||||
device.mute = mute;
|
||||
await invoke("set_sound_mute", { kind: section.kind, name: device.name, mute });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet deviceSection(section: DeviceSection | null)}
|
||||
{#if section}
|
||||
<Group title={section.title}>
|
||||
{#if section.devices.length === 0}
|
||||
<Hint text="No devices found." />
|
||||
{:else}
|
||||
<Row label="Device">
|
||||
<select
|
||||
value={section.selected}
|
||||
onchange={(e) => selectDevice(section, Number(e.currentTarget.value))}
|
||||
>
|
||||
{#each section.devices as d, i (d.name)}
|
||||
<option value={i}>{d.description}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
{#snippet sectionCard(section: DeviceSection)}
|
||||
<Group title={section.title}>
|
||||
{#if section.devices.length === 0}
|
||||
<Hint text="No devices found." />
|
||||
{:else}
|
||||
{#each section.devices as d, i (d.name + i)}
|
||||
<button type="button" class="dev" class:on={section.selected === i} onclick={() => selectDevice(section, i)}>
|
||||
<span class="dev-name">{d.description || d.name}</span>
|
||||
{#if section.selected === i}<span class="mark">In use</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if current(section)}
|
||||
<Row label="Volume">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="150"
|
||||
value={section.devices[section.selected].percent}
|
||||
value={Math.round(current(section)!.percent)}
|
||||
oninput={(e) => setVolume(section, Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="pct">{section.devices[section.selected].percent}%</span>
|
||||
<span class="pct">{Math.round(current(section)!.percent)}%</span>
|
||||
</Row>
|
||||
<SwitchField
|
||||
label="Mute"
|
||||
bind:value={
|
||||
() => section.devices[section.selected].mute,
|
||||
(v) => setMute(section, v)
|
||||
}
|
||||
bind:value={() => current(section)!.mute, (v) => setMute(section, v)}
|
||||
/>
|
||||
{/if}
|
||||
</Group>
|
||||
{/if}
|
||||
{/if}
|
||||
</Group>
|
||||
{/snippet}
|
||||
|
||||
<ViewScaffold title="Sound">
|
||||
{@render deviceSection(output)}
|
||||
{@render deviceSection(input)}
|
||||
<ViewScaffold title="Sound" lede="Output, input, and volume.">
|
||||
{#if output}
|
||||
{@render sectionCard(output)}
|
||||
{/if}
|
||||
{#if input}
|
||||
{@render sectionCard(input)}
|
||||
{/if}
|
||||
|
||||
<Group title="Advanced" hint="Per-app volume, port selection, and profile switching aren't covered here.">
|
||||
<button class="secondary" onclick={() => invoke("open_mixer")}>Open advanced mixer (pavucontrol)</button>
|
||||
<Group title="Advanced">
|
||||
<button class="secondary" onclick={() => invoke("open_mixer")}>Per-app volume</button>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
select {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
.dev {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
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"] {
|
||||
width: 180px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.pct {
|
||||
margin-left: var(--space-sm, 8px);
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
min-width: 4ch;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
border-radius: 10px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
|
|
|||
24
frontend/src/lib/views/SystemHub.svelte
Normal file
24
frontend/src/lib/views/SystemHub.svelte
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<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 },
|
||||
]}
|
||||
/>
|
||||
250
frontend/src/lib/views/Updates.svelte
Normal file
250
frontend/src/lib/views/Updates.svelte
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import { go } from "$lib/nav.svelte";
|
||||
import Download from "@lucide/svelte/icons/download";
|
||||
import Cpu from "@lucide/svelte/icons/cpu";
|
||||
|
||||
interface PendingUpdate {
|
||||
name: string;
|
||||
current: string;
|
||||
latest: string;
|
||||
}
|
||||
interface FwDevice {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
interface NvidiaOffer {
|
||||
gpu: string;
|
||||
reason: string;
|
||||
packages: string[];
|
||||
installed: boolean;
|
||||
}
|
||||
interface UpdatesStatus {
|
||||
pacman: PendingUpdate[];
|
||||
pacman_error: string | null;
|
||||
bakery: PendingUpdate[];
|
||||
bakery_error: string | null;
|
||||
firmware: FwDevice[];
|
||||
nvidia: NvidiaOffer | null;
|
||||
}
|
||||
|
||||
let status = $state<UpdatesStatus | null>(null);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
status = await invoke<UpdatesStatus>("get_updates_status");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
function appendLine(line: string) {
|
||||
log = [...log, line];
|
||||
}
|
||||
|
||||
async function run(command: string, args: Record<string, unknown> = {}) {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed(command, args, appendLine);
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Updates">
|
||||
{#if status?.nvidia}
|
||||
<Group title="NVIDIA driver" wide>
|
||||
<div class="offer">
|
||||
<Cpu size={20} />
|
||||
<div class="offer-text">
|
||||
<strong>{status.nvidia.gpu}</strong>
|
||||
<p>{status.nvidia.reason}</p>
|
||||
{#if status.nvidia.installed}
|
||||
<Hint text="Driver and Hyprland env drop-in are in place. Reboot to start a working session." />
|
||||
{:else}
|
||||
<Hint text="Installs nvidia + nvidia-utils (not cuda) and writes ~/.config/hypr/nvidia.lua. hyprland.lua loads that file only if it exists. Reboot after." />
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="primary"
|
||||
disabled={busy}
|
||||
onclick={() => run("nvidia_setup")}
|
||||
>
|
||||
{status.nvidia.installed ? "Re-run setup" : "Install driver"}
|
||||
</button>
|
||||
</div>
|
||||
</Group>
|
||||
{/if}
|
||||
|
||||
<Group
|
||||
title="System packages (pacman)"
|
||||
hint="The same set Packages covers with pacman -Syu. Needs your password (polkit)."
|
||||
wide
|
||||
>
|
||||
{#if !status}
|
||||
<Hint text="Loading…" />
|
||||
{:else if status.pacman_error}
|
||||
<Hint text={status.pacman_error} />
|
||||
{:else if status.pacman.length === 0}
|
||||
<EmptyState icon={Download} title="Pacman is up to date" hint="No pending official-repo upgrades." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each status.pacman as pkg (pkg.name)}
|
||||
<div class="row">
|
||||
<span class="name">{pkg.name}</span>
|
||||
<span class="version">{pkg.current} → {pkg.latest}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} class="primary" onclick={() => run("pacman_system_update")}>Update system</button>
|
||||
<button disabled={busy} onclick={refresh}>Refresh</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Bread ecosystem (bakery)"
|
||||
hint="Bakery packages waiting to update."
|
||||
wide
|
||||
>
|
||||
{#if !status}
|
||||
<Hint text="Loading…" />
|
||||
{:else if status.bakery_error}
|
||||
<Hint text={status.bakery_error} />
|
||||
{:else if status.bakery.length === 0}
|
||||
<EmptyState icon={Download} title="Bakery packages are current" hint="Nothing on this track wants an update." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each status.bakery as pkg (pkg.name)}
|
||||
<div class="row">
|
||||
<span class="name">{pkg.name}</span>
|
||||
<span class="version">{pkg.current ? `${pkg.current} → ` : ""}{pkg.latest}</span>
|
||||
<button disabled={busy} onclick={() => run("bakery_update", { name: pkg.name })}>Update</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} class="primary" onclick={() => run("bakery_update_all")}>Update all bakery</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Firmware (fwupd)" hint="Same list as the Firmware page. fwupd-refresh.timer already refreshes metadata in the background." wide>
|
||||
{#if !status}
|
||||
<Hint text="Loading…" />
|
||||
{:else if status.firmware.length === 0}
|
||||
<EmptyState icon={Download} title="No updatable firmware" hint="Not every device speaks fwupd." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each status.firmware as dev (dev.name)}
|
||||
<div class="row">
|
||||
<span class="name">{dev.name}</span>
|
||||
<span class="version">{dev.version}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} onclick={() => run("fwupd_refresh")}>Check for updates</button>
|
||||
<button disabled={busy} class="primary" onclick={() => run("fwupd_update")}>Update firmware</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Rollback"
|
||||
hint="Boot a snapshot from GRUB (BOS snapshots)."
|
||||
>
|
||||
<Hint text="Pick the snapshot in GRUB. bakery rollback undoes one package." />
|
||||
<div class="btn-row">
|
||||
<button onclick={() => go("snapshots")}>Open Snapshots</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.offer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.offer-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.offer-text p {
|
||||
margin: 4px 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version {
|
||||
opacity: 0.75;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -69,7 +69,7 @@
|
|||
</script>
|
||||
|
||||
<ViewScaffold title="Users">
|
||||
<Group title="Accounts" hint="Real login accounts on this machine (system/service accounts aren't shown). Your own account can't be removed from here." wide>
|
||||
<Group title="Accounts" hint="Login accounts. You cannot delete yourself." wide>
|
||||
<div class="list">
|
||||
{#if accounts}
|
||||
{#each accounts as acc (acc.username)}
|
||||
|
|
|
|||
148
frontend/src/lib/views/Vpn.svelte
Normal file
148
frontend/src/lib/views/Vpn.svelte
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import FileField from "$lib/components/FileField.svelte";
|
||||
import ShieldEllipsis from "@lucide/svelte/icons/shield-ellipsis";
|
||||
|
||||
interface VpnConnection {
|
||||
name: string;
|
||||
kind: string;
|
||||
active: boolean;
|
||||
autoconnect: boolean;
|
||||
}
|
||||
interface VpnStatus {
|
||||
connections: VpnConnection[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
let status = $state<VpnStatus | null>(null);
|
||||
let importPath = $state("");
|
||||
let message = $state("");
|
||||
let busy = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
status = await invoke<VpnStatus>("get_vpn_connections");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function connect(name: string, up: boolean) {
|
||||
busy = true;
|
||||
message = "";
|
||||
try {
|
||||
await invoke(up ? "vpn_connect" : "vpn_disconnect", { name });
|
||||
message = up ? `Connected ${name}` : `Disconnected ${name}`;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (!importPath.trim()) return;
|
||||
busy = true;
|
||||
message = "";
|
||||
try {
|
||||
await invoke("vpn_import", { path: importPath.trim() });
|
||||
message = "Imported. Connect it from the list.";
|
||||
importPath = "";
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="VPN / WireGuard">
|
||||
<Group
|
||||
title="NetworkManager tunnels"
|
||||
hint="WireGuard and OpenVPN. Tailscale is under Saved networks."
|
||||
wide
|
||||
>
|
||||
{#if !status}
|
||||
<Hint text="Loading…" />
|
||||
{:else if status.error}
|
||||
<EmptyState icon={ShieldEllipsis} title="Couldn't list connections" hint={status.error} />
|
||||
{:else if status.connections.length === 0}
|
||||
<EmptyState icon={ShieldEllipsis} title="No VPN or WireGuard connections" hint="Import a .conf below, or use nm-connection-editor from Network." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each status.connections as c (c.name)}
|
||||
<div class="row">
|
||||
<div class="meta">
|
||||
<span class="name">{c.name}</span>
|
||||
<span class="kind">{c.kind}{c.active ? " · connected" : ""}{c.autoconnect ? " · autoconnect" : ""}</span>
|
||||
</div>
|
||||
{#if c.active}
|
||||
<button disabled={busy} onclick={() => connect(c.name, false)}>Disconnect</button>
|
||||
{:else}
|
||||
<button class="primary" disabled={busy} onclick={() => connect(c.name, true)}>Connect</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={refresh}>Refresh</button>
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Import" hint="WireGuard .conf or OpenVPN .ovpn. NetworkManager stores the secret after import.">
|
||||
<FileField label="Config" bind:value={importPath} placeholder="wg0.conf" extensions={["conf", "ovpn"]} />
|
||||
<button class="primary" disabled={busy || !importPath.trim()} onclick={doImport}>Import</button>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kind {
|
||||
opacity: 0.7;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,63 +1,30 @@
|
|||
// 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 About from "./About.svelte";
|
||||
import Breadclip from "./Breadclip.svelte";
|
||||
import Bread from "./Bread.svelte";
|
||||
import Breadbar from "./Breadbar.svelte";
|
||||
import Breadbox from "./Breadbox.svelte";
|
||||
import Breadpad from "./Breadpad.svelte";
|
||||
import Breadpaper from "./Breadpaper.svelte";
|
||||
import Breadsearch from "./Breadsearch.svelte";
|
||||
import Breadcrumbs from "./Breadcrumbs.svelte";
|
||||
import Appearance from "./Appearance.svelte";
|
||||
import Autostart from "./Autostart.svelte";
|
||||
import Display from "./Display.svelte";
|
||||
import Keybinds from "./Keybinds.svelte";
|
||||
import Sound from "./Sound.svelte";
|
||||
import DateTime from "./DateTime.svelte";
|
||||
import Power from "./Power.svelte";
|
||||
import Network from "./Network.svelte";
|
||||
import AboutHub from "./AboutHub.svelte";
|
||||
import AppearanceHub from "./AppearanceHub.svelte";
|
||||
import AppsHub from "./AppsHub.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 DesktopHub from "./DesktopHub.svelte";
|
||||
import DisplaysHub from "./DisplaysHub.svelte";
|
||||
import Home from "./Home.svelte";
|
||||
import InputHub from "./InputHub.svelte";
|
||||
import NetworkHub from "./NetworkHub.svelte";
|
||||
import Power from "./Power.svelte";
|
||||
import PrivacyHub from "./PrivacyHub.svelte";
|
||||
import Sound from "./Sound.svelte";
|
||||
import SystemHub from "./SystemHub.svelte";
|
||||
|
||||
export const VIEWS: Record<string, Component> = {
|
||||
about: About,
|
||||
breadclip: Breadclip,
|
||||
bread: Bread,
|
||||
breadbar: Breadbar,
|
||||
breadbox: Breadbox,
|
||||
breadpad: Breadpad,
|
||||
breadpaper: Breadpaper,
|
||||
breadsearch: Breadsearch,
|
||||
breadcrumbs: Breadcrumbs,
|
||||
appearance: Appearance,
|
||||
autostart: Autostart,
|
||||
hyprland: Display,
|
||||
keybinds: Keybinds,
|
||||
sound: Sound,
|
||||
datetime: DateTime,
|
||||
power: Power,
|
||||
network: Network,
|
||||
home: Home,
|
||||
network: NetworkHub,
|
||||
bluetooth: Bluetooth,
|
||||
firewall: Firewall,
|
||||
users: Users,
|
||||
packages: Packages,
|
||||
aur: Aur,
|
||||
firmware: Firmware,
|
||||
snapshots: Snapshots,
|
||||
breadlock: Breadlock,
|
||||
breadshot: Breadshot,
|
||||
breadmon: Breadmon,
|
||||
breadhelp: Breadhelp,
|
||||
displays: DisplaysHub,
|
||||
sound: Sound,
|
||||
power: Power,
|
||||
appearance: AppearanceHub,
|
||||
desktop: DesktopHub,
|
||||
input: InputHub,
|
||||
apps: AppsHub,
|
||||
privacy: PrivacyHub,
|
||||
system: SystemHub,
|
||||
about: AboutHub,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,65 +1,66 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, setContext } from "svelte";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import Sidebar from "$lib/components/Sidebar.svelte";
|
||||
import Titlebar from "$lib/components/Titlebar.svelte";
|
||||
import Placeholder from "$lib/components/Placeholder.svelte";
|
||||
import { DEFAULT_PAGE } from "$lib/sidebar";
|
||||
import { go, nav, NAVIGATE_KEY, type Navigate } from "$lib/nav.svelte";
|
||||
import { initTheme } from "$lib/theme";
|
||||
import { VIEWS } from "$lib/views/registry";
|
||||
import "$lib/styles/app.css";
|
||||
|
||||
let activePage = $state(DEFAULT_PAGE);
|
||||
let ActiveView = $derived(VIEWS[activePage]);
|
||||
let ActiveView = $derived(VIEWS[nav.page]);
|
||||
|
||||
setContext<Navigate>(NAVIGATE_KEY, go);
|
||||
|
||||
onMount(() => {
|
||||
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) => {
|
||||
activePage = event.payload;
|
||||
go(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 () => {
|
||||
unlisten.then((f) => f());
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<Sidebar bind:activePage />
|
||||
<main class="content">
|
||||
{#if ActiveView}
|
||||
<ActiveView />
|
||||
{:else}
|
||||
<Placeholder page={activePage} />
|
||||
{/if}
|
||||
</main>
|
||||
<div class="app">
|
||||
<Titlebar />
|
||||
<div class="shell">
|
||||
<Sidebar />
|
||||
<main class="content">
|
||||
{#key nav.page}
|
||||
{#if ActiveView}
|
||||
<ActiveView />
|
||||
{:else}
|
||||
<Placeholder page={nav.page} />
|
||||
{/if}
|
||||
{/key}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(html, body) {
|
||||
margin: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
height: 100%;
|
||||
color-scheme: dark;
|
||||
background-color: var(--bg, #0c0c0c);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-family, sans-serif);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
}
|
||||
|
||||
:global(#svelte) {
|
||||
height: 100%;
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: var(--bg, #12161c);
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background-color: var(--bg, #0c0c0c);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import path from "node:path";
|
|||
import { defineConfig } from "vite";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// The Rust/Tauri project dir (../src relative to this file) — an absolute
|
||||
|
|
|
|||
456
src/Cargo.lock
generated
456
src/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "bos-settings"
|
||||
version = "0.8.0"
|
||||
version = "0.8.2"
|
||||
description = "System settings app for BOS (Bread Operating System)"
|
||||
authors = ["Breadway"]
|
||||
edition = "2021"
|
||||
|
|
@ -36,7 +36,7 @@ toml_edit = "0.22"
|
|||
tokio = { version = "1", features = ["process", "io-util", "time", "macros"] }
|
||||
notify = "7"
|
||||
regex = "1"
|
||||
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
||||
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" }
|
||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml"] }
|
||||
anyhow = "1"
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
116
src/src/commands/a11y.rs
Normal file
116
src/src/commands/a11y.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
//! Accessibility toggles that actually do something on Hyprland.
|
||||
//! Orca launches. Magnifier is Hyprland `cursor:zoom_factor`. Sticky/slow
|
||||
//! keys are not exposed by Hyprland or xkeyboard-config rules — the UI
|
||||
//! must show that honestly rather than a dead switch.
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::util::{command_exists, fail_output, pacman_installed};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct A11yStatus {
|
||||
orca_installed: bool,
|
||||
orca_running: bool,
|
||||
zoom_factor: f64,
|
||||
sticky_keys_supported: bool,
|
||||
slow_keys_supported: bool,
|
||||
kmag_installed: bool,
|
||||
note: String,
|
||||
}
|
||||
|
||||
async fn orca_running() -> bool {
|
||||
Command::new("pgrep")
|
||||
.args(["-x", "orca"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn read_zoom() -> f64 {
|
||||
let output = Command::new("hyprctl")
|
||||
.args(["getoption", "cursor:zoom_factor", "-j"])
|
||||
.output()
|
||||
.await;
|
||||
let Ok(output) = output else {
|
||||
return 1.0;
|
||||
};
|
||||
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
|
||||
return 1.0;
|
||||
};
|
||||
v.get("float")
|
||||
.and_then(|x| x.as_f64())
|
||||
.or_else(|| v.get("int").and_then(|x| x.as_i64()).map(|i| i as f64))
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_a11y_status() -> A11yStatus {
|
||||
A11yStatus {
|
||||
orca_installed: command_exists("orca") || pacman_installed("orca"),
|
||||
orca_running: orca_running().await,
|
||||
zoom_factor: read_zoom().await,
|
||||
sticky_keys_supported: false,
|
||||
slow_keys_supported: false,
|
||||
kmag_installed: command_exists("kmag") || pacman_installed("kmag"),
|
||||
note: "Hyprland does not expose XKB AccessX (sticky keys / slow keys). Those toggles stay off because they would not do anything.".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_cursor_zoom(factor: f64) -> Result<f64, String> {
|
||||
let factor = factor.clamp(1.0, 8.0);
|
||||
let value = format!("{factor:.2}");
|
||||
let output = Command::new("hyprctl")
|
||||
.args(["keyword", "cursor:zoom_factor", &value])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(factor)
|
||||
} else {
|
||||
Err(fail_output(&output, "hyprctl keyword cursor:zoom_factor"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_orca_running(running: bool) -> Result<(), String> {
|
||||
if running {
|
||||
if !command_exists("orca") {
|
||||
return Err("orca is not installed".into());
|
||||
}
|
||||
std::process::Command::new("orca")
|
||||
.arg("--replace")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start orca: {e}"))?;
|
||||
Ok(())
|
||||
} else {
|
||||
let _ = Command::new("pkill").args(["-x", "orca"]).status().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_kmag() -> Result<(), String> {
|
||||
if !command_exists("kmag") {
|
||||
return Err("kmag is not installed".into());
|
||||
}
|
||||
std::process::Command::new("kmag")
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start kmag: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn zoom_clamp_bounds() {
|
||||
let f = 0.2_f64.clamp(1.0, 8.0);
|
||||
assert_eq!(f, 1.0);
|
||||
assert_eq!(12.0_f64.clamp(1.0, 8.0), 8.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,8 +23,10 @@ fn os_pretty_name() -> String {
|
|||
fs::read_to_string("/etc/os-release")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines()
|
||||
.find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string()))
|
||||
s.lines().find_map(|l| {
|
||||
l.strip_prefix("PRETTY_NAME=")
|
||||
.map(|v| v.trim_matches('"').to_string())
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "BOS".to_string())
|
||||
}
|
||||
|
|
@ -49,11 +51,15 @@ fn cpu() -> String {
|
|||
let model = fs::read_to_string("/proc/cpuinfo")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines()
|
||||
.find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string()))
|
||||
s.lines().find_map(|l| {
|
||||
l.strip_prefix("model name")
|
||||
.map(|v| v.trim_start_matches([':', ' ', '\t']).to_string())
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(0);
|
||||
if cores > 0 {
|
||||
format!("{model} ({cores} threads)")
|
||||
} else {
|
||||
|
|
@ -62,14 +68,12 @@ fn cpu() -> String {
|
|||
}
|
||||
|
||||
fn memory() -> String {
|
||||
let kb = fs::read_to_string("/proc/meminfo")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines()
|
||||
.find(|l| l.starts_with("MemTotal:"))
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
});
|
||||
let kb = fs::read_to_string("/proc/meminfo").ok().and_then(|s| {
|
||||
s.lines()
|
||||
.find(|l| l.starts_with("MemTotal:"))
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
});
|
||||
match kb {
|
||||
Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0),
|
||||
None => "unknown".to_string(),
|
||||
|
|
@ -96,7 +100,11 @@ async fn gpu() -> String {
|
|||
}
|
||||
|
||||
async fn disk_usage() -> String {
|
||||
let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output().await else {
|
||||
let Ok(output) = Command::new("df")
|
||||
.args(["-h", "--output=used,size,pcent", "/"])
|
||||
.output()
|
||||
.await
|
||||
else {
|
||||
return "unknown".to_string();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
|
|
@ -136,11 +144,35 @@ pub async fn get_system_info() -> SystemInfo {
|
|||
}
|
||||
}
|
||||
|
||||
/// RFC 1123 labels (digit start allowed), no leading `-`. Linux static
|
||||
/// hostnames are also capped at `HOST_NAME_MAX` (64).
|
||||
fn valid_hostname(name: &str) -> bool {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.len() > 64 || name.starts_with('-') {
|
||||
return false;
|
||||
}
|
||||
if name.contains('\n') || name.contains('\r') || name.contains('\0') {
|
||||
return false;
|
||||
}
|
||||
name.split('.').all(valid_dns_label)
|
||||
}
|
||||
|
||||
fn valid_dns_label(label: &str) -> bool {
|
||||
let b = label.as_bytes();
|
||||
if b.is_empty() || b.len() > 63 {
|
||||
return false;
|
||||
}
|
||||
if !b[0].is_ascii_alphanumeric() || !b[b.len() - 1].is_ascii_alphanumeric() {
|
||||
return false;
|
||||
}
|
||||
b.iter().all(|c| c.is_ascii_alphanumeric() || *c == b'-')
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_hostname(name: String) -> Result<(), String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Hostname can't be empty".into());
|
||||
if !valid_hostname(name) {
|
||||
return Err("invalid hostname".into());
|
||||
}
|
||||
let output = Command::new("pkexec")
|
||||
.args(["hostnamectl", "set-hostname", name])
|
||||
|
|
@ -153,3 +185,24 @@ pub async fn set_hostname(name: String) -> Result<(), String> {
|
|||
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,5 +69,7 @@ pub fn get_appearance() -> Appearance {
|
|||
#[tauri::command]
|
||||
pub fn save_appearance(appearance: Appearance) -> Result<(), 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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::util;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct AurResult {
|
||||
name: String,
|
||||
|
|
@ -19,7 +21,11 @@ pub struct AurResult {
|
|||
|
||||
#[tauri::command]
|
||||
pub async fn search_aur(query: String) -> Vec<AurResult> {
|
||||
let Ok(output) = tokio::process::Command::new("yay").args(["-Ss", "--aur", &query]).output().await else {
|
||||
let Ok(output) = tokio::process::Command::new("yay")
|
||||
.args(["-Ss", "--aur", &query])
|
||||
.output()
|
||||
.await
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
|
|
@ -28,12 +34,18 @@ pub async fn search_aur(query: String) -> Vec<AurResult> {
|
|||
while let Some(header) = lines.next() {
|
||||
// "aur/name version (+votes score) [Orphaned]" — name/version are
|
||||
// always the first two whitespace-separated fields after "aur/".
|
||||
let Some(rest) = header.strip_prefix("aur/") else { continue };
|
||||
let Some(rest) = header.strip_prefix("aur/") else {
|
||||
continue;
|
||||
};
|
||||
let mut parts = rest.split_whitespace();
|
||||
let Some(name) = parts.next() else { continue };
|
||||
let version = parts.next().unwrap_or("").to_string();
|
||||
let description = lines.next().unwrap_or("").trim().to_string();
|
||||
results.push(AurResult { name: name.to_string(), version, description });
|
||||
results.push(AurResult {
|
||||
name: name.to_string(),
|
||||
version,
|
||||
description,
|
||||
});
|
||||
if results.len() >= 50 {
|
||||
break;
|
||||
}
|
||||
|
|
@ -42,6 +54,13 @@ pub async fn search_aur(query: String) -> Vec<AurResult> {
|
|||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn install_aur_package(pkg: String) {
|
||||
let _ = std::process::Command::new("kitty").args(["-e", "yay", "-S", &pkg]).spawn();
|
||||
pub fn install_aur_package(pkg: String) -> Result<(), String> {
|
||||
if !util::valid_pkg_name(&pkg) {
|
||||
return Err(format!("refusing to install '{pkg}'"));
|
||||
}
|
||||
std::process::Command::new("kitty")
|
||||
.args(["-e", "yay", "-S", &pkg])
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
488
src/src/commands/backup.rs
Normal file
488
src/src/commands/backup.rs
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
//! restic backups of `$HOME`. Repo path + password live in
|
||||
//! `~/.config/bos-settings/backup.toml` (0600). The password is write-only
|
||||
//! to the webview — empty on save keeps the stored secret.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::AppHandle;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::config;
|
||||
use super::streaming;
|
||||
use super::util::{self, command_exists, fail_output};
|
||||
|
||||
fn home_dir() -> PathBuf {
|
||||
std::env::var("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/root"))
|
||||
}
|
||||
|
||||
fn backup_toml() -> PathBuf {
|
||||
util::bos_settings_dir().join("backup.toml")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BackupSecrets {
|
||||
pub repo: String,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
impl BackupSecrets {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
repo: String::new(),
|
||||
password: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_secrets() -> BackupSecrets {
|
||||
load_secrets_from(&backup_toml())
|
||||
}
|
||||
|
||||
fn load_secrets_from(path: &Path) -> BackupSecrets {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return BackupSecrets::empty();
|
||||
};
|
||||
let doc = text.parse::<toml_edit::DocumentMut>().unwrap_or_default();
|
||||
BackupSecrets {
|
||||
repo: config::get_str(&doc, &["repo"]).unwrap_or_default(),
|
||||
password: config::get_str(&doc, &["password"]).filter(|s| !s.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_secrets_to(path: &Path, repo: &str, password: Option<&str>) -> Result<(), String> {
|
||||
let existing = load_secrets_from(path);
|
||||
let password = match password.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(p) => Some(p.to_string()),
|
||||
None => existing.password,
|
||||
};
|
||||
let mut doc = toml_edit::DocumentMut::new();
|
||||
config::set_str(&mut doc, &["repo"], repo.trim());
|
||||
if let Some(p) = password.as_deref() {
|
||||
config::set_str(&mut doc, &["password"], p);
|
||||
}
|
||||
util::write_secure(path, &doc.to_string())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BackupStatus {
|
||||
restic_installed: bool,
|
||||
repo: String,
|
||||
has_password: bool,
|
||||
snapshots: Vec<ResticSnapshot>,
|
||||
error: Option<String>,
|
||||
home: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ResticSnapshot {
|
||||
id: String,
|
||||
time: String,
|
||||
paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_backup_config() -> BackupStatus {
|
||||
let s = load_secrets();
|
||||
BackupStatus {
|
||||
restic_installed: command_exists("restic"),
|
||||
repo: s.repo,
|
||||
has_password: s.password.is_some(),
|
||||
snapshots: Vec::new(),
|
||||
error: None,
|
||||
home: home_dir().to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SaveBackupInput {
|
||||
repo: String,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_backup_config(input: SaveBackupInput) -> Result<(), String> {
|
||||
if !valid_repo(&input.repo) {
|
||||
return Err("repo must be an absolute path or sftp:user@host:path".into());
|
||||
}
|
||||
save_secrets_to(&backup_toml(), &input.repo, input.password.as_deref())
|
||||
}
|
||||
|
||||
pub fn valid_repo(repo: &str) -> bool {
|
||||
let repo = repo.trim();
|
||||
if repo.is_empty() || repo.len() > 512 || repo.contains('\n') || repo.contains('\0') {
|
||||
return false;
|
||||
}
|
||||
if let Some(rest) = repo.strip_prefix("sftp:") {
|
||||
return !rest.is_empty() && rest.contains('@') && rest.contains(':') && !rest.contains(' ');
|
||||
}
|
||||
std::path::Path::new(repo).is_absolute()
|
||||
}
|
||||
|
||||
fn require_ready() -> Result<BackupSecrets, String> {
|
||||
if !command_exists("restic") {
|
||||
return Err("restic is not installed".into());
|
||||
}
|
||||
let s = load_secrets();
|
||||
if !valid_repo(&s.repo) {
|
||||
return Err("set a repository path first".into());
|
||||
}
|
||||
if s.password.is_none() {
|
||||
return Err("set a repository password first".into());
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn restic_args<'a>(repo: &'a str, extra: &'a [&'a str]) -> Vec<&'a str> {
|
||||
let mut args = vec!["--repo", repo];
|
||||
args.extend_from_slice(extra);
|
||||
args
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restic_init(app: AppHandle, session_id: String) -> bool {
|
||||
let Ok(s) = require_ready() else {
|
||||
streaming::emit_line(
|
||||
&app,
|
||||
&session_id,
|
||||
"Error: configure repo and password first",
|
||||
);
|
||||
return false;
|
||||
};
|
||||
let password = s.password.clone().unwrap_or_default();
|
||||
let extra = ["init"];
|
||||
let args = restic_args(&s.repo, &extra);
|
||||
streaming::run_hardcoded_env(
|
||||
app,
|
||||
session_id,
|
||||
"restic",
|
||||
&args,
|
||||
&[("RESTIC_PASSWORD", password)],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn exclude_args(home: &str) -> Vec<String> {
|
||||
let extras = [
|
||||
".cache",
|
||||
".local/share/Trash",
|
||||
".local/share/Steam",
|
||||
".local/share/containers",
|
||||
".npm",
|
||||
".cargo/registry",
|
||||
".cargo/git",
|
||||
".rustup",
|
||||
".var/app",
|
||||
];
|
||||
let mut args = vec![
|
||||
"--exclude-caches".into(),
|
||||
"--exclude".into(),
|
||||
"node_modules".into(),
|
||||
"--exclude".into(),
|
||||
"target".into(),
|
||||
"--exclude".into(),
|
||||
".git".into(),
|
||||
];
|
||||
for rel in extras {
|
||||
args.push("--exclude".into());
|
||||
args.push(format!("{home}/{rel}"));
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restic_backup(app: AppHandle, session_id: String) -> bool {
|
||||
let s = match require_ready() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
streaming::emit_line(&app, &session_id, &format!("Error: {e}"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
|
||||
let password = s.password.clone().unwrap_or_default();
|
||||
let excludes = exclude_args(&home);
|
||||
let mut args = vec!["--repo".to_string(), s.repo.clone()];
|
||||
args.extend(excludes);
|
||||
args.push("backup".into());
|
||||
args.push(home);
|
||||
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
streaming::run_hardcoded_env(
|
||||
app,
|
||||
session_id,
|
||||
"restic",
|
||||
&arg_refs,
|
||||
&[("RESTIC_PASSWORD", password)],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `~/bos-restore-<id>`. Never `$HOME` itself — restore writes into a new
|
||||
/// directory so a bad snapshot cannot clobber the live home.
|
||||
pub fn default_restore_dir(snapshot: &str) -> PathBuf {
|
||||
home_dir().join(format!("bos-restore-{snapshot}"))
|
||||
}
|
||||
|
||||
fn normalize_abs(path: &Path) -> PathBuf {
|
||||
path.components().collect()
|
||||
}
|
||||
|
||||
/// Absolute path, not `$HOME` and not `/`. Empty target means the default.
|
||||
pub fn valid_restore_target(path: &Path) -> bool {
|
||||
if !path.is_absolute() {
|
||||
return false;
|
||||
}
|
||||
let s = path.to_string_lossy();
|
||||
if s.is_empty() || s.len() > 512 || s.contains('\n') || s.contains('\0') {
|
||||
return false;
|
||||
}
|
||||
let normalized = normalize_abs(path);
|
||||
if normalized == *"/" {
|
||||
return false;
|
||||
}
|
||||
normalized != normalize_abs(&home_dir())
|
||||
}
|
||||
|
||||
fn resolve_restore_target(snapshot: &str, target: Option<&str>) -> Result<PathBuf, String> {
|
||||
if !valid_snapshot_id(snapshot) {
|
||||
return Err("invalid snapshot id".into());
|
||||
}
|
||||
let dest = match target.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(t) => PathBuf::from(t),
|
||||
None => default_restore_dir(snapshot),
|
||||
};
|
||||
if !valid_restore_target(&dest) {
|
||||
return Err(
|
||||
"restore target must be an absolute path that is not $HOME (default is ~/bos-restore-<id>)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
async fn run_restic_restore(
|
||||
app: AppHandle,
|
||||
session_id: String,
|
||||
snapshot: String,
|
||||
target: Option<String>,
|
||||
dry_run: bool,
|
||||
) -> bool {
|
||||
let s = match require_ready() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
streaming::emit_line(&app, &session_id, &format!("Error: {e}"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let snap = snapshot.trim();
|
||||
let dest = match resolve_restore_target(snap, target.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
streaming::emit_line(&app, &session_id, &format!("Error: {e}"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let dest_s = dest.to_string_lossy().into_owned();
|
||||
let password = s.password.clone().unwrap_or_default();
|
||||
let mut extra = vec![
|
||||
"restore".to_string(),
|
||||
snap.to_string(),
|
||||
"--target".into(),
|
||||
dest_s.clone(),
|
||||
];
|
||||
if dry_run {
|
||||
extra.push("--dry-run".into());
|
||||
}
|
||||
streaming::emit_line(
|
||||
&app,
|
||||
&session_id,
|
||||
&format!(
|
||||
"{} {snap} → {dest_s}",
|
||||
if dry_run {
|
||||
"Dry-run restore"
|
||||
} else {
|
||||
"Restoring"
|
||||
}
|
||||
),
|
||||
);
|
||||
let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
|
||||
let args = restic_args(&s.repo, &extra_refs);
|
||||
streaming::run_hardcoded_env(
|
||||
app,
|
||||
session_id,
|
||||
"restic",
|
||||
&args,
|
||||
&[("RESTIC_PASSWORD", password)],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restic_restore_dry_run(
|
||||
app: AppHandle,
|
||||
session_id: String,
|
||||
snapshot: String,
|
||||
target: Option<String>,
|
||||
) -> bool {
|
||||
run_restic_restore(app, session_id, snapshot, target, true).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restic_restore(
|
||||
app: AppHandle,
|
||||
session_id: String,
|
||||
snapshot: String,
|
||||
target: Option<String>,
|
||||
) -> bool {
|
||||
run_restic_restore(app, session_id, snapshot, target, false).await
|
||||
}
|
||||
|
||||
fn valid_snapshot_id(id: &str) -> bool {
|
||||
if id == "latest" {
|
||||
return true;
|
||||
}
|
||||
let bytes = id.as_bytes();
|
||||
!bytes.is_empty() && bytes.len() <= 64 && bytes.iter().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_restic_snapshots() -> Result<Vec<ResticSnapshot>, String> {
|
||||
let s = require_ready()?;
|
||||
let password = s.password.clone().unwrap_or_default();
|
||||
let output = Command::new("restic")
|
||||
.args(["--repo", &s.repo, "snapshots", "--json"])
|
||||
.env("RESTIC_PASSWORD", password)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !output.status.success() {
|
||||
return Err(fail_output(&output, "restic snapshots"));
|
||||
}
|
||||
parse_snapshots(&output.stdout)
|
||||
}
|
||||
|
||||
fn parse_snapshots(bytes: &[u8]) -> Result<Vec<ResticSnapshot>, String> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_slice(bytes).map_err(|e| format!("restic json: {e}"))?;
|
||||
let Some(arr) = v.as_array() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
Ok(arr
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
let id = s
|
||||
.get("short_id")
|
||||
.or_else(|| s.get("id"))
|
||||
.and_then(|x| x.as_str())?
|
||||
.to_string();
|
||||
let time = s
|
||||
.get("time")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let paths = s
|
||||
.get("paths")
|
||||
.and_then(|x| x.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|p| p.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(ResticSnapshot { id, time, paths })
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repo_accepts_abs_and_sftp() {
|
||||
assert!(valid_repo("/mnt/backup/bos"));
|
||||
assert!(valid_repo("sftp:user@host:/backups/bos"));
|
||||
assert!(!valid_repo("relative/path"));
|
||||
assert!(!valid_repo("sftp:nocolon"));
|
||||
assert!(!valid_repo("sftp:user host:/x"));
|
||||
assert!(!valid_repo(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_id_hex_or_latest() {
|
||||
assert!(valid_snapshot_id("latest"));
|
||||
assert!(valid_snapshot_id("a1b2c3d4"));
|
||||
assert!(!valid_snapshot_id("../x"));
|
||||
assert!(!valid_snapshot_id("latest;rm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_secure_is_0600_and_keeps_password() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"bos-settings-backup-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("backup.toml");
|
||||
save_secrets_to(&path, "/tmp/repo", Some("hunter2")).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("hunter2"));
|
||||
assert!(text.contains("/tmp/repo"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "backup.toml must be 0600, got {mode:o}");
|
||||
}
|
||||
save_secrets_to(&path, "/tmp/repo2", Some("")).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("hunter2"), "empty password keeps secret");
|
||||
assert!(text.contains("/tmp/repo2"));
|
||||
let loaded = load_secrets_from(&path);
|
||||
assert_eq!(loaded.password.as_deref(), Some("hunter2"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_restic_json() {
|
||||
let json = br#"[{"short_id":"abc123","time":"2026-08-15T01:00:00Z","paths":["/home/a"]}]"#;
|
||||
let v = parse_snapshots(json).unwrap();
|
||||
assert_eq!(v[0].id, "abc123");
|
||||
assert_eq!(v[0].paths[0], "/home/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_defaults_to_bos_restore_id_not_home() {
|
||||
let dest = default_restore_dir("a1b2c3d4");
|
||||
let home = home_dir();
|
||||
assert_eq!(dest, home.join("bos-restore-a1b2c3d4"));
|
||||
assert_ne!(dest, home);
|
||||
assert!(valid_restore_target(&dest));
|
||||
assert!(!valid_restore_target(&home));
|
||||
assert!(!valid_restore_target(Path::new("/")));
|
||||
assert!(!valid_restore_target(Path::new("relative/path")));
|
||||
assert!(valid_restore_target(Path::new("/tmp/bos-restore-custom")));
|
||||
let resolved = resolve_restore_target("latest", None).unwrap();
|
||||
assert_eq!(resolved, home.join("bos-restore-latest"));
|
||||
assert!(resolve_restore_target("latest", Some(home.to_str().unwrap())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_covers_caches_and_containers() {
|
||||
let args = exclude_args("/home/a");
|
||||
let joined = args.join(" ");
|
||||
assert!(joined.contains("/home/a/.cache"));
|
||||
assert!(joined.contains("/home/a/.local/share/Trash"));
|
||||
assert!(joined.contains("/home/a/.local/share/Steam"));
|
||||
assert!(joined.contains("/home/a/.local/share/containers"));
|
||||
assert!(joined.contains("node_modules"));
|
||||
assert!(joined.contains("target"));
|
||||
assert!(joined.contains(".git"));
|
||||
}
|
||||
}
|
||||
|
|
@ -35,22 +35,35 @@ pub struct BreadpadConfig {
|
|||
pub fn get_breadpad_config() -> BreadpadConfig {
|
||||
let doc = config::load_doc(&config_path());
|
||||
BreadpadConfig {
|
||||
default_type: config::get_str(&doc, &["settings", "default_type"]).unwrap_or_else(|| "note".into()),
|
||||
default_type: config::get_str(&doc, &["settings", "default_type"])
|
||||
.unwrap_or_else(|| "note".into()),
|
||||
workspace_tag: config::get_bool(&doc, &["settings", "workspace_tag"]).unwrap_or(true),
|
||||
snooze_options: config::get_str_list(&doc, &["settings", "snooze_options"]),
|
||||
archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"]).unwrap_or(30),
|
||||
archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"])
|
||||
.unwrap_or(30),
|
||||
model_path: config::get_str(&doc, &["model", "path"]).unwrap_or_default(),
|
||||
tokenizer_path: config::get_str(&doc, &["model", "tokenizer"]).unwrap_or_default(),
|
||||
ollama_enabled: config::get_bool(&doc, &["model", "ollama", "enabled"]).unwrap_or(true),
|
||||
ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"]).unwrap_or_default(),
|
||||
ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"])
|
||||
.unwrap_or_default(),
|
||||
ollama_model: config::get_str(&doc, &["model", "ollama", "model"]).unwrap_or_default(),
|
||||
ollama_confidence_threshold: config::get_f64(&doc, &["model", "ollama", "confidence_threshold"]).unwrap_or(0.6),
|
||||
reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"]).unwrap_or_else(|| "7:00".into()),
|
||||
reminders_missed_grace_minutes: config::get_i64(&doc, &["reminders", "missed_grace_minutes"]).unwrap_or(60),
|
||||
ollama_confidence_threshold: config::get_f64(
|
||||
&doc,
|
||||
&["model", "ollama", "confidence_threshold"],
|
||||
)
|
||||
.unwrap_or(0.6),
|
||||
reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"])
|
||||
.unwrap_or_else(|| "7:00".into()),
|
||||
reminders_missed_grace_minutes: config::get_i64(
|
||||
&doc,
|
||||
&["reminders", "missed_grace_minutes"],
|
||||
)
|
||||
.unwrap_or(60),
|
||||
calendar_enabled: config::get_bool(&doc, &["calendar", "enabled"]).unwrap_or(false),
|
||||
calendar_url: config::get_str(&doc, &["calendar", "url"]).unwrap_or_default(),
|
||||
calendar_username: config::get_str(&doc, &["calendar", "username"]).unwrap_or_default(),
|
||||
calendar_password: config::get_str(&doc, &["calendar", "password"]).unwrap_or_default(),
|
||||
// Write-only to the webview, same as restic — never round-trip the secret.
|
||||
calendar_password: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,19 +73,76 @@ pub fn save_breadpad_config(cfg: BreadpadConfig) -> Result<(), String> {
|
|||
let mut doc = config::load_doc(&path);
|
||||
config::set_str(&mut doc, &["settings", "default_type"], &cfg.default_type);
|
||||
config::set_bool(&mut doc, &["settings", "workspace_tag"], cfg.workspace_tag);
|
||||
config::set_str_list(&mut doc, &["settings", "snooze_options"], &cfg.snooze_options);
|
||||
config::set_i64(&mut doc, &["settings", "archive_after_days"], cfg.archive_after_days);
|
||||
config::set_str_list(
|
||||
&mut doc,
|
||||
&["settings", "snooze_options"],
|
||||
&cfg.snooze_options,
|
||||
);
|
||||
config::set_i64(
|
||||
&mut doc,
|
||||
&["settings", "archive_after_days"],
|
||||
cfg.archive_after_days,
|
||||
);
|
||||
config::set_str_or_remove(&mut doc, &["model", "path"], &cfg.model_path);
|
||||
config::set_str_or_remove(&mut doc, &["model", "tokenizer"], &cfg.tokenizer_path);
|
||||
config::set_bool(&mut doc, &["model", "ollama", "enabled"], cfg.ollama_enabled);
|
||||
config::set_str_or_remove(&mut doc, &["model", "ollama", "endpoint"], &cfg.ollama_endpoint);
|
||||
config::set_bool(
|
||||
&mut doc,
|
||||
&["model", "ollama", "enabled"],
|
||||
cfg.ollama_enabled,
|
||||
);
|
||||
config::set_str_or_remove(
|
||||
&mut doc,
|
||||
&["model", "ollama", "endpoint"],
|
||||
&cfg.ollama_endpoint,
|
||||
);
|
||||
config::set_str_or_remove(&mut doc, &["model", "ollama", "model"], &cfg.ollama_model);
|
||||
config::set_f64(&mut doc, &["model", "ollama", "confidence_threshold"], cfg.ollama_confidence_threshold);
|
||||
config::set_str_or_remove(&mut doc, &["reminders", "default_morning"], &cfg.reminders_default_morning);
|
||||
config::set_i64(&mut doc, &["reminders", "missed_grace_minutes"], cfg.reminders_missed_grace_minutes);
|
||||
config::set_f64(
|
||||
&mut doc,
|
||||
&["model", "ollama", "confidence_threshold"],
|
||||
cfg.ollama_confidence_threshold,
|
||||
);
|
||||
config::set_str_or_remove(
|
||||
&mut doc,
|
||||
&["reminders", "default_morning"],
|
||||
&cfg.reminders_default_morning,
|
||||
);
|
||||
config::set_i64(
|
||||
&mut doc,
|
||||
&["reminders", "missed_grace_minutes"],
|
||||
cfg.reminders_missed_grace_minutes,
|
||||
);
|
||||
config::set_bool(&mut doc, &["calendar", "enabled"], cfg.calendar_enabled);
|
||||
config::set_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", "password"], &cfg.calendar_password);
|
||||
apply_calendar_password(&mut doc, &cfg.calendar_password);
|
||||
config::save_doc(&path, &doc).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Empty incoming password keeps the existing secret (PasswordField is write-only).
|
||||
fn apply_calendar_password(doc: &mut toml_edit::DocumentMut, incoming: &str) {
|
||||
if incoming.is_empty() {
|
||||
return;
|
||||
}
|
||||
config::set_str(doc, &["calendar", "password"], incoming);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_password_keeps_existing_secret() {
|
||||
let mut doc: toml_edit::DocumentMut =
|
||||
"[calendar]\npassword = \"secret\"\n".parse().unwrap();
|
||||
apply_calendar_password(&mut doc, "");
|
||||
assert_eq!(
|
||||
config::get_str(&doc, &["calendar", "password"]).as_deref(),
|
||||
Some("secret")
|
||||
);
|
||||
apply_calendar_password(&mut doc, "newpass");
|
||||
assert_eq!(
|
||||
config::get_str(&doc, &["calendar", "password"]).as_deref(),
|
||||
Some("newpass")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
91
src/src/commands/channel.rs
Normal file
91
src/src/commands/channel.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
//! Bakery track (stable / beta / dev). Preference only — `bakery update
|
||||
//! --all` afterwards actually installs the new track's builds.
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::util::{fail_output, strip_ansi};
|
||||
|
||||
const TRACKS: &[&str] = &["stable", "beta", "dev"];
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BakeryTrack {
|
||||
current: String,
|
||||
tracks: Vec<String>,
|
||||
}
|
||||
|
||||
fn parse_track_show(text: &str) -> String {
|
||||
let text = strip_ansi(text);
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
let lower = line.to_ascii_lowercase();
|
||||
if let Some(rest) = lower.strip_prefix("current track:") {
|
||||
let raw = line[line.len() - rest.len()..].trim();
|
||||
return raw.to_ascii_lowercase();
|
||||
}
|
||||
if TRACKS.contains(&line) {
|
||||
return line.to_string();
|
||||
}
|
||||
}
|
||||
let lower = text.to_ascii_lowercase();
|
||||
for track in TRACKS {
|
||||
if lower.contains(track) {
|
||||
return (*track).to_string();
|
||||
}
|
||||
}
|
||||
"stable".into()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_bakery_track() -> Result<BakeryTrack, String> {
|
||||
let output = Command::new("bakery")
|
||||
.args(["track", "show"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("couldn't run bakery: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(fail_output(&output, "bakery track show"));
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(BakeryTrack {
|
||||
current: parse_track_show(&text),
|
||||
tracks: TRACKS.iter().map(|s| (*s).to_string()).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_bakery_track(track: String) -> Result<BakeryTrack, String> {
|
||||
let track = track.trim().to_ascii_lowercase();
|
||||
if !TRACKS.contains(&track.as_str()) {
|
||||
return Err(format!(
|
||||
"unknown track '{track}' — expected stable, beta, or dev"
|
||||
));
|
||||
}
|
||||
let output = Command::new("bakery")
|
||||
.args(["track", "set", &track])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !output.status.success() {
|
||||
return Err(fail_output(&output, "bakery track set"));
|
||||
}
|
||||
get_bakery_track().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_current_track_line() {
|
||||
assert_eq!(parse_track_show("current track: dev\n"), "dev");
|
||||
assert_eq!(parse_track_show("current track: stable"), "stable");
|
||||
assert_eq!(parse_track_show("beta"), "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_in_set_guard() {
|
||||
assert!(!TRACKS.contains(&"nightly"));
|
||||
assert!(TRACKS.contains(&"stable"));
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,12 @@ async fn list_timezones() -> Vec<String> {
|
|||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect())
|
||||
.map(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
|
|
@ -60,10 +65,32 @@ pub async fn get_datetime_info() -> DateTimeInfo {
|
|||
}
|
||||
}
|
||||
|
||||
/// Reject flags, path traversal, and newlines before we ever exec. Charset
|
||||
/// matches IANA names (`Area/City`, `UTC`, `Etc/GMT+6`).
|
||||
fn timezone_looks_safe(tz: &str) -> bool {
|
||||
let tz = tz.trim();
|
||||
if tz.is_empty() || tz.len() > 64 || tz.starts_with('-') {
|
||||
return false;
|
||||
}
|
||||
if tz.contains('\n') || tz.contains('\r') || tz.contains('\0') || tz.contains("..") {
|
||||
return false;
|
||||
}
|
||||
tz.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-'))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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")
|
||||
.args(["timedatectl", "set-timezone", &tz])
|
||||
.args(["timedatectl", "set-timezone", tz])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
|
@ -77,6 +104,28 @@ pub async fn set_timezone(tz: String) -> Result<(), String> {
|
|||
#[tauri::command]
|
||||
pub async fn set_ntp_enabled(enabled: bool) -> Result<(), String> {
|
||||
let val = if enabled { "true" } else { "false" };
|
||||
Command::new("pkexec").args(["timedatectl", "set-ntp", val]).status().await.map_err(|e| e.to_string())?;
|
||||
Command::new("pkexec")
|
||||
.args(["timedatectl", "set-ntp", val])
|
||||
.status()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn timezone_rejects_flags_and_traversal() {
|
||||
assert!(timezone_looks_safe("UTC"));
|
||||
assert!(timezone_looks_safe("America/New_York"));
|
||||
assert!(timezone_looks_safe("Etc/GMT+6"));
|
||||
assert!(!timezone_looks_safe(""));
|
||||
assert!(!timezone_looks_safe("-UTC"));
|
||||
assert!(!timezone_looks_safe("--help"));
|
||||
assert!(!timezone_looks_safe("America/../UTC"));
|
||||
assert!(!timezone_looks_safe("UTC\n--adjust"));
|
||||
assert!(!timezone_looks_safe("UTC;reboot"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
431
src/src/commands/defaults.rs
Normal file
431
src/src/commands/defaults.rs
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
//! Default applications via `~/.config/mimeapps.list`. Categories cover
|
||||
//! the associations BOS already ships in skel (browser, files, images,
|
||||
//! PDF, editor) plus a terminal entry.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::config;
|
||||
|
||||
const CATEGORIES: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"browser",
|
||||
&[
|
||||
"x-scheme-handler/http",
|
||||
"x-scheme-handler/https",
|
||||
"text/html",
|
||||
],
|
||||
),
|
||||
("files", &["inode/directory"]),
|
||||
("terminal", &["x-scheme-handler/terminal"]),
|
||||
(
|
||||
"image",
|
||||
&[
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/svg+xml",
|
||||
],
|
||||
),
|
||||
("pdf", &["application/pdf"]),
|
||||
("editor", &["text/plain", "text/markdown"]),
|
||||
];
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct DesktopApp {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DefaultsStatus {
|
||||
path: String,
|
||||
current: HashMap<String, String>,
|
||||
options: HashMap<String, Vec<DesktopApp>>,
|
||||
}
|
||||
|
||||
fn mimeapps_path() -> PathBuf {
|
||||
config::config_dir().join("mimeapps.list")
|
||||
}
|
||||
|
||||
fn xdg_terminals_path() -> PathBuf {
|
||||
config::config_dir().join("xdg-terminals.list")
|
||||
}
|
||||
|
||||
fn applications_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = vec![
|
||||
PathBuf::from("/usr/share/applications"),
|
||||
PathBuf::from("/usr/local/share/applications"),
|
||||
];
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
dirs.push(PathBuf::from(home).join(".local/share/applications"));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DesktopMeta {
|
||||
id: String,
|
||||
name: String,
|
||||
mimes: Vec<String>,
|
||||
terminal: bool,
|
||||
}
|
||||
|
||||
fn parse_desktop(id: &str, text: &str) -> Option<DesktopMeta> {
|
||||
let mut in_entry = false;
|
||||
let mut name = String::new();
|
||||
let mut mimes = Vec::new();
|
||||
let mut terminal = false;
|
||||
let mut hidden = false;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('[') {
|
||||
in_entry = line.eq_ignore_ascii_case("[Desktop Entry]");
|
||||
continue;
|
||||
}
|
||||
if !in_entry {
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = line.strip_prefix("Name=") {
|
||||
if name.is_empty() {
|
||||
name = v.to_string();
|
||||
}
|
||||
} else if let Some(v) = line.strip_prefix("MimeType=") {
|
||||
mimes = v
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
} else if let Some(v) = line.strip_prefix("Categories=") {
|
||||
terminal |= v.split(';').any(|c| c.trim() == "TerminalEmulator");
|
||||
} else if line == "Hidden=true" || line == "NoDisplay=true" {
|
||||
hidden = true;
|
||||
}
|
||||
}
|
||||
if hidden || name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(DesktopMeta {
|
||||
id: id.to_string(),
|
||||
name,
|
||||
mimes,
|
||||
terminal,
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_desktops() -> Vec<DesktopMeta> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for dir in applications_dirs() {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert(id.to_string()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(meta) = parse_desktop(id, &text) {
|
||||
out.push(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|a| a.name.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_default_applications(text: &str) -> BTreeMap<String, String> {
|
||||
let mut map = BTreeMap::new();
|
||||
let mut in_defaults = false;
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.starts_with('[') {
|
||||
in_defaults = t.eq_ignore_ascii_case("[Default Applications]");
|
||||
continue;
|
||||
}
|
||||
if !in_defaults || t.is_empty() || t.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = t.split_once('=') {
|
||||
let desktop = v.split(';').next().unwrap_or("").trim();
|
||||
if !desktop.is_empty() {
|
||||
map.insert(k.trim().to_string(), desktop.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn current_for_category(defaults: &BTreeMap<String, String>, mimes: &[&str]) -> String {
|
||||
for mime in mimes {
|
||||
if let Some(v) = defaults.get(*mime) {
|
||||
return v.clone();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn options_for(
|
||||
apps: &[DesktopMeta],
|
||||
category: &str,
|
||||
mimes: &[&str],
|
||||
current: &str,
|
||||
) -> Vec<DesktopApp> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for app in apps {
|
||||
let matches = if category == "terminal" {
|
||||
app.terminal || app.mimes.iter().any(|m| mimes.contains(&m.as_str()))
|
||||
} else {
|
||||
app.mimes.iter().any(|m| mimes.contains(&m.as_str()))
|
||||
};
|
||||
if matches && seen.insert(app.id.clone()) {
|
||||
out.push(DesktopApp {
|
||||
id: app.id.clone(),
|
||||
name: app.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if !current.is_empty() && !seen.contains(current) {
|
||||
out.insert(
|
||||
0,
|
||||
DesktopApp {
|
||||
id: current.to_string(),
|
||||
name: current.trim_end_matches(".desktop").to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_default_apps() -> DefaultsStatus {
|
||||
let path = mimeapps_path();
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let defaults = parse_default_applications(&text);
|
||||
let apps = scan_desktops();
|
||||
let mut current = HashMap::new();
|
||||
let mut options = HashMap::new();
|
||||
for (cat, mimes) in CATEGORIES {
|
||||
let cur = if *cat == "terminal" {
|
||||
read_terminal_default(&defaults)
|
||||
} else {
|
||||
current_for_category(&defaults, mimes)
|
||||
};
|
||||
options.insert((*cat).to_string(), options_for(&apps, cat, mimes, &cur));
|
||||
current.insert((*cat).to_string(), cur);
|
||||
}
|
||||
DefaultsStatus {
|
||||
path: path.display().to_string(),
|
||||
current,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_terminal_default(defaults: &BTreeMap<String, String>) -> String {
|
||||
if let Ok(text) = std::fs::read_to_string(xdg_terminals_path()) {
|
||||
if let Some(id) = text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
{
|
||||
return id.to_string();
|
||||
}
|
||||
}
|
||||
current_for_category(defaults, &["x-scheme-handler/terminal"])
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SaveDefaultsInput {
|
||||
current: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_default_apps(input: SaveDefaultsInput) -> Result<(), String> {
|
||||
let path = mimeapps_path();
|
||||
let existing = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let mut replacements = BTreeMap::new();
|
||||
for (cat, mimes) in CATEGORIES {
|
||||
let Some(desktop) = input.current.get(*cat).map(|s| s.trim()) else {
|
||||
continue;
|
||||
};
|
||||
if desktop.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !valid_desktop_id(desktop) {
|
||||
return Err(format!("invalid desktop id '{desktop}'"));
|
||||
}
|
||||
for mime in *mimes {
|
||||
replacements.insert((*mime).to_string(), desktop.to_string());
|
||||
}
|
||||
if *cat == "terminal" {
|
||||
write_terminal_list(desktop)?;
|
||||
}
|
||||
}
|
||||
let text = upsert_defaults(&existing, &replacements);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
config::atomic_write(&path, &text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn write_terminal_list(desktop: &str) -> Result<(), String> {
|
||||
let path = xdg_terminals_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
config::atomic_write(&path, &format!("{desktop}\n")).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn valid_desktop_id(id: &str) -> bool {
|
||||
let bytes = id.as_bytes();
|
||||
bytes.ends_with(b".desktop")
|
||||
&& bytes.len() > ".desktop".len()
|
||||
&& bytes.len() <= 128
|
||||
&& bytes
|
||||
.iter()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.' | b'+'))
|
||||
}
|
||||
|
||||
fn upsert_defaults(existing: &str, replacements: &BTreeMap<String, String>) -> String {
|
||||
if existing.trim().is_empty() {
|
||||
let mut out = String::from("[Default Applications]\n");
|
||||
for (mime, desktop) in replacements {
|
||||
out.push_str(&format!("{mime}={desktop}\n"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut in_defaults = false;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut wrote_header = false;
|
||||
for line in existing.lines() {
|
||||
let t = line.trim();
|
||||
if t.starts_with('[') {
|
||||
if in_defaults {
|
||||
for (mime, desktop) in replacements {
|
||||
if seen.insert(mime.clone()) {
|
||||
out.push_str(&format!("{mime}={desktop}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
in_defaults = t.eq_ignore_ascii_case("[Default Applications]");
|
||||
if in_defaults {
|
||||
wrote_header = true;
|
||||
}
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
continue;
|
||||
}
|
||||
if in_defaults {
|
||||
if let Some((k, _)) = t.split_once('=') {
|
||||
let key = k.trim();
|
||||
if let Some(desktop) = replacements.get(key) {
|
||||
out.push_str(&format!("{key}={desktop}\n"));
|
||||
seen.insert(key.to_string());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
if in_defaults {
|
||||
for (mime, desktop) in replacements {
|
||||
if seen.insert(mime.clone()) {
|
||||
out.push_str(&format!("{mime}={desktop}\n"));
|
||||
}
|
||||
}
|
||||
} else if !wrote_header {
|
||||
if !out.ends_with('\n') && !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("\n[Default Applications]\n");
|
||||
for (mime, desktop) in replacements {
|
||||
out.push_str(&format!("{mime}={desktop}\n"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_skel_defaults() {
|
||||
let text = "\
|
||||
[Default Applications]
|
||||
text/html=zen.desktop
|
||||
x-scheme-handler/http=zen.desktop
|
||||
inode/directory=org.gnome.Nautilus.desktop
|
||||
";
|
||||
let map = parse_default_applications(text);
|
||||
assert_eq!(map.get("text/html").unwrap(), "zen.desktop");
|
||||
assert_eq!(
|
||||
current_for_category(&map, &["x-scheme-handler/http", "text/html"]),
|
||||
"zen.desktop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_replaces_only_named_keys() {
|
||||
let existing = "\
|
||||
# keep
|
||||
[Default Applications]
|
||||
text/html=old.desktop
|
||||
image/png=org.gnome.Loupe.desktop
|
||||
|
||||
[Added Associations]
|
||||
text/html=extra.desktop;
|
||||
";
|
||||
let mut rep = BTreeMap::new();
|
||||
rep.insert("text/html".into(), "zen.desktop".into());
|
||||
rep.insert("x-scheme-handler/http".into(), "zen.desktop".into());
|
||||
let out = upsert_defaults(existing, &rep);
|
||||
assert!(out.contains("# keep"));
|
||||
assert!(out.contains("text/html=zen.desktop"));
|
||||
assert!(out.contains("x-scheme-handler/http=zen.desktop"));
|
||||
assert!(out.contains("image/png=org.gnome.Loupe.desktop"));
|
||||
assert!(out.contains("[Added Associations]"));
|
||||
assert!(out.contains("text/html=extra.desktop;"));
|
||||
assert_eq!(out.matches("text/html=zen.desktop").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_id_check() {
|
||||
assert!(valid_desktop_id("zen.desktop"));
|
||||
assert!(valid_desktop_id("org.gnome.Nautilus.desktop"));
|
||||
assert!(!valid_desktop_id("zen"));
|
||||
assert!(!valid_desktop_id("../evil.desktop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_desktop_skips_hidden() {
|
||||
let hidden = parse_desktop(
|
||||
"x.desktop",
|
||||
"[Desktop Entry]\nName=X\nNoDisplay=true\nMimeType=text/plain;\n",
|
||||
);
|
||||
assert!(hidden.is_none());
|
||||
let ok = parse_desktop(
|
||||
"ed.desktop",
|
||||
"[Desktop Entry]\nName=Editor\nMimeType=text/plain;\nCategories=Utility;\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ok.name, "Editor");
|
||||
assert!(ok.mimes.contains(&"text/plain".into()));
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,9 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
|
|||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if stderr.is_empty() {
|
||||
match output.status.code() {
|
||||
Some(127) => "no polkit authentication agent is available in this session".to_string(),
|
||||
Some(127) => {
|
||||
"no polkit authentication agent is available in this session".to_string()
|
||||
}
|
||||
Some(code) => format!("pkexec exited with status {code}"),
|
||||
None => "pkexec was terminated by a signal".to_string(),
|
||||
}
|
||||
|
|
@ -41,7 +43,10 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
|
|||
});
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active");
|
||||
let active = text
|
||||
.lines()
|
||||
.next()
|
||||
.is_some_and(|l| l.trim() == "Status: active");
|
||||
let rules = text
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
|
|
@ -51,7 +56,10 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
|
|||
}
|
||||
let (num, rest) = l.split_once(']')?;
|
||||
let number = num.trim_start_matches('[').trim().to_string();
|
||||
Some(FirewallRule { number, text: rest.trim().to_string() })
|
||||
Some(FirewallRule {
|
||||
number,
|
||||
text: rest.trim().to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(FirewallStatus { active, rules })
|
||||
|
|
@ -60,7 +68,11 @@ pub async fn get_firewall_status() -> Result<FirewallStatus, String> {
|
|||
#[tauri::command]
|
||||
pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> {
|
||||
let verb = if enabled { "enable" } else { "disable" };
|
||||
let output = Command::new("pkexec").args(["ufw", "--force", verb]).output().await.map_err(|e| e.to_string())?;
|
||||
let output = Command::new("pkexec")
|
||||
.args(["ufw", "--force", verb])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
|
|
@ -68,9 +80,54 @@ pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Port, optional `/tcp`/`/udp`, or optional space-separated proto. No
|
||||
/// service names, IPs, or flags — those become extra `ufw allow` operands.
|
||||
fn valid_firewall_rule(rule: &str) -> bool {
|
||||
let rule = rule.trim();
|
||||
if rule.is_empty() || rule.len() > 16 || rule.starts_with('-') {
|
||||
return false;
|
||||
}
|
||||
if rule.contains('\n') || rule.contains('\r') || rule.contains('\0') {
|
||||
return false;
|
||||
}
|
||||
let (port, proto) = if let Some((p, rest)) = rule.split_once('/') {
|
||||
(p, Some(rest))
|
||||
} else if let Some((p, rest)) = rule.split_once(' ') {
|
||||
(p, Some(rest.trim()))
|
||||
} else {
|
||||
(rule, None)
|
||||
};
|
||||
let Ok(n) = port.parse::<u16>() else {
|
||||
return false;
|
||||
};
|
||||
if n == 0 {
|
||||
return false;
|
||||
}
|
||||
match proto {
|
||||
None => true,
|
||||
Some(p) => p == "tcp" || p == "udp",
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_rule_number(number: &str) -> bool {
|
||||
let t = number.trim();
|
||||
!t.is_empty()
|
||||
&& t.len() <= 8
|
||||
&& t.bytes().all(|b| b.is_ascii_digit())
|
||||
&& t.parse::<u32>().is_ok_and(|n| n > 0)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_firewall_rule(rule: String) -> Result<(), String> {
|
||||
let output = Command::new("pkexec").args(["ufw", "allow", rule.trim()]).output().await.map_err(|e| e.to_string())?;
|
||||
let rule = rule.trim();
|
||||
if !valid_firewall_rule(rule) {
|
||||
return Err("invalid firewall rule".into());
|
||||
}
|
||||
let output = Command::new("pkexec")
|
||||
.args(["ufw", "allow", rule])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
|
|
@ -80,10 +137,48 @@ pub async fn add_firewall_rule(rule: String) -> Result<(), String> {
|
|||
|
||||
#[tauri::command]
|
||||
pub async fn remove_firewall_rule(number: String) -> Result<(), String> {
|
||||
let output = Command::new("pkexec").args(["ufw", "--force", "delete", &number]).output().await.map_err(|e| e.to_string())?;
|
||||
if !valid_rule_number(&number) {
|
||||
return Err("invalid rule number".into());
|
||||
}
|
||||
let output = Command::new("pkexec")
|
||||
.args(["ufw", "--force", "delete", number.trim()])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
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(""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,10 +51,30 @@ fn hypr_path(name: &str) -> std::path::PathBuf {
|
|||
config::config_dir().join("hypr").join(name)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct LiveMonitor {
|
||||
name: String,
|
||||
mode: String,
|
||||
pub name: String,
|
||||
pub 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]
|
||||
|
|
@ -69,15 +89,103 @@ pub fn get_live_monitors() -> Vec<LiveMonitor> {
|
|||
monitors
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let name = m.get("name")?.as_str()?;
|
||||
let w = m.get("width")?.as_u64()?;
|
||||
let h = m.get("height")?.as_u64()?;
|
||||
let refresh = m.get("refreshRate")?.as_f64()?;
|
||||
Some(LiveMonitor { name: name.to_string(), mode: format!("{w}x{h} @ {refresh:.0}Hz") })
|
||||
let name = m.get("name")?.as_str()?.to_string();
|
||||
let width = json_u32(m, "width")?;
|
||||
let height = json_u32(m, "height")?;
|
||||
let refresh = json_f64(m, "refreshRate").unwrap_or(60.0);
|
||||
let x = json_i32(m, "x").unwrap_or(0);
|
||||
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()
|
||||
}
|
||||
|
||||
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]
|
||||
pub fn get_monitor_rules() -> Vec<MonitorRule> {
|
||||
std::fs::read_to_string(config_path())
|
||||
|
|
@ -90,9 +198,9 @@ pub fn get_monitor_rules() -> Vec<MonitorRule> {
|
|||
|
||||
#[tauri::command]
|
||||
pub fn save_monitor_rules(rules: Vec<MonitorRule>) -> Result<(), String> {
|
||||
let file = MonitorsFile { monitors: rules };
|
||||
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())
|
||||
write_monitor_rules(&rules)?;
|
||||
super::util::hypr_reload();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens `hyprland.lua` in `$EDITOR` (nano if unset) inside a terminal —
|
||||
|
|
|
|||
177
src/src/commands/ime.rs
Normal file
177
src/src/commands/ime.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
//! fcitx5 input method for this session: environment.d + Hyprland env +
|
||||
//! systemd --user / `fcitx5 -d`. Missing packages are offered via the
|
||||
//! allowlisted pacman installer, not installed on page load.
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::config;
|
||||
use super::util::{self, command_exists, pacman_installed};
|
||||
|
||||
const FRAGMENT: &str = "fcitx5.conf";
|
||||
const ENV_FILE: &str = "90-fcitx5.conf";
|
||||
|
||||
const ENV_LINES_SYSTEMD: &str = "\
|
||||
GTK_IM_MODULE=fcitx
|
||||
QT_IM_MODULE=fcitx
|
||||
XMODIFIERS=@im=fcitx
|
||||
SDL_IM_MODULE=fcitx
|
||||
";
|
||||
|
||||
const ENV_LINES_HYPR: &str = "\
|
||||
env = GTK_IM_MODULE,fcitx
|
||||
env = QT_IM_MODULE,fcitx
|
||||
env = XMODIFIERS,@im=fcitx
|
||||
env = SDL_IM_MODULE,fcitx
|
||||
exec-once = fcitx5 -d
|
||||
";
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ImePackage {
|
||||
name: String,
|
||||
installed: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ImeStatus {
|
||||
enabled: bool,
|
||||
running: bool,
|
||||
packages: Vec<ImePackage>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn env_path() -> std::path::PathBuf {
|
||||
config::config_dir().join("environment.d").join(ENV_FILE)
|
||||
}
|
||||
|
||||
fn wanted_packages() -> &'static [&'static str] {
|
||||
&[
|
||||
"fcitx5",
|
||||
"fcitx5-gtk",
|
||||
"fcitx5-qt",
|
||||
"fcitx5-configtool",
|
||||
"fcitx5-chinese-addons",
|
||||
]
|
||||
}
|
||||
|
||||
fn packages_status() -> Vec<ImePackage> {
|
||||
wanted_packages()
|
||||
.iter()
|
||||
.map(|name| ImePackage {
|
||||
name: (*name).to_string(),
|
||||
installed: pacman_installed(name),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn env_file_present() -> bool {
|
||||
env_path().is_file()
|
||||
}
|
||||
|
||||
async fn fcitx_running() -> bool {
|
||||
Command::new("pgrep")
|
||||
.args(["-x", "fcitx5"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_ime_status() -> ImeStatus {
|
||||
ImeStatus {
|
||||
enabled: env_file_present(),
|
||||
running: fcitx_running().await,
|
||||
packages: packages_status(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_ime_enabled(enabled: bool) -> Result<ImeStatus, String> {
|
||||
if enabled {
|
||||
enable_ime().await?;
|
||||
} else {
|
||||
disable_ime().await?;
|
||||
}
|
||||
Ok(ImeStatus {
|
||||
enabled: env_file_present(),
|
||||
running: fcitx_running().await,
|
||||
packages: packages_status(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn enable_ime() -> Result<(), String> {
|
||||
if !command_exists("fcitx5") {
|
||||
return Err("fcitx5 is not installed".into());
|
||||
}
|
||||
let env = env_path();
|
||||
if let Some(parent) = env.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
config::atomic_write(&env, ENV_LINES_SYSTEMD).map_err(|e| e.to_string())?;
|
||||
|
||||
let hypr = util::hypr_dir().join(FRAGMENT);
|
||||
std::fs::create_dir_all(util::hypr_dir()).map_err(|e| e.to_string())?;
|
||||
config::atomic_write(&hypr, ENV_LINES_HYPR).map_err(|e| e.to_string())?;
|
||||
util::ensure_hypr_source(FRAGMENT)?;
|
||||
|
||||
let _ = Command::new("systemctl")
|
||||
.args([
|
||||
"--user",
|
||||
"import-environment",
|
||||
"GTK_IM_MODULE",
|
||||
"QT_IM_MODULE",
|
||||
"XMODIFIERS",
|
||||
"SDL_IM_MODULE",
|
||||
])
|
||||
.status()
|
||||
.await;
|
||||
|
||||
let enabled_unit = Command::new("systemctl")
|
||||
.args(["--user", "enable", "--now", "fcitx5.service"])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !enabled_unit && !fcitx_running().await {
|
||||
std::process::Command::new("fcitx5")
|
||||
.arg("-d")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start fcitx5: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disable_ime() -> Result<(), String> {
|
||||
let _ = std::fs::remove_file(env_path());
|
||||
let _ = std::fs::remove_file(util::hypr_dir().join(FRAGMENT));
|
||||
util::remove_hypr_source(FRAGMENT)?;
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "disable", "--now", "fcitx5.service"])
|
||||
.status()
|
||||
.await;
|
||||
let _ = Command::new("pkill").args(["-x", "fcitx5"]).status().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_fcitx_config() {
|
||||
let _ = std::process::Command::new("fcitx5-configtool").spawn();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn env_files_use_fcitx_module_name() {
|
||||
assert!(ENV_LINES_SYSTEMD.contains("GTK_IM_MODULE=fcitx"));
|
||||
assert!(ENV_LINES_HYPR.contains("XMODIFIERS,@im=fcitx"));
|
||||
assert!(ENV_LINES_HYPR.contains("exec-once = fcitx5 -d"));
|
||||
}
|
||||
}
|
||||
|
|
@ -254,7 +254,9 @@ pub fn get_keybinds() -> BindsPayload {
|
|||
|
||||
#[tauri::command]
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
pub mod a11y;
|
||||
pub mod about;
|
||||
pub mod appearance;
|
||||
pub mod aur;
|
||||
pub mod autostart;
|
||||
pub mod backup;
|
||||
pub mod bluetooth;
|
||||
pub mod bread;
|
||||
pub mod breadbar;
|
||||
|
|
@ -15,18 +17,28 @@ pub mod breadpad;
|
|||
pub mod breadpaper;
|
||||
pub mod breadsearch;
|
||||
pub mod breadshot;
|
||||
pub mod channel;
|
||||
pub mod config;
|
||||
pub mod datetime;
|
||||
pub mod defaults;
|
||||
pub mod firewall;
|
||||
pub mod firmware;
|
||||
pub mod hyprland;
|
||||
pub mod ime;
|
||||
pub mod keybinds;
|
||||
pub mod network;
|
||||
pub mod nightlight;
|
||||
pub mod nvidia;
|
||||
pub mod optional;
|
||||
pub mod packages;
|
||||
pub mod power;
|
||||
pub mod printing;
|
||||
pub mod service;
|
||||
pub mod snapshots;
|
||||
pub mod sound;
|
||||
pub mod streaming;
|
||||
pub mod theme;
|
||||
pub mod updates;
|
||||
pub mod users;
|
||||
pub mod util;
|
||||
pub mod vpn;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ use serde::Serialize;
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::util;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct WifiNetwork {
|
||||
ssid: String,
|
||||
|
|
@ -95,6 +97,9 @@ pub async fn scan_wifi() -> Vec<WifiNetwork> {
|
|||
|
||||
#[tauri::command]
|
||||
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 output = if let Some(password) = password {
|
||||
Command::new("nmcli").args(["dev", "wifi", "connect", &ssid, "password", &password]).output().await
|
||||
|
|
|
|||
216
src/src/commands/nightlight.rs
Normal file
216
src/src/commands/nightlight.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
//! Night light via hyprsunset (Hyprland twilight IPC). The compositor
|
||||
//! talks to a hyprsunset daemon socket; if the binary is missing we offer
|
||||
//! a pacman install rather than pretending the toggle works.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::config;
|
||||
use super::util::{self, command_exists, fail_output};
|
||||
|
||||
const FRAGMENT: &str = "nightlight.conf";
|
||||
const DEFAULT_TEMP: u32 = 3500;
|
||||
const MIN_TEMP: u32 = 2000;
|
||||
const MAX_TEMP: u32 = 6500;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct NightlightConfig {
|
||||
enabled: bool,
|
||||
temperature: u32,
|
||||
}
|
||||
|
||||
impl Default for NightlightConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
temperature: DEFAULT_TEMP,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NightlightStatus {
|
||||
installed: bool,
|
||||
running: bool,
|
||||
enabled: bool,
|
||||
temperature: u32,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn persist_path() -> std::path::PathBuf {
|
||||
util::bos_settings_dir().join("nightlight.toml")
|
||||
}
|
||||
|
||||
fn load_persist() -> NightlightConfig {
|
||||
let Ok(text) = std::fs::read_to_string(persist_path()) else {
|
||||
return NightlightConfig::default();
|
||||
};
|
||||
let doc = text.parse::<toml_edit::DocumentMut>().unwrap_or_default();
|
||||
NightlightConfig {
|
||||
enabled: config::get_bool(&doc, &["enabled"]).unwrap_or(false),
|
||||
temperature: config::get_i64(&doc, &["temperature"])
|
||||
.unwrap_or(DEFAULT_TEMP as i64)
|
||||
.clamp(MIN_TEMP as i64, MAX_TEMP as i64) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
fn save_persist(cfg: &NightlightConfig) -> Result<(), String> {
|
||||
let mut doc = toml_edit::DocumentMut::new();
|
||||
config::set_bool(&mut doc, &["enabled"], cfg.enabled);
|
||||
config::set_i64(&mut doc, &["temperature"], cfg.temperature as i64);
|
||||
let path = persist_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
config::atomic_write(&path, &doc.to_string()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn clamp_temp(t: u32) -> u32 {
|
||||
t.clamp(MIN_TEMP, MAX_TEMP)
|
||||
}
|
||||
|
||||
async fn hyprsunset_running() -> bool {
|
||||
Command::new("hyprctl")
|
||||
.args(["hyprsunset", "gamma", "1.0"])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn start_daemon() -> Result<(), String> {
|
||||
if hyprsunset_running().await {
|
||||
return Ok(());
|
||||
}
|
||||
if !command_exists("hyprsunset") {
|
||||
return Err("hyprsunset is not installed".into());
|
||||
}
|
||||
std::process::Command::new("hyprsunset")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("couldn't start hyprsunset: {e}"))?;
|
||||
for _ in 0..15 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
if hyprsunset_running().await {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("hyprsunset started but Hyprland twilight socket never came up".into())
|
||||
}
|
||||
|
||||
async fn apply_temperature(temp: u32) -> Result<(), String> {
|
||||
start_daemon().await?;
|
||||
let t = clamp_temp(temp).to_string();
|
||||
let output = Command::new("hyprctl")
|
||||
.args(["hyprsunset", "temperature", &t])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(fail_output(&output, "hyprctl hyprsunset"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_identity() -> Result<(), String> {
|
||||
if !hyprsunset_running().await {
|
||||
return Ok(());
|
||||
}
|
||||
let output = Command::new("hyprctl")
|
||||
.args(["hyprsunset", "identity"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(fail_output(&output, "hyprctl hyprsunset"))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_autostart() -> Result<(), String> {
|
||||
let path = util::hypr_dir().join(FRAGMENT);
|
||||
std::fs::create_dir_all(util::hypr_dir()).map_err(|e| e.to_string())?;
|
||||
config::atomic_write(&path, "exec-once = hyprsunset\n").map_err(|e| e.to_string())?;
|
||||
util::ensure_hypr_source(FRAGMENT)
|
||||
}
|
||||
|
||||
fn clear_autostart() -> Result<(), String> {
|
||||
let path = util::hypr_dir().join(FRAGMENT);
|
||||
let _ = std::fs::remove_file(path);
|
||||
util::remove_hypr_source(FRAGMENT)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_nightlight() -> NightlightStatus {
|
||||
let persist = load_persist();
|
||||
let installed = command_exists("hyprsunset");
|
||||
let running = if installed {
|
||||
hyprsunset_running().await
|
||||
} else {
|
||||
false
|
||||
};
|
||||
NightlightStatus {
|
||||
installed,
|
||||
running,
|
||||
enabled: persist.enabled && running,
|
||||
temperature: persist.temperature,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_nightlight(enabled: bool, temperature: u32) -> Result<NightlightStatus, String> {
|
||||
if !command_exists("hyprsunset") {
|
||||
return Ok(NightlightStatus {
|
||||
installed: false,
|
||||
running: false,
|
||||
enabled: false,
|
||||
temperature: clamp_temp(temperature),
|
||||
error: Some("hyprsunset is not installed".into()),
|
||||
});
|
||||
}
|
||||
let mut cfg = NightlightConfig {
|
||||
enabled,
|
||||
temperature: clamp_temp(temperature),
|
||||
};
|
||||
let mut error = None;
|
||||
if enabled {
|
||||
if let Err(e) = apply_temperature(cfg.temperature).await {
|
||||
error = Some(e);
|
||||
cfg.enabled = false;
|
||||
} else if let Err(e) = write_autostart() {
|
||||
error = Some(e);
|
||||
}
|
||||
} else {
|
||||
if let Err(e) = apply_identity().await {
|
||||
error = Some(e);
|
||||
}
|
||||
if let Err(e) = clear_autostart() {
|
||||
error = Some(error.unwrap_or(e));
|
||||
}
|
||||
}
|
||||
save_persist(&cfg)?;
|
||||
Ok(NightlightStatus {
|
||||
installed: true,
|
||||
running: hyprsunset_running().await,
|
||||
enabled: cfg.enabled,
|
||||
temperature: cfg.temperature,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn temp_clamps() {
|
||||
assert_eq!(clamp_temp(100), MIN_TEMP);
|
||||
assert_eq!(clamp_temp(9000), MAX_TEMP);
|
||||
assert_eq!(clamp_temp(3500), 3500);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue