Compare commits

...

40 commits

Author SHA1 Message Date
Breadway
d4fa0f901b CONTRIBUTING.md: document single-trunk + RC-tag release model
All checks were successful
dev release / build (push) Successful in 7m57s
2026-07-31 11:08:41 +08:00
Breadway
09789f53ef CI: single-trunk model — dev triggers on main, beta becomes RC-tag-triggered
Replaces the dev/beta branch split with one trunk (main): dev-track
builds still publish on every push, but the beta track now publishes
from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained
beta branch. Removes the branch nobody reliably kept in sync.
2026-07-31 11:05:29 +08:00
Breadway
a0bb9099cb Merge feature/full-view-capture: capture every sidebar section
All checks were successful
dev release / build (push) Successful in 3m59s
2026-07-29 21:59:45 +08:00
Breadway
93aeb26027 bos-settings: capture every sidebar section, not just the default
The first pass only proved the Tauri capture pipeline against the
default landing view ("About"). All 24 real sidebar sections are wired
up now (every id in lib/sidebar.ts has a real component in
lib/views/registry.ts — no Placeholder fallbacks to skip).

Since there's no URL/route per section (activePage is plain Svelte
$state, switched by the sidebar's own click handler), the frontend now
also listens for a screenshot-set-view Tauri event in +page.svelte's
onMount and sets activePage from its payload — the same effect a real
sidebar click has, just driven from the Rust side. screenshot.rs emits
that event after the initial page-load settle delay, then waits a
second settle delay for the newly-active view's own Tauri-command data
fetch to land before capturing (each section fetches its own state on
mount, same as the About page's system-info fetch needed 2s in the
first pass).
2026-07-29 21:45:09 +08:00
Breadway
ba7a43ad62 Merge feature/screenshot-mode: add --screenshot CLI mode for automated capture
All checks were successful
dev release / build (push) Successful in 3m49s
2026-07-29 16:50:55 +08:00
Breadway
fe4a455cef bos-settings: first-pass --screenshot CLI mode for automated capture
First bread-ecosystem app on the Tauri/webview stack rather than raw
GTK4 — proves the isolated-headless-Sway capture pipeline works against
a plain (non-layer-shell) tao/wry window with no gtk4-rs-specific signal
to hook. No connect_map here: since the window isn't owned by gtk4-rs
directly, this instead waits a fixed settle delay (2s — longer than the
native apps need, since a webview's first paint means a full page load
plus an async Tauri-command round-trip for real content, not just GTK
widget layout — confirmed by an initial 800ms attempt capturing the
sidebar correctly but the About panel's data still blank) on Tauri's own
async runtime, then captures the known-size canvas and exits.

Only one view ("default", the initial landing section) is wired up.
Capturing a specific sidebar section would mean routing the Svelte
frontend to a URL/hash on window creation — real, separate frontend
work, deferred past this first pass.

bread-screenshots is pulled from bread-ecosystem's dev branch, unlike
this repo's existing bread-theme/bread-utils deps which point at main —
it doesn't exist on main yet.
2026-07-29 16:47:47 +08:00
Breadway
3d36ebc568 ci: retrigger now that hestia has Tauri build deps installed
All checks were successful
dev release / build (push) Successful in 2m5s
beta release / build (push) Successful in 2m7s
2026-07-23 10:56:15 +08:00
Breadway
78fb01fa77 Fix bread-theme/bread-utils dependency: path -> git (broke bakery CI)
Some checks failed
dev release / build (push) Failing after 31s
beta release / build (push) Failing after 42s
The path dependency on ../../bread-ecosystem/bread-theme (and bread-utils)
assumed a sibling checkout that doesn't exist on the CI runner — this was
the actual cause of release.yml never having succeeded, on top of the
Tauri build-path issue fixed separately. Switched to a git dependency
pinned to branch = "main" rather than a tag, since the functions this
migration needs (css_custom_properties/css_tokens) aren't in a tagged
bread-theme release yet. Verified with a full local build.
2026-07-23 10:52:02 +08:00
Breadway
b1429784a7 Drop pacman packaging, bakery-only; fix broken Tauri build path in CI
Some checks failed
dev release / build (push) Failing after 10s
beta release / build (push) Failing after 9s
bakery.toml gains license_file/desktop_file (closing the same gap found
across the rest of the ecosystem) plus hicolor-icon-theme as an optional
dep. Removes packaging/PKGBUILD and .forgejo/workflows/package.yml.

Also fixes release.yml, which has never actually succeeded since the
GTK4->Tauri migration (93c3b88): Cargo.toml moved to src/src/Cargo.toml
(the checkout dir is conventionally named "src", and this repo's own
Rust backend directory is *also* named "src", not the usual src-tauri),
and the Rust build needs frontend/build to already exist (tauri.conf.json's
frontendDist) — that's normally handled by `cargo tauri build`'s
beforeBuildCommand hook, which a plain `cargo build` never runs, so it's
now a separate explicit "build frontend" step. Adds dev-release.yml/
beta-release.yml for the same three-track policy used ecosystem-wide.
2026-07-23 10:43:23 +08:00
Breadway
f5d70476f0 Fix standalone release build (custom-protocol feature was never enabled)
cargo build --release alone doesn't produce a working standalone binary in
Tauri v2 — embedding the frontend vs. hitting devUrl at runtime is gated by
the tauri/custom-protocol Cargo feature, not the debug/release profile.
The scaffold never declared it, so even release builds tried to connect to
localhost:1420. Added the standard default-on custom-protocol feature
(cargo tauri dev strips it via --no-default-features for dev builds); a
plain `cargo build --release --locked` in src/ now produces a real
standalone binary with no dev-server dependency.

Also fixes vite.config.js's file-watch ignore pattern, left stale by the
src-tauri -> src rename: a bare "**/src/**" glob would have also matched
frontend/src itself, so it's now an absolute path scoped to the Rust
project dir specifically.
2026-07-23 09:52:55 +08:00
Breadway
40da0b3517 Will change this commit message to mean something later 2026-07-22 19:53:25 +08:00
Breadway
f1c8b3d0e3 Add autocomplete suggestions to TagsField, use it for Daemon's module blocklist
TagsField now takes an optional suggestions list, rendered as a native
<datalist> on the tag input. Wired the Daemon page's "Disabled modules"
field to breadd's real built-in module names (bread.monitors/devices/
workspaces/binds, from bread/breadd/src/lua/mod.rs's BUILTIN_* registry)
so blocking one is a pick, not free-typed guesswork.
2026-07-22 19:28:52 +08:00
Breadway
93c3b88b10 Migrate bos-settings from GTK4 to Tauri + Svelte 5
Ports every remaining view (Keybinds, the last one — dual Flat/MultiLayout
schema detection, per-action dedicated fields with a raw-JSON fallback for
anything not modeled) and removes the now-fully-dead GTK4 crate (root
Cargo.toml + src/), leaving src-tauri/ as the single Rust binary crate.

Also folds in the UX pass done alongside the migration: responsive
multi-column layout instead of a single centered column, chip/dropdown
pickers replacing free-typed fields (Wi-Fi networks, Bar style, keyboard
layout, tag lists), consistent pill-switch toggles and boxed-list card
styling across every page, and a sidebar scroll-chaining fix.
2026-07-22 19:23:02 +08:00
Breadway
62365ebf10 ci: remove GitHub push-mirror workflow 2026-07-21 19:16:38 +08:00
Breadway
3ebe42790c fix(ui): drop RefCell borrow before match statement in populate. Extracts model.borrow().kind to a local variable to drop the temporary immutable borrow before entering 'populate_*' arms, preventing a runtime panic when nested functions attempt a mutable borrrow.
Some checks failed
release / build (push) Failing after 1m2s
Mirror to GitHub / mirror (push) Failing after 1s
Build and publish package / package (push) Successful in 1m43s
2026-07-19 14:07:29 +08:00
Breadway
bde95e4bd6 Switch to tag-pinned bread-ecosystem deps; bump version to v0.7.0 2026-07-19 03:51:45 +08:00
Breadway
0f1cf85114 Switch to tag-pinned bread-ecosystem deps; bump version to v0.7.0
Some checks failed
Mirror to GitHub / mirror (push) Failing after 2s
release / build (push) Failing after 1m8s
Build and publish package / package (push) Successful in 1m41s
2026-07-19 03:32:57 +08:00
Breadway
5d23a586fa Add missing bakery-channel release.yml
bos-settings has had a bakery.toml, a PKGBUILD, and package.yml
(pacman channel) for a while, but never got the release.yml needed to
actually publish to dl.breadway.dev / GitHub releases for the bakery
channel — despite bos/DESIGN.md documenting the intent to make it
bakery-installable standalone. Modeled on breadmon/release.yml (same
shape: single binary, no systemd service). Paired with a new registry
entry in bread-ecosystem-fix-worktree.

Note for whoever runs this for real: bos-settings' Cargo.toml currently
has bread-utils pinned via a path dependency
(../bread-ecosystem-fix-worktree/bread-utils) rather than the tag-pinned
git dependency bread-theme uses — there's already a TODO on that line.
This release.yml (and the existing package.yml) will fail to build in a
clean CI checkout until that's resolved; pre-existing issue, not
something this pass touched.
2026-07-17 14:06:35 +08:00
Breadway
041d927b00 Timeout-guard the hyprctl monitors query
get_live_monitors used a bare Command::new("hyprctl").output() with no
timeout. Switched to bread_utils::proc::run_json, now that this crate
already depends on bread-utils (added in the config-migration commit).
2026-07-17 09:55:45 +08:00
Breadway
f3a5839cf1 Migrate config load/save/atomic_write to bread_utils::tomlcfg
load_doc/save_doc/atomic_write's bodies now delegate to
bread_utils::tomlcfg and bread_utils::atomic (path dependency for now, see
the TODO in Cargo.toml) instead of owning the temp-then-rename +
.bak-before-overwrite logic locally. Public function names/signatures in
config/mod.rs are unchanged, so none of the ~10 call sites across
ui/views/*.rs needed touching.

This is the other half of tonight's earlier BOS fix pass: that pass gave
breadhelp/src/config.rs its own byte-for-byte copy of this exact logic
(its own doc comment says "same discipline as bos-settings/src/config/
mod.rs::atomic_write") rather than sharing it — breadhelp's migration
follows in the next commit.

Builds clean; all 11 existing tests pass, including the atomic-write
backup/no-leftover-tmp-file test that now exercises the delegated code.
2026-07-17 09:30:36 +08:00
Breadway
da6af6abf9 Fix keybinds editor destroying real BOS binds.json + make config writes atomic
keybinds.rs was built only against a personal multi-layout binds.json schema
(globals/common/layouts). Loading the real BOS-shipped flat schema
(default_mods/bindings, with per-bind label/category/demo_cmd breadhelp
depends on) into that model and saving silently dropped the bindings key --
still valid JSON, so the Lua pcall failsafes never caught it.

- Add SchemaKind (Flat/MultiLayout/Unknown), detected from the file's
  top-level keys at load time and pinned for the session so save() always
  emits the same shape it read.
- Flat mode hides the layout-switching UI (there's nothing to switch) and
  edits a single Bindings list; MultiLayout keeps today's UI unchanged.
- Bind's #[serde(flatten)] extra map already preserved label/category/
  demo_cmd/options across a round trip; changed `mods` from Vec<String> to
  Option<Vec<String>> so an explicit "mods": [] (media keys pinning "no
  modifiers") isn't collapsed into "field omitted -> falls back to
  default_mods" -- a real behavior-changing loss the old round trip had.
- Unknown schema refuses to save (visible status-label error) instead of
  guessing a shape and overwriting the file.
- New round-trip tests in keybinds.rs load a real BOS-shaped binds.json
  fixture through load -> save and assert the bindings key and every
  per-bind extra field survive untouched; this is the regression test that
  would have caught the original bug.

Also: config writes across the app (TOML via config::save_doc, and the
JSON views -- autostart, appearance/settings.json, hyprland.rs/
monitors.json, breadbar's CSS) now go through a shared
config::atomic_write: write to a temp file in the same directory, rename
over the target, and back up whatever was there first to <path>.bak. A
crash or disk-full mid-write can no longer leave a config truncated or
corrupted with no way back.
2026-07-17 03:28:25 +08:00
Breadway
515101b39a 0.6.3: image previews, Hyprland-scale fix, password reveal, Keybinds editor
Some checks failed
Mirror to GitHub / mirror (push) Failing after 1s
Build and publish package / package (push) Successful in 1m40s
Wallpaper: file picker moved off the deprecated FileChooserDialog to
gtk4::FileDialog, plus a "Browse ~/Pictures/Backgrounds" thumbnail
grid (recursive scan, decoded previews) so picking a wallpaper isn't
guessing blind from filenames.

Fixed the app becoming unusable at Hyprland scale > 1.0: the sidebar's
~30-row nav list had no ScrolledWindow, so its unscrollable minimum
height exceeded the logical screen size once scale shrank it below
~1000px, clipping the window (Save buttons included) with no way to
reach the rest. Sidebar now scrolls independently; content column's
minimum width also dropped 760->560 for the same reason at scale 2.0.

Wi-Fi/saved-network password fields had no way to unhide what you
typed. Swapped every masked Entry (network.rs, breadcrumbs.rs, and the
shared password_row widget) to gtk4::PasswordEntry, which has a native
reveal-icon toggle.

Added a Keybinds editor for hypr/binds.json -- the one JSON Hyprland
config (globals/common/per-layout binds) with no GUI anywhere in the
app, despite Display/Appearance/Startup Apps all having one for their
own JSON config. Action-specific fields (command/direction/workspace/
options/...) round-trip through a compact inline-JSON column rather
than hardcoding a widget per action shape.
2026-07-16 18:11:33 +08:00
Breadway
dc765bb526 can't be bothered writing a commit message
Some checks failed
Mirror to GitHub / mirror (push) Failing after 1s
2026-07-16 18:05:49 +08:00
Breadway
9c85a89297 0.6.2: add Bluetooth panel
Some checks failed
Mirror to GitHub / mirror (push) Failing after 2s
Build and publish package / package (push) Failing after 1m48s
Adapter power switch, paired devices (connect/disconnect/forget), and
scan-for-new-devices with pairing -- BOS had bluez/bluez-utils packaged
and bread's own daemon already monitors Bluetooth events, but there was
no GUI to actually manage devices anywhere in the settings app.

Shells out to bluetoothctl's non-interactive mode, same "standard CLI,
no new dependency" choice as Network (nmcli) and Snapshots (snapper).
Pairing only covers "just works" SSP (most audio/HID devices) -- a
device requiring a PIN would need this app to register its own
bluetoothd agent, out of scope for now; it just fails with the reason
shown, same honest-failure pattern the Firewall fix added.
2026-07-06 22:41:24 +08:00
Breadway
783f7460bf 0.6.1: fix Firewall silent failures and Snapshots error messaging
Some checks failed
Mirror to GitHub / mirror (push) Failing after 1s
Build and publish package / package (push) Failing after 1m41s
Firewall's fetch_status() discarded stderr/exit code on failure, so a
failed pkexec call (e.g. no polkit agent in the session) left the page
silently stuck on "Status not loaded" with no indication anything went
wrong. Now surfaces the actual failure reason, and attaches the
previously-invisible per-action log buffer to a visible TextView
(matching the Packages page's existing pattern).

Snapshots' "No snapshots yet" empty state was shown identically whether
snapper was genuinely unconfigured or the user just lacked permissions
(a "No permissions." stderr) -- now distinguishes the two.
2026-07-05 09:02:54 +08:00
Breadway
121e93d273 Extract bos-settings into its own repo; add Appearance/Startup Apps/Display editors
Split out of the bos repo so a bos-settings release no longer requires a
BOS ISO release (and vice versa) — CI here publishes straight to the
[breadway] pacman repo on tag push, independent of bos's own release cadence.

Also adds real GUI editors for the Hyprland JSON config surfaces bos gained
recently (hypr/settings.json, hypr/monitors.json, hypr/autostart.json):
- Appearance panel: gaps, borders (with a real color picker), blur, shadow,
  tiling layout, and basic input settings.
- Display panel: a monitor-rule list editor alongside the existing live
  connected-monitor readout.
- Startup Apps panel: toggle/edit/add/remove the extra autostart commands.
- --page argv support for deep-linking (breadhelp's guides can now open
  bos-settings straight to a specific page).
- Fixed a stale "View keybinds cheat sheet" button pointing at a file
  deleted when breadhelp replaced bos-keybinds/bos-welcome.
2026-07-04 23:31:18 +08:00
Breadway
d0fc16bc84 bos-settings 0.6.0: Power, Firewall, Users, AUR, Firmware panels
Continues reducing terminal-reliance for graphical system control. Five
new panels, plus three GUI apps shipped for things better served by an
existing dedicated tool than reimplemented in bos-settings
(gnome-disk-utility, gufw, mission-center).

- Power: battery status/health, brightness, charge-limit thresholds where
  the hardware exposes them, TLP profile shown read-only by design (no
  Balanced/Performance switcher — TLP auto-selects by power source, and
  power-profiles-daemon isn't installed because it conflicts with tlp).
- Firewall: ufw enable/disable, add/remove rules, view active rules.
  ufw's own status check requires root (confirmed against the installed
  script — not just changes, reads too), so unlike every other panel this
  one does NOT query state in build(): every view is constructed eagerly
  at app launch, and an unconditional privileged read here would mean a
  polkit prompt on every single bos-settings open. Starts blank with a
  "Status not loaded" placeholder; state loads only on an explicit Refresh
  click, with a guard so refresh's own set_active() doesn't loop back into
  triggering ufw enable/disable.
- Users: add/remove accounts, change passwords. All through pkexec on a
  background thread. Can't remove the account you're currently running as.
- AUR: search via yay. Installing deliberately opens a terminal instead of
  a silent --noconfirm install — yay's interactive PKGBUILD diff review and
  sudo prompt are the actual safety mechanism against a malicious AUR
  package, not a formality worth automating away.
- Firmware: fwupd device list + updates, same stream-output-then-refresh
  pattern as Packages.

packages.x86_64: gnome-disk-utility, gufw, mission-center for disk/firewall/
task-manager GUIs that don't need reinventing inside bos-settings.
2026-07-04 11:04:16 +08:00
Breadway
23ea36dff4 bos-settings: round-2 GUI review fixes (stable column width, service UX, switch styling)
Root cause of two panels rendering full-bleed instead of the 760px column
(breadsearch, breadclip): hint()'s long unwrapped text reported an
unbounded natural width, and CONTENT_MAX_WIDTH is a floor (set_size_request),
not a cap — nothing was actually capping width. hint() and empty_state()
now bound their labels with set_max_width_chars, which stabilizes every
panel's column at a consistent width and left edge for the first time.

service_control(): added a second status line (enabled-at-boot vs
just-running), a confirm dialog on Stop for critical units (breadd — the
whole desktop's event backbone was one accidental click from stopping,
styled identically to optional helpers), and a consistent "Save only
writes the file, Restart applies it" hint on panels that have both a
service and a config file (previously only breadsearch said this, in its
own inconsistent wording).

Consistency sweep: Network's Scan button and empty-state treatment now
match the rest of the app (was full-width where every other secondary
action is auto-width; "not scanned" was a hint while "no results" was an
empty_state card — now both empty_state). breadclip gets an actual "Open
history" action instead of reading as an unfinished stub. breadbox's Save
button pulled out of a mixed row into its own row like every other panel.
Packages list rows now get the same card styling as every other row in
the app.

Panel title now shares view_scaffold's width+center treatment with the
content column, so it actually heads the column instead of sitting ~440px
to its left.

Also: switch widgets had a stray box-shadow/outline/border showing as a
faint ring around the knob, and bread-theme's switch slider color
(@on-surface) was being masked by Adwaita's default gloss background-image
— clearing it reveals the correctly-themed near-white knob against the
dark surface.
2026-07-04 10:09:59 +08:00
Breadway
a9c73926c2 bos-settings: single-source-of-truth default page
sidebar.rs and window.rs each independently hardcoded "about" as the
default selected/visible page — harmless while both literals happened to
match, but a real latent bug: changing one without the other silently
desyncs the sidebar highlight from the actually-displayed panel. Found
while capturing screenshots of every panel for a design review, where
exactly that happened. window.rs now owns DEFAULT_PAGE and passes it to
sidebar::build().
2026-07-04 00:00:10 +08:00
Breadway
3065de1838 bos-settings 0.5.0: GNOME-Settings-style redesign + live app control
New panels: About, Network (Wi-Fi/Ethernet via nmcli), Sound (PipeWire
volume/device via pactl), Date & Time (timedatectl), and Clipboard
(breadclip previously had no bos-settings presence at all despite running
its own daemon).

Layout: every panel now shares one scaffold (6 views were hand-rolling their
own, inconsistently) with a centered, width-capped content column instead of
rows stretching edge-to-edge on a wide window with the control stranded far
from its label. Rows render as individual cards. Sidebar got human labels +
icons + task-based grouping (System/Personalization/Maintenance/About)
instead of raw binary names under "Apps"/"System".

New capability: a shared service_control() widget gives every bread app
backed by a systemd --user daemon (breadd, breadbox-sync, breadcrumbs,
breadmill, breadclipd) live status plus Start/Stop/Restart/View logs —
previously these panels only ever edited a TOML file and hoped the running
process picked it up. breadbar (no systemd unit) gets an equivalent: Save
now actually sends the SIGHUP its own reload mechanism needs, instead of
just claiming to in the hint text.

Fixed two real bugs surfaced while building About: CPU model string kept a
stray leading tab+colon (trim_start_matches was missing '\t'), and GPU
showed "unknown" on hardware whose lspci entry says "Display controller"
instead of "VGA compatible controller".

Local CSS patches (bos-settings/src/theme.rs) for two upstream bread-theme
gaps: suggested/destructive buttons and scale widgets don't clear Adwaita's
default background-image, so flat colour overrides were invisible; and
destructive-action red must not be pywal-derived (it can land on gold
depending on the wallpaper, indistinguishable from a primary action).
2026-07-03 23:50:26 +08:00
Breadway
3884a49b11 bos-settings 0.4.2: bump bread-theme to v0.2.10 (fixed dark background/surface)
Pulls the fix for panels turning brown/off-hue when pywal extracts a light
or muddy background from the wallpaper — background/surface now stay BOS's
fixed dark constants regardless of what the wallpaper looks like.
2026-07-03 22:52:21 +08:00
Breadway
811710c20d bos-settings: expose npu/rocm/cuda in breadsearch compute backend dropdown
breadsearch v0.2.1+ ships breadmill built with --features full (npu + rocm
+ cuda in one binary via ort's load-dynamic/dlopen mode), so the earlier
CPU-only restriction here no longer applies. Hint explains what each
backend needs and how to confirm it actually took effect, since a failed
GPU EP registration falls back to CPU silently at the ONNX Runtime level.
2026-07-03 22:38:59 +08:00
Breadway
0a62637a1a Fix boot-critical, session, and UX bugs found in round-3 UX audit
Snapshots panel was non-functional (wrong snapper flag); breadgreet picked
the wrong session .desktop and skipped bos-session's PATH fixup; Calamares
aborted offline installs over an unnecessary packages module; snapshot
rollback silently no-op'd on BOS's pinned-subvolume layout (now points at
grub-btrfs instead); breadcrumbs was configurable but had no daemon to run
it. Also: SUPER+I double-bind, breadpaper/packages panels blocking the GTK
main thread, dead polkit rule, and a sweep of smaller drift (default
sidebar view, stale wording, missing .desktop launchers, wallpaper daemon
not recording its own default).
2026-07-03 22:04:26 +08:00
Breadway
ff370b35d2 bos-settings 0.4.1: fix broken panels, add breadsearch view
- breadbox panel edited [[contexts]] but breadbox reads [[context]]
  (serde rename mismatch) — panel was always empty and Save appended
  a dead array.
- Packages panel parsed installed.json as a flat map instead of
  unwrapping {"packages": {...}} — always showed one bogus row.
- load_doc silently produced an empty document on any parse error,
  so the next Save could clobber a config file with a syntax typo;
  now backs it up first.
- Hyprland panel opened hyprland.conf/keybinds.conf (BOS ships
  hyprland.lua) via $EDITOR with no terminal (dead click for any TUI
  editor) and defaulted to foot (BOS ships kitty). Now opens
  hyprland.lua and a keybinds cheat sheet via kitty -e $EDITOR.
- New breadsearch view: power (enabled/run-on-battery), compute
  backend (cpu/npu/rocm), index/search settings.
2026-07-03 13:31:05 +08:00
Breadway
0ead260560 v0.4.0: branding refresh, breadpaper baked in, bos-settings 0.4.0
- Move assets to assets/ directory (bread_white.svg, icons 256/512/1024px)
- Update Calamares branding + Plymouth theme logos
- Bake breadpaper (wallpaper manager + pywal) into /etc/skel alongside the
  rest of the bread ecosystem — previously missing from the ISO build
- Bump bos-settings to 0.4.0
2026-06-18 14:50:44 +08:00
Breadway
a23b25e4e6 Minor cleanups: gitignore out/, expect() messages, comment wording
- .gitignore: ignore the /out/ ISO build dir
- bos-settings: use expect() with messages over unwrap() for piped stdio;
  drop a stray blank line
- pacman.conf: reword the SigLevel=Never TODO as a future-improvement note
2026-06-17 22:57:58 +08:00
Breadway
6650454fae bos-settings 0.3.1: bread-theme v0.2.8 (working live reload)
Pick up the directory-watch fix so bos-settings hot-reloads the shared stylesheet
on `bread-theme reload` like the rest of the desktop (its v0.2.6 build had the
broken file-watch). No code change — only the dependency + version bump.
2026-06-17 13:59:59 +08:00
Breadway
e3573b624b bos-settings 0.3.0: shared theme release
Bump to 0.3.0 and pin bread-theme v0.2.6 in the lockfile so the [breadway]
package build (cargo --locked) picks up the shared-stylesheet migration.
2026-06-16 18:37:55 +08:00
Breadway
8e3998926e bos-settings: use the shared bread-theme stylesheet
Replace the hardcoded Nord palette (which ignored pywal and the rest of the
ecosystem entirely) with bread_theme::gtk::apply_shared() — bos-settings now
loads the same generated stylesheet as breadbar/breadbox/breadpad and keeps
only its own layout rules (.view-content padding). It recolours live with the
desktop. Bump gtk4 0.9 -> 0.11 / glib -> 0.22 to match the ecosystem.

Note: bread-theme dep pins tag v0.2.6 (cut at release); Cargo.lock to be
regenerated then.
2026-06-16 16:47:52 +08:00
Breadway
7fb4ae5e9f bos-settings: full, non-destructive control of every bread* config
The bread/breadpad/breadcrumbs/breadbox views wrote invented schemas
(e.g. top-level log_level, [[profile]] name/ssids) that did not match the
apps' real TOML, so they showed empty and — worse — clobbered the real
config on Save, since the old config::save serialized only the keys it
modelled.

Rework the config layer onto toml_edit: parse each file into a
DocumentMut, mutate only the specific keys a view exposes, and write it
back preserving comments and any unmodelled keys (calendar password,
saved-network passwords, model paths). Unit-tested.

Add ui/widgets.rs (switch/entry/password/dropdown/spin/float/csv rows +
view scaffold + save button) bound to the shared document, then rewrite
the four views against the real schemas with far more coverage:

- bread: [daemon], [lua], [modules], all five [adapters.*] with their
  sub-options, [events], [notifications]
- breadpad: [settings], [model] + [model.ollama], [reminders], [calendar]
- breadcrumbs: [settings] (7 keys), [[networks]] editor, [profiles.*] editor
- breadbox: fixed to real [[contexts]] name/priority array editor

Goal: configure everything from the GUI rather than hand-editing TOML.
2026-06-16 14:26:49 +08:00
142 changed files with 16809 additions and 1484 deletions

View file

@ -0,0 +1,91 @@
name: dev release
# Publishes a dev-track build on every push to `main` (the trunk
# branch — there is no separate `dev` branch). See bread-ecosystem's
# docs/release-channels.md for the release-track policy this is part of.
on:
push:
branches: ['main']
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch main --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
# bos-settings is a Tauri app: Cargo.toml lives at src/src/Cargo.toml
# (checkout root is named "src" by convention above; the repo's own
# Rust backend is also in a dir called "src", not the usual
# src-tauri — hence src/src below), and the Rust build expects the
# frontend already built at frontend/build (tauri.conf.json's
# frontendDist) — that only happens automatically under `cargo
# tauri build`, so it's run explicitly here since this workflow
# just uses plain `cargo build`.
- name: build frontend
run: |
set -euo pipefail
cd src/frontend
npm ci
npm run build
- name: build
run: cd src/src && cargo build --release --locked
- name: compute dev version
run: |
set -euo pipefail
cd src
# Base the dev version off the latest published stable tag,
# not Cargo.toml — Cargo.toml can go stale relative to the last
# real release, which would make a dev build sort as OLDER than
# what's already installed and bakery would correctly refuse it.
LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}"
else
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
fi
IFS='.' read -r MA MI PA <<< "${CUR}"
SHA="$(git rev-parse --short HEAD)"
TS="$(date -u +%Y%m%d%H%M%S)"
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
- name: prepare artifacts
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/dev/bos-settings/${VERSION}"
mkdir -p "${PKG_DIR}"
cp "src/src/target/release/bos-settings" "${PKG_DIR}/bos-settings-x86_64"
strip "${PKG_DIR}/bos-settings-x86_64"
sha256sum "${PKG_DIR}/bos-settings-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/bos-settings-x86_64.sha256"
cp src/packaging/bos-settings.desktop "${PKG_DIR}/"
cp src/LICENSE "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/bos-settings/latest"
# No GitHub Release upload — dev, like the other non-stable track,
# is only distributed via dl.breadway.dev/dev/.
- name: regenerate dev index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' dev/beta
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -0,0 +1,55 @@
name: beta (rc) release
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag
# pushed to `main` — there is no separate `beta` branch; "freezing" is
# just pausing pushes to main while an RC gets tested. See
# bread-ecosystem's docs/release-channels.md for the release-track policy.
on:
push:
tags: ['v*']
jobs:
build:
if: ${{ contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/beta/bos-settings/${VERSION}"
mkdir -p "${PKG_DIR}"
cp "src/src/target/release/bos-settings" "${PKG_DIR}/bos-settings-x86_64"
strip "${PKG_DIR}/bos-settings-x86_64"
sha256sum "${PKG_DIR}/bos-settings-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/bos-settings-x86_64.sha256"
cp src/packaging/bos-settings.desktop "${PKG_DIR}/"
cp src/LICENSE "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bos-settings/latest"
# No GitHub Release upload — beta, like dev, is only distributed via
# dl.breadway.dev/beta/.
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' dev/beta
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -0,0 +1,79 @@
name: release
on:
push:
tags: ["v*"]
jobs:
build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
# bos-settings is a Tauri app: Cargo.toml lives at src/src/Cargo.toml
# (checkout root is named "src" by convention above; the repo's own
# Rust backend is also in a dir called "src", not the usual
# src-tauri — hence src/src below), and the Rust build expects the
# frontend already built at frontend/build (tauri.conf.json's
# frontendDist) — that only happens automatically under `cargo
# tauri build`, so it's run explicitly here since this workflow
# just uses plain `cargo build`.
- name: build frontend
run: |
set -euo pipefail
cd src/frontend
npm ci
npm run build
- name: build
run: cd src/src && cargo build --release --locked
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/bos-settings/${VERSION}"
mkdir -p "${PKG_DIR}"
cp "src/src/target/release/bos-settings" "${PKG_DIR}/bos-settings-x86_64"
strip "${PKG_DIR}/bos-settings-x86_64"
sha256sum "${PKG_DIR}/bos-settings-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/bos-settings-x86_64.sha256"
cp src/packaging/bos-settings.desktop "${PKG_DIR}/"
cp src/LICENSE "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/bos-settings/latest"
- name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"
- name: upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/bos-settings/${VERSION}"
gh release create "${GITHUB_REF_NAME}" --repo Breadway/bos-settings \
--title "bos-settings v${VERSION}" --generate-notes 2>/dev/null || true
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bos-settings \
"${PKG_DIR}/bos-settings-x86_64" \
"${PKG_DIR}/bos-settings-x86_64.sha256" \
--clobber

37
.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
# Rust build artifacts
/target/
**/*.pdb
# Editor / IDE
.vscode/
.idea/
*.swp
*.swo
*~
.direnv/
# OS artifacts
.DS_Store
Thumbs.db
desktop.ini
# Environment / secrets
.env
.env.local
*.env.*
secrets/
*.pem
*.key
*.p12
# Runtime / logs
*.log
logs/
*.pid
*.sock
# Claude Code local agent state
.claude/
# Local hygiene notes (not for commit)
CLAUDE.md

92
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,92 @@
# Contributing
`bos-settings` — System settings app for Bread OS.
Part of the bread ecosystem; this repo follows the same branch/release
workflow as every other ecosystem product.
## Branches
There is one long-lived branch: **`main`**. All day-to-day work lands here.
Every push to `main` automatically builds and publishes a **dev-track**
build (see Tracks below) — a real install you can test before cutting
anything more formal.
New work — features and bug fixes alike — goes on a short-lived branch:
```
feature/<short-name>
fix/<issue-number-or-short-name>
```
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
branches get deleted on merge — they never accumulate the kind of drift a
second long-lived branch does.
## The release cycle
There's no separate `beta` or release branch — "stable" and "beta" are both
just **tags** on `main`, not branches that need to be kept in sync:
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
auto-publishes a dev build — install it with `bakery track set dev` and
`bakery update --all`, then fix anything broken with another push.
2. When you want to stabilize before a real release, tag a release
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
both remotes). That tag alone triggers a beta-track build —
"freezing" is just pausing pushes to `main` while you test it, not a
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
3. Once an RC has gone without issues, tag the real release:
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
signed stable release build.
## Tracks, from a user's perspective
```
bakery track show # what you're currently on (defaults to stable)
bakery track set dev # or beta, or stable
bakery update --all # pull the latest build on your current track
```
| Track | What it is | Published from |
|--------|-----------|-----------------|
| `stable` | The last tagged release | a `vX.Y.Z` tag |
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
| `dev` | Bleeding edge | `main`, on every push |
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
latest published stable tag, so they always sort as newer than what you
have installed — no manual version bumping needed. Beta versions are just
the RC tag itself (already valid semver, already sorts below the real
release it's a candidate for).
## Local development
`bos-settings` is a Tauri app: the Svelte frontend lives in `frontend/`,
the Rust backend in `src/src/` (the checkout root is conventionally named
`src`, and the backend's own crate is also called `src`, not `src-tauri`).
The frontend must be built before the Rust build — `tauri.conf.json`'s
`beforeBuildCommand` hook only fires under `cargo tauri build`, not plain
`cargo build`, so CI runs it explicitly:
```sh
cd frontend && npm ci && npm run build
cd ../src/src && cargo build --release --locked
cargo test --release --locked
```
## CI
- `dev-release.yml` — triggered on push to `main`.
- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push.
- `release.yml` — triggered on any other `v*` tag push, cuts the actual
stable release.
All CI runs on a self-hosted runner; nothing runs automatically on plain
commits or PRs beyond the track builds above. See
[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md)
for the full policy, including how a new product gets wired onto these tracks.
## Questions
Open an issue on this repo's Forgejo tracker.

View file

@ -1,12 +0,0 @@
[package]
name = "bos-settings"
version = "0.1.0"
edition = "2021"
[dependencies]
gtk4 = { version = "0.9", features = ["v4_12"] }
glib = "0.20"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
async-channel = "2"

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Breadway
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

15
README.md Normal file
View file

@ -0,0 +1,15 @@
# bos-settings
System settings app for [BOS (Bread Operating System)](https://git.breadway.dev/Breadway/bos) — GTK4, configures every bread\* app's config plus core system settings (network, sound, power, users, firewall, snapshots, packages, AUR, firmware, Hyprland display/appearance/autostart) non-destructively.
Split out of the `bos` repo into its own repo so a bos-settings release doesn't require a BOS ISO release, and vice versa. Distributed via `bakery` — see `bread-ecosystem`'s `CONTRIBUTING.md` for the dev/beta/stable track workflow shared across the bread ecosystem.
## Building
```bash
cargo build --release
```
## Packaging / releasing
Bump `Cargo.toml`'s version, tag `vX.Y.Z`, push the tag to both remotes — `.forgejo/workflows/release.yml` builds and publishes to `dl.breadway.dev` (bakery) automatically. Pushes to `dev`/`beta` publish a dev/beta-track build the same way, no tag needed.

View file

@ -1,9 +1,11 @@
name = "bos-settings"
description = "System settings app for Bread OS"
binaries = ["bos-settings"]
system_deps = ["gtk4", "glib2"]
system_deps = ["gtk4", "glib2", "hicolor-icon-theme"]
optional_system_deps = ["snapper"]
bread_deps = []
license_file = "LICENSE"
desktop_file = "bos-settings.desktop"
[config]
dir = "~/.config"

10
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

7
frontend/README.md Normal file
View file

@ -0,0 +1,7 @@
# Tauri + SvelteKit + TypeScript
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).

1942
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

31
frontend/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "bos-settings-frontend",
"version": "0.8.0",
"description": "Frontend for BOS Settings",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri"
},
"license": "MIT",
"dependencies": {
"@lucide/svelte": "^1.25.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.2",
"vite": "^6.0.3"
}
}

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

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

View file

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

View file

@ -0,0 +1,35 @@
<script lang="ts">
import Row from "./Row.svelte";
let { label, value = $bindable(), placeholder = "" }: { label: string; value: string[]; placeholder?: string } =
$props();
let text = $state(value.join(", "));
function onInput() {
value = text
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
</script>
<Row {label}>
<input type="text" bind:value={text} oninput={onInput} {placeholder} />
</Row>
<style>
input {
width: 28ch;
background-color: var(--bg);
color: var(--on-surface);
border: 1px solid transparent;
border-radius: var(--radius-secondary, 6px);
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
input:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,31 @@
<script lang="ts">
import Row from "./Row.svelte";
let {
label,
value = $bindable(),
min,
max,
step = 1,
}: { label: string; value: number; min: number; max: number; step?: number } = $props();
</script>
<Row {label}>
<input type="number" bind:value {min} {max} {step} />
</Row>
<style>
input {
width: 10ch;
background-color: var(--bg);
color: var(--on-surface);
border: 1px solid transparent;
border-radius: var(--radius-secondary, 6px);
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
input:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,34 @@
<script lang="ts">
import Row from "./Row.svelte";
let { label, value = $bindable(), options }: { label: string; value: string; options: string[] } = $props();
</script>
<Row {label}>
<select bind:value>
{#each options as opt (opt)}
<option value={opt}>{opt}</option>
{/each}
</select>
</Row>
<style>
select {
color-scheme: dark;
background-color: var(--bg);
color: var(--on-surface);
border: 1px solid transparent;
border-radius: var(--radius-secondary, 6px);
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
option {
background-color: var(--bg);
color: var(--on-surface);
}
select:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

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

View file

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

View file

@ -0,0 +1,43 @@
<script lang="ts">
let { value = $bindable(), ariaLabel }: { value: boolean; ariaLabel?: string } = $props();
</script>
<button
type="button"
class="switch"
class:on={value}
role="switch"
aria-checked={value}
aria-label={ariaLabel}
onclick={() => (value = !value)}
>
<span class="knob"></span>
</button>
<style>
.switch {
width: 40px;
height: 22px;
flex-shrink: 0;
border-radius: 999px;
border: none;
background-color: var(--overlay);
padding: 2px;
cursor: pointer;
display: flex;
align-items: center;
}
.switch.on {
background-color: var(--accent);
justify-content: flex-end;
}
.knob {
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--on-surface);
display: block;
}
</style>

View file

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

View file

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

View file

@ -0,0 +1,29 @@
<script lang="ts">
import Row from "./Row.svelte";
let {
label,
value = $bindable(),
placeholder = "",
}: { label: string; value: string; placeholder?: string } = $props();
</script>
<Row {label}>
<input type="text" bind:value {placeholder} />
</Row>
<style>
input {
width: 28ch;
background-color: var(--bg);
color: var(--on-surface);
border: 1px solid transparent;
border-radius: var(--radius-secondary, 6px);
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
input:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,121 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import InfoRow from "$lib/components/InfoRow.svelte";
import Group from "$lib/components/Group.svelte";
import Row from "$lib/components/Row.svelte";
import Hint from "$lib/components/Hint.svelte";
interface DateTimeInfo {
current_time: string;
timezones: string[];
current_tz: string;
ntp_enabled: boolean;
ntp_synced: boolean;
}
let info = $state<DateTimeInfo | null>(null);
let tzStatus = $state("");
let ntpEnabled = $state(false);
onMount(async () => {
info = await invoke<DateTimeInfo>("get_datetime_info");
ntpEnabled = info.ntp_enabled;
});
async function applyTimezone(tz: string) {
tzStatus = "Applying…";
try {
await invoke("set_timezone", { tz });
tzStatus = "Applied";
} catch (e) {
tzStatus = `${e}`;
}
}
async function toggleNtp(enabled: boolean) {
ntpEnabled = enabled;
await invoke("set_ntp_enabled", { enabled });
}
</script>
<ViewScaffold title="Date & Time">
{#if info}
<Group title="Current time">
<InfoRow label="Right now" value={info.current_time} />
</Group>
<Group title="Timezone">
{#if info.timezones.length === 0}
<Hint text="Couldn't list timezones (is timedatectl available?)." />
{:else}
<Row label="Timezone">
<select value={info.current_tz} onchange={(e) => applyTimezone(e.currentTarget.value)}>
{#each info.timezones as tz (tz)}
<option value={tz}>{tz}</option>
{/each}
</select>
</Row>
{#if tzStatus}
<span class="status">{tzStatus}</span>
{/if}
{/if}
</Group>
<Group title="Network time" hint={info.ntp_synced ? "Synchronized." : "Not synchronized yet (needs network)."}>
<Row label="Synchronize automatically">
<button
class="switch"
class:on={ntpEnabled}
role="switch"
aria-checked={ntpEnabled}
aria-label="Synchronize automatically"
onclick={() => toggleNtp(!ntpEnabled)}
>
<span class="knob"></span>
</button>
</Row>
</Group>
{/if}
</ViewScaffold>
<style>
select {
background-color: var(--bg);
color: var(--on-surface);
border: 1px solid transparent;
border-radius: var(--radius-secondary, 6px);
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
.status {
opacity: 0.6;
font-size: var(--font-size-secondary, 12px);
}
.switch {
width: 40px;
height: 22px;
border-radius: 999px;
border: none;
background-color: var(--overlay);
padding: 2px;
cursor: pointer;
display: flex;
align-items: center;
}
.switch.on {
background-color: var(--accent);
justify-content: flex-end;
}
.knob {
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--on-surface);
display: block;
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,152 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamingCommand } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import LogView from "$lib/components/LogView.svelte";
import Package from "@lucide/svelte/icons/package";
interface InstalledPackage {
name: string;
version: string;
}
let packages = $state<InstalledPackage[] | null>(null);
let log = $state<string[]>([]);
let busy = $state(false);
async function refresh() {
packages = await invoke<InstalledPackage[]>("get_installed_packages");
}
onMount(refresh);
function appendLine(line: string) {
log = [...log, line];
}
async function updatePackage(name: string) {
log = [];
busy = true;
await runStreamingCommand("bakery", ["update", name], appendLine);
busy = false;
await refresh();
}
async function listInstalled() {
log = [];
busy = true;
await runStreamingCommand("bakery", ["list"], appendLine);
busy = false;
}
async function updateAll() {
log = [];
busy = true;
await runStreamingCommand("bakery", ["update", "--all"], appendLine);
busy = false;
await refresh();
}
async function updateSystem() {
log = [];
busy = true;
await runStreamingCommand("pkexec", ["pacman", "-Syu", "--noconfirm"], appendLine);
busy = false;
}
</script>
<ViewScaffold title="Packages">
<Group title="Bread ecosystem (bakery)" wide>
<div class="list">
{#if packages === null}
<Hint text="Loading…" />
{:else if packages.length === 0}
<EmptyState icon={Package} title="No bakery packages found" hint="~/.local/state/bakery/installed.json is missing or empty." />
{:else}
{#each packages as pkg (pkg.name)}
<div class="row">
<span class="name" title={pkg.name}>{pkg.name}</span>
<span class="version" title={pkg.version}>{pkg.version}</span>
<button disabled={busy} onclick={() => updatePackage(pkg.name)}>Update</button>
</div>
{/each}
{/if}
</div>
<div class="btn-row">
<button disabled={busy} onclick={listInstalled}>List installed</button>
<button disabled={busy} onclick={updateAll}>Update all</button>
</div>
</Group>
<Group
title="System packages (pacman)"
hint="Base system, kernel, bos-settings, and republished AUR packages — the other half of what bos-update covers. Needs your password (polkit) since pacman requires root."
>
<button disabled={busy} onclick={updateSystem}>Update system (pacman -Syu)</button>
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.list {
max-height: 320px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.row {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
min-width: 0;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.version {
opacity: 0.75;
font-size: var(--font-size-secondary, 12px);
flex-shrink: 0;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-row {
display: flex;
gap: var(--space-sm, 8px);
margin-top: var(--space-sm, 8px);
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

BIN
frontend/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

18
frontend/svelte.config.js Normal file
View file

@ -0,0 +1,18 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: "index.html",
}),
},
};
export default config;

19
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

40
frontend/vite.config.js Normal file
View file

@ -0,0 +1,40 @@
import { fileURLToPath } from "node:url";
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
// path, not a bare "**/src/**" glob, because this repo has two directories
// named `src`: this one (frontend/src, which must be watched for HMR) and
// the sibling Rust crate (../src, which must not be).
const rustProjectDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../src");
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [sveltekit()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching the Tauri/Rust project dir
ignored: [`${rustProjectDir}/**`],
},
},
}));

View file

@ -1,38 +0,0 @@
# Maintainer: Breadway <rileyhorsham@gmail.com>
pkgname=bos-settings
pkgver=0.1.0
pkgrel=1
pkgdesc="System settings app for Bread OS"
arch=('x86_64')
url="https://github.com/Breadway/bos"
license=('MIT')
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
# causing undefined-symbol errors. Disable LTO.
options=(!lto !debug)
depends=('gtk4' 'glib2' 'hicolor-icon-theme')
optdepends=(
'snapper: snapshot management view'
)
makedepends=('rust' 'cargo')
source=("${pkgname}-${pkgver}.tar.gz")
sha256sums=('SKIP')
build() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo build --release --locked -p bos-settings
}
check() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo test --release --locked -p bos-settings
}
package() {
cd "${srcdir}/${pkgname}-${pkgver}"
install -Dm755 target/release/bos-settings "${pkgdir}/usr/bin/bos-settings"
install -Dm644 packaging/arch/bos-settings.desktop \
"${pkgdir}/usr/share/applications/bos-settings.desktop"
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}

View file

@ -1,25 +0,0 @@
Arch packaging
==============
`PKGBUILD` builds and installs `bos-settings` from source.
## Local build
```bash
makepkg -si
```
## Before publishing to [breadway] repo
1. Tag a release on GitHub.
2. Update `pkgver` to match the tag.
3. Update `source` to the release tarball URL.
4. Run `updpkgsums` (or manually set `sha256sums`).
## Runtime dependencies
| Package | Required | Notes |
|---------|----------|-------|
| `gtk4` | yes | UI toolkit |
| `glib2` | yes | always |
| `snapper` | optional | snapshot management view |

7
src/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

5335
src/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

50
src/Cargo.toml Normal file
View file

@ -0,0 +1,50 @@
[package]
name = "bos-settings"
version = "0.8.0"
description = "System settings app for BOS (Bread Operating System)"
authors = ["Breadway"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "bos_settings_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[features]
# Default-on so a plain `cargo build --release` produces a standalone binary
# that embeds the built frontend instead of loading it from `devUrl` —
# `cargo tauri dev` strips this back off via `--no-default-features` so dev
# builds keep talking to the Vite dev server. See
# https://v2.tauri.app/reference/cargo-features/#custom-protocol
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[dependencies]
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml_edit = "0.22"
tokio = { version = "1", features = ["process", "io-util", "time", "macros"] }
notify = "7"
regex = "1"
# TODO(owner): switch to a tag-pinned git dependency once bread-theme cuts a
# release including css_custom_properties/css_tokens (added 2026-07-21 for
# this migration) — pinned to branch = "main" for now rather than a path
# dependency (which broke bakery/CI builds — no sibling bread-ecosystem
# checkout exists on the runner) since no tag has these functions yet.
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", branch = "main" }
bread-utils = { git = "https://github.com/Breadway/bread-ecosystem", branch = "main", features = ["toml"] }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs. On
# "dev", not "main" like the two deps above: it doesn't exist on main yet.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" }
anyhow = "1"

3
src/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"dialog:default"
]
}

View file

@ -1,27 +0,0 @@
use std::error::Error;
use std::path::{Path, PathBuf};
pub fn load<T: for<'de> serde::Deserialize<'de>>(path: &Path) -> Result<T, Box<dyn Error>> {
let text = std::fs::read_to_string(path)?;
Ok(toml::from_str(&text)?)
}
pub fn save<T: serde::Serialize>(path: &Path, val: &T) -> Result<(), Box<dyn Error>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, toml::to_string_pretty(val)?)?;
Ok(())
}
pub fn config_dir() -> PathBuf {
// Honour XDG_CONFIG_HOME if set; otherwise fall back to $HOME/.config.
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
let p = PathBuf::from(xdg);
if p.is_absolute() {
return p;
}
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
PathBuf::from(home).join(".config")
}

BIN
src/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
src/icons/128x128@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
src/icons/StoreLogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src/icons/icon.icns Normal file

Binary file not shown.

BIN
src/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,13 +0,0 @@
mod config;
mod theme;
mod ui;
use gtk4::prelude::*;
fn main() {
let app = gtk4::Application::builder()
.application_id("com.breadway.bos-settings")
.build();
app.connect_activate(ui::window::build_ui);
app.run();
}

155
src/src/commands/about.rs Normal file
View file

@ -0,0 +1,155 @@
//! Read-only system info, plus the one thing worth making writable: hostname.
//! BOS is a rolling release (no fixed version number to show — `os-release`
//! ships `BUILD_ID=rolling` on purpose), so there's no "BOS 1.2.3" readout
//! here the way a point-release distro's About panel would have one.
use serde::Serialize;
use std::fs;
use tokio::process::Command;
#[derive(Serialize)]
pub struct SystemInfo {
os: String,
kernel: String,
cpu: String,
gpu: String,
memory: String,
disk: String,
uptime: String,
hostname: String,
}
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()))
})
.unwrap_or_else(|| "BOS".to_string())
}
fn hostname() -> String {
fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "unknown".to_string())
}
async fn kernel() -> String {
Command::new("uname")
.arg("-r")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
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()))
})
.unwrap_or_else(|| "unknown".to_string());
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
if cores > 0 {
format!("{model} ({cores} threads)")
} else {
model
}
}
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())
});
match kb {
Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0),
None => "unknown".to_string(),
}
}
async fn gpu() -> String {
let Ok(output) = Command::new("lspci").output().await else {
return "unknown".to_string();
};
let text = String::from_utf8_lossy(&output.stdout);
text.lines()
// "Display controller" covers integrated GPUs some laptop chipsets
// (this dev laptop's AMD Radeon 860M included) report under instead
// of "VGA compatible controller" — without it those show "unknown".
.find(|l| {
l.contains("VGA compatible controller")
|| l.contains("3D controller")
|| l.contains("Display controller")
})
.and_then(|l| l.split(": ").nth(1))
.unwrap_or("unknown")
.to_string()
}
async fn disk_usage() -> String {
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);
text.lines()
.nth(1)
.map(|l| {
let cols: Vec<&str> = l.split_whitespace().collect();
match cols.as_slice() {
[used, size, pcent] => format!("{used} of {size} used ({pcent})"),
_ => l.trim().to_string(),
}
})
.unwrap_or_else(|| "unknown".to_string())
}
async fn uptime() -> String {
Command::new("uptime")
.arg("-p")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
#[tauri::command]
pub async fn get_system_info() -> SystemInfo {
SystemInfo {
os: os_pretty_name(),
kernel: kernel().await,
cpu: cpu(),
gpu: gpu().await,
memory: memory(),
disk: disk_usage().await,
uptime: uptime().await,
hostname: hostname(),
}
}
#[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());
}
let output = Command::new("pkexec")
.args(["hostnamectl", "set-hostname", name])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}

View file

@ -0,0 +1,73 @@
//! hypr/settings.json — Hyprland gaps/borders/blur/shadow/input, read by
//! `scripts/ui/settings.lua` on the Hyprland side. No comments to preserve,
//! so it's a plain typed struct round-tripped whole.
//!
//! `Default` here must stay in sync with `scripts/ui/settings.lua`'s
//! `DEFAULTS` table on the Hyprland side — two different languages/
//! processes reading the same file, neither able to import the other's
//! defaults.
use serde::{Deserialize, Serialize};
use super::config;
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Appearance {
gaps_in: i64,
gaps_out: i64,
border_size: i64,
active_border: String,
inactive_border: String,
layout: String,
resize_on_border: bool,
rounding: i64,
blur_enabled: bool,
blur_size: i64,
blur_passes: i64,
shadow_enabled: bool,
shadow_range: i64,
shadow_render_power: i64,
kb_layout: String,
follow_mouse: i64,
natural_scroll: bool,
}
impl Default for Appearance {
fn default() -> Self {
Self {
gaps_in: 5,
gaps_out: 10,
border_size: 2,
active_border: "rgba(88c0d0ff)".to_string(),
inactive_border: "rgba(4c566aff)".to_string(),
layout: "dwindle".to_string(),
resize_on_border: true,
rounding: 8,
blur_enabled: true,
blur_size: 6,
blur_passes: 2,
shadow_enabled: true,
shadow_range: 12,
shadow_render_power: 3,
kb_layout: "us".to_string(),
follow_mouse: 1,
natural_scroll: true,
}
}
}
fn config_path() -> std::path::PathBuf {
config::config_dir().join("hypr/settings.json")
}
#[tauri::command]
pub fn get_appearance() -> Appearance {
std::fs::read_to_string(config_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
}
#[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())
}

47
src/src/commands/aur.rs Normal file
View file

@ -0,0 +1,47 @@
//! AUR search via yay — graphical discovery beyond bakery's bread ecosystem
//! and [breadway]'s own republished packages.
//!
//! Installing opens a terminal running `yay -S <pkg>` instead of a silent
//! `--noconfirm` install — deliberate, not a shortcut skipped. AUR packages
//! run arbitrary maintainer-supplied build scripts, and yay's interactive
//! PKGBUILD diff review (plus the sudo prompt) is the actual safety
//! mechanism against a malicious/compromised package; automating it away
//! would remove the one step that exists to catch that.
use serde::Serialize;
#[derive(Serialize, Clone)]
pub struct AurResult {
name: String,
version: String,
description: String,
}
#[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 {
return Vec::new();
};
let text = String::from_utf8_lossy(&output.stdout);
let mut results = Vec::new();
let mut lines = text.lines().peekable();
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 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 });
if results.len() >= 50 {
break;
}
}
results
}
#[tauri::command]
pub fn install_aur_package(pkg: String) {
let _ = std::process::Command::new("kitty").args(["-e", "yay", "-S", &pkg]).spawn();
}

View file

@ -0,0 +1,59 @@
//! hypr/autostart.json — the *extra*, user-toggleable autostart apps. The
//! core bootstrap sequence stays hardcoded in hyprland.lua on purpose (it's
//! timing/order-sensitive infrastructure, not something this panel exposes).
use serde::{Deserialize, Serialize};
use super::config;
#[derive(Clone, Serialize, Deserialize)]
pub struct AutostartEntry {
command: String,
#[serde(default)]
label: String,
#[serde(default = "default_true")]
enabled: bool,
}
fn default_true() -> bool {
true
}
#[derive(Serialize, Deserialize)]
struct AutostartFile {
#[serde(default)]
extra: Vec<AutostartEntry>,
}
fn default_extra() -> Vec<AutostartEntry> {
vec![
AutostartEntry { command: "breadbar".into(), label: "Bar (breadbar)".into(), enabled: true },
AutostartEntry { command: "hypridle".into(), label: "Idle / lock daemon (hypridle)".into(), enabled: true },
AutostartEntry { command: "bos-netcheck".into(), label: "Network connectivity check".into(), enabled: true },
AutostartEntry { command: "breadhelp --autostart".into(), label: "BOS Help (first-run onboarding)".into(), enabled: true },
]
}
fn config_path() -> std::path::PathBuf {
config::config_dir().join("hypr/autostart.json")
}
#[tauri::command]
pub fn get_autostart_entries() -> Vec<AutostartEntry> {
std::fs::read_to_string(config_path())
.ok()
.and_then(|s| serde_json::from_str::<AutostartFile>(&s).ok())
.map(|f| f.extra)
.unwrap_or_else(default_extra)
}
/// Empty-command rows (still-being-typed "Add app" entries) are dropped on
/// save rather than written as a broken autostart.json entry the Lua loader
/// would otherwise have to reject.
#[tauri::command]
pub fn save_autostart_entries(entries: Vec<AutostartEntry>) -> Result<(), String> {
let entries: Vec<AutostartEntry> = entries.into_iter().filter(|e| !e.command.trim().is_empty()).collect();
let file = AutostartFile { extra: entries };
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())
}

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