Compare commits

..

42 commits
v0.6.1 ... main

Author SHA1 Message Date
b34fd6c807 Merge pull request 'Settings redesign: hub navigation (reconciled with the operand hardening)' (#2) from feature/settings-redesign into main
All checks were successful
dev release / build (push) Successful in 2m31s
2026-08-31 18:14:20 +08:00
Breadway
3dfc9d56f3 Reconcile the redesign with the f7b114f privileged-command hardening
The redesign branch was cut before "Harden privileged command operands
against injection" landed on main; the rebase conflicts in users.rs,
power.rs, firewall.rs and capabilities/default.json were resolved to
keep the stricter side of each:

- users.rs: keep `chpasswd_input` / `valid_chpasswd_password` (validates
  the *password* for `:` / newline, which the redesign's inline checks
  missed) and `may_delete_user` (refuses root / the current user);
  fold in the redesign's `--` argv separators and GECOS control-char
  stripping on top.
- power.rs: keep `charge_threshold_write` (validates `which` ∈
  {start,end} and clamps) + `util::run_with_stdin` for the tee pipe,
  plus its test; take the redesign's brightness clamp.
- firewall.rs: keep the port-only `valid_firewall_rule` /
  `valid_rule_number` allowlist from f7b114f. NOTE: the redesign's
  Firewall UI hint advertises service names ("OpenSSH") which this
  validator rejects — either loosen it to `util::valid_cli_value` or
  drop the service-name hint. Left strict pending that call.
- capabilities/default.json: keep `opener:default` removed (nothing in
  the frontend or backend uses the opener plugin); add the four
  `core🪟*` perms the redesign's custom Titlebar needs.

Drops `util::valid_username` / `util::valid_cli_value` (added by the
redesign, now unused — every call site uses the stricter f7b114f local
validator) and the unused `use super::util` in firewall.rs.
2026-08-31 18:01:57 +08:00
Breadway
57898400d3 gitignore untracked .freebuff/ and graphify-out/ local tool state
Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-31 16:04:02 +08:00
Breadway
dce2031743 Settings redesign: hub-based navigation, plus hardening and bug fixes
Rework the settings app around hub pages (home, network, displays, input,
apps, privacy, system) with a redesigned sidebar, shared nav state, and new
hub view components. Alongside the redesign: validate webview inputs into
root commands (users, firewall, snapshots, wifi), fix set_charge_threshold
writing the percentage via tee stdin, prevent streaming installs from
hanging on inherited stdin, add frontend type fixes, sync versions to
0.8.2, and clean up clippy/svelte-check warnings.
2026-08-31 16:04:02 +08:00
Breadway
f7b114f778 Harden privileged command operands against injection
All checks were successful
dev release / build (push) Successful in 3m2s
Validate chpasswd user:password fields, refuse deleting root or the
current user, and pipe charge-threshold writes through tee stdin.
Allowlist firewall rules, systemd units, AUR names, hostname, and
timezone. Keep CalDAV passwords write-only like restic.
2026-08-23 14:40:03 +08:00
Breadway
77b5a223d8 Bump version to v0.8.2
Some checks failed
beta (rc) release / build (push) Has been skipped
release / build (push) Failing after 5m13s
dev release / build (push) Successful in 5m30s
2026-08-16 14:09:46 +08:00
Breadway
a6cb245ae4 Load bread-theme CSS from the window's monitor palette
All checks were successful
dev release / build (push) Successful in 6m1s
Pin bread-theme to v0.7.4.
2026-08-16 13:23:21 +08:00
Breadway
11c4ecb3cc Backup: restore snapshots into ~/bos-restore-<id>
Some checks failed
dev release / build (push) Successful in 3m12s
beta (rc) release / build (push) Has been skipped
release / build (push) Failing after 2m59s
Add a real restic restore that writes into a new directory instead of
$HOME, keep dry-run, confirm in the UI, and exclude container storage.
2026-08-16 00:56:43 +08:00
Breadway
73589ae36d Wire Updates NVIDIA card to bos-nvidia-setup
Some checks are pending
dev release / build (push) Waiting to run
The Install driver button runs the real setup instead of only
pacman_install. Prefers pkexec bos-nvidia-setup; falls back to the
allowlisted packages plus the same hyprland.lua drop-in. Tells the
user to reboot. Still click-only — first boot does not install.
2026-08-16 00:55:59 +08:00
Breadway
3aad09c9ef Make rollback a GRUB snapshot reboot
Some checks failed
dev release / build (push) Has been cancelled
Updates leads with “Boot a snapshot from GRUB (BOS snapshots)”
and links to Snapshots. Snapshots is the list to pick in GRUB,
not snapper rollback.
2026-08-16 00:48:25 +08:00
Breadway
0798fad697 Adopt bread_utils::screenshot_cli for --screenshot flags
All checks were successful
dev release / build (push) Successful in 2m41s
Use the crate for pair validation and canvas defaults. Webview settle
delays stay local; Tauri still does not have a GTK map signal.
2026-08-16 00:26:06 +08:00
Breadway
60a473fff0 Add OS settings panels for updates, printing, VPN, and related surfaces
All checks were successful
dev release / build (push) Successful in 2m11s
Typed commands only: pacman/bakery/fwupd compose on Updates, CUPS/nmcli,
hyprsunset, fcitx5, MIME defaults, bakery track, restic (backup.toml 0600),
curated optional software, and an NVIDIA offer card gated on the probe file.
2026-08-16 00:07:04 +08:00
Breadway
32604b492b Pin bread-theme and bread-utils to bread-ecosystem v0.7.2
Some checks failed
dev release / build (push) Successful in 3m13s
beta (rc) release / build (push) Has been skipped
release / build (push) Failing after 2m29s
2026-08-15 22:53:46 +08:00
Breadway
4741dff61b Fix clippy warnings: needless lifetime, sort_by_key, dead_code guard field
Some checks failed
dev release / build (push) Successful in 3m44s
beta (rc) release / build (push) Has been skipped
release / build (push) Has been cancelled
- find_block: elide the unnecessary 'a lifetime
- scan_wifi: use sort_by_key(Reverse) instead of sort_by for the signal-descending sort
- WatcherHandle: mark the RecommendedWatcher field as intentionally unread —
  it's held only to keep the watcher alive for the process lifetime, never
  accessed directly
2026-08-15 22:33:02 +08:00
Breadway
68db82dee1 Remove CLAUDE.md (renamed to AGENTS.md)
All checks were successful
dev release / build (push) Successful in 2m52s
2026-08-15 22:04:00 +08:00
Breadway
c1f620c1fa Use Arch package name webkit2gtk-4.1; track AGENTS.md 2026-08-15 22:03:30 +08:00
Breadway
cbac50683e Harden settings: split breadcrumbs secrets, typed exec, new panels
Load and save Wi-Fi networks in networks.toml (0600) instead of writing
PSKs back into breadcrumbs.toml. The password field is write-only.

Replace the generic argv runner with typed bakery/pacman/fwupd commands
and set a real Tauri CSP. Add Lock, Screenshots, Monitors, and Help
panels. Pin bread-theme and bread-utils to bread-ecosystem v0.7.1.
bread-screenshots is not on that tag, so --screenshot calls grim
locally. bakery.toml lists webkitgtk-4.1 deps; README/CLAUDE.md match
Tauri 2 + Svelte 5 and single-trunk main.
2026-08-15 21:47:00 +08:00
Breadway
abfb4fd4a4 Repoint bread-ecosystem git deps from dev to main branch
All checks were successful
dev release / build (push) Successful in 7m58s
2026-07-31 14:30:01 +08:00
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
209 changed files with 24612 additions and 6481 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

@ -1,21 +0,0 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
# --prune deletes GitHub refs that no longer exist on Forgejo.
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bos-settings.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

@ -1,40 +0,0 @@
name: Build and publish package
on:
push:
tags: ['v*']
jobs:
package:
runs-on: [self-hosted, hestia]
container:
image: archlinux:latest
steps:
# Note: no actions/checkout — the archlinux image has no Node, which JS
# actions require. Everything runs as shell steps and clones manually.
- name: Build and publish
env:
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
pacman -Syu --noconfirm base-devel git rust cargo gtk4 glib2
useradd -m builder
git config --global --add safe.directory '*'
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
cd /home/builder/src
git archive --format=tar.gz --prefix="bos-settings-${VERSION}/" HEAD \
> packaging/bos-settings-${VERSION}.tar.gz
SHA=$(sha256sum packaging/bos-settings-${VERSION}.tar.gz | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/PKGBUILD
chown -R builder:builder /home/builder/src
# --nocheck: packaging builds the artifact; tests belong in a CI job.
su builder -c "cd /home/builder/src/packaging && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging -name '*.pkg.tar.zst' | head -1)
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}" \
"https://git.breadway.dev/api/packages/Breadway/arch/os"

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

5
.gitignore vendored
View file

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

33
AGENTS.md Normal file
View file

@ -0,0 +1,33 @@
# AGENTS.md — Repo hygiene
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation.
This repo is a **Tauri 2 + Svelte 5** settings app, bakery-distributed. It follows the branch/release workflow in `CONTRIBUTING.md` — read and follow it for any git, branch, or release work here (the single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, etc). Don't improvise a different workflow. The short version: there is one long-lived branch, `main` — no `dev` or `beta` branch exists. `main` auto-publishes a bakery **dev-track** build on every push. "Beta" and "stable" are both just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track build, push a plain `vX.Y.Z` tag to cut the signed stable release. "Freezing" for stabilization means pausing pushes to `main`, not moving a branch.
This is not a GTK4 app. There is no `package.yml` pacman workflow here; bakery is the distribution channel.
## Layout
- `frontend/` — Svelte 5 + SvelteKit (static adapter) + TypeScript
- `src/` — Tauri 2 Rust crate (`bos-settings`). Commands live in `src/src/commands/`.
- Config edits are non-destructive (`toml_edit` / `bread_utils::tomlcfg`) except for JSON files that have no comments to preserve.
## Remotes
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
- `github` — GitHub mirror. Push `origin` only; the github remote auto-mirrors.
## CI
- `dev-release.yml` — push to `main`.
- `rc-release.yml``vX.Y.Z-rc.N` tags.
- `release.yml` — other `v*` tags (signed stable).
No build/lint/test CI runs on ordinary commits or PRs to `main` beyond the dev-track workflow above.
## Don't
- Don't commit `frontend/node_modules`.
- Don't embed credentials in remote URLs — SSH or a credential helper only.
- Don't write Wi-Fi passwords back into `breadcrumbs.toml`. Networks live in `~/.config/breadcrumbs/networks.toml` (0600).
- Don't expose a generic argv runner to the webview. Streaming updates are typed commands (`bakery_update`, `pacman_system_update`, `fwupd_*`).

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.

978
Cargo.lock generated
View file

@ -1,978 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bos-settings"
version = "0.6.1"
dependencies = [
"async-channel",
"bread-theme",
"glib",
"gtk4",
"serde",
"serde_json",
"toml 0.8.23",
"toml_edit 0.22.27",
]
[[package]]
name = "bread-theme"
version = "0.2.3"
source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780"
dependencies = [
"dirs",
"gtk4",
"serde",
"serde_json",
]
[[package]]
name = "cairo-rs"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
dependencies = [
"bitflags",
"cairo-sys-rs",
"glib",
"libc",
]
[[package]]
name = "cairo-sys-rs"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "cfg-expr"
version = "0.20.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
dependencies = [
"smallvec",
"target-lexicon",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "field-offset"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
dependencies = [
"memoffset",
"rustc_version",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "gdk-pixbuf"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646"
dependencies = [
"gdk-pixbuf-sys",
"gio",
"glib",
"libc",
]
[[package]]
name = "gdk-pixbuf-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "gdk4"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39"
dependencies = [
"cairo-rs",
"gdk-pixbuf",
"gdk4-sys",
"gio",
"glib",
"libc",
"pango",
]
[[package]]
name = "gdk4-sys"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"pango-sys",
"pkg-config",
"system-deps",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "gio"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"gio-sys",
"glib",
"libc",
"pin-project-lite",
"smallvec",
]
[[package]]
name = "gio-sys"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.61.2",
]
[[package]]
name = "glib"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100"
dependencies = [
"bitflags",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"libc",
"memchr",
"smallvec",
]
[[package]]
name = "glib-macros"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "glib-sys"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233"
dependencies = [
"libc",
"system-deps",
]
[[package]]
name = "gobject-sys"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "graphene-rs"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff"
dependencies = [
"glib",
"graphene-sys",
]
[[package]]
name = "graphene-sys"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]]
name = "gsk4"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff"
dependencies = [
"cairo-rs",
"gdk4",
"glib",
"graphene-rs",
"gsk4-sys",
"libc",
"pango",
]
[[package]]
name = "gsk4-sys"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088"
dependencies = [
"cairo-sys-rs",
"gdk4-sys",
"glib-sys",
"gobject-sys",
"graphene-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "gtk4"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9"
dependencies = [
"cairo-rs",
"field-offset",
"futures-channel",
"gdk-pixbuf",
"gdk4",
"gio",
"glib",
"graphene-rs",
"gsk4",
"gtk4-macros",
"gtk4-sys",
"libc",
"pango",
]
[[package]]
name = "gtk4-macros"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "gtk4-sys"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gdk4-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"graphene-sys",
"gsk4-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"libc",
]
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "pango"
version = "0.22.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c"
dependencies = [
"gio",
"glib",
"pango-sys",
]
[[package]]
name = "pango-sys"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.12+spec-1.1.0",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom",
"libredox",
"thiserror",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "system-deps"
version = "7.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml 1.1.2+spec-1.1.0",
"version-compare",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.3",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.15",
]
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.3",
]
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
"winnow 1.0.3",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View file

@ -1,19 +0,0 @@
[package]
name = "bos-settings"
version = "0.6.1"
edition = "2021"
[dependencies]
gtk4 = { version = "0.11", features = ["v4_12"] }
glib = "0.22"
# Shared ecosystem theming — bos-settings loads the same generated stylesheet as
# breadbar/breadbox/breadpad so the whole desktop looks consistent.
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
# toml_edit drives non-destructive config editing: it preserves comments and
# any keys the UI doesn't model, so saving a single field never rewrites or
# drops the rest of the user's config file.
toml_edit = "0.22"
async-channel = "2"

View file

@ -1,15 +1,24 @@
# bos-settings
System settings app for [BOS (Bread Operating System)](https://github.com/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.
System settings app for [BOS (Bread Operating System)](https://git.breadway.dev/Breadway/bos) — Tauri 2 + Svelte 5. 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. Still the only pacman-packaged (not bakery-managed) bread app — see `packaging/README.md`.
Distributed via `bakery`. There is one long-lived branch, `main`; see `CONTRIBUTING.md` for the single-trunk / RC-tag release model shared across the bread ecosystem.
## Building
The Svelte frontend lives in `frontend/`, the Rust backend in `src/` (this repo's crate is not named `src-tauri`). `cargo tauri build` runs the frontend build hook; a plain `cargo build` does not.
```bash
cargo build --release
cd frontend && npm ci && npm run build
cd ../src && cargo build --release
```
Dev (Vite + `cargo tauri dev`):
```bash
cd src && cargo tauri dev
```
## Packaging / releasing
See `packaging/README.md`. In short: bump `Cargo.toml`'s version, tag `vX.Y.Z`, push the tag to both remotes — `.forgejo/workflows/package.yml` builds and publishes to the `[breadway]` pacman repo automatically.
Bump `src/Cargo.toml` (and `frontend/package.json`) version, then follow `CONTRIBUTING.md`: work lands on `main` via `feature/` / `fix/` branches (every push to `main` publishes a bakery **dev** build). Tag `vX.Y.Z-rc.N` for beta, `vX.Y.Z` for the signed stable release. Do not push to a `dev` branch — there isn't one.

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 = ["webkit2gtk-4.1", "gtk3", "libsoup3", "librsvg", "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).

1960
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
frontend/package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "bos-settings-frontend",
"version": "0.8.2",
"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",
"@types/node": "^26.4.0",
"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,10 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { setContext } from "svelte";
import { EMBEDDED_KEY } from "$lib/embed";
let { children }: { children: Snippet } = $props();
setContext(EMBEDDED_KEY, true);
</script>
{@render children()}

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

View file

@ -0,0 +1,14 @@
<script lang="ts">
let { text }: { text: string } = $props();
</script>
<p class="hint">{text}</p>
<style>
.hint {
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
font-size: var(--font-size-secondary, 12px);
margin: 0;
line-height: 1.4;
}
</style>

View file

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

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-sm, 10px);
padding: 6px 10px;
}
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>Unknown page "{page}".</p>
</div>
<style>
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
opacity: 0.5;
}
</style>

View file

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

View file

@ -0,0 +1,38 @@
<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 class="btn primary" 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: 4px;
}
.status {
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
font-size: var(--font-size-secondary, 12px);
}
</style>

View file

@ -0,0 +1,46 @@
<script lang="ts">
import Row from "./Row.svelte";
let {
label,
hint,
value = $bindable(),
options,
labels = {},
}: {
label: string;
hint?: string;
value: string;
options: string[];
labels?: Record<string, string>;
} = $props();
</script>
<Row {label} {hint}>
<select bind:value>
{#each options as opt (opt)}
<option value={opt}>{labels[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-sm, 10px);
padding: 6px 10px;
}
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}? Other bread apps may stall until it is 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 writes the file. Restart the service to apply." />
{/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-2, var(--bg));
color: var(--on-surface);
border: 1px solid var(--line, transparent);
border-radius: var(--radius-sm, 10px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
}
button:hover {
background-color: color-mix(in srgb, var(--on-surface) 10%, transparent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,50 @@
<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)}
>
<i></i>
</button>
<style>
.switch {
width: 42px;
height: 24px;
flex-shrink: 0;
border-radius: 999px;
border: none;
background: color-mix(in srgb, var(--fg) 12%, transparent);
padding: 2px;
cursor: pointer;
display: flex;
align-items: center;
transition: background 0.18s ease;
}
.switch.on {
background: var(--accent);
}
i {
width: 20px;
height: 20px;
border-radius: 50%;
background: #efe8e6;
display: block;
transform: translateX(0);
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.switch.on i {
transform: translateX(18px);
background: var(--on-accent);
}
</style>

View file

@ -0,0 +1,14 @@
<script lang="ts">
import Row from "./Row.svelte";
import Switch from "./Switch.svelte";
let {
label,
hint,
value = $bindable(),
}: { label: string; hint?: string; value: boolean } = $props();
</script>
<Row {label} {hint}>
<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-sm, 10px);
padding: 6px 10px;
}
input:focus {
outline: none;
border-color: var(--accent);
}
</style>

View file

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

View file

@ -0,0 +1,64 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { getContext } from "svelte";
import { EMBEDDED_KEY } from "$lib/embed";
let { title, lede, children }: { title: string; lede?: string; children: Snippet } = $props();
const embedded = getContext<boolean>(EMBEDDED_KEY) ?? false;
</script>
{#if embedded}
<div class="embed">
{@render children()}
</div>
{:else}
<div class="view">
<h1 class="title">{title}</h1>
{#if lede}
<p class="lede">{lede}</p>
{/if}
<div class="content">
{@render children()}
</div>
</div>
{/if}
<style>
.view {
padding: 28px 36px 56px;
min-height: 100%;
}
.title {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.04em;
margin: 0 auto;
max-width: 920px;
width: 100%;
}
.lede {
margin: 6px auto 0;
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
font-size: 14px;
line-height: 1.45;
max-width: 920px;
width: 100%;
}
.content,
.embed {
display: flex;
flex-direction: column;
gap: 18px;
max-width: 920px;
width: 100%;
margin-top: 22px;
margin-inline: auto;
}
.embed {
margin-top: 0;
}
</style>

View file

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

View file

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

View file

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

120
frontend/src/lib/search.ts Normal file
View file

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

112
frontend/src/lib/sidebar.ts Normal file
View file

@ -0,0 +1,112 @@
import type { Component } from "svelte";
import House from "@lucide/svelte/icons/house";
import Wifi from "@lucide/svelte/icons/wifi";
import Bluetooth from "@lucide/svelte/icons/bluetooth";
import Monitor from "@lucide/svelte/icons/monitor";
import Volume2 from "@lucide/svelte/icons/volume-2";
import BatteryFull from "@lucide/svelte/icons/battery-full";
import Palette from "@lucide/svelte/icons/palette";
import AppWindow from "@lucide/svelte/icons/app-window";
import Keyboard from "@lucide/svelte/icons/keyboard";
import LayoutGrid from "@lucide/svelte/icons/layout-grid";
import Shield from "@lucide/svelte/icons/shield";
import Download from "@lucide/svelte/icons/download";
import Info from "@lucide/svelte/icons/info";
export interface SidebarItem {
id: string;
label: string;
icon: Component;
}
export interface SidebarSection {
title: string | null;
items: SidebarItem[];
}
export const SIDEBAR_SECTIONS: SidebarSection[] = [
{ title: "Overview", items: [{ id: "home", label: "Home", icon: House }] },
{
title: "Devices",
items: [
{ id: "network", label: "Wi-Fi & internet", icon: Wifi },
{ id: "bluetooth", label: "Bluetooth", icon: Bluetooth },
{ id: "displays", label: "Displays", icon: Monitor },
{ id: "sound", label: "Sound", icon: Volume2 },
{ id: "power", label: "Power & battery", icon: BatteryFull },
],
},
{
title: "Desktop",
items: [
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "desktop", label: "Bar & apps", icon: AppWindow },
{ id: "input", label: "Keyboard & mouse", icon: Keyboard },
{ id: "apps", label: "Default apps", icon: LayoutGrid },
],
},
{
title: "System",
items: [
{ id: "privacy", label: "Privacy & users", icon: Shield },
{ id: "system", label: "Updates & backup", icon: Download },
{ id: "about", label: "About", icon: Info },
],
},
];
/** Old sidebar ids (screenshot CLI, in-app jumps) → hub page + optional tab. */
export const PAGE_ALIASES: Record<string, { page: string; tab?: string }> = {
home: { page: "home" },
network: { page: "network" },
vpn: { page: "network", tab: "vpn" },
breadcrumbs: { page: "network", tab: "breadcrumbs" },
bluetooth: { page: "bluetooth" },
displays: { page: "displays" },
hyprland: { page: "displays", tab: "hyprland" },
nightlight: { page: "displays", tab: "nightlight" },
breadmon: { page: "displays", tab: "breadmon" },
sound: { page: "sound" },
power: { page: "power" },
appearance: { page: "appearance" },
breadpaper: { page: "appearance", tab: "breadpaper" },
desktop: { page: "desktop" },
breadbar: { page: "desktop", tab: "breadbar" },
breadbox: { page: "desktop", tab: "breadbox" },
breadlock: { page: "desktop", tab: "breadlock" },
breadshot: { page: "desktop", tab: "breadshot" },
autostart: { page: "desktop", tab: "autostart" },
breadclip: { page: "desktop", tab: "more" },
breadpad: { page: "desktop", tab: "more" },
breadsearch: { page: "desktop", tab: "more" },
bread: { page: "desktop", tab: "more" },
breadhelp: { page: "desktop", tab: "more" },
more: { page: "desktop", tab: "more" },
input: { page: "input" },
keybinds: { page: "input", tab: "keybinds" },
ime: { page: "input", tab: "ime" },
accessibility: { page: "input", tab: "accessibility" },
apps: { page: "apps" },
defaults: { page: "apps" },
optional: { page: "apps", tab: "optional" },
printing: { page: "apps", tab: "printing" },
privacy: { page: "privacy" },
firewall: { page: "privacy" },
users: { page: "privacy", tab: "users" },
system: { page: "system" },
updates: { page: "system" },
packages: { page: "system", tab: "packages" },
aur: { page: "system", tab: "aur" },
firmware: { page: "system", tab: "firmware" },
snapshots: { page: "system", tab: "snapshots" },
backup: { page: "system", tab: "backup" },
channel: { page: "system", tab: "channel" },
about: { page: "about" },
datetime: { page: "about", tab: "datetime" },
};
export const DEFAULT_PAGE = "home";
export function resolvePage(id: string): { page: string; tab?: string } {
return PAGE_ALIASES[id] ?? { page: id };
}

View file

@ -0,0 +1,25 @@
// Frontend half of the event-streaming command pattern (see
// src/src/commands/streaming.rs) - listens for `cmd-output` lines from a
// typed Tauri command that runs a hardcoded program, then resolves once
// the process exits.
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
export async function runStreamed(
command: string,
args: Record<string, unknown>,
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>(command, { sessionId, ...args });
} finally {
unlisten();
}
}

View file

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

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

View file

@ -0,0 +1,197 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import NumberField from "$lib/components/NumberField.svelte";
import LogView from "$lib/components/LogView.svelte";
interface A11yStatus {
orca_installed: boolean;
orca_running: boolean;
zoom_factor: number;
sticky_keys_supported: boolean;
slow_keys_supported: boolean;
kmag_installed: boolean;
note: string;
}
let st = $state<A11yStatus | null>(null);
let zoom = $state(1);
let log = $state<string[]>([]);
let busy = $state(false);
let message = $state("");
async function refresh() {
st = await invoke<A11yStatus>("get_a11y_status");
zoom = st.zoom_factor;
}
onMount(refresh);
async function install(packages: string[]) {
log = [];
busy = true;
await runStreamed("pacman_install", { packages }, (line) => {
log = [...log, line];
});
busy = false;
await refresh();
}
async function toggleOrca(running: boolean) {
message = "";
try {
await invoke("set_orca_running", { running });
await refresh();
} catch (e) {
message = `${e}`;
}
}
async function applyZoom() {
message = "";
try {
zoom = await invoke<number>("set_cursor_zoom", { factor: zoom });
} catch (e) {
message = `${e}`;
}
}
async function openKmag() {
message = "";
try {
await invoke("open_kmag");
} catch (e) {
message = `${e}`;
}
}
</script>
<ViewScaffold title="Accessibility">
<Group title="Screen reader" hint="Orca. Works on Wayland; start/stop is process-level, not a compositor setting.">
{#if !st}
<Hint text="Loading…" />
{:else if !st.orca_installed}
<Hint text="orca is not installed." />
<button class="primary" disabled={busy} onclick={() => install(["orca"])}>Install orca</button>
{:else}
<div class="row-switch">
<span>Orca</span>
<button
class="switch"
class:on={st.orca_running}
role="switch"
aria-checked={st.orca_running}
aria-label="Orca"
onclick={() => toggleOrca(!st!.orca_running)}
>
<span class="knob"></span>
</button>
</div>
{/if}
</Group>
<Group title="Magnifier" hint="Zooms around the cursor. This session only.">
{#if st}
<NumberField label="Zoom factor" bind:value={zoom} min={1} max={8} step={0.25} />
<button class="primary" onclick={applyZoom}>Apply zoom</button>
<Hint text="1.0 is off. Values above 1 enlarge around the cursor." />
{#if !st.kmag_installed}
<Hint text="kmag is a separate KDE magnifier and is not wired into Hyprland. Optional install only." />
<button disabled={busy} onclick={() => install(["kmag"])}>Install kmag</button>
{:else}
<button onclick={openKmag}>Open kmag</button>
{/if}
{/if}
</Group>
<Group title="Sticky keys / Slow keys">
{#if st}
<div class="row-switch disabled">
<span>Sticky keys</span>
<button class="switch" disabled role="switch" aria-checked="false" aria-label="Sticky keys">
<span class="knob"></span>
</button>
</div>
<div class="row-switch disabled">
<span>Slow keys</span>
<button class="switch" disabled role="switch" aria-checked="false" aria-label="Slow keys">
<span class="knob"></span>
</button>
</div>
<Hint text={st.note} />
{/if}
</Group>
{#if message}<Hint text={message} />{/if}
<LogView lines={log} />
</ViewScaffold>
<style>
.row-switch {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px) var(--space-lg, 16px);
margin-bottom: var(--space-sm, 8px);
}
.row-switch.disabled {
opacity: 0.55;
}
.switch {
width: 40px;
height: 22px;
flex-shrink: 0;
border-radius: 999px;
border: none;
background-color: var(--overlay);
padding: 2px;
cursor: pointer;
display: flex;
align-items: center;
}
.switch.on {
background-color: var(--accent);
justify-content: flex-end;
}
.switch:disabled {
cursor: not-allowed;
}
.knob {
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--on-surface);
display: block;
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
align-self: flex-start;
margin-top: var(--space-xs, 4px);
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,162 @@
<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 SliderField from "$lib/components/SliderField.svelte";
import HyprColorField from "$lib/components/HyprColorField.svelte";
import Hint from "$lib/components/Hint.svelte";
import { debounce } from "$lib/debounce";
// 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: string; label: string }[] = [
{ value: "0", label: "Click to focus" },
{ value: "1", label: "Follow mouse" },
{ value: "2", label: "Follow, keep focus on click" },
{ value: "3", label: "Mouse and keys independent" },
];
const KB_LABELS: Record<string, string> = {
us: "English (US)",
gb: "English (UK)",
de: "German",
fr: "French",
es: "Spanish",
it: "Italian",
pt: "Portuguese",
nl: "Dutch",
se: "Swedish",
no: "Norwegian",
dk: "Danish",
fi: "Finnish",
pl: "Polish",
cz: "Czech",
sk: "Slovak",
hu: "Hungarian",
ro: "Romanian",
gr: "Greek",
tr: "Turkish",
ru: "Russian",
ua: "Ukrainian",
jp: "Japanese",
kr: "Korean",
cn: "Chinese",
br: "Portuguese (Brazil)",
ca: "English (Canada)",
ch: "German (Switzerland)",
be: "Belgian",
at: "German (Austria)",
ie: "English (Ireland)",
};
const LAYOUT_LABELS: Record<string, string> = {
dwindle: "Tiling",
master: "Master stack",
};
interface Appearance {
gaps_in: number;
gaps_out: number;
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 loaded = $state(false);
let followMouse = $state("1");
let kbLayoutOptions = $derived(
cfg && !COMMON_KB_LAYOUTS.includes(cfg.kb_layout) ? [...COMMON_KB_LAYOUTS, cfg.kb_layout] : COMMON_KB_LAYOUTS,
);
const persist = debounce(() => {
if (!cfg) return;
invoke("save_appearance", { appearance: { ...cfg, follow_mouse: Number(followMouse) } });
}, 450);
onMount(async () => {
cfg = await invoke<Appearance>("get_appearance");
followMouse = String(cfg.follow_mouse);
loaded = true;
});
let primed = false;
$effect(() => {
if (!loaded || !cfg) return;
JSON.stringify(cfg);
void followMouse;
if (!primed) {
primed = true;
return;
}
persist();
});
</script>
<ViewScaffold title="Appearance">
{#if cfg}
<Group title="Windows">
<SliderField label="Gaps between windows" bind:value={cfg.gaps_in} min={0} max={50} />
<SliderField label="Gaps around the edge" bind:value={cfg.gaps_out} min={0} max={50} />
<SliderField label="Border width" bind:value={cfg.border_size} min={0} max={10} />
<HyprColorField label="Active border" bind:value={cfg.active_border} />
<HyprColorField label="Inactive border" bind:value={cfg.inactive_border} />
<SelectField label="Layout" bind:value={cfg.layout} options={["dwindle", "master"]} labels={LAYOUT_LABELS} />
<SwitchField label="Resize by dragging the border" bind:value={cfg.resize_on_border} />
</Group>
<Group title="Effects">
<SliderField label="Corner rounding" bind:value={cfg.rounding} min={0} max={30} />
<SwitchField label="Blur" bind:value={cfg.blur_enabled} />
{#if cfg.blur_enabled}
<SliderField label="Blur size" bind:value={cfg.blur_size} min={0} max={20} />
<SliderField label="Blur quality" bind:value={cfg.blur_passes} min={1} max={5} />
{/if}
<SwitchField label="Shadows" bind:value={cfg.shadow_enabled} />
{#if cfg.shadow_enabled}
<SliderField label="Shadow size" bind:value={cfg.shadow_range} min={0} max={40} />
{/if}
</Group>
<Group title="Keyboard and mouse">
<SelectField label="Keyboard layout" bind:value={cfg.kb_layout} options={kbLayoutOptions} labels={KB_LABELS} />
<SelectField
label="Focus"
bind:value={followMouse}
options={FOLLOW_MOUSE_MODES.map((m) => m.value)}
labels={Object.fromEntries(FOLLOW_MOUSE_MODES.map((m) => [m.value, m.label]))}
/>
<SwitchField label="Natural scrolling" hint="Touchpad" bind:value={cfg.natural_scroll} />
</Group>
<Hint text="Applies as you change it." />
{/if}
</ViewScaffold>

View file

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

View file

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

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="Opens a terminal. Review the PKGBUILD before you confirm."
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="Extra apps after login. Bar and clipboard always start."
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,254 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import TextField from "$lib/components/TextField.svelte";
import FileField from "$lib/components/FileField.svelte";
import PasswordField from "$lib/components/PasswordField.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import LogView from "$lib/components/LogView.svelte";
import Archive from "@lucide/svelte/icons/archive";
interface ResticSnapshot {
id: string;
time: string;
paths: string[];
}
interface BackupStatus {
restic_installed: boolean;
repo: string;
has_password: boolean;
snapshots: ResticSnapshot[];
error: string | null;
home: string;
}
let st = $state<BackupStatus | null>(null);
let repo = $state("");
let password = $state("");
let snapshots = $state<ResticSnapshot[]>([]);
let selected = $state("latest");
let restoreTarget = $state("");
let lastAutoTarget = $state("");
let log = $state<string[]>([]);
let busy = $state(false);
let message = $state("");
function defaultTarget(id: string): string {
const home = st?.home ?? "";
if (!home) return "";
return `${home}/bos-restore-${id || "latest"}`;
}
$effect(() => {
if (!st?.home) return;
const auto = defaultTarget(selected || "latest");
if (restoreTarget === "" || restoreTarget === lastAutoTarget) {
if (restoreTarget !== auto) restoreTarget = auto;
if (lastAutoTarget !== auto) lastAutoTarget = auto;
}
});
async function refresh() {
st = await invoke<BackupStatus>("get_backup_config");
repo = st.repo;
}
onMount(refresh);
async function save() {
await invoke("save_backup_config", { input: { repo, password: password || null } });
password = "";
await refresh();
}
async function installRestic() {
log = [];
busy = true;
await runStreamed("pacman_install", { packages: ["restic"] }, (line) => {
log = [...log, line];
});
busy = false;
await refresh();
}
async function run(command: string, args: Record<string, unknown> = {}) {
log = [];
busy = true;
message = "";
const ok = await runStreamed(command, args, (line) => {
log = [...log, line];
});
busy = false;
if (!ok) message = "Failed. See the log.";
}
async function listSnaps() {
message = "";
try {
snapshots = await invoke<ResticSnapshot[]>("list_restic_snapshots");
if (snapshots.length === 0) message = "No snapshots in this repo yet.";
} catch (e) {
message = `${e}`;
snapshots = [];
}
}
async function restore(dryRun: boolean) {
const snap = selected || "latest";
const target = restoreTarget.trim() || defaultTarget(snap);
const home = st?.home ?? "";
if (!dryRun && home && (target === home || target === `${home}/`)) {
message = "Refusing to restore onto $HOME. Leave the default ~/bos-restore-<id> or pick another folder.";
return;
}
if (!dryRun) {
const ok = confirm(
`Restore snapshot ${snap} into ${target}?\n\nFiles go into that directory. Your live home is not overwritten.`,
);
if (!ok) return;
}
await run(dryRun ? "restic_restore_dry_run" : "restic_restore", {
snapshot: snap,
target,
});
}
</script>
<ViewScaffold title="Backup">
<Group
title="Repository"
hint="Local folder or sftp. Password is write-only."
wide
>
{#if !st}
<Hint text="Loading…" />
{:else if !st.restic_installed}
<Hint text="restic is not installed." />
<button class="primary" disabled={busy} onclick={installRestic}>Install restic</button>
{:else}
<FileField label="Local path" bind:value={repo} mode="folder" placeholder="/mnt/backup/bos" />
<TextField label="Or SFTP" bind:value={repo} placeholder="sftp:user@host:/backups/bos" />
<PasswordField label={st.has_password ? "Password (leave empty to keep)" : "Password"} bind:value={password} />
<SaveButton onSave={save} />
{/if}
</Group>
<Group
title="Actions"
hint="Home directory. Snapshots page is the root filesystem."
>
<div class="btn-row">
<button disabled={busy} onclick={() => run("restic_init")}>Init repo</button>
<button class="primary" disabled={busy} onclick={() => run("restic_backup")}>Backup home</button>
<button disabled={busy} onclick={listSnaps}>List snapshots</button>
</div>
{#if message}<Hint text={message} />{/if}
</Group>
<Group
title="Restore"
hint="Writes into a new folder (default ~/bos-restore-<id>). Does not overwrite $HOME. Dry-run previews the same target."
>
<FileField label="Restore into" bind:value={restoreTarget} mode="folder" placeholder="/home/you/bos-restore-latest" />
<div class="btn-row">
<button disabled={busy || !st?.restic_installed} onclick={() => restore(true)}>Restore dry-run</button>
<button class="primary" disabled={busy || !st?.restic_installed} onclick={() => restore(false)}>Restore</button>
</div>
</Group>
<Group title="Snapshots" wide>
{#if snapshots.length === 0}
<EmptyState icon={Archive} title="No snapshots loaded" hint="Init, backup, then list." />
{:else}
<div class="list">
{#each snapshots as s (s.id)}
<button class="row" class:selected={selected === s.id} onclick={() => (selected = s.id)}>
<span class="id">{s.id}</span>
<span class="time">{s.time}</span>
<span class="paths">{s.paths.join(", ")}</span>
</button>
{/each}
</div>
{/if}
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.btn-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm, 8px);
}
.list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
overflow-y: auto;
}
.row {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
background-color: var(--surface);
border: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
padding: var(--space-sm, 8px) var(--space-md, 12px);
border-radius: var(--radius-primary, 8px);
}
.row.selected {
background-color: var(--accent);
color: var(--on-accent);
}
.id {
width: 10ch;
flex-shrink: 0;
font-family: monospace;
}
.time {
width: 22ch;
flex-shrink: 0;
opacity: 0.85;
}
.paths {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,195 @@
<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"
lede="Paired devices and nearby scan."
>
{#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="btn" 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="PIN pairing is not supported."
>
<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);
padding: 10px 2px;
border-top: 1px solid var(--line);
}
.row:first-child {
border-top: none;
padding-top: 0;
}
.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,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 TextField from "$lib/components/TextField.svelte";
import SliderField from "$lib/components/SliderField.svelte";
import Hint from "$lib/components/Hint.svelte";
import { debounce } from "$lib/debounce";
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
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 loaded = $state(false);
let advancedOpen = $state(false);
let css = $state("");
let cssStatus = $state("");
let cssSaving = $state(false);
const persist = debounce(() => {
if (!style) return;
invoke("save_breadbar_style", { style }).then(() => invoke<string>("get_breadbar_css").then((c) => (css = c)));
}, 320);
onMount(async () => {
style = await invoke<BreadbarStyle>("get_breadbar_style");
css = await invoke<string>("get_breadbar_css");
loaded = true;
});
let primed = false;
$effect(() => {
if (!loaded || !style) return;
JSON.stringify(style);
if (!primed) {
primed = true;
return;
}
persist();
});
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="Clock, workspace numbers, and stats.">
<TextField label="Font" bind:value={style.font_family} placeholder="Varela Round" />
<SliderField label="Font size" bind:value={style.font_size} min={8} max={32} />
</Group>
<Group title="Bar shape">
<SliderField label="Corner rounding" bind:value={style.bar_border_radius} min={0} max={40} />
<SliderField label="Inner padding" bind:value={style.bar_padding} min={0} max={40} />
</Group>
<Group title="Workspaces">
<SliderField label="Number size" bind:value={style.workspace_font_size} min={8} max={40} />
<SliderField
label="Inactive dimness"
bind:value={style.workspace_inactive_opacity}
min={0}
max={1}
step={0.05}
/>
</Group>
<Group title="Spacing and icons">
<SliderField label="Gap between stats" bind:value={style.stat_gap} min={0} max={40} />
<SliderField label="Tray icon size" bind:value={style.tray_icon_size} min={8} max={32} />
<SliderField label="Notification rounding" bind:value={style.notification_border_radius} min={0} max={24} />
</Group>
<Hint text="Applies as you change it." />
{/if}
<Group title="Advanced" hint="Raw CSS. Same file as the fields above." wide>
<button class="toggle" onclick={() => (advancedOpen = !advancedOpen)}>
{#if advancedOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
Edit raw CSS
</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>
.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="Apps and categories shown 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="History popup. Super+V."
>
<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,303 @@
<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;
// Write-only. The backend never returns a stored PSK; empty means
// "keep the on-disk secret / let NetworkManager remember."
password?: string | null;
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 () => {
const loaded = await invoke<BreadcrumbsConfig>("get_breadcrumbs_config");
loaded.networks = loaded.networks.map((n) => ({ ...n, password: "" }));
cfg = loaded;
});
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() {
const payload = {
...cfg!,
networks: cfg!.networks.map((n) => ({
ssid: n.ssid,
hidden: n.hidden,
// Omit empty so the backend treats it as "keep / let NM remember"
// rather than writing password = "".
...(n.password && n.password.length > 0 ? { password: n.password } : {}),
})),
};
await invoke("save_breadcrumbs_config", { input: payload });
// Clear typed secrets from the UI after a successful save so a later
// glance at the field doesn't look like a stored PSK came back.
cfg!.networks = cfg!.networks.map((n) => ({ ...n, password: "" }));
}
</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"
hint="Leave password blank to keep the saved one."
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"
autocomplete="new-password"
bind:value={net.password}
placeholder="New password (blank = keep / NM remembers)"
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,76 @@
<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 SwitchField from "$lib/components/SwitchField.svelte";
interface AutostartEntry {
command: string;
label: string;
enabled: boolean;
}
const BREADHELP_CMD = "breadhelp --autostart";
const BREADHELP_LABEL = "BOS Help (first-run onboarding)";
let entries = $state<AutostartEntry[] | null>(null);
let status = $state("");
let helpEntry = $derived(entries?.find((e) => e.command.trim().startsWith("breadhelp")) ?? null);
let autostartOn = $derived(helpEntry?.enabled ?? false);
onMount(async () => {
entries = await invoke<AutostartEntry[]>("get_autostart_entries");
});
async function setAutostart(enabled: boolean) {
if (!entries) return;
const next = entries.map((e) => ({ ...e }));
const i = next.findIndex((e) => e.command.trim().startsWith("breadhelp"));
if (i >= 0) {
next[i].enabled = enabled;
} else if (enabled) {
next.push({ command: BREADHELP_CMD, label: BREADHELP_LABEL, enabled: true });
}
try {
await invoke("save_autostart_entries", { entries: next });
entries = next;
status = enabled ? "First-run help will launch at login." : "First-run help won't launch at login.";
} catch (e) {
status = `Error: ${e}`;
}
}
</script>
<ViewScaffold title="Help">
<Group
title="BOS Help"
hint="Opens the help app."
>
<button class="primary" onclick={() => invoke("open_breadhelp")}>Open breadhelp</button>
</Group>
<Group title="Show on login">
<SwitchField label="Show help on login" bind:value={() => autostartOn, (v) => setAutostart(v)} />
{#if !helpEntry}
<Hint text="Turning this on adds the first-run command." />
{/if}
{#if status}
<Hint text={status} />
{/if}
</Group>
</ViewScaffold>
<style>
.primary {
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,89 @@
<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 SelectField from "$lib/components/SelectField.svelte";
import FileField from "$lib/components/FileField.svelte";
import TextField from "$lib/components/TextField.svelte";
import NumberField from "$lib/components/NumberField.svelte";
import SwitchField from "$lib/components/SwitchField.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
interface BreadlockConfig {
background_mode: string;
background_path: string;
background_blur: boolean;
clock_format: string;
font_family: string;
fail_timeout_ms: number;
}
let cfg = $state<BreadlockConfig | null>(null);
let examplePath = $state<string | null>(null);
onMount(async () => {
cfg = await invoke<BreadlockConfig>("get_breadlock_config");
examplePath = await invoke<string | null>("breadlock_example_path");
});
async function save() {
await invoke("save_breadlock_config", { cfg });
}
</script>
<ViewScaffold title="Lock">
<Group
title="How locking works"
hint="Login and PAM are packaged. This only styles the lock screen."
>
<Hint text="Super+L locks. Login screen is packaged separately." />
<button class="primary" onclick={() => invoke("lock_session")}>Lock now</button>
</Group>
{#if cfg}
<Group title="Lock screen">
<SelectField label="Background" bind:value={cfg.background_mode} options={["color", "image"]} />
{#if cfg.background_mode === "image"}
<FileField label="Image" bind:value={cfg.background_path} placeholder="PNG, cover-fit" extensions={["png"]} />
{/if}
<SwitchField label="Blur background" bind:value={cfg.background_blur} />
<Hint text="Blur is saved but not drawn yet." />
<TextField label="Clock format" bind:value={cfg.clock_format} placeholder="%H:%M" />
<TextField label="Font" bind:value={cfg.font_family} placeholder="Varela Round" />
<NumberField label="Wrong-password timeout (ms)" bind:value={cfg.fail_timeout_ms} min={0} max={10000} />
<SaveButton onSave={save} />
</Group>
{/if}
<Group title="Files">
<button class="secondary" onclick={() => invoke("open_breadlock_config")}>Open breadlock.toml in editor</button>
{#if examplePath}
<button class="secondary" onclick={() => invoke("open_breadlock_example")}>Open example ({examplePath})</button>
{:else}
<Hint text="No packaged breadlock.example.toml found. The schema is [background] mode/path/blur, [clock] format, [font] family, [input] fail_timeout_ms." />
{/if}
</Group>
</ViewScaffold>
<style>
button {
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-lg, 16px);
cursor: pointer;
align-self: flex-start;
margin-top: var(--space-xs, 4px);
}
.primary {
background-color: var(--accent);
color: var(--on-accent);
}
.secondary {
background-color: var(--surface);
color: var(--on-surface);
}
</style>

View file

@ -0,0 +1,29 @@
<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";
</script>
<ViewScaffold title="Monitors">
<Group
title="Two different jobs"
hint="Live arrange, mirror, and named profiles."
>
<Hint text="Layout tab is the login config. This opens the live TUI." />
<button class="primary" onclick={() => invoke("open_breadmon")}>Open breadmon</button>
</Group>
</ViewScaffold>
<style>
.primary {
align-self: flex-start;
margin-top: var(--space-sm, 8px);
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,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="Local model. No network.">
<FileField label="ONNX model" bind:value={cfg.model_path} placeholder="~/.local/share/breadpad/model/classifier.onnx" extensions={["onnx"]} />
<FileField label="Tokenizer" bind:value={cfg.tokenizer_path} placeholder="~/.local/share/breadpad/model/tokenizer.json" extensions={["json"]} />
</Group>
<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,164 @@
<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();
scanning = true;
try {
library = await invoke<LibraryEntry[]>("list_wallpaper_library");
} finally {
scanning = false;
}
});
async function apply(path: string) {
status = "Setting…";
try {
await invoke("set_wallpaper", { path });
await refreshCurrent();
status = "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);
}
</script>
<ViewScaffold title="Wallpaper">
<Group title="Wallpaper" hint="This also updates desktop colors.">
{#if currentPath}
<div class="hero" style="background-image: url('{convertFileSrc(currentPath)}')">
<span class="cap">{currentPath.split("/").pop()}</span>
</div>
{:else}
<div class="hero placeholder">No wallpaper</div>
{/if}
<div class="btn-row">
<button class="btn primary" onclick={chooseImage}>Choose image</button>
<span class="status">{status}</span>
</div>
</Group>
<Group title="Library" wide>
{#if scanning && library === null}
<div class="empty">Loading {libraryDir}</div>
{:else if library && library.length === 0}
<div class="empty">Nothing in {libraryDir}</div>
{:else if library}
<div class="grid">
{#each library as item (item.path)}
<button
class="thumb"
class:on={item.path === currentPath}
onclick={() => apply(item.path)}
title={item.path}
>
<img src={convertFileSrc(item.path)} alt={item.name} loading="lazy" />
</button>
{/each}
</div>
{/if}
</Group>
</ViewScaffold>
<style>
.hero {
height: 180px;
border-radius: 12px;
background-size: cover;
background-position: center;
position: relative;
overflow: hidden;
}
.hero.placeholder {
display: grid;
place-items: center;
background: var(--bg);
color: var(--muted);
}
.cap {
position: absolute;
left: 12px;
bottom: 10px;
font-size: 12px;
color: #fffc;
background: #0006;
padding: 4px 8px;
border-radius: 6px;
}
.btn-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.status {
color: var(--muted);
font-size: 12px;
}
.empty {
color: var(--muted);
font-size: 12px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
}
.thumb {
padding: 0;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: transparent;
aspect-ratio: 16/10;
}
.thumb.on {
border-color: var(--accent);
}
.thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
</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="Indexing is heavy. Pause it on battery if you want."
>
<SwitchField label="Enabled" bind:value={cfg.power_enabled} />
<SwitchField label="Index while on battery" bind:value={cfg.run_on_battery} />
</Group>
<Group
title="Model"
hint="Hardware used for indexing."
>
<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,78 @@
<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 InfoRow from "$lib/components/InfoRow.svelte";
import FileField from "$lib/components/FileField.svelte";
import TextField from "$lib/components/TextField.svelte";
import NumberField from "$lib/components/NumberField.svelte";
import SwitchField from "$lib/components/SwitchField.svelte";
import SaveButton from "$lib/components/SaveButton.svelte";
interface ShotBind {
shortcut: string;
command: string;
}
interface BreadshotConfig {
save_dir: string;
silent: boolean;
freeze: boolean;
notif_timeout: number;
date_format: string;
}
let binds = $state<ShotBind[] | null>(null);
let cfg = $state<BreadshotConfig | null>(null);
onMount(async () => {
binds = await invoke<ShotBind[]>("get_breadshot_binds");
cfg = await invoke<BreadshotConfig>("get_breadshot_config");
});
async function save() {
await invoke("save_breadshot_config", { cfg });
}
</script>
<ViewScaffold title="Screenshots">
<Group
title="Keybinds"
hint="Change shortcuts under Keyboard."
>
{#if binds && binds.length > 0}
{#each binds as b (`${b.shortcut}:${b.command}`)}
<InfoRow label={b.shortcut} value={b.command} />
{/each}
{:else}
<Hint text="No breadshot binds found." />
{/if}
<button class="primary" onclick={() => invoke("breadshot_region_clipboard")}>Capture region to clipboard</button>
</Group>
{#if cfg}
<Group title="Capture">
<FileField label="Save directory" bind:value={cfg.save_dir} placeholder="~/Pictures/Screenshots" mode="folder" />
<SwitchField label="Silent (no notifications)" bind:value={cfg.silent} />
<SwitchField label="Freeze screen during select" bind:value={cfg.freeze} />
<Hint text="Freeze needs hyprpicker. Filenames are <date_format>_breadshot.png." />
<NumberField label="Notification timeout (ms)" bind:value={cfg.notif_timeout} min={0} max={60000} />
<TextField label="Filename date format" bind:value={cfg.date_format} placeholder="%Y-%m-%d-%H%M%S" />
<SaveButton onSave={save} />
</Group>
{/if}
</ViewScaffold>
<style>
.primary {
align-self: flex-start;
margin-top: var(--space-sm, 8px);
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,127 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import LogView from "$lib/components/LogView.svelte";
interface BakeryTrack {
current: string;
tracks: string[];
}
const BLURBS: Record<string, string> = {
stable: "Last tagged release.",
beta: "Latest release candidate (vX.Y.Z-rc.N).",
dev: "Every push to main.",
};
let track = $state<BakeryTrack | null>(null);
let message = $state("");
let log = $state<string[]>([]);
let busy = $state(false);
async function refresh() {
try {
track = await invoke<BakeryTrack>("get_bakery_track");
} catch (e) {
message = `${e}`;
}
}
onMount(refresh);
async function setTrack(name: string) {
message = "";
try {
track = await invoke<BakeryTrack>("set_bakery_track", { track: name });
message = `Now on ${name}. Run bakery update --all to install this tracks builds.`;
} catch (e) {
message = `${e}`;
}
}
async function updateAll() {
log = [];
busy = true;
await runStreamed("bakery_update_all", {}, (line) => {
log = [...log, line];
});
busy = false;
}
</script>
<ViewScaffold title="Bakery channel">
<Group
title="Track"
hint="Nothing downloads until you update."
>
{#if !track}
<Hint text="Loading…" />
{:else}
<div class="tracks">
{#each track.tracks as name (name)}
<button class="track" class:on={track.current === name} onclick={() => setTrack(name)}>
<strong>{name}</strong>
<span>{BLURBS[name] ?? ""}</span>
</button>
{/each}
</div>
<Hint text={`Current track: ${track.current}`} />
{/if}
<button class="primary" disabled={busy} onclick={updateAll}>Update all on this track</button>
{#if message}<Hint text={message} />{/if}
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.tracks {
display: flex;
flex-direction: column;
gap: 6px;
}
.track {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
background-color: var(--surface);
color: inherit;
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px) var(--space-lg, 16px);
cursor: pointer;
text-align: left;
font: inherit;
}
.track span {
opacity: 0.7;
font-size: var(--font-size-secondary, 12px);
}
.track.on {
background-color: var(--accent);
color: var(--on-accent);
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
align-self: flex-start;
margin-top: var(--space-sm, 8px);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

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,75 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import Row from "$lib/components/Row.svelte";
interface DesktopApp {
id: string;
name: string;
}
interface DefaultsStatus {
path: string;
current: Record<string, string>;
options: Record<string, DesktopApp[]>;
}
const CATEGORIES: { id: string; label: string }[] = [
{ id: "browser", label: "Browser" },
{ id: "files", label: "File manager" },
{ id: "terminal", label: "Terminal" },
{ id: "image", label: "Images" },
{ id: "pdf", label: "PDF" },
{ id: "editor", label: "Editor" },
];
let st = $state<DefaultsStatus | null>(null);
onMount(async () => {
st = await invoke<DefaultsStatus>("get_default_apps");
});
async function save() {
if (!st) return;
await invoke("save_default_apps", { input: { current: st.current } });
}
</script>
<ViewScaffold title="Default apps">
<Group title="Apps" wide>
{#if st}
{#each CATEGORIES as cat (cat.id)}
<Row label={cat.label}>
<select bind:value={st.current[cat.id]} onchange={save}>
<option value="">Not set</option>
{#each st.options[cat.id] ?? [] as app (app.id)}
<option value={app.id}>{app.name}</option>
{/each}
</select>
</Row>
{/each}
<Hint text="Applies as you change it." />
{:else}
<Hint text="Loading…" />
{/if}
</Group>
</ViewScaffold>
<style>
select {
color-scheme: dark;
background: var(--bg);
color: var(--on-surface);
border: 1px solid transparent;
border-radius: 10px;
padding: 6px 10px;
min-width: 22ch;
max-width: 36ch;
}
select:focus {
border-color: var(--accent);
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,166 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import LogView from "$lib/components/LogView.svelte";
interface ImePackage {
name: string;
installed: boolean;
}
interface ImeStatus {
enabled: boolean;
running: boolean;
packages: ImePackage[];
error: string | null;
}
let st = $state<ImeStatus | null>(null);
let log = $state<string[]>([]);
let busy = $state(false);
let message = $state("");
const installSet = ["fcitx5-im", "fcitx5-gtk", "fcitx5-qt", "fcitx5-chinese-addons"];
async function refresh() {
st = await invoke<ImeStatus>("get_ime_status");
}
onMount(refresh);
async function toggle(enabled: boolean) {
message = "";
try {
st = await invoke<ImeStatus>("set_ime_enabled", { enabled });
} catch (e) {
message = `${e}`;
}
}
async function installMissing() {
const missing = st?.packages.filter((p) => !p.installed).map((p) => p.name) ?? [];
const packages = ["fcitx5-im", ...missing.filter((n) => n !== "fcitx5")];
const unique = [...new Set(packages.length ? packages : installSet)];
log = [];
busy = true;
await runStreamed("pacman_install", { packages: unique }, (line) => {
log = [...log, line];
});
busy = false;
await refresh();
}
let missing = $derived(st?.packages.filter((p) => !p.installed) ?? []);
</script>
<ViewScaffold title="Input method">
<Group
title="fcitx5"
hint="Starts fcitx5. Running apps need a logout."
>
{#if !st}
<Hint text="Loading…" />
{:else}
<div class="row-switch">
<span>Enable fcitx5 for this session</span>
<button
class="switch"
class:on={st.enabled}
role="switch"
aria-checked={st.enabled}
aria-label="Enable fcitx5"
onclick={() => toggle(!st!.enabled)}
>
<span class="knob"></span>
</button>
</div>
<Hint text={st.running ? "fcitx5 is running." : "fcitx5 is not running."} />
<button onclick={() => invoke("open_fcitx_config")}>Open fcitx5 config</button>
{/if}
{#if message}<Hint text={message} />{/if}
</Group>
<Group title="Packages" hint="fcitx5-im (group) plus GTK/Qt modules and a CJK table (fcitx5-chinese-addons).">
{#if st}
<ul>
{#each st.packages as p (p.name)}
<li class:missing={!p.installed}>{p.name}{p.installed ? "" : " (missing)"}</li>
{/each}
</ul>
{/if}
{#if missing.length}
<button class="primary" disabled={busy} onclick={installMissing}>Install missing</button>
{/if}
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.row-switch {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px) var(--space-lg, 16px);
margin-bottom: var(--space-sm, 8px);
}
.switch {
width: 40px;
height: 22px;
flex-shrink: 0;
border-radius: 999px;
border: none;
background-color: var(--overlay);
padding: 2px;
cursor: pointer;
display: flex;
align-items: center;
}
.switch.on {
background-color: var(--accent);
justify-content: flex-end;
}
.knob {
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--on-surface);
display: block;
}
ul {
margin: 0 0 var(--space-sm, 8px);
padding-left: 1.2em;
}
.missing {
opacity: 0.7;
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
align-self: flex-start;
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,786 @@
<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;
capturing: boolean;
}
const ACTION_LABELS: Record<string, string> = {
exec: "Run command",
close: "Close window",
fullscreen: "Fullscreen",
float: "Float window",
pseudo: "Fake fullscreen",
resize: "Resize",
focus: "Go to workspace",
focus_last: "Last workspace",
move: "Move window to workspace",
move_dir: "Move window",
resize_dir: "Resize window",
layout: "Layout",
drag: "Mouse drag",
exit: "Exit Hyprland",
};
const KEY_LABELS: Record<string, string> = {
RETURN: "Enter",
SPACE: "Space",
ESCAPE: "Esc",
BACKSPACE: "Backspace",
TAB: "Tab",
SUPER: "Super",
CTRL: "Ctrl",
ALT: "Alt",
SHIFT: "Shift",
XF86AudioRaiseVolume: "Vol +",
XF86AudioLowerVolume: "Vol -",
XF86AudioMute: "Mute",
XF86AudioMicMute: "Mic mute",
XF86MonBrightnessUp: "Bright +",
XF86MonBrightnessDown: "Bright -",
XF86AudioNext: "Next",
XF86AudioPrev: "Prev",
XF86AudioPlay: "Play",
Print: "Print",
};
const CODE_TO_HYPR: Record<string, string> = {
Space: "SPACE",
Enter: "RETURN",
Escape: "ESCAPE",
Backspace: "BACKSPACE",
Tab: "TAB",
AudioVolumeUp: "XF86AudioRaiseVolume",
AudioVolumeDown: "XF86AudioLowerVolume",
AudioVolumeMute: "XF86AudioMute",
AudioMicMute: "XF86AudioMicMute",
BrightnessUp: "XF86MonBrightnessUp",
BrightnessDown: "XF86MonBrightnessDown",
MediaTrackNext: "XF86AudioNext",
MediaTrackPrevious: "XF86AudioPrev",
MediaPlayPause: "XF86AudioPlay",
PrintScreen: "Print",
};
function keyLabel(k: string): string {
return KEY_LABELS[k] ?? k;
}
function bindTitle(row: EditRow): string {
if (row.action === "exec") {
const cmd = String(row.extraValue.command ?? "");
if (cmd.includes("set-volume") && cmd.includes("+")) return "Volume up";
if (cmd.includes("set-volume") && cmd.includes("-")) return "Volume down";
if (cmd.includes("set-mute") && cmd.includes("SINK")) return "Mute";
if (cmd.includes("set-mute") && cmd.includes("SOURCE")) return "Mute mic";
if (cmd.includes("brightnessctl") && cmd.includes("+")) return "Brightness up";
if (cmd.includes("brightnessctl") && cmd.includes("-")) return "Brightness down";
if (cmd.includes("playerctl next")) return "Next track";
if (cmd.includes("playerctl previous")) return "Previous track";
if (cmd.includes("playerctl play-pause")) return "Play/pause";
if (cmd) return cmd.split(/\s+/)[0].split("/").pop() ?? "Command";
}
return ACTION_LABELS[row.action] ?? row.action;
}
function keyChips(row: EditRow): string[] {
const mods = textToMods(row.mods);
const key = row.key.trim();
return [...mods, key].filter(Boolean).map(keyLabel);
}
function hyprFromEvent(e: KeyboardEvent): { mods: string[]; key: string } | null {
if (["Shift", "Control", "Alt", "Meta"].includes(e.key)) return null;
const mods: string[] = [];
if (e.metaKey) mods.push("SUPER");
if (e.ctrlKey) mods.push("CTRL");
if (e.altKey) mods.push("ALT");
if (e.shiftKey) mods.push("SHIFT");
let key = CODE_TO_HYPR[e.code] ?? CODE_TO_HYPR[e.key];
if (!key) {
if (e.code.startsWith("Key") && e.code.length === 4) key = e.code.slice(3);
else if (e.code.startsWith("Digit")) key = e.code.slice(5);
else if (e.code.startsWith("F") && /^F\d+$/.test(e.code)) key = e.code;
else key = e.key.length === 1 ? e.key.toUpperCase() : e.key;
}
return { mods, key };
}
function allRows(): EditRow[] {
return [...globalsRows, ...commonRows, ...bindingsRows, ...Object.values(layoutRows).flat()];
}
function beginCapture(row: EditRow) {
for (const r of allRows()) r.capturing = false;
row.capturing = true;
}
function syncExtraText(row: EditRow) {
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,
capturing: 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,
capturing: 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(() => {
const onKey = (e: KeyboardEvent) => {
const row = allRows().find((r) => r.capturing);
if (!row) return;
const mapped = hyprFromEvent(e);
if (!mapped) return;
e.preventDefault();
row.mods = mapped.mods.join(", ");
row.modsTouched = true;
row.key = mapped.key;
row.capturing = false;
};
window.addEventListener("keydown", onKey);
void loadKeybinds();
// Register cleanup synchronously so Svelte types the onMount
// callback as returning a function (not a Promise-of-function).
return () => window.removeEventListener("keydown", onKey);
});
async function loadKeybinds() {
try {
const p = await invoke<BindsPayload>("get_keybinds");
kind = p.kind;
// Rust's `#[serde(skip_serializing_if = ...)]` on every one of
// these fields means an empty one is OMITTED from the JSON
// entirely, not sent as `[]`/`{}`/`""` - every field here needs
// 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.</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">
<span class="what">{bindTitle(row)}</span>
<button type="button" class="capture" class:listening={row.capturing} onclick={() => beginCapture(row)}>
{#if row.capturing}
Press a key
{:else if keyChips(row).length}
{#each keyChips(row) as k, ki (`${k}-${ki}`)}
{#if ki > 0}<span class="plus">+</span>{/if}
<span class="kbd">{k}</span>
{/each}
{:else}
Set shortcut
{/if}
</button>
<button type="button" class="remove" onclick={() => onRemove(i)}>Remove</button>
</div>
<div class="bind-bottom">
{@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="Click a shortcut, then press the keys. Save when you are done."
>
<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="Click a shortcut, then press the keys. Save when you are done."
>
{#if layoutOrder.length > 0}
<SelectField label="Active layout" bind:value={activeLayout} options={layoutOrder} />
{/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);
padding: 10px 2px;
border-top: 1px solid var(--line);
}
.bind-card:first-child {
border-top: none;
padding-top: 0;
}
.bind-top,
.bind-bottom {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
flex-wrap: wrap;
}
.what {
flex: 1;
min-width: 12ch;
}
.capture {
display: flex;
align-items: center;
gap: 4px;
background: color-mix(in srgb, var(--fg) 6%, transparent);
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px 10px;
color: inherit;
cursor: pointer;
min-height: 32px;
}
.capture.listening {
border-color: var(--accent);
color: var(--accent);
}
.plus {
opacity: 0.4;
padding: 0 1px;
}
.bind-card input,
.bind-card select {
background-color: var(--bg);
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);
}
.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,274 @@
<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 InfoRow from "$lib/components/InfoRow.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("");
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";
});
let activeNet = $derived(networks?.find((n) => n.active) ?? null);
let nearby = $derived(networks?.filter((n) => !n.active) ?? []);
onMount(async () => {
const info = await invoke<{ radio_enabled: boolean; ethernet: string | null }>("get_network_info");
radioEnabled = info.radio_enabled;
ethernet = info.ethernet;
if (info.radio_enabled) await scan();
});
async function toggleRadio(enabled: boolean) {
radioEnabled = enabled;
await invoke("set_wifi_radio", { enabled });
}
async function scan() {
scanning = true;
try {
networks = await invoke<WifiNetwork[]>("scan_wifi");
} finally {
scanning = false;
status = "";
}
}
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}`;
await scan();
} catch (e) {
status = `Failed: ${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}`;
await scan();
} catch {
status = "Wrong password?";
}
}
function bars(signal: number): number {
if (signal >= 75) return 4;
if (signal >= 50) return 3;
if (signal >= 25) return 2;
return 1;
}
</script>
<ViewScaffold title="Wi-Fi">
<Group title="Radio">
<SwitchField label="Wi-Fi" hint={radioEnabled ? "On" : "Off"} bind:value={() => radioEnabled, (v) => toggleRadio(v)} />
{#if ethernetLabel}
<InfoRow label="Ethernet" value={ethernetLabel} />
{/if}
</Group>
{#if activeNet}
<Group title="This network">
<div class="wifi">
<div class="bars on">
{#each [1, 2, 3, 4] as n (n)}
<b style="height: {3 + n * 3}px" class:lit={bars(activeNet.signal) >= n}></b>
{/each}
</div>
<div class="ssid">
<strong>{activeNet.ssid}</strong>
<small>{activeNet.secured ? "Secured" : "Open"} · {activeNet.signal}%</small>
</div>
<span class="pill on">Connected</span>
</div>
</Group>
{/if}
<Group title="Nearby" wide>
<div class="list">
{#if scanning && networks === null}
<div class="empty">
<Wifi size={36} />
<span>Scanning…</span>
</div>
{:else if networks === null}
<div class="empty">
<Wifi size={36} />
<span>No scan yet</span>
</div>
{:else if nearby.length === 0 && !activeNet}
<div class="empty">
<WifiOff size={36} />
<span>No networks found</span>
</div>
{:else}
{#each nearby as net (net.ssid)}
<div class="wifi">
<div class="bars">
{#each [1, 2, 3, 4] as n (n)}
<b style="height: {3 + n * 3}px" class:lit={bars(net.signal) >= n}></b>
{/each}
</div>
<div class="ssid">
{net.ssid}
<small>{net.known ? "Saved" : net.secured ? "Secured" : "Open"}</small>
</div>
{#if net.secured}<Lock size={14} />{/if}
<button class="btn" onclick={() => connect(net.ssid, net.secured, net.known)}>Connect</button>
</div>
{/each}
{/if}
</div>
{#if pendingSsid}
<div class="pw-row">
<input type="password" bind:value={password} placeholder="Password for {pendingSsid}" />
<button class="btn primary" onclick={connectWithPassword}>Connect</button>
</div>
{/if}
{#if status}
<span class="status">{status}</span>
{/if}
<button class="btn" disabled={scanning} onclick={scan}>{scanning ? "Scanning…" : "Scan"}</button>
</Group>
<Group title="Advanced">
<button class="btn" onclick={() => invoke("open_connection_editor")}>Connection editor</button>
</Group>
</ViewScaffold>
<style>
.list {
min-height: 72px;
max-height: 280px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 20px 0;
opacity: 0.6;
}
.wifi {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 2px;
}
.wifi + .wifi {
border-top: 1px solid var(--line);
}
.bars {
display: flex;
align-items: flex-end;
gap: 2px;
height: 14px;
width: 16px;
flex-shrink: 0;
}
.bars b {
width: 3px;
background: color-mix(in srgb, var(--fg) 22%, transparent);
border-radius: 1px;
display: block;
}
.bars b.lit,
.bars.on b.lit {
background: var(--accent);
}
.ssid {
flex: 1;
min-width: 0;
}
.ssid small {
display: block;
color: var(--muted);
font-size: 11px;
}
.pw-row {
display: flex;
gap: 8px;
margin-top: 8px;
}
.pw-row input {
flex: 1;
background: var(--bg);
border: 1px solid transparent;
border-radius: 10px;
padding: 6px 10px;
}
.status {
display: block;
margin: 8px 0;
color: var(--muted);
font-size: 12px;
}
.btn {
margin-top: 10px;
align-self: flex-start;
}
.wifi .btn {
margin-top: 0;
}
</style>

View file

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

View file

@ -0,0 +1,85 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import SwitchField from "$lib/components/SwitchField.svelte";
import SliderField from "$lib/components/SliderField.svelte";
import LogView from "$lib/components/LogView.svelte";
import { debounce } from "$lib/debounce";
interface NightlightStatus {
installed: boolean;
running: boolean;
enabled: boolean;
temperature: number;
error: string | null;
}
let st = $state<NightlightStatus | null>(null);
let log = $state<string[]>([]);
let busy = $state(false);
let message = $state("");
async function refresh() {
st = await invoke<NightlightStatus>("get_nightlight");
}
onMount(refresh);
async function applyNow() {
if (!st) return;
message = "";
try {
st = await invoke<NightlightStatus>("set_nightlight", {
enabled: st.enabled,
temperature: st.temperature,
});
if (st.error) message = st.error;
} catch (e) {
message = `${e}`;
}
}
const persistTemp = debounce(applyNow, 200);
async function install() {
log = [];
busy = true;
await runStreamed("pacman_install", { packages: ["hyprsunset"] }, (line) => {
log = [...log, line];
});
busy = false;
await refresh();
}
</script>
<ViewScaffold title="Night light">
<Group title="Night light" hint="Warmer screen after dark.">
{#if !st}
<Hint text="Loading…" />
{:else if !st.installed}
<Hint text="hyprsunset is not installed." />
<button class="btn primary" disabled={busy} onclick={install}>Install</button>
{:else}
<SwitchField
label="Night light"
bind:value={() => st!.enabled, (v) => { st!.enabled = v; applyNow(); }}
/>
{#if st.enabled}
<SliderField
label="Warmth"
bind:value={st.temperature}
min={2000}
max={6500}
step={100}
suffix="K"
onChange={persistTemp}
/>
{/if}
{/if}
{#if message}<Hint text={message} />{/if}
</Group>
<LogView lines={log} />
</ViewScaffold>

View file

@ -0,0 +1,149 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import LogView from "$lib/components/LogView.svelte";
interface OptionalItem {
id: string;
title: string;
detail: string;
installed: boolean;
via: string;
}
interface OptionalStatus {
items: OptionalItem[];
flathub: boolean;
}
let st = $state<OptionalStatus | null>(null);
let log = $state<string[]>([]);
let busy = $state(false);
let message = $state("");
async function refresh() {
st = await invoke<OptionalStatus>("get_optional_software");
}
onMount(refresh);
async function stream(command: string, args: Record<string, unknown> = {}) {
log = [];
busy = true;
message = "";
const ok = await runStreamed(command, args, (line) => {
log = [...log, line];
});
busy = false;
if (!ok) message = "Install failed. See the log.";
await refresh();
return ok;
}
async function install(id: string) {
if (id === "breadcast") {
await stream("bakery_install", { name: "breadcast" });
return;
}
if (id === "flatpak") {
const ok = await stream("pacman_install", { packages: ["flatpak"] });
if (ok) {
try {
await invoke("enable_flathub");
message = "Flathub user remote added.";
} catch (e) {
message = `${e}`;
}
await refresh();
}
return;
}
if (id === "office") {
await stream("pacman_install", { packages: ["libreoffice-fresh", "papers"] });
return;
}
if (id === "steam") {
await stream("pacman_install", { packages: ["steam"] });
}
}
</script>
<ViewScaffold title="Optional software">
<Group
title="Curated extras"
hint="Not an AUR browser. breadcast is bakery-only and is not on the ISO. Pacman items use the allowlisted installer."
wide
>
{#if !st}
<Hint text="Loading…" />
{:else}
{#each st.items as item (item.id)}
<div class="card">
<div class="meta">
<strong>{item.title}</strong>
<p>{item.detail}</p>
<span class="via">{item.via}{item.installed ? " · installed" : ""}</span>
</div>
{#if item.installed}
<span class="done">Installed</span>
{:else}
<button class="primary" disabled={busy} onclick={() => install(item.id)}>Install</button>
{/if}
</div>
{/each}
{/if}
{#if message}<Hint text={message} />{/if}
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.card {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px) var(--space-lg, 16px);
margin-bottom: var(--space-sm, 8px);
}
.meta {
flex: 1;
min-width: 0;
}
.meta p {
margin: 4px 0;
opacity: 0.8;
font-size: var(--font-size-secondary, 12px);
}
.via,
.done {
opacity: 0.65;
font-size: var(--font-size-secondary, 12px);
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

@ -0,0 +1,152 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import LogView from "$lib/components/LogView.svelte";
import 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 runStreamed("bakery_update", { name }, appendLine);
busy = false;
await refresh();
}
async function listInstalled() {
log = [];
busy = true;
await runStreamed("bakery_list", {}, appendLine);
busy = false;
}
async function updateAll() {
log = [];
busy = true;
await runStreamed("bakery_update_all", {}, appendLine);
busy = false;
await refresh();
}
async function updateSystem() {
log = [];
busy = true;
await runStreamed("pacman_system_update", {}, 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="Official repos. Needs your password."
>
<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,101 @@
<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 & battery"
lede="Brightness, battery, and charge limits."
>
{#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="Keeps the battery off 100% when this machine stays plugged in."
>
<Row label="Start charging below">
<input type="range" min="0" max="100" bind:value={chargeStart} onchange={() => setChargeThreshold("start", chargeStart)} />
<span class="pct">{chargeStart}%</span>
</Row>
<Row label="Stop charging at">
<input type="range" min="1" max="100" bind:value={chargeEnd} onchange={() => setChargeThreshold("end", chargeEnd)} />
<span class="pct">{chargeEnd}%</span>
</Row>
</Group>
{/if}
<Group
title="TLP"
hint="TLP picks battery vs AC on its own."
>
<InfoRow label="Current profile" value={info.tlp_profile ?? "unknown"} />
</Group>
{/if}
</ViewScaffold>
<style>
input[type="range"] {
width: 180px;
}
.pct {
margin-left: var(--space-sm, 8px);
font-size: var(--font-size-secondary, 12px);
opacity: 0.7;
}
</style>

View file

@ -0,0 +1,147 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import TextField from "$lib/components/TextField.svelte";
import Printer from "@lucide/svelte/icons/printer";
interface PrinterRow {
name: string;
status: string;
enabled: boolean;
is_default: boolean;
}
interface PrintingStatus {
printers: PrinterRow[];
default: string | null;
cups_ok: boolean;
error: string | null;
}
let status = $state<PrintingStatus | null>(null);
let message = $state("");
let newName = $state("");
let newUri = $state("");
async function refresh() {
status = await invoke<PrintingStatus>("get_printers");
}
onMount(refresh);
async function setDefault(name: string) {
message = "";
try {
await invoke("set_default_printer", { name });
message = `${name} is the default printer.`;
await refresh();
} catch (e) {
message = `${e}`;
}
}
async function addIpp() {
message = "";
try {
await invoke("add_ipp_printer", { name: newName.trim(), uri: newUri.trim() });
message = `Added ${newName.trim()}.`;
newName = "";
newUri = "";
await refresh();
} catch (e) {
message = `${e}`;
}
}
</script>
<ViewScaffold title="Printing">
<Group title="Printers" hint="CUPS + Avahi ship on the ISO. Discovery and drivers for odd hardware live in system-config-printer." wide>
{#if !status}
<Hint text="Loading…" />
{:else if status.error}
<EmptyState icon={Printer} title="Couldn't talk to CUPS" hint={status.error} />
{:else if status.printers.length === 0}
<EmptyState icon={Printer} title="No printers yet" hint="Add one below, or open the CUPS printer wizard." />
{:else}
<div class="list">
{#each status.printers as p (p.name)}
<div class="row">
<div class="meta">
<span class="name">{p.name}{p.is_default ? " (default)" : ""}</span>
<span class="status">{p.enabled ? p.status : "disabled"}</span>
</div>
<button disabled={p.is_default} onclick={() => setDefault(p.name)}>Set default</button>
</div>
{/each}
</div>
{/if}
<div class="btn-row">
<button onclick={refresh}>Refresh</button>
<button class="primary" onclick={() => invoke("open_printer_settings")}>Add printer…</button>
</div>
{#if message}<Hint text={message} />{/if}
</Group>
<Group title="IPP Everywhere" hint="For a printer that already speaks IPP. Name must be letters, digits, dash, underscore.">
<TextField label="Name" bind:value={newName} placeholder="Office" />
<TextField label="URI" bind:value={newUri} placeholder="ipp://192.168.1.20/ipp/print" />
<button disabled={!newName.trim() || !newUri.trim()} onclick={addIpp}>Add IPP printer</button>
</Group>
</ViewScaffold>
<style>
.list {
display: flex;
flex-direction: column;
gap: 6px;
}
.row {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
}
.meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.status {
opacity: 0.7;
font-size: var(--font-size-secondary, 12px);
}
.btn-row {
display: flex;
gap: var(--space-sm, 8px);
margin-top: var(--space-sm, 8px);
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View file

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

View file

@ -0,0 +1,190 @@
<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 is not in ALLOW_USERS for snapper.";
} else if (msg.includes("unknown config") || msg.includes("no such file")) {
errorHint = "No snapper config exists for root yet, so nothing is being snapshotted. This should be set up automatically at install.";
} else {
errorHint = `${e}`;
}
snapshots = [];
}
selected = null;
}
onMount(refresh);
function bootIntoSelected() {
if (!selected) return;
if (
confirm(
`Reboot to pick snapshot #${selected} in GRUB? BOS boots snapshots from the GRUB “BOS snapshots” submenu. This does not run snapper rollback.`,
)
) {
invoke("reboot_system");
}
}
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">
<Hint text="Reboot and pick a snapshot in GRUB to undo an update." />
<Group
title="What to pick in GRUB"
hint="Reboot and choose that entry in GRUB."
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}
<div class="row header" aria-hidden="true">
<span class="number">#</span>
<span class="date">Date</span>
<span class="desc">Description</span>
</div>
{#each snapshots as snap (snap.number)}
<button
class="row"
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}>Reboot to pick in GRUB</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.header {
background: transparent;
cursor: default;
opacity: 0.55;
font-size: var(--font-size-secondary, 12px);
padding-top: 0;
}
.row:hover:not(.header) {
background-color: color-mix(in srgb, var(--surface), var(--on-surface) 8%);
}
.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,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 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);
function pick(devices: SoundDevice[], defaultName: string | null): number {
const i = devices.findIndex((d) => d.name === defaultName);
return i >= 0 ? i : 0;
}
async function loadSection(kind: "sinks" | "sources", title: string): Promise<DeviceSection> {
const section = await invoke<{ devices: SoundDevice[]; default_name: string | null }>("get_sound_section", {
kind,
});
return { kind, title, devices: section.devices, selected: pick(section.devices, section.default_name) };
}
onMount(async () => {
try {
output = await loadSection("sinks", "Output");
input = await loadSection("sources", "Input");
} catch (e) {
console.error(e);
output = { kind: "sinks", title: "Output", devices: [], selected: 0 };
input = { kind: "sources", title: "Input", devices: [], selected: 0 };
}
});
function current(section: DeviceSection): SoundDevice | null {
return section.devices[section.selected] ?? section.devices[0] ?? null;
}
async function selectDevice(section: DeviceSection, index: number) {
const device = section.devices[index];
if (!device) return;
section.selected = index;
await invoke("set_default_sound_device", { kind: section.kind, name: device.name });
}
async function setVolume(section: DeviceSection, percent: number) {
const device = current(section);
if (!device) return;
device.percent = percent;
await invoke("set_sound_volume", { kind: section.kind, name: device.name, percent: Math.round(percent) });
}
async function setMute(section: DeviceSection, mute: boolean) {
const device = current(section);
if (!device) return;
device.mute = mute;
await invoke("set_sound_mute", { kind: section.kind, name: device.name, mute });
}
</script>
{#snippet sectionCard(section: DeviceSection)}
<Group title={section.title}>
{#if section.devices.length === 0}
<Hint text="No devices found." />
{:else}
{#each section.devices as d, i (d.name + i)}
<button type="button" class="dev" class:on={section.selected === i} onclick={() => selectDevice(section, i)}>
<span class="dev-name">{d.description || d.name}</span>
{#if section.selected === i}<span class="mark">In use</span>{/if}
</button>
{/each}
{#if current(section)}
<Row label="Volume">
<input
type="range"
min="0"
max="150"
value={Math.round(current(section)!.percent)}
oninput={(e) => setVolume(section, Number(e.currentTarget.value))}
/>
<span class="pct">{Math.round(current(section)!.percent)}%</span>
</Row>
<SwitchField
label="Mute"
bind:value={() => current(section)!.mute, (v) => setMute(section, v)}
/>
{/if}
{/if}
</Group>
{/snippet}
<ViewScaffold title="Sound" lede="Output, input, and volume.">
{#if output}
{@render sectionCard(output)}
{/if}
{#if input}
{@render sectionCard(input)}
{/if}
<Group title="Advanced">
<button class="secondary" onclick={() => invoke("open_mixer")}>Per-app volume</button>
</Group>
</ViewScaffold>
<style>
.dev {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
text-align: left;
background: transparent;
border: none;
border-top: 1px solid var(--line, #ffffff12);
padding: 10px 2px;
color: inherit;
cursor: pointer;
}
.dev:first-child {
border-top: none;
padding-top: 0;
}
.dev.on .dev-name {
font-weight: 600;
}
.dev-name {
flex: 1;
}
.mark {
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
}
input[type="range"] {
width: 180px;
accent-color: var(--accent);
}
.pct {
margin-left: 8px;
font-size: 12px;
opacity: 0.7;
min-width: 4ch;
}
.secondary {
background-color: var(--bg);
color: var(--on-surface);
border: none;
border-radius: 10px;
padding: 8px 16px;
cursor: pointer;
align-self: flex-start;
}
</style>

View file

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

View file

@ -0,0 +1,250 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { runStreamed } from "$lib/streaming";
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
import Group from "$lib/components/Group.svelte";
import Hint from "$lib/components/Hint.svelte";
import EmptyState from "$lib/components/EmptyState.svelte";
import LogView from "$lib/components/LogView.svelte";
import { go } from "$lib/nav.svelte";
import Download from "@lucide/svelte/icons/download";
import Cpu from "@lucide/svelte/icons/cpu";
interface PendingUpdate {
name: string;
current: string;
latest: string;
}
interface FwDevice {
name: string;
version: string;
}
interface NvidiaOffer {
gpu: string;
reason: string;
packages: string[];
installed: boolean;
}
interface UpdatesStatus {
pacman: PendingUpdate[];
pacman_error: string | null;
bakery: PendingUpdate[];
bakery_error: string | null;
firmware: FwDevice[];
nvidia: NvidiaOffer | null;
}
let status = $state<UpdatesStatus | null>(null);
let log = $state<string[]>([]);
let busy = $state(false);
async function refresh() {
status = await invoke<UpdatesStatus>("get_updates_status");
}
onMount(refresh);
function appendLine(line: string) {
log = [...log, line];
}
async function run(command: string, args: Record<string, unknown> = {}) {
log = [];
busy = true;
await runStreamed(command, args, appendLine);
busy = false;
await refresh();
}
</script>
<ViewScaffold title="Updates">
{#if status?.nvidia}
<Group title="NVIDIA driver" wide>
<div class="offer">
<Cpu size={20} />
<div class="offer-text">
<strong>{status.nvidia.gpu}</strong>
<p>{status.nvidia.reason}</p>
{#if status.nvidia.installed}
<Hint text="Driver and Hyprland env drop-in are in place. Reboot to start a working session." />
{:else}
<Hint text="Installs nvidia + nvidia-utils (not cuda) and writes ~/.config/hypr/nvidia.lua. hyprland.lua loads that file only if it exists. Reboot after." />
{/if}
</div>
<button
class="primary"
disabled={busy}
onclick={() => run("nvidia_setup")}
>
{status.nvidia.installed ? "Re-run setup" : "Install driver"}
</button>
</div>
</Group>
{/if}
<Group
title="System packages (pacman)"
hint="The same set Packages covers with pacman -Syu. Needs your password (polkit)."
wide
>
{#if !status}
<Hint text="Loading…" />
{:else if status.pacman_error}
<Hint text={status.pacman_error} />
{:else if status.pacman.length === 0}
<EmptyState icon={Download} title="Pacman is up to date" hint="No pending official-repo upgrades." />
{:else}
<div class="list">
{#each status.pacman as pkg (pkg.name)}
<div class="row">
<span class="name">{pkg.name}</span>
<span class="version">{pkg.current}{pkg.latest}</span>
</div>
{/each}
</div>
{/if}
<div class="btn-row">
<button disabled={busy} class="primary" onclick={() => run("pacman_system_update")}>Update system</button>
<button disabled={busy} onclick={refresh}>Refresh</button>
</div>
</Group>
<Group
title="Bread ecosystem (bakery)"
hint="Bakery packages waiting to update."
wide
>
{#if !status}
<Hint text="Loading…" />
{:else if status.bakery_error}
<Hint text={status.bakery_error} />
{:else if status.bakery.length === 0}
<EmptyState icon={Download} title="Bakery packages are current" hint="Nothing on this track wants an update." />
{:else}
<div class="list">
{#each status.bakery as pkg (pkg.name)}
<div class="row">
<span class="name">{pkg.name}</span>
<span class="version">{pkg.current ? `${pkg.current} ` : ""}{pkg.latest}</span>
<button disabled={busy} onclick={() => run("bakery_update", { name: pkg.name })}>Update</button>
</div>
{/each}
</div>
{/if}
<div class="btn-row">
<button disabled={busy} class="primary" onclick={() => run("bakery_update_all")}>Update all bakery</button>
</div>
</Group>
<Group title="Firmware (fwupd)" hint="Same list as the Firmware page. fwupd-refresh.timer already refreshes metadata in the background." wide>
{#if !status}
<Hint text="Loading…" />
{:else if status.firmware.length === 0}
<EmptyState icon={Download} title="No updatable firmware" hint="Not every device speaks fwupd." />
{:else}
<div class="list">
{#each status.firmware as dev (dev.name)}
<div class="row">
<span class="name">{dev.name}</span>
<span class="version">{dev.version}</span>
</div>
{/each}
</div>
{/if}
<div class="btn-row">
<button disabled={busy} onclick={() => run("fwupd_refresh")}>Check for updates</button>
<button disabled={busy} class="primary" onclick={() => run("fwupd_update")}>Update firmware</button>
</div>
</Group>
<Group
title="Rollback"
hint="Boot a snapshot from GRUB (BOS snapshots)."
>
<Hint text="Pick the snapshot in GRUB. bakery rollback undoes one package." />
<div class="btn-row">
<button onclick={() => go("snapshots")}>Open Snapshots</button>
</div>
</Group>
<LogView lines={log} />
</ViewScaffold>
<style>
.offer {
display: flex;
align-items: flex-start;
gap: var(--space-md, 12px);
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-md, 12px);
}
.offer-text {
flex: 1;
min-width: 0;
}
.offer-text p {
margin: 4px 0;
opacity: 0.8;
}
.list {
max-height: 240px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.row {
display: flex;
align-items: center;
gap: var(--space-md, 12px);
background-color: var(--surface);
border-radius: var(--radius-primary, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
min-width: 0;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.version {
opacity: 0.75;
font-size: var(--font-size-secondary, 12px);
flex-shrink: 0;
}
.btn-row {
display: flex;
gap: var(--space-sm, 8px);
margin-top: var(--space-sm, 8px);
}
button {
background-color: var(--surface);
color: var(--on-surface);
border: none;
border-radius: var(--radius-primary, 8px);
padding: var(--space-xs, 4px) var(--space-md, 12px);
cursor: pointer;
}
button.primary {
background-color: var(--accent);
color: var(--on-accent);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>

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