From bde95e4bd60d44fc905e34cbd968b093bd1f3b13 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 19 Jul 2026 03:51:45 +0800 Subject: [PATCH 01/31] Switch to tag-pinned bread-ecosystem deps; bump version to v0.7.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c08cbe2..e1449b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,7 +28,7 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bos-settings" -version = "0.6.3" +version = "0.7.0" dependencies = [ "async-channel", "bread-theme", diff --git a/Cargo.toml b/Cargo.toml index ee3b09e..e396c3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bos-settings" -version = "0.6.3" +version = "0.7.0" edition = "2021" [dependencies] From 3ebe42790c881f59a9a09b609aa86c88d7e296bf Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 19 Jul 2026 14:07:29 +0800 Subject: [PATCH 02/31] 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. --- src/ui/views/keybinds.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ui/views/keybinds.rs b/src/ui/views/keybinds.rs index ab5a0dc..a8ae2a1 100644 --- a/src/ui/views/keybinds.rs +++ b/src/ui/views/keybinds.rs @@ -564,7 +564,9 @@ fn populate(content: &GBox, model: &Rc>, status: &Label) { Rc::new(move || rerender(&content, &model, &status)) }; - match model.borrow().kind { + let kind = model.borrow().kind; + + match kind { SchemaKind::Unknown => populate_unknown(content, status), SchemaKind::Flat => populate_flat(content, model, status, &rerender), SchemaKind::MultiLayout => populate_multi_layout(content, model, &rerender), From 62365ebf103a863fa9806a075e9da492dcb34b79 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 21 Jul 2026 19:16:38 +0800 Subject: [PATCH 03/31] ci: remove GitHub push-mirror workflow --- .forgejo/workflows/mirror.yml | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .forgejo/workflows/mirror.yml diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index 0fee1b9..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -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/*' From 93c3b88b10175414b09c0a376ecf1d1378066a7c Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 22 Jul 2026 19:23:02 +0800 Subject: [PATCH 04/31] Migrate bos-settings from GTK4 to Tauri + Svelte 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 3 + Cargo.lock | 990 --- Cargo.toml | 21 - frontend/.gitignore | 10 + frontend/README.md | 7 + frontend/package-lock.json | 1942 ++++++ frontend/package.json | 31 + frontend/src/app.html | 13 + .../src/lib/components/ChipPickerField.svelte | 126 + frontend/src/lib/components/CsvField.svelte | 35 + frontend/src/lib/components/EmptyState.svelte | 32 + frontend/src/lib/components/FileField.svelte | 67 + frontend/src/lib/components/Group.svelte | 60 + frontend/src/lib/components/Hint.svelte | 18 + .../src/lib/components/HyprColorField.svelte | 56 + frontend/src/lib/components/InfoRow.svelte | 16 + frontend/src/lib/components/LogView.svelte | 24 + .../src/lib/components/NumberField.svelte | 31 + .../src/lib/components/PasswordField.svelte | 60 + .../src/lib/components/PathListField.svelte | 135 + .../src/lib/components/Placeholder.svelte | 17 + frontend/src/lib/components/Row.svelte | 57 + frontend/src/lib/components/SaveButton.svelte | 55 + .../src/lib/components/SelectField.svelte | 34 + .../src/lib/components/ServiceControl.svelte | 93 + frontend/src/lib/components/Sidebar.svelte | 84 + frontend/src/lib/components/Switch.svelte | 43 + .../src/lib/components/SwitchField.svelte | 10 + frontend/src/lib/components/TagsField.svelte | 104 + frontend/src/lib/components/TextField.svelte | 29 + .../src/lib/components/ViewScaffold.svelte | 40 + frontend/src/lib/sidebar.ts | 87 + frontend/src/lib/streaming.ts | 25 + frontend/src/lib/theme/index.ts | 29 + frontend/src/lib/views/About.svelte | 132 + frontend/src/lib/views/Appearance.svelte | 128 + frontend/src/lib/views/Aur.svelte | 151 + frontend/src/lib/views/Autostart.svelte | 168 + frontend/src/lib/views/Bluetooth.svelte | 188 + frontend/src/lib/views/Bread.svelte | 86 + frontend/src/lib/views/Breadbar.svelte | 178 + frontend/src/lib/views/Breadbox.svelte | 128 + frontend/src/lib/views/Breadclip.svelte | 32 + frontend/src/lib/views/Breadcrumbs.svelte | 276 + frontend/src/lib/views/Breadpad.svelte | 138 + frontend/src/lib/views/Breadpaper.svelte | 200 + frontend/src/lib/views/Breadsearch.svelte | 105 + frontend/src/lib/views/DateTime.svelte | 121 + frontend/src/lib/views/Display.svelte | 152 + frontend/src/lib/views/Firewall.svelte | 205 + frontend/src/lib/views/Firmware.svelte | 140 + frontend/src/lib/views/Keybinds.svelte | 653 ++ frontend/src/lib/views/Network.svelte | 276 + frontend/src/lib/views/Packages.svelte | 152 + frontend/src/lib/views/Power.svelte | 105 + frontend/src/lib/views/Snapshots.svelte | 176 + frontend/src/lib/views/Sound.svelte | 131 + frontend/src/lib/views/Users.svelte | 196 + frontend/src/lib/views/registry.ts | 56 + frontend/src/routes/+layout.ts | 5 + frontend/src/routes/+page.svelte | 61 + frontend/static/favicon.png | Bin 0 -> 1571 bytes frontend/svelte.config.js | 18 + frontend/tsconfig.json | 19 + frontend/vite.config.js | 32 + src-tauri/.gitignore | 7 + src-tauri/Cargo.lock | 5311 +++++++++++++++++ src-tauri/Cargo.toml | 36 + src-tauri/build.rs | 3 + src-tauri/capabilities/default.json | 11 + src-tauri/icons/128x128.png | Bin 0 -> 3512 bytes src-tauri/icons/128x128@2x.png | Bin 0 -> 7012 bytes src-tauri/icons/32x32.png | Bin 0 -> 974 bytes src-tauri/icons/Square107x107Logo.png | Bin 0 -> 2863 bytes src-tauri/icons/Square142x142Logo.png | Bin 0 -> 3858 bytes src-tauri/icons/Square150x150Logo.png | Bin 0 -> 3966 bytes src-tauri/icons/Square284x284Logo.png | Bin 0 -> 7737 bytes src-tauri/icons/Square30x30Logo.png | Bin 0 -> 903 bytes src-tauri/icons/Square310x310Logo.png | Bin 0 -> 8591 bytes src-tauri/icons/Square44x44Logo.png | Bin 0 -> 1299 bytes src-tauri/icons/Square71x71Logo.png | Bin 0 -> 2011 bytes src-tauri/icons/Square89x89Logo.png | Bin 0 -> 2468 bytes src-tauri/icons/StoreLogo.png | Bin 0 -> 1523 bytes src-tauri/icons/icon.icns | Bin 0 -> 98451 bytes src-tauri/icons/icon.ico | Bin 0 -> 86642 bytes src-tauri/icons/icon.png | Bin 0 -> 14183 bytes .../views => src-tauri/src/commands}/about.rs | 117 +- src-tauri/src/commands/appearance.rs | 73 + src-tauri/src/commands/aur.rs | 47 + src-tauri/src/commands/autostart.rs | 59 + src-tauri/src/commands/bluetooth.rs | 123 + src-tauri/src/commands/bread.rs | 80 + src-tauri/src/commands/breadbar.rs | 144 + src-tauri/src/commands/breadbox.rs | 64 + src-tauri/src/commands/breadclip.rs | 8 + src-tauri/src/commands/breadcrumbs.rs | 176 + src-tauri/src/commands/breadpad.rs | 78 + src-tauri/src/commands/breadpaper.rs | 111 + src-tauri/src/commands/breadsearch.rs | 55 + .../src/commands/config.rs | 0 src-tauri/src/commands/datetime.rs | 82 + src-tauri/src/commands/firewall.rs | 89 + src-tauri/src/commands/firmware.rs | 32 + src-tauri/src/commands/hyprland.rs | 110 + src-tauri/src/commands/keybinds.rs | 354 ++ .../views => src-tauri/src/commands}/mod.rs | 4 + src-tauri/src/commands/network.rs | 117 + src-tauri/src/commands/packages.rs | 39 + src-tauri/src/commands/power.rs | 145 + src-tauri/src/commands/service.rs | 77 + src-tauri/src/commands/snapshots.rs | 62 + src-tauri/src/commands/sound.rs | 102 + src-tauri/src/commands/streaming.rs | 57 + src-tauri/src/commands/theme.rs | 74 + src-tauri/src/commands/users.rs | 104 + src-tauri/src/lib.rs | 92 + src-tauri/src/main.rs | 6 + src-tauri/tauri.conf.json | 41 + src/main.rs | 38 - src/theme.rs | 56 - src/ui/mod.rs | 4 - src/ui/sidebar.rs | 157 - src/ui/views/appearance.rs | 221 - src/ui/views/aur.rs | 169 - src/ui/views/autostart.rs | 198 - src/ui/views/bluetooth.rs | 384 -- src/ui/views/bread.rs | 160 - src/ui/views/breadbar.rs | 90 - src/ui/views/breadbox.rs | 210 - src/ui/views/breadclip.rs | 33 - src/ui/views/breadcrumbs.rs | 466 -- src/ui/views/breadpad.rs | 146 - src/ui/views/breadpaper.rs | 299 - src/ui/views/breadsearch.rs | 115 - src/ui/views/datetime.rs | 107 - src/ui/views/firewall.rs | 322 - src/ui/views/firmware.rs | 159 - src/ui/views/hyprland.rs | 287 - src/ui/views/keybinds.rs | 754 --- src/ui/views/network.rs | 328 - src/ui/views/packages.rs | 205 - src/ui/views/power.rs | 193 - src/ui/views/snapshots.rs | 281 - src/ui/views/sound.rs | 167 - src/ui/views/users.rs | 293 - src/ui/widgets.rs | 515 -- src/ui/window.rs | 81 - 147 files changed, 15804 insertions(+), 7516 deletions(-) delete mode 100644 Cargo.lock delete mode 100644 Cargo.toml create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/app.html create mode 100644 frontend/src/lib/components/ChipPickerField.svelte create mode 100644 frontend/src/lib/components/CsvField.svelte create mode 100644 frontend/src/lib/components/EmptyState.svelte create mode 100644 frontend/src/lib/components/FileField.svelte create mode 100644 frontend/src/lib/components/Group.svelte create mode 100644 frontend/src/lib/components/Hint.svelte create mode 100644 frontend/src/lib/components/HyprColorField.svelte create mode 100644 frontend/src/lib/components/InfoRow.svelte create mode 100644 frontend/src/lib/components/LogView.svelte create mode 100644 frontend/src/lib/components/NumberField.svelte create mode 100644 frontend/src/lib/components/PasswordField.svelte create mode 100644 frontend/src/lib/components/PathListField.svelte create mode 100644 frontend/src/lib/components/Placeholder.svelte create mode 100644 frontend/src/lib/components/Row.svelte create mode 100644 frontend/src/lib/components/SaveButton.svelte create mode 100644 frontend/src/lib/components/SelectField.svelte create mode 100644 frontend/src/lib/components/ServiceControl.svelte create mode 100644 frontend/src/lib/components/Sidebar.svelte create mode 100644 frontend/src/lib/components/Switch.svelte create mode 100644 frontend/src/lib/components/SwitchField.svelte create mode 100644 frontend/src/lib/components/TagsField.svelte create mode 100644 frontend/src/lib/components/TextField.svelte create mode 100644 frontend/src/lib/components/ViewScaffold.svelte create mode 100644 frontend/src/lib/sidebar.ts create mode 100644 frontend/src/lib/streaming.ts create mode 100644 frontend/src/lib/theme/index.ts create mode 100644 frontend/src/lib/views/About.svelte create mode 100644 frontend/src/lib/views/Appearance.svelte create mode 100644 frontend/src/lib/views/Aur.svelte create mode 100644 frontend/src/lib/views/Autostart.svelte create mode 100644 frontend/src/lib/views/Bluetooth.svelte create mode 100644 frontend/src/lib/views/Bread.svelte create mode 100644 frontend/src/lib/views/Breadbar.svelte create mode 100644 frontend/src/lib/views/Breadbox.svelte create mode 100644 frontend/src/lib/views/Breadclip.svelte create mode 100644 frontend/src/lib/views/Breadcrumbs.svelte create mode 100644 frontend/src/lib/views/Breadpad.svelte create mode 100644 frontend/src/lib/views/Breadpaper.svelte create mode 100644 frontend/src/lib/views/Breadsearch.svelte create mode 100644 frontend/src/lib/views/DateTime.svelte create mode 100644 frontend/src/lib/views/Display.svelte create mode 100644 frontend/src/lib/views/Firewall.svelte create mode 100644 frontend/src/lib/views/Firmware.svelte create mode 100644 frontend/src/lib/views/Keybinds.svelte create mode 100644 frontend/src/lib/views/Network.svelte create mode 100644 frontend/src/lib/views/Packages.svelte create mode 100644 frontend/src/lib/views/Power.svelte create mode 100644 frontend/src/lib/views/Snapshots.svelte create mode 100644 frontend/src/lib/views/Sound.svelte create mode 100644 frontend/src/lib/views/Users.svelte create mode 100644 frontend/src/lib/views/registry.ts create mode 100644 frontend/src/routes/+layout.ts create mode 100644 frontend/src/routes/+page.svelte create mode 100644 frontend/static/favicon.png create mode 100644 frontend/svelte.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.js create mode 100644 src-tauri/.gitignore create mode 100644 src-tauri/Cargo.lock create mode 100644 src-tauri/Cargo.toml create mode 100644 src-tauri/build.rs create mode 100644 src-tauri/capabilities/default.json create mode 100644 src-tauri/icons/128x128.png create mode 100644 src-tauri/icons/128x128@2x.png create mode 100644 src-tauri/icons/32x32.png create mode 100644 src-tauri/icons/Square107x107Logo.png create mode 100644 src-tauri/icons/Square142x142Logo.png create mode 100644 src-tauri/icons/Square150x150Logo.png create mode 100644 src-tauri/icons/Square284x284Logo.png create mode 100644 src-tauri/icons/Square30x30Logo.png create mode 100644 src-tauri/icons/Square310x310Logo.png create mode 100644 src-tauri/icons/Square44x44Logo.png create mode 100644 src-tauri/icons/Square71x71Logo.png create mode 100644 src-tauri/icons/Square89x89Logo.png create mode 100644 src-tauri/icons/StoreLogo.png create mode 100644 src-tauri/icons/icon.icns create mode 100644 src-tauri/icons/icon.ico create mode 100644 src-tauri/icons/icon.png rename {src/ui/views => src-tauri/src/commands}/about.rs (57%) create mode 100644 src-tauri/src/commands/appearance.rs create mode 100644 src-tauri/src/commands/aur.rs create mode 100644 src-tauri/src/commands/autostart.rs create mode 100644 src-tauri/src/commands/bluetooth.rs create mode 100644 src-tauri/src/commands/bread.rs create mode 100644 src-tauri/src/commands/breadbar.rs create mode 100644 src-tauri/src/commands/breadbox.rs create mode 100644 src-tauri/src/commands/breadclip.rs create mode 100644 src-tauri/src/commands/breadcrumbs.rs create mode 100644 src-tauri/src/commands/breadpad.rs create mode 100644 src-tauri/src/commands/breadpaper.rs create mode 100644 src-tauri/src/commands/breadsearch.rs rename src/config/mod.rs => src-tauri/src/commands/config.rs (100%) create mode 100644 src-tauri/src/commands/datetime.rs create mode 100644 src-tauri/src/commands/firewall.rs create mode 100644 src-tauri/src/commands/firmware.rs create mode 100644 src-tauri/src/commands/hyprland.rs create mode 100644 src-tauri/src/commands/keybinds.rs rename {src/ui/views => src-tauri/src/commands}/mod.rs (86%) create mode 100644 src-tauri/src/commands/network.rs create mode 100644 src-tauri/src/commands/packages.rs create mode 100644 src-tauri/src/commands/power.rs create mode 100644 src-tauri/src/commands/service.rs create mode 100644 src-tauri/src/commands/snapshots.rs create mode 100644 src-tauri/src/commands/sound.rs create mode 100644 src-tauri/src/commands/streaming.rs create mode 100644 src-tauri/src/commands/theme.rs create mode 100644 src-tauri/src/commands/users.rs create mode 100644 src-tauri/src/lib.rs create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/tauri.conf.json delete mode 100644 src/main.rs delete mode 100644 src/theme.rs delete mode 100644 src/ui/mod.rs delete mode 100644 src/ui/sidebar.rs delete mode 100644 src/ui/views/appearance.rs delete mode 100644 src/ui/views/aur.rs delete mode 100644 src/ui/views/autostart.rs delete mode 100644 src/ui/views/bluetooth.rs delete mode 100644 src/ui/views/bread.rs delete mode 100644 src/ui/views/breadbar.rs delete mode 100644 src/ui/views/breadbox.rs delete mode 100644 src/ui/views/breadclip.rs delete mode 100644 src/ui/views/breadcrumbs.rs delete mode 100644 src/ui/views/breadpad.rs delete mode 100644 src/ui/views/breadpaper.rs delete mode 100644 src/ui/views/breadsearch.rs delete mode 100644 src/ui/views/datetime.rs delete mode 100644 src/ui/views/firewall.rs delete mode 100644 src/ui/views/firmware.rs delete mode 100644 src/ui/views/hyprland.rs delete mode 100644 src/ui/views/keybinds.rs delete mode 100644 src/ui/views/network.rs delete mode 100644 src/ui/views/packages.rs delete mode 100644 src/ui/views/power.rs delete mode 100644 src/ui/views/snapshots.rs delete mode 100644 src/ui/views/sound.rs delete mode 100644 src/ui/views/users.rs delete mode 100644 src/ui/widgets.rs delete mode 100644 src/ui/window.rs diff --git a/.gitignore b/.gitignore index 45cd856..19b9179 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ logs/ # Claude Code local agent state .claude/ + +# Local hygiene notes (not for commit) +CLAUDE.md diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index e1449b5..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,990 +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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "bos-settings" -version = "0.7.0" -dependencies = [ - "async-channel", - "bread-theme", - "bread-utils", - "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 = "bread-utils" -version = "0.3.0" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" -dependencies = [ - "dirs", - "serde", - "serde_json", - "toml_edit 0.22.27", -] - -[[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.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[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.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" - -[[package]] -name = "futures-executor" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" - -[[package]] -name = "futures-macro" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-task" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" - -[[package]] -name = "futures-util" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" -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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[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.13+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.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -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.3+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.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" -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.4", -] - -[[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.13+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.4", -] - -[[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.4", -] - -[[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.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index e396c3f..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "bos-settings" -version = "0.7.0" -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" -# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["toml"] } diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..6635cf5 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..858d179 --- /dev/null +++ b/frontend/README.md @@ -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). diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..6f1479c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1942 @@ +{ + "name": "bos-settings-frontend", + "version": "0.8.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bos-settings-frontend", + "version": "0.8.0", + "license": "MIT", + "dependencies": { + "@lucide/svelte": "^1.25.0", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-opener": "^2" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.6", + "@sveltejs/kit": "^2.9.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tauri-apps/cli": "^2", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "~5.6.2", + "vite": "^6.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lucide/svelte": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz", + "integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==", + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz", + "integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", + "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", + "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz", + "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", + "integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.7", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.7.tgz", + "integrity": "sha512-5qERUZX80oQj6XrDMUmD2Uhd/cIpCPDWWKBK3ZHmyRUC9apPyamWM8xMo31mbWsIQxwG2hVoSnOJ/EcnhVkkzQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz", + "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..08d2563 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "bos-settings-frontend", + "version": "0.8.0", + "description": "Frontend for BOS Settings", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "tauri": "tauri" + }, + "license": "MIT", + "dependencies": { + "@lucide/svelte": "^1.25.0", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.2", + "@tauri-apps/plugin-opener": "^2" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.6", + "@sveltejs/kit": "^2.9.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tauri-apps/cli": "^2", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "~5.6.2", + "vite": "^6.0.3" + } +} diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..d367d13 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,13 @@ + + + + + + + BOS Settings + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib/components/ChipPickerField.svelte b/frontend/src/lib/components/ChipPickerField.svelte new file mode 100644 index 0000000..0e790c0 --- /dev/null +++ b/frontend/src/lib/components/ChipPickerField.svelte @@ -0,0 +1,126 @@ + + +
+
+ {label} +
+
+ {#each value as name (name)} + + {name} + + + {/each} + {#if adding} + {#if available.length > 0} + + + {:else} + {emptyOptionsHint} + {/if} + {:else if available.length > 0} + + {/if} +
+
+ + diff --git a/frontend/src/lib/components/CsvField.svelte b/frontend/src/lib/components/CsvField.svelte new file mode 100644 index 0000000..b515002 --- /dev/null +++ b/frontend/src/lib/components/CsvField.svelte @@ -0,0 +1,35 @@ + + + + + + + diff --git a/frontend/src/lib/components/EmptyState.svelte b/frontend/src/lib/components/EmptyState.svelte new file mode 100644 index 0000000..357d48f --- /dev/null +++ b/frontend/src/lib/components/EmptyState.svelte @@ -0,0 +1,32 @@ + + +
+ + {title} + {hint} +
+ + diff --git a/frontend/src/lib/components/FileField.svelte b/frontend/src/lib/components/FileField.svelte new file mode 100644 index 0000000..e320be6 --- /dev/null +++ b/frontend/src/lib/components/FileField.svelte @@ -0,0 +1,67 @@ + + + +
+ + +
+
+ + diff --git a/frontend/src/lib/components/Group.svelte b/frontend/src/lib/components/Group.svelte new file mode 100644 index 0000000..5a6caae --- /dev/null +++ b/frontend/src/lib/components/Group.svelte @@ -0,0 +1,60 @@ + + +
+

{title}

+ {#if hint}

{hint}

{/if} +
+ {@render children()} +
+
+ + diff --git a/frontend/src/lib/components/Hint.svelte b/frontend/src/lib/components/Hint.svelte new file mode 100644 index 0000000..450e2ad --- /dev/null +++ b/frontend/src/lib/components/Hint.svelte @@ -0,0 +1,18 @@ + + +

{text}

+ + diff --git a/frontend/src/lib/components/HyprColorField.svelte b/frontend/src/lib/components/HyprColorField.svelte new file mode 100644 index 0000000..115d4d7 --- /dev/null +++ b/frontend/src/lib/components/HyprColorField.svelte @@ -0,0 +1,56 @@ + + + +
+ update(e.currentTarget.value, parsed.alpha)} /> + update(parsed.hex, Number(e.currentTarget.value))} + /> +
+
+ + diff --git a/frontend/src/lib/components/InfoRow.svelte b/frontend/src/lib/components/InfoRow.svelte new file mode 100644 index 0000000..f714f94 --- /dev/null +++ b/frontend/src/lib/components/InfoRow.svelte @@ -0,0 +1,16 @@ + + + + {value} + + + diff --git a/frontend/src/lib/components/LogView.svelte b/frontend/src/lib/components/LogView.svelte new file mode 100644 index 0000000..a3206b5 --- /dev/null +++ b/frontend/src/lib/components/LogView.svelte @@ -0,0 +1,24 @@ + + +{#if lines.length > 0} +
{lines.join("\n")}
+{/if} + + diff --git a/frontend/src/lib/components/NumberField.svelte b/frontend/src/lib/components/NumberField.svelte new file mode 100644 index 0000000..7d0f8ac --- /dev/null +++ b/frontend/src/lib/components/NumberField.svelte @@ -0,0 +1,31 @@ + + + + + + + diff --git a/frontend/src/lib/components/PasswordField.svelte b/frontend/src/lib/components/PasswordField.svelte new file mode 100644 index 0000000..fb47eff --- /dev/null +++ b/frontend/src/lib/components/PasswordField.svelte @@ -0,0 +1,60 @@ + + + +
+ + +
+
+ + diff --git a/frontend/src/lib/components/PathListField.svelte b/frontend/src/lib/components/PathListField.svelte new file mode 100644 index 0000000..ac56678 --- /dev/null +++ b/frontend/src/lib/components/PathListField.svelte @@ -0,0 +1,135 @@ + + +
+
+ {label} + +
+ {#if hint}

{hint}

{/if} + {#if value.length === 0} +

None added.

+ {:else} +
+ {#each value as path (path)} + + {shorten(path)} + + + {/each} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/Placeholder.svelte b/frontend/src/lib/components/Placeholder.svelte new file mode 100644 index 0000000..0f37632 --- /dev/null +++ b/frontend/src/lib/components/Placeholder.svelte @@ -0,0 +1,17 @@ + + +
+

"{page}" hasn't been migrated to Tauri yet.

+
+ + diff --git a/frontend/src/lib/components/Row.svelte b/frontend/src/lib/components/Row.svelte new file mode 100644 index 0000000..b04dbd7 --- /dev/null +++ b/frontend/src/lib/components/Row.svelte @@ -0,0 +1,57 @@ + + +
+ {label} +
+ {@render children()} +
+
+ + diff --git a/frontend/src/lib/components/SaveButton.svelte b/frontend/src/lib/components/SaveButton.svelte new file mode 100644 index 0000000..5dcd916 --- /dev/null +++ b/frontend/src/lib/components/SaveButton.svelte @@ -0,0 +1,55 @@ + + +
+ + {status} +
+ + diff --git a/frontend/src/lib/components/SelectField.svelte b/frontend/src/lib/components/SelectField.svelte new file mode 100644 index 0000000..2dc50c6 --- /dev/null +++ b/frontend/src/lib/components/SelectField.svelte @@ -0,0 +1,34 @@ + + + + + + + diff --git a/frontend/src/lib/components/ServiceControl.svelte b/frontend/src/lib/components/ServiceControl.svelte new file mode 100644 index 0000000..1c81d29 --- /dev/null +++ b/frontend/src/lib/components/ServiceControl.svelte @@ -0,0 +1,93 @@ + + + + + {active ? "Running" : "Stopped"} + + + {enabled ? "Yes" : "No"} + +
+ + + +
+ {#if hasConfig} + + {/if} +
+ + diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..95fb43e --- /dev/null +++ b/frontend/src/lib/components/Sidebar.svelte @@ -0,0 +1,84 @@ + + + + + diff --git a/frontend/src/lib/components/Switch.svelte b/frontend/src/lib/components/Switch.svelte new file mode 100644 index 0000000..e2504d3 --- /dev/null +++ b/frontend/src/lib/components/Switch.svelte @@ -0,0 +1,43 @@ + + + + + diff --git a/frontend/src/lib/components/SwitchField.svelte b/frontend/src/lib/components/SwitchField.svelte new file mode 100644 index 0000000..a49caaf --- /dev/null +++ b/frontend/src/lib/components/SwitchField.svelte @@ -0,0 +1,10 @@ + + + + + diff --git a/frontend/src/lib/components/TagsField.svelte b/frontend/src/lib/components/TagsField.svelte new file mode 100644 index 0000000..ee7793d --- /dev/null +++ b/frontend/src/lib/components/TagsField.svelte @@ -0,0 +1,104 @@ + + +
+
+ {label} +
+
+ {#each value as name (name)} + + {name} + + + {/each} + +
+
+ + diff --git a/frontend/src/lib/components/TextField.svelte b/frontend/src/lib/components/TextField.svelte new file mode 100644 index 0000000..62fa918 --- /dev/null +++ b/frontend/src/lib/components/TextField.svelte @@ -0,0 +1,29 @@ + + + + + + + diff --git a/frontend/src/lib/components/ViewScaffold.svelte b/frontend/src/lib/components/ViewScaffold.svelte new file mode 100644 index 0000000..bb27e82 --- /dev/null +++ b/frontend/src/lib/components/ViewScaffold.svelte @@ -0,0 +1,40 @@ + + +
+

{title}

+
+ {@render children()} +
+
+ + diff --git a/frontend/src/lib/sidebar.ts b/frontend/src/lib/sidebar.ts new file mode 100644 index 0000000..7e945ed --- /dev/null +++ b/frontend/src/lib/sidebar.ts @@ -0,0 +1,87 @@ +// Ports src/ui/sidebar.rs's declarative item lists verbatim. Grouped by task, +// not "app vs system internals" — a user thinks "I want to change my Wi-Fi", +// not "which of these is a bread-ecosystem app" (why breadcrumbs/Wi-Fi +// Profiles lives in System, not Personalization). + +import type { Component } from "svelte"; +import Wifi from "@lucide/svelte/icons/wifi"; +import Network from "@lucide/svelte/icons/network"; +import Bluetooth from "@lucide/svelte/icons/bluetooth"; +import Shield from "@lucide/svelte/icons/shield"; +import Volume2 from "@lucide/svelte/icons/volume-2"; +import BatteryFull from "@lucide/svelte/icons/battery-full"; +import Clock from "@lucide/svelte/icons/clock"; +import Monitor from "@lucide/svelte/icons/monitor"; +import Keyboard from "@lucide/svelte/icons/keyboard"; +import Rocket from "@lucide/svelte/icons/rocket"; +import Users from "@lucide/svelte/icons/users"; +import Palette from "@lucide/svelte/icons/palette"; +import Image from "@lucide/svelte/icons/image"; +import LayoutGrid from "@lucide/svelte/icons/layout-grid"; +import Grid3x3 from "@lucide/svelte/icons/grid-3x3"; +import Clipboard from "@lucide/svelte/icons/clipboard"; +import NotebookPen from "@lucide/svelte/icons/notebook-pen"; +import Search from "@lucide/svelte/icons/search"; +import Cog from "@lucide/svelte/icons/cog"; +import Package from "@lucide/svelte/icons/package"; +import RefreshCw from "@lucide/svelte/icons/refresh-cw"; +import History from "@lucide/svelte/icons/history"; +import Info from "@lucide/svelte/icons/info"; + +export interface SidebarItem { + /** Must match a key in the view component map (see routing in +page.svelte). */ + id: string; + label: string; + /** Dim second line — the underlying binary/config name, for items whose + * human label doesn't already make that obvious. */ + sublabel?: string; + icon: Component; +} + +export const SYSTEM_ITEMS: SidebarItem[] = [ + { id: "network", label: "Network", icon: Wifi }, + { id: "breadcrumbs", label: "Wi-Fi Profiles", sublabel: "breadcrumbs", icon: Network }, + { id: "bluetooth", label: "Bluetooth", icon: Bluetooth }, + { id: "firewall", label: "Firewall", icon: Shield }, + { id: "sound", label: "Sound", icon: Volume2 }, + { id: "power", label: "Power", icon: BatteryFull }, + { id: "datetime", label: "Date & Time", icon: Clock }, + { id: "hyprland", label: "Display", sublabel: "monitors.json", icon: Monitor }, + { id: "keybinds", label: "Keybinds", sublabel: "binds.json", icon: Keyboard }, + { id: "autostart", label: "Startup Apps", sublabel: "autostart.json", icon: Rocket }, + { id: "users", label: "Users", icon: Users }, +]; + +export const PERSONALIZATION_ITEMS: SidebarItem[] = [ + { id: "appearance", label: "Appearance", sublabel: "settings.json", icon: Palette }, + { id: "breadpaper", label: "Wallpaper", sublabel: "breadpaper", icon: Image }, + { id: "breadbar", label: "Bar", sublabel: "breadbar", icon: LayoutGrid }, + { id: "breadbox", label: "Launcher", sublabel: "breadbox", icon: Grid3x3 }, + { id: "breadclip", label: "Clipboard", sublabel: "breadclipd", icon: Clipboard }, + { id: "breadpad", label: "Notes", sublabel: "breadpad", icon: NotebookPen }, + { id: "breadsearch", label: "File Search", sublabel: "breadsearch", icon: Search }, + { id: "bread", label: "Daemon", sublabel: "breadd", icon: Cog }, +]; + +export const MAINTENANCE_ITEMS: SidebarItem[] = [ + { id: "packages", label: "Packages", icon: Package }, + { id: "aur", label: "AUR", icon: Search }, + { id: "firmware", label: "Firmware", icon: RefreshCw }, + { id: "snapshots", label: "Snapshots", icon: History }, +]; + +export const ABOUT_ITEMS: SidebarItem[] = [{ id: "about", label: "About", icon: Info }]; + +export interface SidebarSection { + title: string | null; + items: SidebarItem[]; +} + +export const SIDEBAR_SECTIONS: SidebarSection[] = [ + { title: "System", items: SYSTEM_ITEMS }, + { title: "Personalization", items: PERSONALIZATION_ITEMS }, + { title: "Maintenance", items: MAINTENANCE_ITEMS }, + { title: null, items: ABOUT_ITEMS }, +]; + +export const DEFAULT_PAGE = "about"; diff --git a/frontend/src/lib/streaming.ts b/frontend/src/lib/streaming.ts new file mode 100644 index 0000000..cd285f7 --- /dev/null +++ b/frontend/src/lib/streaming.ts @@ -0,0 +1,25 @@ +// Frontend half of the event-streaming command pattern (see +// src-tauri/src/commands/streaming.rs) — runs a command, appends each +// stdout/stderr line to a reactive log as it arrives, and resolves once the +// process exits with whether it succeeded. + +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; + +export async function runStreamingCommand( + program: string, + args: string[], + onLine: (line: string) => void, +): Promise { + 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("run_streaming_command", { sessionId, program, args }); + } finally { + unlisten(); + } +} diff --git a/frontend/src/lib/theme/index.ts b/frontend/src/lib/theme/index.ts new file mode 100644 index 0000000..a358759 --- /dev/null +++ b/frontend/src/lib/theme/index.ts @@ -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 { + const css = await invoke("get_theme_css"); + applyThemeCss(css); + + await listen("theme-changed", (event) => { + applyThemeCss(event.payload); + }); +} diff --git a/frontend/src/lib/views/About.svelte b/frontend/src/lib/views/About.svelte new file mode 100644 index 0000000..894aca2 --- /dev/null +++ b/frontend/src/lib/views/About.svelte @@ -0,0 +1,132 @@ + + + + {#if info} + + + + + + + + + + + +
+ + +
+ {#if status} + {status} + {/if} +
+ {/if} +
+ + diff --git a/frontend/src/lib/views/Appearance.svelte b/frontend/src/lib/views/Appearance.svelte new file mode 100644 index 0000000..b9d7541 --- /dev/null +++ b/frontend/src/lib/views/Appearance.svelte @@ -0,0 +1,128 @@ + + + + {#if cfg} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/if} + + + diff --git a/frontend/src/lib/views/Aur.svelte b/frontend/src/lib/views/Aur.svelte new file mode 100644 index 0000000..11c035f --- /dev/null +++ b/frontend/src/lib/views/Aur.svelte @@ -0,0 +1,151 @@ + + + + +
+ e.key === "Enter" && search()} /> + +
+ + + +
+ {#if results && results.length === 0} + + {:else if results} + {#each results as r (r.name)} +
+
+ {r.name} + {r.version} + +
+ {r.description} +
+ {/each} + {/if} +
+
+
+ + diff --git a/frontend/src/lib/views/Autostart.svelte b/frontend/src/lib/views/Autostart.svelte new file mode 100644 index 0000000..5bc5248 --- /dev/null +++ b/frontend/src/lib/views/Autostart.svelte @@ -0,0 +1,168 @@ + + + + {#if entries} + + {#each entries as entry, i (i)} +
+ +
+ + +
+ + +
+ {/each} + + + +
+ {/if} +
+ + diff --git a/frontend/src/lib/views/Bluetooth.svelte b/frontend/src/lib/views/Bluetooth.svelte new file mode 100644 index 0000000..49d5962 --- /dev/null +++ b/frontend/src/lib/views/Bluetooth.svelte @@ -0,0 +1,188 @@ + + + + {#if powered === null} + + {:else} + + powered ?? false, (v) => togglePower(v)} /> + + + +
+ {#if paired === null} + + {:else if paired.length === 0} + + {:else} + {#each paired as dev (dev.address)} +
+ {dev.name}{dev.connected ? " (connected)" : ""} + + +
+ {/each} + {/if} +
+
+ + +
+ {#if scanResults === null} + + {:else if scanResults.length === 0} + + {:else} + {#each scanResults as dev (dev.address)} +
+ {dev.name} + +
+ {/each} + {/if} +
+ +
+ + + {/if} +
+ + diff --git a/frontend/src/lib/views/Bread.svelte b/frontend/src/lib/views/Bread.svelte new file mode 100644 index 0000000..e4e4d87 --- /dev/null +++ b/frontend/src/lib/views/Bread.svelte @@ -0,0 +1,86 @@ + + + + + + {#if cfg} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/if} + diff --git a/frontend/src/lib/views/Breadbar.svelte b/frontend/src/lib/views/Breadbar.svelte new file mode 100644 index 0000000..ee844ef --- /dev/null +++ b/frontend/src/lib/views/Breadbar.svelte @@ -0,0 +1,178 @@ + + + + {#if style} + + + + + + + + + + + + + +
+ + {Math.round(style.workspace_inactive_opacity * 100)}% +
+
+
+ + + + + + + + + {/if} + + + + + {#if advancedOpen} + +
+ + {cssStatus} +
+ {/if} +
+
+ + diff --git a/frontend/src/lib/views/Breadbox.svelte b/frontend/src/lib/views/Breadbox.svelte new file mode 100644 index 0000000..db0a5ef --- /dev/null +++ b/frontend/src/lib/views/Breadbox.svelte @@ -0,0 +1,128 @@ + + + + {#if contexts} + + {#if contexts.length === 0} +
No launcher contexts yet. Add one to control which apps/categories breadbox surfaces first.
+ {/if} + {#each contexts as ctx, i (i)} +
+ + setPriority(ctx, e.currentTarget.value)} + placeholder="firefox, code, Development, ..." + class="priority" + /> + +
+ {/each} + +
+ + + {/if} + + +
+ + diff --git a/frontend/src/lib/views/Breadclip.svelte b/frontend/src/lib/views/Breadclip.svelte new file mode 100644 index 0000000..4f3c2ef --- /dev/null +++ b/frontend/src/lib/views/Breadclip.svelte @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/frontend/src/lib/views/Breadcrumbs.svelte b/frontend/src/lib/views/Breadcrumbs.svelte new file mode 100644 index 0000000..5c85e17 --- /dev/null +++ b/frontend/src/lib/views/Breadcrumbs.svelte @@ -0,0 +1,276 @@ + + + + + + {#if cfg} + + {#if profileNames.length > 0} + + {:else} + + {/if} + + + + + + + + + +
+ {#each cfg.networks as net, i (i)} +
+ + +
+ + +
+ +
+ {/each} +
+ +
+ + + {#each cfg.profiles as profile, i (i)} +
+
+ + +
+ + + + + + +
+ + +
+
+ + +
+
+ {/each} + +
+ + + {/if} +
+ + diff --git a/frontend/src/lib/views/Breadpad.svelte b/frontend/src/lib/views/Breadpad.svelte new file mode 100644 index 0000000..e3eb6e3 --- /dev/null +++ b/frontend/src/lib/views/Breadpad.svelte @@ -0,0 +1,138 @@ + + + + {#if cfg} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/if} + + + diff --git a/frontend/src/lib/views/Breadpaper.svelte b/frontend/src/lib/views/Breadpaper.svelte new file mode 100644 index 0000000..745ff65 --- /dev/null +++ b/frontend/src/lib/views/Breadpaper.svelte @@ -0,0 +1,200 @@ + + + + +
+ {#if currentPath} + Current wallpaper + {currentPath.split("/").pop()} + {:else} +
No wallpaper set
+ {/if} +
+ +
+ + {status} +
+
+ + + {#if library === null} + + {:else if library.length === 0} +
Nothing under {libraryDir} — use Choose image… above instead.
+ {:else} +
+ {#each library as item (item.path)} + + {/each} +
+ {/if} +
+
+ + diff --git a/frontend/src/lib/views/Breadsearch.svelte b/frontend/src/lib/views/Breadsearch.svelte new file mode 100644 index 0000000..c46fec9 --- /dev/null +++ b/frontend/src/lib/views/Breadsearch.svelte @@ -0,0 +1,105 @@ + + + + + + {#if cfg} + + + + + + + + + + + + + + + + + + + + + + + + + {/if} + + + diff --git a/frontend/src/lib/views/DateTime.svelte b/frontend/src/lib/views/DateTime.svelte new file mode 100644 index 0000000..fa92944 --- /dev/null +++ b/frontend/src/lib/views/DateTime.svelte @@ -0,0 +1,121 @@ + + + + {#if info} + + + + + + {#if info.timezones.length === 0} + + {:else} + + + + {#if tzStatus} + {tzStatus} + {/if} + {/if} + + + + + + + + {/if} + + + diff --git a/frontend/src/lib/views/Display.svelte b/frontend/src/lib/views/Display.svelte new file mode 100644 index 0000000..296bc3d --- /dev/null +++ b/frontend/src/lib/views/Display.svelte @@ -0,0 +1,152 @@ + + + + + {#if liveMonitors && liveMonitors.length > 0} + {#each liveMonitors as m (m.name)} + + {/each} + {:else} + + {/if} + + + + + + + + {#if rules} + + {#each rules as rule, i (i)} +
+ + + + + +
+ {/each} + + + +
+ {/if} +
+ + diff --git a/frontend/src/lib/views/Firewall.svelte b/frontend/src/lib/views/Firewall.svelte new file mode 100644 index 0000000..9bbdca0 --- /dev/null +++ b/frontend/src/lib/views/Firewall.svelte @@ -0,0 +1,205 @@ + + + + + + + + + + +
+ + +
+
+ + +
+ {#if status === "unloaded"} + + {:else if "error" in status} + + {:else if status.rules.length === 0} + + {:else} + {#each status.rules as rule (rule.number)} +
+ {rule.text} + +
+ {/each} + {/if} +
+ + +
+ + +
+ + diff --git a/frontend/src/lib/views/Firmware.svelte b/frontend/src/lib/views/Firmware.svelte new file mode 100644 index 0000000..36c9fe9 --- /dev/null +++ b/frontend/src/lib/views/Firmware.svelte @@ -0,0 +1,140 @@ + + + + +
+ {#if devices === null} + + {:else if devices.length === 0} + + {:else} + {#each devices as dev (dev.name)} +
+ {dev.name} + {dev.version} +
+ {/each} + {/if} +
+ +
+ + + +
+
+ + +
+ + diff --git a/frontend/src/lib/views/Keybinds.svelte b/frontend/src/lib/views/Keybinds.svelte new file mode 100644 index 0000000..dc710cf --- /dev/null +++ b/frontend/src/lib/views/Keybinds.svelte @@ -0,0 +1,653 @@ + + +{#snippet extraFields(row: EditRow)} + {#if row.action === "exec"} + setExtra(row, "command", e.currentTarget.value)} + /> + + + {:else if row.action === "focus" || row.action === "move"} + setExtra(row, "workspace", parseWorkspaceValue(e.currentTarget.value))} + /> + {:else if row.action === "move_dir"} + + {:else if row.action === "resize_dir"} + setExtra(row, "x", e.currentTarget.value === "" ? undefined : Number(e.currentTarget.value))} + /> + setExtra(row, "y", e.currentTarget.value === "" ? undefined : Number(e.currentTarget.value))} + /> + + {:else if row.action === "layout"} + setExtra(row, "layout", e.currentTarget.value)} + /> + {:else if row.action === "drag"} + Mouse-drag bind — nothing else to set. + {:else if row.action === "close" || row.action === "fullscreen" || row.action === "float" || row.action === "pseudo" || row.action === "resize" || row.action === "focus_last" || row.action === "exit"} + No extra options for this action. + {:else} + onExtraInput(row, e.currentTarget.value)} + /> + {/if} + + {#if KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number]) && row.action !== "drag"} + + {/if} +{/snippet} + +{#snippet section(rows: EditRow[], onAdd: () => void, onRemove: (i: number) => void)} +
+ {#each rows as row, i (i)} +
+
+ (row.modsTouched = true)} + /> + + + {#if !KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number])} + + {/if} + +
+
+ {@render extraFields(row)} +
+ {#if row.advancedOpen && KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number]) && row.action !== "drag"} + onExtraInput(row, e.currentTarget.value)} + /> + {/if} +
+ {/each} +
+ +{/snippet} + + + {#if loadError} + + + + {/if} + {#if loaded} + {#if kind === "unknown"} + + + + {:else if kind === "flat"} + + + + + + {@render section( + bindingsRows, + () => bindingsRows.push(emptyRow()), + (i) => bindingsRows.splice(i, 1) + )} + + + + {:else} + + {#if layoutOrder.length > 0} + + {/if} + + + + + {@render section( + globalsRows, + () => globalsRows.push(emptyRow()), + (i) => globalsRows.splice(i, 1) + )} + + + + {@render section( + commonRows, + () => commonRows.push(emptyRow()), + (i) => commonRows.splice(i, 1) + )} + + + {#each layoutOrder as name (name)} + + + {@render section( + layoutRows[name] ?? [], + () => (layoutRows[name] ?? (layoutRows[name] = [])).push(emptyRow()), + (i) => layoutRows[name]?.splice(i, 1) + )} + + {/each} + + +
+ + +
+
+ + + {/if} + {/if} +
+ + diff --git a/frontend/src/lib/views/Network.svelte b/frontend/src/lib/views/Network.svelte new file mode 100644 index 0000000..0a8e788 --- /dev/null +++ b/frontend/src/lib/views/Network.svelte @@ -0,0 +1,276 @@ + + + + + + + + + + {#if ethernetLabel} + + + + {/if} + + +
+ {#if networks === null} +
+ + Not scanned yet + Press Scan to see nearby networks. +
+ {:else if networks.length === 0} +
+ + No networks found + Try Scan again, or check Wi-Fi radio is on. +
+ {:else} + {#each networks as net (net.ssid)} +
+ {net.ssid}{net.active ? " (connected)" : ""} + {#if net.secured}{/if} + {signalLabel(net.signal)} + {#if !net.active} + + {/if} +
+ {/each} + {/if} +
+ + {#if pendingSsid} +
+ + +
+ {/if} + {#if status} + {status} + {/if} + + +
+ + + + +
+ + diff --git a/frontend/src/lib/views/Packages.svelte b/frontend/src/lib/views/Packages.svelte new file mode 100644 index 0000000..fd3d92f --- /dev/null +++ b/frontend/src/lib/views/Packages.svelte @@ -0,0 +1,152 @@ + + + + +
+ {#if packages === null} + + {:else if packages.length === 0} + + {:else} + {#each packages as pkg (pkg.name)} +
+ {pkg.name} + {pkg.version} + +
+ {/each} + {/if} +
+ +
+ + +
+
+ + + + + + +
+ + diff --git a/frontend/src/lib/views/Power.svelte b/frontend/src/lib/views/Power.svelte new file mode 100644 index 0000000..d81ea9c --- /dev/null +++ b/frontend/src/lib/views/Power.svelte @@ -0,0 +1,105 @@ + + + + {#if info} + + {#each info.battery as [label, value] (label)} + + {/each} + + + + + {#if info.brightness_pct !== null} + + setBrightness(brightness)} /> + {brightness}% + + {:else} + + {/if} + + + {#if info.charge_start !== null && info.charge_end !== null} + + + setChargeThreshold("start", chargeStart)} /> + + + setChargeThreshold("end", chargeEnd)} /> + + + {/if} + + + + + {/if} + + + diff --git a/frontend/src/lib/views/Snapshots.svelte b/frontend/src/lib/views/Snapshots.svelte new file mode 100644 index 0000000..8687a00 --- /dev/null +++ b/frontend/src/lib/views/Snapshots.svelte @@ -0,0 +1,176 @@ + + + + +
+ {#if snapshots === "loading"} + + {:else if errorHint} + + {:else if snapshots.length === 0} + + {:else} + {#each snapshots as snap (snap.number)} + + {/each} + {/if} +
+ +
+ + + +
+
+
+ + diff --git a/frontend/src/lib/views/Sound.svelte b/frontend/src/lib/views/Sound.svelte new file mode 100644 index 0000000..b863d45 --- /dev/null +++ b/frontend/src/lib/views/Sound.svelte @@ -0,0 +1,131 @@ + + +{#snippet deviceSection(section: DeviceSection | null)} + {#if section} + + {#if section.devices.length === 0} + + {:else} + + + + + setVolume(section, Number(e.currentTarget.value))} + /> + {section.devices[section.selected].percent}% + + section.devices[section.selected].mute, + (v) => setMute(section, v) + } + /> + {/if} + + {/if} +{/snippet} + + + {@render deviceSection(output)} + {@render deviceSection(input)} + + + + + + + diff --git a/frontend/src/lib/views/Users.svelte b/frontend/src/lib/views/Users.svelte new file mode 100644 index 0000000..08868f5 --- /dev/null +++ b/frontend/src/lib/views/Users.svelte @@ -0,0 +1,196 @@ + + + + +
+ {#if accounts} + {#each accounts as acc (acc.username)} +
+
+ {acc.username}{acc.full_name ? ` (${acc.full_name})` : ""} + + +
+ {#if openPasswordFor === acc.username} +
+ + +
+ {/if} + {#if rowStatus[acc.username]} + {rowStatus[acc.username]} + {/if} +
+ {/each} + {/if} +
+
+ + + + + + + + + + + + + {#if addStatus} + {addStatus} + {/if} + +
+ + diff --git a/frontend/src/lib/views/registry.ts b/frontend/src/lib/views/registry.ts new file mode 100644 index 0000000..9c9ed44 --- /dev/null +++ b/frontend/src/lib/views/registry.ts @@ -0,0 +1,56 @@ +// Maps a sidebar page id to its view component. Pages not yet migrated +// fall back to Placeholder (see +page.svelte) — this map only lists pages +// that actually have a real Tauri-backed view. + +import type { Component } from "svelte"; +import About from "./About.svelte"; +import Breadclip from "./Breadclip.svelte"; +import Bread from "./Bread.svelte"; +import Breadbar from "./Breadbar.svelte"; +import Breadbox from "./Breadbox.svelte"; +import Breadpad from "./Breadpad.svelte"; +import Breadpaper from "./Breadpaper.svelte"; +import Breadsearch from "./Breadsearch.svelte"; +import Breadcrumbs from "./Breadcrumbs.svelte"; +import Appearance from "./Appearance.svelte"; +import Autostart from "./Autostart.svelte"; +import Display from "./Display.svelte"; +import Keybinds from "./Keybinds.svelte"; +import Sound from "./Sound.svelte"; +import DateTime from "./DateTime.svelte"; +import Power from "./Power.svelte"; +import Network from "./Network.svelte"; +import Bluetooth from "./Bluetooth.svelte"; +import Firewall from "./Firewall.svelte"; +import Users from "./Users.svelte"; +import Packages from "./Packages.svelte"; +import Aur from "./Aur.svelte"; +import Firmware from "./Firmware.svelte"; +import Snapshots from "./Snapshots.svelte"; + +export const VIEWS: Record = { + about: About, + breadclip: Breadclip, + bread: Bread, + breadbar: Breadbar, + breadbox: Breadbox, + breadpad: Breadpad, + breadpaper: Breadpaper, + breadsearch: Breadsearch, + breadcrumbs: Breadcrumbs, + appearance: Appearance, + autostart: Autostart, + hyprland: Display, + keybinds: Keybinds, + sound: Sound, + datetime: DateTime, + power: Power, + network: Network, + bluetooth: Bluetooth, + firewall: Firewall, + users: Users, + packages: Packages, + aur: Aur, + firmware: Firmware, + snapshots: Snapshots, +}; diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..9d24899 --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1,5 @@ +// Tauri doesn't have a Node.js server to do proper SSR +// so we use adapter-static with a fallback to index.html to put the site in SPA mode +// See: https://svelte.dev/docs/kit/single-page-apps +// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info +export const ssr = false; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..b9fe32e --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,61 @@ + + +
+ +
+ {#if ActiveView} + + {:else} + + {/if} +
+
+ + diff --git a/frontend/static/favicon.png b/frontend/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..825b9e65af7c104cfb07089bb28659393b4f2097 GIT binary patch literal 1571 zcmV+;2Hg3HP)Px)-AP12RCwC$UE6KzI1p6{F2N z1VK2vi|pOpn{~#djwYcWXTI_im_u^TJgMZ4JMOsSj!0ma>B?-(Hr@X&W@|R-$}W@Z zgj#$x=!~7LGqHW?IO8+*oE1MyDp!G=L0#^lUx?;!fXv@l^6SvTnf^ac{5OurzC#ZMYc20lI%HhX816AYVs1T3heS1*WaWH z%;x>)-J}YB5#CLzU@GBR6sXYrD>Vw(Fmt#|JP;+}<#6b63Ike{Fuo!?M{yEffez;| zp!PfsuaC)>h>-AdbnwN13g*1LowNjT5?+lFVd#9$!8Z9HA|$*6dQ8EHLu}U|obW6f z2%uGv?vr=KNq7YYa2Roj;|zooo<)lf=&2yxM@e`kM$CmCR#x>gI>I|*Ubr({5Y^rb zghxQU22N}F51}^yfDSt786oMTc!W&V;d?76)9KXX1 z+6Okem(d}YXmmOiZq$!IPk5t8nnS{%?+vDFz3BevmFNgpIod~R{>@#@5x9zJKEHLHv!gHeK~n)Ld!M8DB|Kfe%~123&Hz1Z(86nU7*G5chmyDe ziV7$pB7pJ=96hpxHv9rCR29%bLOXlKU<_13_M8x)6;P8E1Kz6G<&P?$P^%c!M5`2` zfY2zg;VK5~^>TJGQzc+33-n~gKt{{of8GzUkWmU110IgI0DLxRIM>0US|TsM=L|@F z0Bun8U!cRB7-2apz=y-7*UxOxz@Z0)@QM)9wSGki1AZ38ceG7Q72z5`i;i=J`ILzL z@iUO?SBBG-0cQuo+an4TsLy-g-x;8P4UVwk|D8{W@U1Zi z!M)+jqy@nQ$p?5tsHp-6J304Q={v-B>66$P0IDx&YT(`IcZ~bZfmn11#rXd7<5s}y zBi9eim&zQc0Dk|2>$bs0PnLmDfMP5lcXRY&cvJ=zKxI^f0%-d$tD!`LBf9^jMSYUA zI8U?CWdY@}cRq6{5~y+)#h1!*-HcGW@+gZ4B};0OnC~`xQOyH19z*TA!!BJ%9s0V3F?CAJ{hTd#*tf+ur-W9MOURF-@B77_-OshsY}6 zOXRY=5%C^*26z?l)1=$bz30!so5tfABdSYzO+H=CpV~aaUefmjvfZ3Ttu9W&W3Iu6 zROlh0MFA5h;my}8lB0tAV-Rvc2Zs_CCSJnx@d`**$idgy-iMob4dJWWw|21b4NB=LfsYp0Aeh{Ov)yztQi;eL4y5 zMi>8^SzKqk8~k?UiQK^^-5d8c%bV?$F8%X~czyiaKCI2=UH ({ + plugins: [sveltekit()], + + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // + // 1. prevent Vite from obscuring rust errors + clearScreen: false, + // 2. tauri expects a fixed port, fail if that port is not available + server: { + port: 1420, + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + // 3. tell Vite to ignore watching `src-tauri` + ignored: ["**/src-tauri/**"], + }, + }, +})); diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 0000000..b21bd68 --- /dev/null +++ b/src-tauri/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 0000000..74cae45 --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,5311 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[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 = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bos-settings" +version = "0.8.0" +dependencies = [ + "bread-theme", + "bread-utils", + "notify", + "regex", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-opener", + "tokio", + "toml_edit 0.22.27", +] + +[[package]] +name = "bread-theme" +version = "0.3.1" +dependencies = [ + "dirs 5.0.1", + "serde", + "serde_json", +] + +[[package]] +name = "bread-utils" +version = "0.3.1" +dependencies = [ + "dirs 5.0.1", + "serde", + "serde_json", + "toml_edit 0.22.27", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +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 = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[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 = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[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 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.3+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[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 = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[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 = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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 = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.188" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.13.1", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +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 = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +dependencies = [ + "toml_edit 0.20.7", +] + +[[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.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[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 = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[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 = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.23", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "http-range", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.19", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.19", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.19", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.19", + "toml 1.1.3+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.19", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.19", + "toml 1.1.3+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.3+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[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 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[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 = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[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 = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[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.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[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.4", +] + +[[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.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.19", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[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_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[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_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "winnow 1.0.4", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..f0105fd --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "bos-settings" +version = "0.8.0" +description = "System settings app for BOS (Bread Operating System)" +authors = ["Breadway"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +name = "bos_settings_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["protocol-asset"] } +tauri-plugin-opener = "2" +tauri-plugin-dialog = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml_edit = "0.22" +tokio = { version = "1", features = ["process", "io-util", "time", "macros"] } +notify = "7" +regex = "1" +# TODO(owner): switch to a tag-pinned git dependency once bread-theme cuts a +# release including css_custom_properties/css_tokens (added 2026-07-21 for +# this migration, unreleased) — matches the same pending-tag pattern +# bos-settings' old GTK Cargo.toml used for bread-utils. +bread-theme = { path = "../../bread-ecosystem/bread-theme" } +bread-utils = { path = "../../bread-ecosystem/bread-utils", features = ["toml"] } + diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..778bfb5 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,11 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default", + "dialog:default" + ] +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..6be5e50e9b9ae84d9e2ee433f32ef446495eaf3b GIT binary patch literal 3512 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 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..e81becee571e96f76aa5667f9324c05e5e7a4479 GIT binary patch literal 7012 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 Sh@|Ye~|0>}wMPLT8jm@Y!H33Sz}5aFI6 zM9Lzqz|;A*0sGs=2A1uU!1nk2dGF7knQwr99SAFen)x(eCO;F8y2C~0FD1YxRTPcy zPWVxkUYmeuz}Tv?7&Fe-!UE{)ZW)Mb;H)^#eHDv$`dkZGguJz@^MA!ZNGAUqt{|0H zpZ7Ch9S`q5!>R%}>}62!+(T^evyO+ImSo2wpu)su4^3nw5(%)KD%gbSev^*HZZ&3( z#&c@Z0gH|}Ck)w6fh0&NBJ62ib%R}(3@$VFl*_#l2W$wQ-~4RmZZAt5O*^2Q5}Xr8Hy@c`#pM?kc?hFWxRXr*mUfUCXf4ka5DD~ zat6d85COB05l#(P9*cQZ3EC8fVdS~?&vN#rce(aF9@xp80O2{{FBvU+{X>Hoh;xI` z{$e^Nw1y*VbO8wv`8|-m?NwNaKGTGaF{P^JLB^DbOYWIbn%eT`*!^C1H36=O8Z-M> zkD~88ry`eSo`tEBN4>w7OWZwUzlh{WM1m8R6zepqGcGMaV7vWY9b?K4b6~|HVG)ec wi>I@ws#sZo7or4_*4M>7;p5{nr2pZ?Uu4>Krr0kU)&Kwi07*qoM6N<$f)&@lf&c&j literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0ca4f27198838968bd60ed7d371bfa23496b7fe5 GIT binary patch literal 2863 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 Y2S8W#vM)6=T>t<807*qoM6N<$f*y@n<^TWy literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c021d2ba76619c08969ab688db3b27f29257aa6f GIT binary patch literal 7737 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 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..621970023096ed9f494ba18ace15421a45cd65fa GIT binary patch literal 903 zcmV;219<$2P)2 z+CUKPMqaqGiH;zb!R4$B-WXS^YzQr=@UH>k4?*L)&R=zYjBrZenKdc9|JlS$SO*RJ zKt8FSTDAdk1g_WPAO!p^V!AuL;Lm;uQyV;zKq)J3i(;q*;k+pD%f3eltU`PYdy9(k0&%` zuWAPcV6|-y?|?7O1W!KSK}pbk8#~!|FA@(VJkt^V@0lio{afoAeo*f&$W2s6${5!1eKvAGD2$GZwSB98L2ZVS- zKn8ENRkZ*sb!@QugOrQNK3(sy1v%J#m|rpB+h|Nkqa3FRT>74xSs{#&saU2Lf!_Iq zKmuKAESh`gs!fneGWn+nf}l?7jE$HW!Af&vE5=G!QU)U2v&HLIBGXKk4nQx{hsHjL zLPMAo5=*uInFbq7(aa`Y2VX5wCmaeqvECOFv)a>0t>ZaEb*cJccER=BB?KFZhV$c^ znL*l8x*UYZv4WK|j?~Jt6~~F%{pk~z5A*>^M`?r5m9@RJ_x|uEtX(6Vk@Y()MVto* z93wr)%3m%|#OZ~srm>zF(JvDuTq*@;d&^>_BJm5hOU`3FjG70L#Vzv9I?`<7$T@

jU?lMi@tgxr7CqX_r3uw^y4tVU3Pm0sw;|1WSUO%?=bG`*Kmz6u4{#ti;T7AWIBAEh!(Y zz>O01&#X?Ds@L)Sb{CkG#Yz4$3o d@96)?#cz^xWoA}>B$xmI002ovPDHLkV1l3&k#zt7 literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f9bc04839491e66c07b16ab03743c0c53b4109cc GIT binary patch literal 8591 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 JPDHLkV1lm|RXG3v literal 0 HcmV?d00001 diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..63440d7984936a9caa89275928d8dce97e4d033b GIT binary patch literal 2011 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∓t$dRJGIadE?vc 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 Kx8Vjl{{H|h@<;Lj literal 0 HcmV?d00001 diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b3636e4b22ba65db9061cd60a77b02c92022dfd6 GIT binary patch literal 86642 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 AKmY&$ literal 0 HcmV?d00001 diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e1cd2619e0b5ec089cbba5ec7b03ddf2b1dfceb6 GIT binary patch literal 14183 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

5l#v3A#xI3*z~1~fvo9Q(N^(==!|_FZ z*duZ=+M1~)8E|otX8KNZlr?qels#x_1Xq@9IIw~@9uAREJVH)Xw^}UclF6327}E42 zT)E&?U%TK?(+K7%R!`H5oX0i)4Qn5??Iw3p5J~6_u+aWehY{DSn}3V2p$bgjnAu?o)v@iC254fXeMv50$9YrpU`N?u@QIWs)T?SP|fa}(|9 zqAX+!7`cx=4)cCBg5h~pu(?@9`)aCr#oyz$ld=#RFxYCNZCZls@4v2~*e-t6PEVvV z&bbK3b3wt(Coc!ufAbXXC<**#HQ%J9k`New6iG<5RjtO4XVO?dCvwxD{kJ#tfQr(X zg^NTwF-FwAeS_{V4bfel8l`~NbfrTR2s!G>WduFWxH(t~aK4q=6rEE^$+Uox>gJO2 z{L<;6Q6nHa5#ZEM>H58not!)z(6*_=^~8}jWf*IG$AUKVWOZ4?)GfF z+BM#*wKKmLFD7E~W3U!$IVm$k_k1f&Kz6WV8@55P?r~bcg-Za-!rvW?ns&)KOGT2~ zlkAyqhQj=P$Eg3w#K~}zH@J5bo-BfHjInKSz$@?+Z)NPD4pHj^_Qxmi`UqoTy=`sV zLVxrXGuBr=QRm|}wg75yetQQK4fY3#P_~J}zEfPnb2C4Wo!E(d*(cA;b?7$g2in<( zPn)ghX}nzJPmb6(3Dpeg_GW~Hc}Lt=lgsSZz z!5QXyz7KaR;D`3Ee}d`af{H>WWZ|Io1QI3~4Ll_`g1(cRnhLK73Ro)7zPCd={1W2x zRp%Xlvv4>!<2@}$hz|!V{T}_eHx2xkLl^hQoZTCnsjCl|W_@5Fx2(+j0ogy&Y+;L- z<)G$*CiN7hOm^s!{U>1F7U=iNk{+u~dAC!eDz%=|glFW0jEZU1&o(G_c#wTxUjnG} z#cg3>jEpUi#Mlq@t?Msg_#geK^Lx@DyHWf7=AS5vVyM7YOjvUVCfcpVR<(+5!H?9- zySI6s>o3m&*zr||=wcPGyBkQV`EWJl@bH8qobjOp+sXL*)=&yX)8aAbf~tGv?a2SN zu^Ddo-z?DWk9h9Yz#5p^NU#x~wYSd?H@w@!2Gb4G)6-utEMV~~M85Br5ff(v5O1|T z zIR`9v=XXbK8N1BZV|h34+~1u1oJ_h>7aS*^LOi zS?hm+ec#1L<6bZ!Oc9OG-gV_V$j{5(O1RZD9`g%{h;v>0d zWiz)=`n67_-$k!Qp(dKW6m@Xi_CesKg~LL=e5V3#YN>;l#X) zHz6W=*ucpXy35@nx1)e|M-IcA>?RmWa)fP$3;*?-yraubd*HgRmAxty2ChoMmOJ(z zJKCPRl#%}U=5It0RrpPM-!VH}hd=~)Dgrd$Xa{xl7m@&qyV;7{bKiJt1}0(zWG;nM z*1KXcyD)ss@$q)hg31UNhb@0?Nl9`#klSY~0mVw;&b=%QK~s8IFXc!F5p^a~%zWmV zZJtPB8R=a#DYTy5Z)F|d(vv8Le0cDUfp(A=+8=zftD?-zNk522{i7(|otj9m+yuVX+hY6rRUn6cGGIp1ZdbJid*Uj}>|6O+%M$p(Q32+w2=sfwN14nBnms&GWQT;bYy>aG9 zPr6Cd#uA1P#}T@__%bE|_zq$$Uq0D;)oI(51NepuZw_VsS}Wm3fO?65Ghs-L5Y7GJ zLIb!-G_V};j1QOoJGZuU!{_^uLL^q?67ac`_1g7Ci)<1m$~^foc2@Oz_+n^`6C*Q) z4T02iPh}_YT5x8sN4uk?9(*=IfB@7nLJx4m+z4*1%olhnL{b0QQ?J_k&g=uRR#T@ck<>fO@F?_=pHVa@D;b*RSyCu;(cPAe?GFc~o>pnJbs_ zl1l-I8t{|mTecYcs@j1uvW09EKFp82PJS04Fs+8ys-MS8Kj%a0`K9hOFsr?0KT05_ z-qPfC|ADFn6bo)#`5S)^%6XKt9>$%BPRiU2ACnI78LtlM!3Y|@WCuRmwTvdeR}e|O zoQ_8f>>i3%vce(s;hDMjqMi|dq)o^x#NC#}_V3i1xARk!cH>NLtnx*VG91+hRXb2i z(8Rh(carI}sY2CavhN=3-`7;QH(11wQh zP;d43IbKw1Bs8TPtY$TgJe$}bJ6dRQH}XAxtwrzArUe%5#s*>t*c4ri%riv3((Aa}(}jAR@Z4(p z-St<0$zye=znm-re+QT%YgT0lPQW`C`>bnml$OKpIUb_K)Ln?HtlN7&D? zce9gBWPlhOdWJU%Z$Rp)g}T_;Q-S+@A>VbkYDi-}Xb&x8WhB@;QZD`|oq&vvW6`i`65b&(uy+Zt<<-oGX}plTUIr!V9THGPYbgYYYZ zj~5jMhZ@h}sNarolPDj80vQqXKK3UV90%jX`t-X^Z2HIP%yZi7SW7I*uG-UA1 zVuRN1Z-#@F^j8(GI^$^4?DPv4;ZtL1WdyjrQq$d>ItF4s&Rdc;l6asHjkJ2YfANQ0tp93~R_WJ6W;!Fw6 z`_&T%lm@4jAACAX+oQ?1G)|xS;NylhQw_dgg=$xgY#$BUy?y&%#DFTBJ}oo*y`*WW zh0BBTF|O=ILcEXiIx*WvX?<#QHH=ot+7rnLLWDsQ6n9`7(>}SUD$c_hy|u87|2ehz z!$4Gq)@1SaVZOOIr){?PUr#i=QZXpTP4SE^_HdZ615YT-Mxq zaU=o9m|f2%zQ!`{{bY$e6hmX3)`!B|4Epd^b@RK%3s?=p?RQz&wO;j-(5P1kck$wd zSJ&DfjKN$?vegNGkE)ftChzIhc-&J&UP~)iQS{5IgFrWb(-TpP389q}c`g5_UKr}* zTV`e40XXe8`o2v{SM^gaF{tN~vs1oYEH0ZIG<2|4fWlpe;{Q7v2eV4MT?@pAC#FQ} z1#v^nMVh9F(f8xk1twtl9n%~9=PhY~kse$*zeza6>Y~mucCA-aK#_m8kW$;ho}k)d zef)!x)+xig;L+^Zn@-hLjJ|=MGQgJO48Zh|BVx3qjQpD~&keYzu08*c`6L77$Odq^)ySMSKo~EG>7qO4) zGQ)1PUpjB%VxfNDiDf4Ro1o$&^7Z)mNLab|_7)vaPv5!^CHt3vXwv#|+`R07+H52% zKo%nK#80s-o)YZj?*ITk+}k^g+myi0bp#KfHwslIGiuDjs~yxHx&gptDVWHG=70&V zJ8Io-FR9z~W&kLF(n_>c?3f)cYo6``BMI)wm3jZFbPN8=?HR1B%7>HqNtp?ns~LRX z9I^(_-#Wqs4rYIAzyB*x_rTr;$D0IjmOVaIb*f!eRcm`A$QFiU*E+iYVy(ww*D#+G z4HPQp`u-fa`BDzB*4ZfjHvM8IMi!3!Rv9Ifk3a)bnSGPt_|HayKxwKr8EiZp4ENUM z53~}@bJhH>Z+4qaz_de#z`Nk~-Xj#@`R5upr+J$E_E78H>WPHkEn!|F-Wx92_)~gF z2)F3pQ^!@nTj?i4U^t|f_WD0c>fxtBtXMyIl3x(VyD-sm2;X&fx~*6;rc?rV_gch` zyN$kU`>}KvO#R2AS=Jr7_3Ipox2Z@^{e^GbkT-DuOD$?@^P~b?+CL`B%(rGrZX(XK zB;huyA)r%y72y_VVMa0v_3;!uONHw zoRni;$j1Ra@!^urL#n@$>-xC*WIGo_R5kih{`Gxs4?X65^Z|d%#zxiVbe&$7!wqpB z&Gqq9c!_(*Qp%}ybz$e$eNfD%25@W1%^-Lv!No&Q7eO-*_+I+nyzFbkExed7(pohd zFcaui&L7DXAzjue3 zAncEwaY=bSyTKAntX{Y``Td(kG^niT%yilzTza@SJ?iu5#t=xpcNrHq;5&!j8s6Oy zetM@f_AI0nlI6oafRq+dpX=eD9JgvAw&63Y9DJu}eMQtm%uMgk3K#)+7{ZlVy3fxP zBR(sz&2{V9I!pzKO(qAsz>_xVOOyl^XwC?y4S(8G3sSSj#eFOS0}q)SBw@cO2`27r ze(`We&e5WW?y7A~hhHz4;n*9u=1}rRDJ6V7K~!v*_peughtWU0tpa}h8`F4r1z?lD zN3U_T4#UQb{975_<1b`0`)vi|=5-7rGUbFJ>TCOS;$2XR!cZ|m1HXl4PvaWzU#)Av zV^0!NYg2Yd5~CSM9#DJGNkF{Ab335tD*S3or#<1O%fW*o?Xu^@CP<*c{YpDF|k?t^m$uBbp4Lwi@Baxp9=Mc*(~xK6`g z=hKP^8aedgD#a7mFY}l#Mq+QAZERu0OuxWZS1ULRxwAufv^C?3d%-W=%KJC3-uH}o z1oZPfArJj~@24Pyk@?>uWUms4%sf^D0npR@uxOruAu#d#f3rWINyCbv1WuszHEAz& z=?qL;EJ^}GJt`ml*Cb64NCM3D_Z;&ll82@1V*Vfr;x~{CbpuZ_w~aAeS^5l>0R?!d zOUu`UqI4T!6aN@F4>pDmc_^2GLMq=H1kArrC$v-S;Ly(W+)6v}=fJXt#Kw?r z<4BNZ)kbJ5nvgPW^BF=39{nSI5a0dBXlGZnU!2@8@uC@|B?9ISkRZ)P@>eoY*k`i{ zpIdaL3~cVlGz+YqmT|aE=C-@QkuSOE`e&o-2a`_m#D7^@wTL-hCp^eggtg@r#Kl1# zw4tC;ko=KFA>wgkGS=z*cj@L-#$`K*B|(33f}w1JKLmw^yYL(j>aO0cuko3}1W8{o zrx%w0qh*SnV6qR)#I-k`UGfwvg=!lp*Y)<$?(s5G;XptR`oXMthRorcd&W&C2| z!^L@skGCA-~}Ka^T8SSo0nynP|RU!FKm;e3uRh%sH=JP2(kzg*8>fg z*#_C9z>d<_M#%~*0rduNj`qqMZAAIrbkJN$h+hkbG|IT8OK{Ug*BfV7`67$&?LOS3 zhT3Rfp==4iG-;np#jrT<8R%UC;K~puSgdfHC=_ot5?)jrFH>g5KAHEmwtQHkiiyN6B2g)XX%#m5#`fPyR!RI z5M2-E&!BSvrD+Em(}f*VFd%7AUmA0^Xux{c6R@kes6AJzJ& z$cFLCdjgU*hhG=2ehpu4QV4{1_1}3xN*GT943{@|4Thv)b7D;}$=^aWh^Br?N?865 ze}23(;yHT?oU)V+g#unK^kTnu+&VG#yu?!i1ZS zX#zTt$Y09M-=Rc6Iuhe|Ob~eU*%@fPZN~VrOx>t^1`Q%}NUp)J0DC-ery?iN=fNtg zq7es_@hL>?<+(aOv@b@GpD7&pcXKau3j!2~_)QD3BkTSIY|}(3XJQ?06)6p4G;-;}Y@)~&+B4D(Q#kj~nC@K=65{rb~5fQ?27_$O{UA`h=+ zk-SJ^m5V?CHa5hGtTxIb(OyI-KI(h=_sPXWD{u)Jfy&f{MB0%pYWZKL>oHzz7diuV z|7}09KDCW$bxeIded}%F(v~XTCr-r)5uOjh(AFjgg#6KCwXCfpXOq1yFS3^Z6P|1A z<+TjRjM)9!)l+*g$=V9-@u+q_sGjk)=&553xTvh7zFfhz|Ai$yQkNtPN!M4%ED^8g zosuJv=Y%Lz8R20ju_!X6`D String { fs::read_to_string("/etc/os-release") @@ -26,10 +35,11 @@ fn hostname() -> String { .unwrap_or_else(|_| "unknown".to_string()) } -fn kernel() -> String { +async fn kernel() -> String { Command::new("uname") .arg("-r") .output() + .await .ok() .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_else(|| "unknown".to_string()) @@ -66,8 +76,8 @@ fn memory() -> String { } } -fn gpu() -> String { - let Ok(output) = Command::new("lspci").output() else { +async fn gpu() -> String { + let Ok(output) = Command::new("lspci").output().await else { return "unknown".to_string(); }; let text = String::from_utf8_lossy(&output.stdout); @@ -85,8 +95,8 @@ fn gpu() -> String { .to_string() } -fn disk_usage() -> String { - let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output() else { +async fn disk_usage() -> String { + let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output().await else { return "unknown".to_string(); }; let text = String::from_utf8_lossy(&output.stdout); @@ -102,71 +112,44 @@ fn disk_usage() -> String { .unwrap_or_else(|| "unknown".to_string()) } -fn uptime() -> String { +async fn uptime() -> String { Command::new("uptime") .arg("-p") .output() + .await .ok() .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_else(|| "unknown".to_string()) } -pub fn build() -> GBox { - let (outer, content) = w::view_scaffold("About"); - - content.append(&w::info_row("Operating system", &os_pretty_name())); - content.append(&w::info_row("Kernel", &kernel())); - content.append(&w::info_row("CPU", &cpu())); - content.append(&w::info_row("GPU", &gpu())); - content.append(&w::info_row("Memory", &memory())); - content.append(&w::info_row("Disk (/)", &disk_usage())); - content.append(&w::info_row("Uptime", &uptime())); - - content.append(&w::section("Hostname")); - content.append(&w::hint( - "Changes the machine's network name. Takes effect immediately; \ - needs your password (polkit).", - )); - - let hn_row = GBox::new(Orientation::Horizontal, 12); - let entry = Entry::new(); - entry.set_text(&hostname()); - entry.set_hexpand(true); - let apply_btn = Button::with_label("Apply"); - let status = Label::new(None); - status.add_css_class("dim-label"); - - { - let entry = entry.clone(); - let status = status.clone(); - apply_btn.connect_clicked(move |_| { - let name = entry.text().to_string(); - if name.trim().is_empty() { - status.set_text("Hostname can't be empty"); - return; - } - let log_buf = gtk4::TextBuffer::new(None); - let status2 = status.clone(); - status.set_text("Applying…"); - w::stream_command_then( - &["pkexec", "hostnamectl", "set-hostname", name.trim()], - log_buf.clone(), - move || { - let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false); - if text.trim().is_empty() { - status2.set_text("Applied"); - } else { - status2.set_text(&format!("Error: {}", text.trim())); - } - }, - ); - }); +#[tauri::command] +pub async fn get_system_info() -> SystemInfo { + SystemInfo { + os: os_pretty_name(), + kernel: kernel().await, + cpu: cpu(), + gpu: gpu().await, + memory: memory(), + disk: disk_usage().await, + uptime: uptime().await, + hostname: hostname(), + } +} + +#[tauri::command] +pub async fn set_hostname(name: String) -> Result<(), String> { + let name = name.trim(); + if name.is_empty() { + return Err("Hostname can't be empty".into()); + } + let output = Command::new("pkexec") + .args(["hostnamectl", "set-hostname", name]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) } - - hn_row.append(&entry); - hn_row.append(&apply_btn); - content.append(&hn_row); - content.append(&status); - - outer } diff --git a/src-tauri/src/commands/appearance.rs b/src-tauri/src/commands/appearance.rs new file mode 100644 index 0000000..0920f0f --- /dev/null +++ b/src-tauri/src/commands/appearance.rs @@ -0,0 +1,73 @@ +//! hypr/settings.json — Hyprland gaps/borders/blur/shadow/input, read by +//! `scripts/ui/settings.lua` on the Hyprland side. No comments to preserve, +//! so it's a plain typed struct round-tripped whole. +//! +//! `Default` here must stay in sync with `scripts/ui/settings.lua`'s +//! `DEFAULTS` table on the Hyprland side — two different languages/ +//! processes reading the same file, neither able to import the other's +//! defaults. + +use serde::{Deserialize, Serialize}; + +use super::config; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Appearance { + gaps_in: i64, + gaps_out: i64, + border_size: i64, + active_border: String, + inactive_border: String, + layout: String, + resize_on_border: bool, + rounding: i64, + blur_enabled: bool, + blur_size: i64, + blur_passes: i64, + shadow_enabled: bool, + shadow_range: i64, + shadow_render_power: i64, + kb_layout: String, + follow_mouse: i64, + natural_scroll: bool, +} + +impl Default for Appearance { + fn default() -> Self { + Self { + gaps_in: 5, + gaps_out: 10, + border_size: 2, + active_border: "rgba(88c0d0ff)".to_string(), + inactive_border: "rgba(4c566aff)".to_string(), + layout: "dwindle".to_string(), + resize_on_border: true, + rounding: 8, + blur_enabled: true, + blur_size: 6, + blur_passes: 2, + shadow_enabled: true, + shadow_range: 12, + shadow_render_power: 3, + kb_layout: "us".to_string(), + follow_mouse: 1, + natural_scroll: true, + } + } +} + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("hypr/settings.json") +} + +#[tauri::command] +pub fn get_appearance() -> Appearance { + std::fs::read_to_string(config_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() +} + +#[tauri::command] +pub fn save_appearance(appearance: Appearance) -> Result<(), String> { + let json = serde_json::to_string_pretty(&appearance).map_err(|e| e.to_string())?; + config::atomic_write(&config_path(), &json).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/aur.rs b/src-tauri/src/commands/aur.rs new file mode 100644 index 0000000..226af63 --- /dev/null +++ b/src-tauri/src/commands/aur.rs @@ -0,0 +1,47 @@ +//! AUR search via yay — graphical discovery beyond bakery's bread ecosystem +//! and [breadway]'s own republished packages. +//! +//! Installing opens a terminal running `yay -S ` instead of a silent +//! `--noconfirm` install — deliberate, not a shortcut skipped. AUR packages +//! run arbitrary maintainer-supplied build scripts, and yay's interactive +//! PKGBUILD diff review (plus the sudo prompt) is the actual safety +//! mechanism against a malicious/compromised package; automating it away +//! would remove the one step that exists to catch that. + +use serde::Serialize; + +#[derive(Serialize, Clone)] +pub struct AurResult { + name: String, + version: String, + description: String, +} + +#[tauri::command] +pub async fn search_aur(query: String) -> Vec { + let Ok(output) = tokio::process::Command::new("yay").args(["-Ss", "--aur", &query]).output().await else { + return Vec::new(); + }; + let text = String::from_utf8_lossy(&output.stdout); + let mut results = Vec::new(); + let mut lines = text.lines().peekable(); + while let Some(header) = lines.next() { + // "aur/name version (+votes score) [Orphaned]" — name/version are + // always the first two whitespace-separated fields after "aur/". + let Some(rest) = header.strip_prefix("aur/") else { continue }; + let mut parts = rest.split_whitespace(); + let Some(name) = parts.next() else { continue }; + let version = parts.next().unwrap_or("").to_string(); + let description = lines.next().unwrap_or("").trim().to_string(); + results.push(AurResult { name: name.to_string(), version, description }); + if results.len() >= 50 { + break; + } + } + results +} + +#[tauri::command] +pub fn install_aur_package(pkg: String) { + let _ = std::process::Command::new("kitty").args(["-e", "yay", "-S", &pkg]).spawn(); +} diff --git a/src-tauri/src/commands/autostart.rs b/src-tauri/src/commands/autostart.rs new file mode 100644 index 0000000..47bcb26 --- /dev/null +++ b/src-tauri/src/commands/autostart.rs @@ -0,0 +1,59 @@ +//! hypr/autostart.json — the *extra*, user-toggleable autostart apps. The +//! core bootstrap sequence stays hardcoded in hyprland.lua on purpose (it's +//! timing/order-sensitive infrastructure, not something this panel exposes). + +use serde::{Deserialize, Serialize}; + +use super::config; + +#[derive(Clone, Serialize, Deserialize)] +pub struct AutostartEntry { + command: String, + #[serde(default)] + label: String, + #[serde(default = "default_true")] + enabled: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Serialize, Deserialize)] +struct AutostartFile { + #[serde(default)] + extra: Vec, +} + +fn default_extra() -> Vec { + vec![ + AutostartEntry { command: "breadbar".into(), label: "Bar (breadbar)".into(), enabled: true }, + AutostartEntry { command: "hypridle".into(), label: "Idle / lock daemon (hypridle)".into(), enabled: true }, + AutostartEntry { command: "bos-netcheck".into(), label: "Network connectivity check".into(), enabled: true }, + AutostartEntry { command: "breadhelp --autostart".into(), label: "BOS Help (first-run onboarding)".into(), enabled: true }, + ] +} + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("hypr/autostart.json") +} + +#[tauri::command] +pub fn get_autostart_entries() -> Vec { + std::fs::read_to_string(config_path()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|f| f.extra) + .unwrap_or_else(default_extra) +} + +/// Empty-command rows (still-being-typed "Add app" entries) are dropped on +/// save rather than written as a broken autostart.json entry the Lua loader +/// would otherwise have to reject. +#[tauri::command] +pub fn save_autostart_entries(entries: Vec) -> Result<(), String> { + let entries: Vec = entries.into_iter().filter(|e| !e.command.trim().is_empty()).collect(); + let file = AutostartFile { extra: entries }; + let json = serde_json::to_string_pretty(&file).map_err(|e| e.to_string())?; + config::atomic_write(&config_path(), &json).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/bluetooth.rs b/src-tauri/src/commands/bluetooth.rs new file mode 100644 index 0000000..4bb82f5 --- /dev/null +++ b/src-tauri/src/commands/bluetooth.rs @@ -0,0 +1,123 @@ +//! Bluetooth over `bluetoothctl`'s non-interactive mode — no D-Bus +//! dependency needed, same "shell out to the standard CLI" choice as +//! Network (nmcli). None of this needs `pkexec` — BlueZ's D-Bus policy +//! already allows the active session user. +//! +//! Pairing only covers "Just Works" Simple Secure Pairing — bluetoothd's +//! own built-in default agent auto-accepts that for most audio/HID +//! devices. A device that requires PIN/passkey confirmation isn't +//! supported (would need this app to register its own bluetoothd agent); +//! pairing such a device just fails, surfaced as an error. + +use serde::Serialize; +use std::collections::HashSet; +use tokio::process::Command; + +#[derive(Serialize, Clone)] +pub struct BtDevice { + address: String, + name: String, + connected: bool, +} + +#[tauri::command] +pub async fn get_adapter_powered() -> Option { + let output = Command::new("bluetoothctl").arg("show").output().await.ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + if !text.trim_start().starts_with("Controller") { + return None; + } + Some(text.lines().any(|l| l.trim() == "Powered: yes")) +} + +#[tauri::command] +pub async fn set_adapter_powered(on: bool) { + let val = if on { "on" } else { "off" }; + let _ = Command::new("bluetoothctl").args(["power", val]).status().await; +} + +fn parse_device_lines(text: &str) -> Vec { + text.lines() + .filter_map(|l| { + let rest = l.strip_prefix("Device ")?; + let (addr, name) = rest.split_once(' ')?; + Some(BtDevice { address: addr.trim().to_string(), name: name.trim().to_string(), connected: false }) + }) + .collect() +} + +async fn run_devices(filter: Option<&str>) -> Vec { + let mut args = vec!["devices"]; + if let Some(f) = filter { + args.push(f); + } + let Ok(out) = Command::new("bluetoothctl").args(&args).output().await else { + return Vec::new(); + }; + parse_device_lines(&String::from_utf8_lossy(&out.stdout)) +} + +#[tauri::command] +pub async fn get_paired_devices() -> Vec { + let mut devices = run_devices(Some("Paired")).await; + let connected: HashSet = run_devices(Some("Connected")).await.into_iter().map(|d| d.address).collect(); + for d in &mut devices { + d.connected = connected.contains(&d.address); + } + devices +} + +/// Scans for a few seconds and returns every device BlueZ has seen that +/// isn't already paired. +#[tauri::command] +pub async fn scan_bluetooth() -> Vec { + let _ = Command::new("bluetoothctl").args(["--timeout", "5", "scan", "on"]).output().await; + let paired: HashSet = run_devices(Some("Paired")).await.into_iter().map(|d| d.address).collect(); + run_devices(None).await.into_iter().filter(|d| !paired.contains(&d.address)).collect() +} + +#[tauri::command] +pub async fn bt_connect(address: String) -> Result<(), String> { + let out = Command::new("bluetoothctl").args(["connect", &address]).output().await.map_err(|e| e.to_string())?; + if out.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } +} + +#[tauri::command] +pub async fn bt_disconnect(address: String) -> Result<(), String> { + let out = Command::new("bluetoothctl").args(["disconnect", &address]).output().await.map_err(|e| e.to_string())?; + if out.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } +} + +#[tauri::command] +pub async fn bt_forget(address: String) -> Result<(), String> { + let out = Command::new("bluetoothctl").args(["remove", &address]).output().await.map_err(|e| e.to_string())?; + if out.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } +} + +/// Pairs, then confirms it actually landed in the paired-device cache — +/// bluetoothctl's exit code alone isn't a reliable signal for pairing. +#[tauri::command] +pub async fn bt_pair(address: String) -> Result<(), String> { + let out = Command::new("bluetoothctl").args(["pair", &address]).output().await.map_err(|e| e.to_string())?; + let now_paired = run_devices(Some("Paired")).await.iter().any(|d| d.address == address); + if now_paired { + Ok(()) + } else { + Err(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } +} diff --git a/src-tauri/src/commands/bread.rs b/src-tauri/src/commands/bread.rs new file mode 100644 index 0000000..5fc710f --- /dev/null +++ b/src-tauri/src/commands/bread.rs @@ -0,0 +1,80 @@ +//! breadd.toml — the bread daemon config. Schema mirrors +//! breadd/src/core/config.rs (daemon, lua, modules, adapters, events, +//! notifications). Edited non-destructively via `commands::config`. + +use serde::{Deserialize, Serialize}; + +use super::config; + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("bread/breadd.toml") +} + +#[derive(Serialize, Deserialize)] +pub struct BreadConfig { + log_level: String, + socket_path: String, + lua_entry_point: String, + lua_module_path: String, + modules_builtin: bool, + modules_disable: Vec, + adapter_hyprland: bool, + adapter_udev: bool, + udev_subsystems: Vec, + adapter_power: bool, + power_poll_interval_secs: i64, + adapter_network: bool, + adapter_bluetooth: bool, + dedup_window_ms: i64, + notif_default_timeout_ms: i64, + notif_default_urgency: String, + notif_notify_send_path: String, +} + +#[tauri::command] +pub fn get_bread_config() -> BreadConfig { + let doc = config::load_doc(&config_path()); + BreadConfig { + log_level: config::get_str(&doc, &["daemon", "log_level"]).unwrap_or_else(|| "info".into()), + socket_path: config::get_str(&doc, &["daemon", "socket_path"]).unwrap_or_default(), + lua_entry_point: config::get_str(&doc, &["lua", "entry_point"]).unwrap_or_default(), + lua_module_path: config::get_str(&doc, &["lua", "module_path"]).unwrap_or_default(), + modules_builtin: config::get_bool(&doc, &["modules", "builtin"]).unwrap_or(true), + modules_disable: config::get_str_list(&doc, &["modules", "disable"]), + adapter_hyprland: config::get_bool(&doc, &["adapters", "hyprland", "enabled"]).unwrap_or(true), + adapter_udev: config::get_bool(&doc, &["adapters", "udev", "enabled"]).unwrap_or(true), + udev_subsystems: config::get_str_list(&doc, &["adapters", "udev", "subsystems"]), + adapter_power: config::get_bool(&doc, &["adapters", "power", "enabled"]).unwrap_or(true), + power_poll_interval_secs: config::get_i64(&doc, &["adapters", "power", "poll_interval_secs"]).unwrap_or(30), + adapter_network: config::get_bool(&doc, &["adapters", "network", "enabled"]).unwrap_or(true), + adapter_bluetooth: config::get_bool(&doc, &["adapters", "bluetooth", "enabled"]).unwrap_or(true), + dedup_window_ms: config::get_i64(&doc, &["events", "dedup_window_ms"]).unwrap_or(250), + notif_default_timeout_ms: config::get_i64(&doc, &["notifications", "default_timeout_ms"]).unwrap_or(5000), + notif_default_urgency: config::get_str(&doc, &["notifications", "default_urgency"]).unwrap_or_else(|| "normal".into()), + notif_notify_send_path: config::get_str(&doc, &["notifications", "notify_send_path"]).unwrap_or_default(), + } +} + +#[tauri::command] +pub fn save_bread_config(cfg: BreadConfig) -> Result<(), String> { + let path = config_path(); + let mut doc = config::load_doc(&path); + config::set_str(&mut doc, &["daemon", "log_level"], &cfg.log_level); + config::set_str_or_remove(&mut doc, &["daemon", "socket_path"], &cfg.socket_path); + config::set_str_or_remove(&mut doc, &["lua", "entry_point"], &cfg.lua_entry_point); + config::set_str_or_remove(&mut doc, &["lua", "module_path"], &cfg.lua_module_path); + config::set_bool(&mut doc, &["modules", "builtin"], cfg.modules_builtin); + config::set_str_list(&mut doc, &["modules", "disable"], &cfg.modules_disable); + config::set_bool(&mut doc, &["adapters", "hyprland", "enabled"], cfg.adapter_hyprland); + config::set_bool(&mut doc, &["adapters", "udev", "enabled"], cfg.adapter_udev); + config::set_str_list(&mut doc, &["adapters", "udev", "subsystems"], &cfg.udev_subsystems); + config::set_bool(&mut doc, &["adapters", "power", "enabled"], cfg.adapter_power); + config::set_i64(&mut doc, &["adapters", "power", "poll_interval_secs"], cfg.power_poll_interval_secs); + config::set_bool(&mut doc, &["adapters", "network", "enabled"], cfg.adapter_network); + config::set_bool(&mut doc, &["adapters", "bluetooth", "enabled"], cfg.adapter_bluetooth); + config::set_i64(&mut doc, &["events", "dedup_window_ms"], cfg.dedup_window_ms); + config::set_i64(&mut doc, &["notifications", "default_timeout_ms"], cfg.notif_default_timeout_ms); + config::set_str(&mut doc, &["notifications", "default_urgency"], &cfg.notif_default_urgency); + config::set_str_or_remove(&mut doc, &["notifications", "notify_send_path"], &cfg.notif_notify_send_path); + config::save_doc(&path, &doc).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/breadbar.rs b/src-tauri/src/commands/breadbar.rs new file mode 100644 index 0000000..51fdefa --- /dev/null +++ b/src-tauri/src/commands/breadbar.rs @@ -0,0 +1,144 @@ +//! breadbar/style.css — CSS overrides for the bar. No systemd unit (breadbar +//! is launched directly by hyprland.lua's exec-once); SIGHUP is its own +//! documented live-reload mechanism. +//! +//! The file has a fixed, hand-written structure (see the shipped template's +//! comments), so the common properties users actually tweak — font, bar +//! chrome, workspace indicator, spacing, tray/notification radii — are +//! exposed as a typed `BreadbarStyle` struct. `get`/`set_value` locate a +//! known `selector { ... }` block and rewrite just one declaration's value +//! inside it, leaving comments, ordering, and every unmodeled property +//! (font-weight, per-element opacities, notification padding, etc.) +//! untouched. Anything not modeled here stays reachable via the raw +//! `get`/`save_breadbar_css` pair, kept as an "Advanced" escape hatch. + +use regex::{Captures, Regex}; + +use super::config; + +fn css_path() -> std::path::PathBuf { + config::config_dir().join("breadbar/style.css") +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct BreadbarStyle { + pub font_family: String, + pub font_size: u32, + pub bar_border_radius: u32, + pub bar_padding: u32, + pub workspace_inactive_opacity: f64, + pub workspace_font_size: u32, + pub stat_gap: u32, + pub tray_icon_size: u32, + pub notification_border_radius: u32, +} + +fn find_block<'a>(css: &'a str, selector: &str) -> Option<(usize, usize)> { + let pat = format!(r"(?m)^\s*{}\s*\{{", regex::escape(selector)); + let re = Regex::new(&pat).ok()?; + let m = re.find(css)?; + let body_start = m.end(); + let body_end = body_start + css[body_start..].find('}')?; + Some((body_start, body_end)) +} + +fn get_value(css: &str, selector: &str, property: &str) -> Option { + let (start, end) = find_block(css, selector)?; + let body = &css[start..end]; + let re = Regex::new(&format!(r"(?m)^\s*{}\s*:\s*([^;]+);", regex::escape(property))).ok()?; + Some(re.captures(body)?.get(1)?.as_str().trim().to_string()) +} + +fn set_value(css: &str, selector: &str, property: &str, new_value: &str) -> Option { + let (start, end) = find_block(css, selector)?; + let body = &css[start..end]; + let re = Regex::new(&format!(r"(?m)(^\s*{}\s*:\s*)([^;]+)(;)", regex::escape(property))).ok()?; + if !re.is_match(body) { + return None; + } + let new_body = re + .replace(body, |caps: &Captures| format!("{}{}{}", &caps[1], new_value, &caps[3])) + .into_owned(); + Some(format!("{}{}{}", &css[..start], new_body, &css[end..])) +} + +fn strip_px(v: &str) -> u32 { + v.trim().trim_end_matches("px").trim().parse().unwrap_or(0) +} + +#[tauri::command] +pub fn get_breadbar_css() -> String { + std::fs::read_to_string(css_path()).unwrap_or_default() +} + +/// Saves the CSS and sends breadbar SIGHUP to live-reload it. Returns +/// whether the reload signal was actually delivered (`false` just means +/// breadbar isn't running — the file is saved either way). +#[tauri::command] +pub fn save_breadbar_css(css: String) -> Result { + config::atomic_write(&css_path(), &css).map_err(|e| e.to_string())?; + reload_breadbar() +} + +#[tauri::command] +pub fn get_breadbar_style() -> BreadbarStyle { + let css = std::fs::read_to_string(css_path()).unwrap_or_default(); + BreadbarStyle { + font_family: get_value(&css, "*", "font-family") + .and_then(|v| v.split(',').next().map(|s| s.trim().trim_matches(['\'', '"']).to_string())) + .unwrap_or_else(|| "Varela Round".to_string()), + font_size: get_value(&css, "*", "font-size").map(|v| strip_px(&v)).unwrap_or(14), + bar_border_radius: get_value(&css, "window.breadbar", "border-radius") + .map(|v| strip_px(&v)) + .unwrap_or(0), + bar_padding: get_value(&css, "window.breadbar", "padding").map(|v| strip_px(&v)).unwrap_or(0), + workspace_inactive_opacity: get_value(&css, ".workspace-btn", "opacity") + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(0.45), + workspace_font_size: get_value(&css, ".workspace-btn", "font-size") + .map(|v| strip_px(&v)) + .unwrap_or(20), + stat_gap: get_value(&css, ".stat-pair", "margin-right").map(|v| strip_px(&v)).unwrap_or(12), + tray_icon_size: get_value(&css, ".tray-btn image", "-gtk-icon-size") + .map(|v| strip_px(&v)) + .unwrap_or(16), + notification_border_radius: get_value(&css, "window.breadbar-notification", "border-radius") + .map(|v| strip_px(&v)) + .unwrap_or(6), + } +} + +#[tauri::command] +pub fn save_breadbar_style(style: BreadbarStyle) -> Result { + let mut css = std::fs::read_to_string(css_path()).unwrap_or_default(); + let px = |n: u32| format!("{n}px"); + + let edits: [(&str, &str, String); 9] = [ + ("*", "font-family", format!("'{}', sans-serif", style.font_family)), + ("*", "font-size", px(style.font_size)), + ("window.breadbar", "border-radius", px(style.bar_border_radius)), + ("window.breadbar", "padding", px(style.bar_padding)), + (".workspace-btn", "opacity", style.workspace_inactive_opacity.to_string()), + (".workspace-btn", "font-size", px(style.workspace_font_size)), + (".stat-pair", "margin-right", px(style.stat_gap)), + (".tray-btn image", "-gtk-icon-size", px(style.tray_icon_size)), + ("window.breadbar-notification", "border-radius", px(style.notification_border_radius)), + ]; + + for (selector, property, value) in &edits { + if let Some(updated) = set_value(&css, selector, property, value) { + css = updated; + } + } + + config::atomic_write(&css_path(), &css).map_err(|e| e.to_string())?; + reload_breadbar() +} + +fn reload_breadbar() -> Result { + Ok(std::process::Command::new("pkill") + .args(["-HUP", "-x", "breadbar"]) + .status() + .map(|s| s.success()) + .unwrap_or(false)) +} diff --git a/src-tauri/src/commands/breadbox.rs b/src-tauri/src/commands/breadbox.rs new file mode 100644 index 0000000..61ecbb8 --- /dev/null +++ b/src-tauri/src/commands/breadbox.rs @@ -0,0 +1,64 @@ +//! breadbox config.toml — launcher contexts. Schema mirrors breadbox-shared +//! (`#[serde(rename = "context")]` — the TOML key is `[[context]]`, +//! singular, despite the Rust field being `contexts`), with `name` + +//! `priority`, an ordered list of app/category hints. The context array is +//! rewritten on save; any other top-level keys/comments are preserved. + +use serde::{Deserialize, Serialize}; +use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; + +use super::config; + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("breadbox/config.toml") +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct Context { + name: String, + priority: Vec, +} + +fn read_contexts(doc: &DocumentMut) -> Vec { + let Some(aot) = doc.get("context").and_then(Item::as_array_of_tables) else { + return Vec::new(); + }; + aot.iter() + .map(|t| Context { + name: t.get("name").and_then(Item::as_str).unwrap_or("").to_string(), + priority: t + .get("priority") + .and_then(Item::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(), + }) + .collect() +} + +fn write_contexts(doc: &mut DocumentMut, ctxs: &[Context]) { + let mut aot = ArrayOfTables::new(); + for ctx in ctxs { + let mut t = Table::new(); + t.insert("name", value(&ctx.name)); + let mut arr = Array::new(); + for p in &ctx.priority { + arr.push(p.as_str()); + } + t.insert("priority", value(arr)); + aot.push(t); + } + doc.as_table_mut().insert("context", Item::ArrayOfTables(aot)); +} + +#[tauri::command] +pub fn get_breadbox_contexts() -> Vec { + read_contexts(&config::load_doc(&config_path())) +} + +#[tauri::command] +pub fn save_breadbox_contexts(contexts: Vec) -> Result<(), String> { + let path = config_path(); + let mut doc = config::load_doc(&path); + write_contexts(&mut doc, &contexts); + config::save_doc(&path, &doc).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/breadclip.rs b/src-tauri/src/commands/breadclip.rs new file mode 100644 index 0000000..097c116 --- /dev/null +++ b/src-tauri/src/commands/breadclip.rs @@ -0,0 +1,8 @@ +//! breadclip has no config file to edit — this panel exists purely to make +//! its background daemon (breadclipd, via `service.rs`) and its +//! on-demand popup visible/controllable from Settings. + +#[tauri::command] +pub fn open_breadclip() { + let _ = std::process::Command::new("breadclip").spawn(); +} diff --git a/src-tauri/src/commands/breadcrumbs.rs b/src-tauri/src/commands/breadcrumbs.rs new file mode 100644 index 0000000..3572f82 --- /dev/null +++ b/src-tauri/src/commands/breadcrumbs.rs @@ -0,0 +1,176 @@ +//! breadcrumbs.toml — Wi-Fi profile state machine. Schema mirrors +//! breadcrumbs/src/config.rs: +//! [settings] scalar tunables +//! [[networks]] saved networks (ssid / password / hidden) +//! [profiles.] per-location profile (networks, tailscale, …) +//! `[settings]` is edited in place; `networks`/`profiles` are rewritten from +//! their editors on save. Other keys/comments are preserved. + +use serde::{Deserialize, Serialize}; +use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; + +use super::config; + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("breadcrumbs/breadcrumbs.toml") +} + +#[derive(Serialize, Deserialize, Clone, Default)] +pub struct Network { + ssid: String, + password: String, + hidden: bool, +} + +#[derive(Serialize, Deserialize, Clone, Default)] +pub struct Profile { + name: String, + networks: Vec, + detect_ssids: Vec, + bootstrap: String, + exit_node: String, + tailscale: bool, + include_all_known: bool, +} + +#[derive(Serialize, Deserialize)] +pub struct Settings { + default_profile: String, + dns: String, + exit_node: String, + ping_host: String, + connectivity_url: String, + nmcli_wait: i64, + watch_interval: i64, +} + +#[derive(Serialize)] +pub struct BreadcrumbsConfig { + settings: Settings, + networks: Vec, + profiles: Vec, +} + +fn read_networks(doc: &DocumentMut) -> Vec { + let Some(aot) = doc.get("networks").and_then(Item::as_array_of_tables) else { + return Vec::new(); + }; + aot.iter() + .map(|t| Network { + ssid: t.get("ssid").and_then(Item::as_str).unwrap_or("").to_string(), + password: t.get("password").and_then(Item::as_str).unwrap_or("").to_string(), + hidden: t.get("hidden").and_then(Item::as_bool).unwrap_or(false), + }) + .collect() +} + +fn write_networks(doc: &mut DocumentMut, nets: &[Network]) { + let mut aot = ArrayOfTables::new(); + for n in nets { + let mut t = Table::new(); + t.insert("ssid", value(&n.ssid)); + t.insert("password", value(&n.password)); + t.insert("hidden", value(n.hidden)); + aot.push(t); + } + doc.as_table_mut().insert("networks", Item::ArrayOfTables(aot)); +} + +fn read_profiles(doc: &DocumentMut) -> Vec { + let Some(tbl) = doc.get("profiles").and_then(Item::as_table) else { + return Vec::new(); + }; + let str_list = |item: Option<&Item>| -> Vec { + item.and_then(Item::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default() + }; + tbl.iter() + .filter_map(|(name, item)| { + let p = item.as_table()?; + Some(Profile { + name: name.to_string(), + networks: str_list(p.get("networks")), + detect_ssids: str_list(p.get("detect_ssids")), + bootstrap: p.get("bootstrap").and_then(Item::as_str).unwrap_or("").to_string(), + exit_node: p.get("exit_node").and_then(Item::as_str).unwrap_or("").to_string(), + tailscale: p.get("tailscale").and_then(Item::as_bool).unwrap_or(false), + include_all_known: p.get("include_all_known").and_then(Item::as_bool).unwrap_or(false), + }) + }) + .collect() +} + +fn write_profiles(doc: &mut DocumentMut, profiles: &[Profile]) { + let mut tbl = Table::new(); + let to_arr = |items: &[String]| { + let mut a = Array::new(); + for s in items { + a.push(s.as_str()); + } + a + }; + for p in profiles { + if p.name.is_empty() { + continue; + } + let mut t = Table::new(); + t.insert("networks", value(to_arr(&p.networks))); + t.insert("tailscale", value(p.tailscale)); + t.insert("include_all_known", value(p.include_all_known)); + if !p.detect_ssids.is_empty() { + t.insert("detect_ssids", value(to_arr(&p.detect_ssids))); + } + if !p.bootstrap.is_empty() { + t.insert("bootstrap", value(&p.bootstrap)); + } + if !p.exit_node.is_empty() { + t.insert("exit_node", value(&p.exit_node)); + } + tbl.insert(&p.name, Item::Table(t)); + } + doc.as_table_mut().insert("profiles", Item::Table(tbl)); +} + +#[tauri::command] +pub fn get_breadcrumbs_config() -> BreadcrumbsConfig { + let doc = config::load_doc(&config_path()); + BreadcrumbsConfig { + settings: Settings { + // breadcrumbs' own default_profile_name() is "away", not "home". + default_profile: config::get_str(&doc, &["settings", "default_profile"]).unwrap_or_else(|| "away".into()), + dns: config::get_str(&doc, &["settings", "dns"]).unwrap_or_else(|| "1.1.1.1".into()), + exit_node: config::get_str(&doc, &["settings", "exit_node"]).unwrap_or_default(), + ping_host: config::get_str(&doc, &["settings", "ping_host"]).unwrap_or_else(|| "1.1.1.1".into()), + connectivity_url: config::get_str(&doc, &["settings", "connectivity_url"]) + .unwrap_or_else(|| "http://connectivitycheck.gstatic.com/generate_204".into()), + nmcli_wait: config::get_i64(&doc, &["settings", "nmcli_wait"]).unwrap_or(8), + watch_interval: config::get_i64(&doc, &["settings", "watch_interval"]).unwrap_or(12), + }, + networks: read_networks(&doc), + profiles: read_profiles(&doc), + } +} + +#[derive(Deserialize)] +pub struct SaveBreadcrumbsInput { + settings: Settings, + networks: Vec, + profiles: Vec, +} + +#[tauri::command] +pub fn save_breadcrumbs_config(input: SaveBreadcrumbsInput) -> Result<(), String> { + let path = config_path(); + let mut doc = config::load_doc(&path); + config::set_str(&mut doc, &["settings", "default_profile"], &input.settings.default_profile); + config::set_str(&mut doc, &["settings", "dns"], &input.settings.dns); + config::set_str_or_remove(&mut doc, &["settings", "exit_node"], &input.settings.exit_node); + config::set_str(&mut doc, &["settings", "ping_host"], &input.settings.ping_host); + config::set_str(&mut doc, &["settings", "connectivity_url"], &input.settings.connectivity_url); + config::set_i64(&mut doc, &["settings", "nmcli_wait"], input.settings.nmcli_wait); + config::set_i64(&mut doc, &["settings", "watch_interval"], input.settings.watch_interval); + write_networks(&mut doc, &input.networks); + write_profiles(&mut doc, &input.profiles); + config::save_doc(&path, &doc).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/breadpad.rs b/src-tauri/src/commands/breadpad.rs new file mode 100644 index 0000000..5f0fb5d --- /dev/null +++ b/src-tauri/src/commands/breadpad.rs @@ -0,0 +1,78 @@ +//! breadpad.toml — the breadpad notes/reminders config. Schema mirrors +//! breadpad-shared/src/config.rs (settings, model + model.ollama, reminders, +//! calendar). Edited non-destructively (calendar password + model paths +//! are preserved across saves). + +use serde::{Deserialize, Serialize}; + +use super::config; + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("breadpad/breadpad.toml") +} + +#[derive(Serialize, Deserialize)] +pub struct BreadpadConfig { + default_type: String, + workspace_tag: bool, + snooze_options: Vec, + archive_after_days: i64, + model_path: String, + tokenizer_path: String, + ollama_enabled: bool, + ollama_endpoint: String, + ollama_model: String, + ollama_confidence_threshold: f64, + reminders_default_morning: String, + reminders_missed_grace_minutes: i64, + calendar_enabled: bool, + calendar_url: String, + calendar_username: String, + calendar_password: String, +} + +#[tauri::command] +pub fn get_breadpad_config() -> BreadpadConfig { + let doc = config::load_doc(&config_path()); + BreadpadConfig { + default_type: config::get_str(&doc, &["settings", "default_type"]).unwrap_or_else(|| "note".into()), + workspace_tag: config::get_bool(&doc, &["settings", "workspace_tag"]).unwrap_or(true), + snooze_options: config::get_str_list(&doc, &["settings", "snooze_options"]), + archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"]).unwrap_or(30), + model_path: config::get_str(&doc, &["model", "path"]).unwrap_or_default(), + tokenizer_path: config::get_str(&doc, &["model", "tokenizer"]).unwrap_or_default(), + ollama_enabled: config::get_bool(&doc, &["model", "ollama", "enabled"]).unwrap_or(true), + ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"]).unwrap_or_default(), + ollama_model: config::get_str(&doc, &["model", "ollama", "model"]).unwrap_or_default(), + ollama_confidence_threshold: config::get_f64(&doc, &["model", "ollama", "confidence_threshold"]).unwrap_or(0.6), + reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"]).unwrap_or_else(|| "7:00".into()), + reminders_missed_grace_minutes: config::get_i64(&doc, &["reminders", "missed_grace_minutes"]).unwrap_or(60), + calendar_enabled: config::get_bool(&doc, &["calendar", "enabled"]).unwrap_or(false), + calendar_url: config::get_str(&doc, &["calendar", "url"]).unwrap_or_default(), + calendar_username: config::get_str(&doc, &["calendar", "username"]).unwrap_or_default(), + calendar_password: config::get_str(&doc, &["calendar", "password"]).unwrap_or_default(), + } +} + +#[tauri::command] +pub fn save_breadpad_config(cfg: BreadpadConfig) -> Result<(), String> { + let path = config_path(); + let mut doc = config::load_doc(&path); + config::set_str(&mut doc, &["settings", "default_type"], &cfg.default_type); + config::set_bool(&mut doc, &["settings", "workspace_tag"], cfg.workspace_tag); + config::set_str_list(&mut doc, &["settings", "snooze_options"], &cfg.snooze_options); + config::set_i64(&mut doc, &["settings", "archive_after_days"], cfg.archive_after_days); + config::set_str_or_remove(&mut doc, &["model", "path"], &cfg.model_path); + config::set_str_or_remove(&mut doc, &["model", "tokenizer"], &cfg.tokenizer_path); + config::set_bool(&mut doc, &["model", "ollama", "enabled"], cfg.ollama_enabled); + config::set_str_or_remove(&mut doc, &["model", "ollama", "endpoint"], &cfg.ollama_endpoint); + config::set_str_or_remove(&mut doc, &["model", "ollama", "model"], &cfg.ollama_model); + config::set_f64(&mut doc, &["model", "ollama", "confidence_threshold"], cfg.ollama_confidence_threshold); + config::set_str_or_remove(&mut doc, &["reminders", "default_morning"], &cfg.reminders_default_morning); + config::set_i64(&mut doc, &["reminders", "missed_grace_minutes"], cfg.reminders_missed_grace_minutes); + config::set_bool(&mut doc, &["calendar", "enabled"], cfg.calendar_enabled); + config::set_str_or_remove(&mut doc, &["calendar", "url"], &cfg.calendar_url); + config::set_str_or_remove(&mut doc, &["calendar", "username"], &cfg.calendar_username); + config::set_str_or_remove(&mut doc, &["calendar", "password"], &cfg.calendar_password); + config::save_doc(&path, &doc).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/breadpaper.rs b/src-tauri/src/commands/breadpaper.rs new file mode 100644 index 0000000..62a48f5 --- /dev/null +++ b/src-tauri/src/commands/breadpaper.rs @@ -0,0 +1,111 @@ +//! breadpaper — wallpaper manager. No config file to edit here; breadpaper +//! takes no persistent settings, just an image path via its CLI +//! (`breadpaper set ` / `breadpaper get`). These commands are a thin +//! backend for that CLI so wallpaper (and the pywal-driven theme it +//! generates) has a discoverable home in Settings. + +use serde::Serialize; +use std::path::{Path, PathBuf}; +use tokio::process::Command; + +/// Extensions breadpaper's own `validate()` accepts. +const WALLPAPER_EXTS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp"]; + +/// Caps how many thumbnails the library ever returns, and how deep the +/// recursive scan goes (the library is organized in subfolders, e.g. by +/// show/series, so a non-recursive scan would find nothing). +const MAX_LIBRARY_ITEMS: usize = 80; +const MAX_SCAN_DEPTH: usize = 4; + +pub fn wallpaper_library_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + PathBuf::from(home).join("Pictures/Backgrounds") +} + +fn is_wallpaper_file(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| WALLPAPER_EXTS.iter().any(|ext| ext.eq_ignore_ascii_case(e))) + .unwrap_or(false) +} + +fn scan_wallpapers(dir: &Path) -> Vec { + fn walk(dir: &Path, depth: usize, out: &mut Vec) { + if depth == 0 || out.len() >= MAX_LIBRARY_ITEMS { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let mut entries: Vec<_> = entries.flatten().collect(); + entries.sort_by_key(|e| e.file_name()); + for entry in entries { + if out.len() >= MAX_LIBRARY_ITEMS { + return; + } + let path = entry.path(); + if path.is_dir() { + walk(&path, depth - 1, out); + } else if is_wallpaper_file(&path) { + out.push(path); + } + } + } + let mut out = Vec::new(); + walk(dir, MAX_SCAN_DEPTH, &mut out); + out +} + +#[tauri::command] +pub async fn get_current_wallpaper() -> Option { + let out = Command::new("breadpaper").arg("get").output().await.ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +#[tauri::command] +pub async fn set_wallpaper(path: String) -> Result<(), String> { + let ok = Command::new("breadpaper") + .arg("set") + .arg(&path) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false); + if ok { + Ok(()) + } else { + Err("breadpaper failed — see terminal/journal".into()) + } +} + +#[derive(Serialize)] +pub struct LibraryEntry { + path: String, + name: String, +} + +/// Lists wallpapers under the library dir. Bounded/depth-limited (see the +/// constants above) — this is gated behind an explicit "Browse" click on +/// the frontend, not run at app launch, same "costs real time, gated +/// behind a button" posture as the network view's Wi-Fi scan. +#[tauri::command] +pub fn list_wallpaper_library() -> Vec { + scan_wallpapers(&wallpaper_library_dir()) + .into_iter() + .map(|p| LibraryEntry { + name: p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(), + path: p.to_string_lossy().to_string(), + }) + .collect() +} + +#[tauri::command] +pub fn wallpaper_library_dir_display() -> String { + wallpaper_library_dir().to_string_lossy().to_string() +} diff --git a/src-tauri/src/commands/breadsearch.rs b/src-tauri/src/commands/breadsearch.rs new file mode 100644 index 0000000..3b1be38 --- /dev/null +++ b/src-tauri/src/commands/breadsearch.rs @@ -0,0 +1,55 @@ +//! breadsearch/config.toml — semantic search indexer (breadmill) + GUI. +//! Schema mirrors breadsearch-shared::Config ([index], [search], [model], [power]). + +use serde::{Deserialize, Serialize}; + +use super::config; + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("breadsearch/config.toml") +} + +#[derive(Serialize, Deserialize)] +pub struct BreadsearchConfig { + power_enabled: bool, + run_on_battery: bool, + backend: String, + index_roots: Vec, + index_excludes: Vec, + index_extensions: Vec, + max_file_mb: f64, + search_limit: i64, + snippet_len: i64, +} + +#[tauri::command] +pub fn get_breadsearch_config() -> BreadsearchConfig { + let doc = config::load_doc(&config_path()); + BreadsearchConfig { + power_enabled: config::get_bool(&doc, &["power", "enabled"]).unwrap_or(true), + run_on_battery: config::get_bool(&doc, &["power", "run_on_battery"]).unwrap_or(false), + backend: config::get_str(&doc, &["model", "backend"]).unwrap_or_else(|| "cpu".into()), + index_roots: config::get_str_list(&doc, &["index", "roots"]), + index_excludes: config::get_str_list(&doc, &["index", "excludes"]), + index_extensions: config::get_str_list(&doc, &["index", "extensions"]), + max_file_mb: config::get_f64(&doc, &["index", "max_file_mb"]).unwrap_or(10.0), + search_limit: config::get_i64(&doc, &["search", "limit"]).unwrap_or(10), + snippet_len: config::get_i64(&doc, &["search", "snippet_len"]).unwrap_or(200), + } +} + +#[tauri::command] +pub fn save_breadsearch_config(cfg: BreadsearchConfig) -> Result<(), String> { + let path = config_path(); + let mut doc = config::load_doc(&path); + config::set_bool(&mut doc, &["power", "enabled"], cfg.power_enabled); + config::set_bool(&mut doc, &["power", "run_on_battery"], cfg.run_on_battery); + config::set_str(&mut doc, &["model", "backend"], &cfg.backend); + config::set_str_list(&mut doc, &["index", "roots"], &cfg.index_roots); + config::set_str_list(&mut doc, &["index", "excludes"], &cfg.index_excludes); + config::set_str_list(&mut doc, &["index", "extensions"], &cfg.index_extensions); + config::set_f64(&mut doc, &["index", "max_file_mb"], cfg.max_file_mb); + config::set_i64(&mut doc, &["search", "limit"], cfg.search_limit); + config::set_i64(&mut doc, &["search", "snippet_len"], cfg.snippet_len); + config::save_doc(&path, &doc).map_err(|e| e.to_string()) +} diff --git a/src/config/mod.rs b/src-tauri/src/commands/config.rs similarity index 100% rename from src/config/mod.rs rename to src-tauri/src/commands/config.rs diff --git a/src-tauri/src/commands/datetime.rs b/src-tauri/src/commands/datetime.rs new file mode 100644 index 0000000..2e8660b --- /dev/null +++ b/src-tauri/src/commands/datetime.rs @@ -0,0 +1,82 @@ +//! Timezone + NTP, over `timedatectl`. `systemd-timesyncd` is enabled by +//! default, so NTP sync is on out of the box — this is mostly for picking a +//! timezone and confirming sync is healthy. + +use serde::Serialize; +use tokio::process::Command; + +async fn show_property(prop: &str) -> String { + Command::new("timedatectl") + .args(["show", &format!("--property={prop}")]) + .output() + .await + .ok() + .and_then(|o| { + String::from_utf8_lossy(&o.stdout) + .trim() + .strip_prefix(&format!("{prop}=")) + .map(str::to_string) + }) + .unwrap_or_default() +} + +async fn list_timezones() -> Vec { + Command::new("timedatectl") + .arg("list-timezones") + .output() + .await + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect()) + .unwrap_or_default() +} + +async fn current_time_label() -> String { + Command::new("date") + .arg("+%A, %d %B %Y %H:%M") + .output() + .await + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default() +} + +#[derive(Serialize)] +pub struct DateTimeInfo { + current_time: String, + timezones: Vec, + current_tz: String, + ntp_enabled: bool, + ntp_synced: bool, +} + +#[tauri::command] +pub async fn get_datetime_info() -> DateTimeInfo { + DateTimeInfo { + current_time: current_time_label().await, + timezones: list_timezones().await, + current_tz: show_property("Timezone").await, + ntp_enabled: show_property("NTP").await == "yes", + ntp_synced: show_property("NTPSynchronized").await == "yes", + } +} + +#[tauri::command] +pub async fn set_timezone(tz: String) -> Result<(), String> { + let output = Command::new("pkexec") + .args(["timedatectl", "set-timezone", &tz]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err("Error — check the timezone name".into()) + } +} + +#[tauri::command] +pub async fn set_ntp_enabled(enabled: bool) -> Result<(), String> { + let val = if enabled { "true" } else { "false" }; + Command::new("pkexec").args(["timedatectl", "set-ntp", val]).status().await.map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/src-tauri/src/commands/firewall.rs b/src-tauri/src/commands/firewall.rs new file mode 100644 index 0000000..0841d7b --- /dev/null +++ b/src-tauri/src/commands/firewall.rs @@ -0,0 +1,89 @@ +//! ufw firewall rules. `ufw status` itself requires root (confirmed against +//! the installed ufw script), so unlike every other read-only panel, this +//! doesn't query eagerly — the frontend only calls `get_firewall_status` +//! when the user clicks Refresh, deferring the one unavoidable polkit +//! prompt to an explicit action instead of forcing it on app open. + +use serde::Serialize; +use tokio::process::Command; + +#[derive(Serialize, Clone)] +pub struct FirewallRule { + number: String, + text: String, +} + +#[derive(Serialize)] +pub struct FirewallStatus { + active: bool, + rules: Vec, +} + +/// One `pkexec ufw status numbered` call, parsed for both the active/ +/// inactive line and the numbered rules. +#[tauri::command] +pub async fn get_firewall_status() -> Result { + let output = Command::new("pkexec") + .args(["ufw", "status", "numbered"]) + .output() + .await + .map_err(|e| format!("couldn't run pkexec: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + match output.status.code() { + Some(127) => "no polkit authentication agent is available in this session".to_string(), + Some(code) => format!("pkexec exited with status {code}"), + None => "pkexec was terminated by a signal".to_string(), + } + } else { + stderr + }); + } + let text = String::from_utf8_lossy(&output.stdout); + let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active"); + let rules = text + .lines() + .filter_map(|l| { + let l = l.trim_start(); + if !l.starts_with('[') { + return None; + } + let (num, rest) = l.split_once(']')?; + let number = num.trim_start_matches('[').trim().to_string(); + Some(FirewallRule { number, text: rest.trim().to_string() }) + }) + .collect(); + Ok(FirewallStatus { active, rules }) +} + +#[tauri::command] +pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> { + let verb = if enabled { "enable" } else { "disable" }; + let output = Command::new("pkexec").args(["ufw", "--force", verb]).output().await.map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +#[tauri::command] +pub async fn add_firewall_rule(rule: String) -> Result<(), String> { + let output = Command::new("pkexec").args(["ufw", "allow", rule.trim()]).output().await.map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +#[tauri::command] +pub async fn remove_firewall_rule(number: String) -> Result<(), String> { + let output = Command::new("pkexec").args(["ufw", "--force", "delete", &number]).output().await.map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} diff --git a/src-tauri/src/commands/firmware.rs b/src-tauri/src/commands/firmware.rs new file mode 100644 index 0000000..f3a5c87 --- /dev/null +++ b/src-tauri/src/commands/firmware.rs @@ -0,0 +1,32 @@ +use serde::Serialize; + +#[derive(Serialize, Clone)] +pub struct FwDevice { + name: String, + version: String, +} + +#[tauri::command] +pub async fn get_updatable_firmware() -> Vec { + let Ok(output) = tokio::process::Command::new("fwupdmgr").args(["get-devices", "--json"]).output().await else { + return Vec::new(); + }; + let Ok(root) = serde_json::from_slice::(&output.stdout) else { + return Vec::new(); + }; + let Some(devices) = root.get("Devices").and_then(|d| d.as_array()) else { + return Vec::new(); + }; + devices + .iter() + .filter(|d| { + d.get("Flags").and_then(|f| f.as_array()).is_some_and(|flags| flags.iter().any(|f| f.as_str() == Some("updatable"))) + }) + .filter_map(|d| { + Some(FwDevice { + name: d.get("Name")?.as_str()?.to_string(), + version: d.get("Version").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(), + }) + }) + .collect() +} diff --git a/src-tauri/src/commands/hyprland.rs b/src-tauri/src/commands/hyprland.rs new file mode 100644 index 0000000..eeb1817 --- /dev/null +++ b/src-tauri/src/commands/hyprland.rs @@ -0,0 +1,110 @@ +//! Display: live-connected-monitor readout (from `hyprctl monitors -j`) plus +//! an editor for `hypr/monitors.json` — the monitor *layout* Hyprland itself +//! reads at login. Like appearance.rs/autostart.rs, a plain typed struct +//! round-tripped whole (JSON has no comments to preserve). +//! +//! `Default` here (the single wildcard rule) must stay in sync with +//! `scripts/display/monitors.lua`'s own `DEFAULT_MONITORS` fallback. + +use serde::{Deserialize, Serialize}; + +use super::config; + +#[derive(Clone, Serialize, Deserialize)] +pub struct MonitorRule { + output: String, + #[serde(default = "default_mode")] + mode: String, + #[serde(default = "default_position")] + position: String, + #[serde(default = "default_scale")] + scale: String, +} + +fn default_mode() -> String { + "preferred".to_string() +} +fn default_position() -> String { + "auto".to_string() +} +fn default_scale() -> String { + "auto".to_string() +} + +impl Default for MonitorRule { + fn default() -> Self { + Self { output: String::new(), mode: default_mode(), position: default_position(), scale: default_scale() } + } +} + +#[derive(Serialize, Deserialize)] +struct MonitorsFile { + #[serde(default)] + monitors: Vec, +} + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("hypr/monitors.json") +} + +fn hypr_path(name: &str) -> std::path::PathBuf { + config::config_dir().join("hypr").join(name) +} + +#[derive(Serialize)] +pub struct LiveMonitor { + name: String, + mode: String, +} + +#[tauri::command] +pub fn get_live_monitors() -> Vec { + let Some(value) = bread_utils::proc::run_json("hyprctl", &["monitors", "-j"], std::time::Duration::from_secs(3)) + else { + return Vec::new(); + }; + let Ok(monitors) = serde_json::from_value::>(value) else { + return Vec::new(); + }; + monitors + .iter() + .filter_map(|m| { + let name = m.get("name")?.as_str()?; + let w = m.get("width")?.as_u64()?; + let h = m.get("height")?.as_u64()?; + let refresh = m.get("refreshRate")?.as_f64()?; + Some(LiveMonitor { name: name.to_string(), mode: format!("{w}x{h} @ {refresh:.0}Hz") }) + }) + .collect() +} + +#[tauri::command] +pub fn get_monitor_rules() -> Vec { + std::fs::read_to_string(config_path()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .filter(|f| !f.monitors.is_empty()) + .map(|f| f.monitors) + .unwrap_or_else(|| vec![MonitorRule::default()]) +} + +#[tauri::command] +pub fn save_monitor_rules(rules: Vec) -> Result<(), String> { + let file = MonitorsFile { monitors: rules }; + let json = serde_json::to_string_pretty(&file).map_err(|e| e.to_string())?; + config::atomic_write(&config_path(), &json).map_err(|e| e.to_string()) +} + +/// Opens `hyprland.lua` in `$EDITOR` (nano if unset) inside a terminal — +/// spawning a TUI editor with no terminal to attach to is a silent no-op. +#[tauri::command] +pub fn open_hyprland_conf() { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); + let path = hypr_path("hyprland.lua"); + let _ = std::process::Command::new("kitty").args(["-e", &editor]).arg(path).spawn(); +} + +#[tauri::command] +pub fn open_keybinds_viewer() { + let _ = std::process::Command::new("breadhelp").spawn(); +} diff --git a/src-tauri/src/commands/keybinds.rs b/src-tauri/src/commands/keybinds.rs new file mode 100644 index 0000000..f46cf7a --- /dev/null +++ b/src-tauri/src/commands/keybinds.rs @@ -0,0 +1,354 @@ +//! hypr/binds.json — Hyprland keybind editor, read by +//! `scripts/ui/binds.lua` on the Hyprland side (see hyprland.lua). +//! +//! This file has TWO real on-disk shapes, and which one applies depends on +//! the machine: +//! +//! - **Flat** (`default_mods` + a single `bindings` array) — what BOS itself +//! ships (`iso/airootfs/etc/skel/.config/hypr/binds.json`, read by the +//! BOS-shipped `scripts/input/binds.lua`). No layouts. Each bind carries +//! `label`/`category`/`demo_cmd` fields breadhelp depends on for its +//! cheatsheet and guided tour. +//! - **MultiLayout** (`globals`/`common`/one `layouts` entry per keyboard +//! layout) — a personal, per-machine schema some dev setups use instead, +//! read by a different, personal `binds.lua`. +//! +//! `SchemaKind` detects which shape is actually on disk (from the top-level +//! key set) and `save()` always emits that SAME shape back — see +//! `SchemaKind::detect` and `save_to`. Loading a real BOS (Flat) file under +//! the wrong assumption and saving it back would silently drop the +//! `bindings` key entirely — still valid JSON, so the Lua `pcall` +//! failsafes on the reading side would never catch it. +//! +//! Each bind's shape also varies by `action` (`exec` needs `command`, +//! `move_dir` needs `direction`, workspace-focus needs `workspace`, mouse +//! binds need `options.mouse`, ...). Rather than modelling every action's +//! field set as its own row layout — which would mean a combinatorial +//! explosion of widgets and silently dropping any action shape this editor +//! doesn't already know about — `action`/`key`/`mods` get real fields (the +//! ones every bind has) and everything else round-trips through +//! `#[serde(flatten)]` into a small inline-JSON column, same trade-off the +//! other Hyprland JSON editors (appearance.rs, hyprland.rs, autostart.rs) +//! already make: no comments to preserve, so this is a whole-file round +//! trip, not the `toml_edit`/`Doc` path-based pattern. + +use std::collections::BTreeMap; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::config; + +/// Which on-disk shape `binds.json` was loaded as. Detected once at load +/// time from the top-level key set present in the JSON, then pinned for the +/// lifetime of the editor session (round-tripped to the frontend and back +/// on save) so `save()` always writes back the same shape it read, +/// regardless of what the in-memory model happens to have populated. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SchemaKind { + /// `{ "default_mods": [...], "bindings": [...] }` — BOS's real shipped + /// shape. No layout-switching UI applies; there's nothing to switch. + Flat, + /// `{ "active_layout", "default_mods", "globals", "common", "layouts" }` + /// — the personal, multi-keyboard-layout schema this editor was + /// originally built against. + MultiLayout, + /// Neither key set matched — an empty file, a totally different shape, + /// or unparsable JSON. Loading still renders (empty), but `save()` + /// refuses outright rather than guessing a shape and risking silently + /// destroying whatever the real file's actual schema was. + Unknown, +} + +impl SchemaKind { + fn detect(top_level: &Map) -> Self { + if top_level.contains_key("bindings") { + SchemaKind::Flat + } else if top_level.contains_key("globals") + || top_level.contains_key("common") + || top_level.contains_key("layouts") + { + SchemaKind::MultiLayout + } else { + SchemaKind::Unknown + } + } +} + +#[derive(Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct Bind { + action: String, + #[serde(skip_serializing_if = "Option::is_none")] + key: Option, + /// `None` (key omitted) means "fall back to `default_mods`"; `Some(_)` + /// — including `Some(vec![])` — means "use exactly this, even if that's + /// no modifiers at all." Real BOS binds rely on that distinction (e.g. + /// media keys pin `"mods": []` on purpose so they never inherit + /// `default_mods`), so this can't collapse both cases to "omit the + /// key" the way a bare `Vec` with `skip_serializing_if` would — + /// that would silently turn an explicit "no mods" into "use the + /// default" the next time this editor saves the file. + #[serde(skip_serializing_if = "Option::is_none")] + mods: Option>, + /// Everything else a bind can carry — `command`, `direction`, + /// `workspace`, `x`, `y`, `layout`, `options`, `label`, `category`, + /// `demo_cmd`, and any action shape not yet invented. Edited on the + /// frontend as compact inline JSON. This flatten is what keeps + /// breadhelp's `label`/`category`/`demo_cmd` fields — which this + /// editor's UI has no dedicated widgets for — alive across a full + /// load/save round trip instead of being silently dropped. + #[serde(flatten)] + extra: Map, +} + +#[derive(Serialize, Deserialize, Default)] +#[serde(default)] +pub struct BindsFile { + #[serde(skip_serializing_if = "String::is_empty")] + active_layout: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + default_mods: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + globals: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + common: Vec, + /// A `BTreeMap` (alphabetical), not the file's original insertion order + /// — same "whole-file round trip, formatting not preserved" trade-off as + /// the rest of this file's JSON-config siblings. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + layouts: BTreeMap>, + /// Flat-schema bind list — BOS's real shipped shape. Only ever populated + /// when `SchemaKind::Flat` was detected at load time; stays empty (and + /// so omitted, see `to_json`) for a MultiLayout file. + #[serde(skip_serializing_if = "Vec::is_empty")] + bindings: Vec, +} + +/// What the frontend fetches once at load: which shape was detected, plus +/// the data itself. Sent back verbatim to `save_keybinds` so a stray code +/// path on either side can't accidentally save `file` without knowing which +/// shape it's supposed to come back out as. +#[derive(Serialize)] +pub struct BindsPayload { + kind: SchemaKind, + file: BindsFile, +} + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("hypr/binds.json") +} + +fn load_from(path: &Path) -> (BindsFile, SchemaKind) { + let Ok(text) = std::fs::read_to_string(path) else { + // No file yet (fresh install/environment) — nothing on disk to + // misdetect or destroy. BOS itself ships the flat schema, so a new + // file defaults to Flat rather than the personal MultiLayout schema + // this editor originally assumed. + return (BindsFile::default(), SchemaKind::Flat); + }; + let kind = match serde_json::from_str::(&text) { + Ok(Value::Object(top_level)) => SchemaKind::detect(&top_level), + // Unparsable JSON, or valid JSON that isn't even an object — treat + // as Unknown so save() refuses rather than silently overwriting + // whatever this file actually was with an empty default. + _ => SchemaKind::Unknown, + }; + let file: BindsFile = serde_json::from_str(&text).unwrap_or_default(); + (file, kind) +} + +fn load() -> (BindsFile, SchemaKind) { + load_from(&config_path()) +} + +/// Serialize `f` in exactly the shape `kind` implies: +/// - `Flat` -> `{ "default_mods": [...], "bindings": [...] }`, nothing else +/// — no `active_layout`/`globals`/`common`/`layouts` keys, even if the +/// struct happens to carry empty values for them. +/// - `MultiLayout` -> today's existing shape (whatever fields are +/// non-empty), via `BindsFile`'s own `Serialize` impl. +fn to_json(f: &BindsFile, kind: SchemaKind) -> Value { + match kind { + SchemaKind::Flat => serde_json::json!({ + "default_mods": f.default_mods, + "bindings": f.bindings, + }), + SchemaKind::MultiLayout => serde_json::to_value(f).unwrap_or(Value::Null), + SchemaKind::Unknown => Value::Null, + } +} + +fn save_to(path: &Path, f: &BindsFile, kind: SchemaKind) -> std::io::Result<()> { + if kind == SchemaKind::Unknown { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "binds.json's schema wasn't recognized (expected a \"bindings\" key, or one of \ + \"globals\"/\"common\"/\"layouts\") — refusing to save so nothing gets silently \ + overwritten. Fix or remove the file, then reopen this panel.", + )); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let text = serde_json::to_string_pretty(&to_json(f, kind)).unwrap_or_default(); + config::atomic_write(path, &text) +} + +fn save(f: &BindsFile, kind: SchemaKind) -> std::io::Result<()> { + save_to(&config_path(), f, kind) +} + +#[tauri::command] +pub fn get_keybinds() -> BindsPayload { + let (file, kind) = load(); + BindsPayload { kind, file } +} + +#[tauri::command] +pub fn save_keybinds(file: BindsFile, kind: SchemaKind) -> Result<(), String> { + save(&file, kind).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A representative slice of BOS's real shipped `binds.json` + /// (`iso/airootfs/etc/skel/.config/hypr/binds.json`, flat schema) — + /// chosen to exercise the extra-field variety breadhelp reads (`label`, + /// `category`, `demo_cmd`), an explicit `mods: []` override, a nested + /// `options` object, and both integer and string `workspace` values. + /// This is the fixture that would have caught the original GTK-editor + /// bug: mis-detecting this shape as MultiLayout and silently dropping + /// the whole `bindings` array on save. + const REAL_BOS_FLAT_FIXTURE: &str = r#"{ + "default_mods": ["SUPER"], + "bindings": [ + { "action": "exec", "command": "kitty", "key": "RETURN", "label": "Open a terminal", "category": "apps" }, + { "action": "close", "key": "BACKSPACE", "label": "Close the focused window", "category": "windows" }, + { "action": "exec", "command": "breadbox", "key": "SPACE", "label": "Open the app launcher (breadbox)", "category": "apps", "demo_cmd": "breadbox" }, + { "action": "exec", "command": "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+", "key": "XF86AudioRaiseVolume", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Volume up", "category": "media" }, + { "action": "focus", "workspace": 1, "key": "1", "label": "Switch to workspace 1", "category": "workspaces" }, + { "action": "focus", "workspace": "e+1", "key": "bracketright", "label": "Next workspace", "category": "workspaces" }, + { "action": "resize_dir", "x": 30, "y": 0, "key": "right", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow right)", "category": "focus" }, + { "action": "drag", "key": "mouse:272", "options": { "mouse": true }, "label": "Move a window (drag)", "category": "mouse" } + ] +}"#; + + fn parse(text: &str) -> (BindsFile, SchemaKind) { + let kind = match serde_json::from_str::(text) { + Ok(Value::Object(top)) => SchemaKind::detect(&top), + _ => SchemaKind::Unknown, + }; + let file: BindsFile = serde_json::from_str(text).unwrap_or_default(); + (file, kind) + } + + #[test] + fn detects_flat_schema_from_real_bos_binds_json() { + let (_, kind) = parse(REAL_BOS_FLAT_FIXTURE); + assert_eq!(kind, SchemaKind::Flat); + } + + #[test] + fn round_trips_real_bos_flat_binds_json_through_load_and_save() { + let (file, kind) = parse(REAL_BOS_FLAT_FIXTURE); + assert_eq!(kind, SchemaKind::Flat); + + let original: Value = serde_json::from_str(REAL_BOS_FLAT_FIXTURE).unwrap(); + let saved = to_json(&file, kind); + + // Flat save must emit EXACTLY {default_mods, bindings} — no + // active_layout/globals/common/layouts keys leaking in. + let saved_obj = saved.as_object().expect("flat save must be a JSON object"); + assert_eq!( + saved_obj.keys().cloned().collect::>(), + ["default_mods", "bindings"].into_iter().map(String::from).collect(), + "Flat schema must round-trip as exactly {{default_mods, bindings}}" + ); + + // The `bindings` array — and every per-bind extra field (label, + // category, demo_cmd, mods, options, integer vs string workspace, + // ...) — must survive the round trip semantically untouched. + assert_eq!(saved["bindings"], original["bindings"]); + assert_eq!(saved["default_mods"], original["default_mods"]); + } + + #[test] + fn round_trip_via_files_preserves_bindings_key_and_extras() { + let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("binds.json"); + std::fs::write(&path, REAL_BOS_FLAT_FIXTURE).unwrap(); + + let (file, kind) = load_from(&path); + assert_eq!(kind, SchemaKind::Flat); + save_to(&path, &file, kind).unwrap(); + + let saved_text = std::fs::read_to_string(&path).unwrap(); + let saved: Value = serde_json::from_str(&saved_text).unwrap(); + let original: Value = serde_json::from_str(REAL_BOS_FLAT_FIXTURE).unwrap(); + + assert!(saved.get("bindings").is_some(), "bindings key must survive a load -> save round trip"); + assert_eq!(saved["bindings"], original["bindings"]); + assert_eq!(saved["default_mods"], original["default_mods"]); + + // Backup safety net: a second save must leave `.bak` holding the + // prior contents. + save_to(&path, &file, kind).unwrap(); + let backup_path = dir.join("binds.json.bak"); + assert!(backup_path.exists(), "save must back up the previous file"); + let backup: Value = serde_json::from_str(&std::fs::read_to_string(&backup_path).unwrap()).unwrap(); + assert_eq!(backup["bindings"], original["bindings"]); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn detects_and_round_trips_multi_layout_schema() { + let text = r#"{ + "active_layout": "qwerty", + "default_mods": ["SUPER"], + "globals": [{ "action": "exec", "command": "kitty", "key": "RETURN" }], + "common": [], + "layouts": { "qwerty": [{ "action": "close", "key": "BACKSPACE" }] } + }"#; + let (file, kind) = parse(text); + assert_eq!(kind, SchemaKind::MultiLayout); + + let saved = to_json(&file, kind); + assert!(saved.get("bindings").is_none(), "MultiLayout save must not emit a flat `bindings` key"); + assert_eq!(saved["active_layout"], "qwerty"); + assert_eq!(saved["layouts"]["qwerty"][0]["action"], "close"); + assert_eq!(saved["globals"][0]["command"], "kitty"); + } + + #[test] + fn unknown_schema_is_detected_and_refuses_to_save() { + let text = r#"{ "some_other_shape": true }"#; + let (file, kind) = parse(text); + assert_eq!(kind, SchemaKind::Unknown); + + let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-unknown-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("binds.json"); + + let result = save_to(&path, &file, kind); + assert!(result.is_err(), "save() must refuse when schema kind is Unknown"); + assert!(!path.exists(), "refusing to save must not create/touch the target file"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn missing_file_defaults_to_flat_not_multi_layout() { + let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-missing-test-{}", std::process::id())); + // Don't create the file at all. + let path = dir.join("binds.json"); + let (_, kind) = load_from(&path); + assert_eq!(kind, SchemaKind::Flat); + } +} diff --git a/src/ui/views/mod.rs b/src-tauri/src/commands/mod.rs similarity index 86% rename from src/ui/views/mod.rs rename to src-tauri/src/commands/mod.rs index fca274f..e26b415 100644 --- a/src/ui/views/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod breadcrumbs; pub mod breadpad; pub mod breadpaper; pub mod breadsearch; +pub mod config; pub mod datetime; pub mod firewall; pub mod firmware; @@ -19,6 +20,9 @@ pub mod keybinds; pub mod network; pub mod packages; pub mod power; +pub mod service; pub mod snapshots; pub mod sound; +pub mod streaming; +pub mod theme; pub mod users; diff --git a/src-tauri/src/commands/network.rs b/src-tauri/src/commands/network.rs new file mode 100644 index 0000000..cf0254f --- /dev/null +++ b/src-tauri/src/commands/network.rs @@ -0,0 +1,117 @@ +//! Wi-Fi + Ethernet over `nmcli`. NetworkManager lets the active session +//! user manage connections via polkit already, so the common paths (scan, +//! connect, toggle radio) need no `pkexec`. VPN import, 802.1x, and other +//! edge cases are punted to `nm-connection-editor` via the Advanced button. + +use serde::Serialize; +use std::collections::{HashMap, HashSet}; +use tokio::process::Command; + +#[derive(Serialize, Clone)] +pub struct WifiNetwork { + ssid: String, + signal: i32, + secured: bool, + active: bool, + known: bool, +} + +async fn radio_enabled() -> bool { + Command::new("nmcli") + .args(["radio", "wifi"]) + .output() + .await + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "enabled") + .unwrap_or(false) +} + +async fn ethernet_status() -> Option { + let out = Command::new("nmcli").args(["-t", "-f", "DEVICE,TYPE,STATE"]).arg("dev").output().await.ok()?; + let text = String::from_utf8_lossy(&out.stdout); + text.lines().find_map(|l| { + let mut cols = l.splitn(3, ':'); + let (dev, ty, state) = (cols.next()?, cols.next()?, cols.next()?); + (ty == "ethernet").then(|| format!("{dev}: {state}")) + }) +} + +async fn known_connection_names() -> HashSet { + let Ok(out) = Command::new("nmcli").args(["-t", "-f", "NAME"]).arg("con").arg("show").output().await else { + return HashSet::new(); + }; + String::from_utf8_lossy(&out.stdout).lines().map(str::to_string).collect() +} + +#[derive(Serialize)] +pub struct NetworkInfo { + radio_enabled: bool, + ethernet: Option, +} + +#[tauri::command] +pub async fn get_network_info() -> NetworkInfo { + NetworkInfo { radio_enabled: radio_enabled().await, ethernet: ethernet_status().await } +} + +#[tauri::command] +pub async fn set_wifi_radio(enabled: bool) -> Result<(), String> { + let val = if enabled { "on" } else { "off" }; + Command::new("nmcli").args(["radio", "wifi", val]).status().await.map_err(|e| e.to_string())?; + Ok(()) +} + +/// Scan + list Wi-Fi networks, deduplicated by SSID (keeping the strongest +/// signal — the same AP shows once per band/BSSID otherwise). +#[tauri::command] +pub async fn scan_wifi() -> Vec { + let _ = Command::new("nmcli").args(["dev", "wifi", "rescan"]).output().await; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let Ok(out) = Command::new("nmcli").args(["-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi", "list"]).output().await + else { + return Vec::new(); + }; + let known = known_connection_names().await; + let text = String::from_utf8_lossy(&out.stdout); + let mut by_ssid: HashMap = HashMap::new(); + for line in text.lines() { + let mut cols = line.splitn(4, ':'); + let (ssid, signal, security, in_use) = (cols.next(), cols.next(), cols.next(), cols.next()); + let Some(ssid) = ssid.filter(|s| !s.is_empty()) else { continue }; + let signal: i32 = signal.and_then(|s| s.parse().ok()).unwrap_or(0); + let net = WifiNetwork { + ssid: ssid.to_string(), + signal, + secured: security.map(|s| !s.is_empty()).unwrap_or(false), + active: in_use == Some("*"), + known: known.contains(ssid), + }; + by_ssid.entry(ssid.to_string()).and_modify(|existing| if net.signal > existing.signal { *existing = net.clone() }).or_insert(net); + } + let mut list: Vec<_> = by_ssid.into_values().collect(); + list.sort_by(|a, b| b.signal.cmp(&a.signal)); + list +} + +#[tauri::command] +pub async fn connect_wifi(ssid: String, password: Option) -> Result<(), String> { + let known = known_connection_names().await; + let output = if let Some(password) = password { + Command::new("nmcli").args(["dev", "wifi", "connect", &ssid, "password", &password]).output().await + } else if known.contains(&ssid) { + Command::new("nmcli").args(["con", "up", &ssid]).output().await + } else { + Command::new("nmcli").args(["dev", "wifi", "connect", &ssid]).output().await + } + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +#[tauri::command] +pub fn open_connection_editor() { + let _ = std::process::Command::new("nm-connection-editor").spawn(); +} diff --git a/src-tauri/src/commands/packages.rs b/src-tauri/src/commands/packages.rs new file mode 100644 index 0000000..29e522b --- /dev/null +++ b/src-tauri/src/commands/packages.rs @@ -0,0 +1,39 @@ +use serde::Serialize; +use std::collections::HashMap; + +#[derive(Serialize, Clone)] +pub struct InstalledPackage { + name: String, + version: String, +} + +#[tauri::command] +pub fn get_installed_packages() -> Vec { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let path = std::path::Path::new(&home).join(".local/state/bakery/installed.json"); + + let Ok(text) = std::fs::read_to_string(&path) else { + return Vec::new(); + }; + let Ok(mut parsed) = serde_json::from_str::(&text) else { + return Vec::new(); + }; + // installed.json is {"packages": {name: {version, binaries, services}}}, + // not a flat map of package name to metadata. + let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else { + return Vec::new(); + }; + let Ok(packages) = serde_json::from_value::>(packages) else { + return Vec::new(); + }; + + let mut list: Vec = packages + .into_iter() + .map(|(name, val)| { + let version = val.get("version").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); + InstalledPackage { name, version } + }) + .collect(); + list.sort_by(|a, b| a.name.cmp(&b.name)); + list +} diff --git a/src-tauri/src/commands/power.rs b/src-tauri/src/commands/power.rs new file mode 100644 index 0000000..3d82def --- /dev/null +++ b/src-tauri/src/commands/power.rs @@ -0,0 +1,145 @@ +//! Battery/power status (upower), brightness (brightnessctl), and TLP's +//! current profile. Deliberately no AC/Battery/Performance *switcher* — TLP +//! automatically picks a profile by power source, so this only exposes +//! controls for things that are actually user choices: brightness, and +//! charge thresholds where the hardware supports them. + +use serde::Serialize; +use tokio::process::Command; + +async fn upower_device(kind: &str) -> Option { + let out = Command::new("upower").arg("-e").output().await.ok()?; + String::from_utf8_lossy(&out.stdout).lines().find(|l| l.to_lowercase().contains(kind)).map(str::to_string) +} + +async fn upower_field(device: &str, field: &str) -> Option { + let out = Command::new("upower").args(["-i", device]).output().await.ok()?; + let text = String::from_utf8_lossy(&out.stdout); + text.lines() + .find(|l| l.trim_start().starts_with(field)) + .and_then(|l| l.split(':').nth(1)) + .map(|v| v.trim().to_string()) +} + +async fn battery_summary() -> Vec<(String, String)> { + let Some(bat) = upower_device("bat").await else { + return vec![("Battery".to_string(), "No battery detected".to_string())]; + }; + let mut rows = Vec::new(); + if let Some(state) = upower_field(&bat, "state").await { + rows.push(("Status".to_string(), state)); + } + if let Some(pct) = upower_field(&bat, "percentage").await { + rows.push(("Charge".to_string(), pct)); + } + let t = match upower_field(&bat, "time to empty").await { + Some(t) => Some(t), + None => upower_field(&bat, "time to full").await, + }; + if let Some(t) = t { + rows.push(("Time remaining".to_string(), t)); + } + let full: Option = upower_field(&bat, "energy-full").await.and_then(|v| v.split_whitespace().next()?.parse().ok()); + let design: Option = + upower_field(&bat, "energy-full-design").await.and_then(|v| v.split_whitespace().next()?.parse().ok()); + if let (Some(full), Some(design)) = (full, design) { + if design > 0.0 { + rows.push(("Battery health".to_string(), format!("{:.0}% of design capacity", full / design * 100.0))); + } + } + rows +} + +async fn power_source() -> String { + match upower_device("ac").await { + Some(ac) => match upower_field(&ac, "online").await { + Some(v) if v == "yes" => "AC power".to_string(), + Some(_) => "Battery".to_string(), + None => "Unknown".to_string(), + }, + None => "Unknown".to_string(), + } +} + +async fn tlp_profile() -> Option { + let out = Command::new("tlp-stat").arg("-s").output().await.ok()?; + let text = String::from_utf8_lossy(&out.stdout); + text.lines().find(|l| l.trim_start().starts_with("TLP profile")).and_then(|l| l.split('=').nth(1)).map(|v| v.trim().to_string()) +} + +async fn brightness_device() -> Option { + let out = Command::new("brightnessctl").output().await.ok()?; + String::from_utf8_lossy(&out.stdout).lines().find(|l| l.starts_with("Device")).and_then(|l| l.split('\'').nth(1)).map(str::to_string) +} + +async fn brightness_pct() -> Option { + let out = Command::new("brightnessctl").output().await.ok()?; + let text = String::from_utf8_lossy(&out.stdout); + text.lines() + .find(|l| l.contains("Current brightness")) + .and_then(|l| l.split('(').nth(1)) + .and_then(|v| v.trim_end_matches("%)").parse().ok()) +} + +/// Charge-threshold sysfs paths, only Some when the running kernel driver +/// actually exposes them — genuinely hardware-dependent, not every install +/// will have this. +fn charge_threshold_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)> { + let base = std::path::Path::new("/sys/class/power_supply"); + let entries = std::fs::read_dir(base).ok()?; + for entry in entries.flatten() { + let start = entry.path().join("charge_control_start_threshold"); + let end = entry.path().join("charge_control_end_threshold"); + if start.exists() && end.exists() { + return Some((start, end)); + } + } + None +} + +fn read_threshold(path: &std::path::Path) -> i64 { + std::fs::read_to_string(path).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(100) +} + +#[derive(Serialize)] +pub struct PowerInfo { + battery: Vec<(String, String)>, + power_source: String, + brightness_pct: Option, + charge_start: Option, + charge_end: Option, + tlp_profile: Option, +} + +#[tauri::command] +pub async fn get_power_info() -> PowerInfo { + let charge = charge_threshold_paths(); + PowerInfo { + battery: battery_summary().await, + power_source: power_source().await, + brightness_pct: brightness_pct().await, + charge_start: charge.as_ref().map(|(s, _)| read_threshold(s)), + charge_end: charge.as_ref().map(|(_, e)| read_threshold(e)), + tlp_profile: tlp_profile().await, + } +} + +#[tauri::command] +pub async fn set_brightness(percent: i64) -> Result<(), String> { + let Some(device) = brightness_device().await else { + return Err("No controllable backlight found".into()); + }; + let pct = format!("{percent}%"); + Command::new("brightnessctl").args(["--device", &device, "set", &pct]).status().await.map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub async fn set_charge_threshold(which: String, percent: i64) -> Result<(), String> { + let Some((start, end)) = charge_threshold_paths() else { + return Err("No charge threshold support on this hardware".into()); + }; + let path = if which == "start" { start } else { end }; + Command::new("pkexec").args(["tee", &path.display().to_string()]).arg(percent.to_string()).output().await.map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs new file mode 100644 index 0000000..c99fee4 --- /dev/null +++ b/src-tauri/src/commands/service.rs @@ -0,0 +1,77 @@ +//! Live systemd `--user` unit status plus start/stop/restart/logs — every +//! bread-ecosystem panel whose app is actually a daemon (not just a config +//! file) gets this. Ported from `src/ui/widgets.rs`'s `service_control`; +//! the `critical`-unit confirm-before-stop behavior moves to the frontend +//! (a confirm step gating the call to `service_action`), since that's UI +//! policy, not something the command itself needs to know. + +use serde::{Deserialize, Serialize}; +use tokio::process::Command; + +#[derive(Serialize)] +pub struct ServiceStatus { + active: bool, + enabled: bool, +} + +#[derive(Deserialize)] +pub enum ServiceAction { + Start, + Stop, + Restart, +} + +async fn systemctl_active(unit: &str) -> bool { + Command::new("systemctl") + .args(["--user", "is-active", "--quiet", unit]) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) +} + +async fn systemctl_enabled(unit: &str) -> bool { + Command::new("systemctl") + .args(["--user", "is-enabled", "--quiet", unit]) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) +} + +#[tauri::command] +pub async fn get_service_status(unit: String) -> ServiceStatus { + ServiceStatus { + active: systemctl_active(&unit).await, + enabled: systemctl_enabled(&unit).await, + } +} + +#[tauri::command] +pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), String> { + let verb = match action { + ServiceAction::Start => "start", + ServiceAction::Stop => "stop", + ServiceAction::Restart => "restart", + }; + let output = Command::new("systemctl") + .args(["--user", verb, &unit]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +/// Opens a terminal following the unit's journal — same as today's GTK +/// panel, no reason to pull an open-ended `journalctl -f` tail into the +/// webview. +#[tauri::command] +pub fn open_logs(unit: String) { + let _ = std::process::Command::new("kitty") + .args(["-e", "journalctl", "--user", "-u", &unit, "-f"]) + .spawn(); +} diff --git a/src-tauri/src/commands/snapshots.rs b/src-tauri/src/commands/snapshots.rs new file mode 100644 index 0000000..f80a509 --- /dev/null +++ b/src-tauri/src/commands/snapshots.rs @@ -0,0 +1,62 @@ +use serde::Serialize; +use tokio::process::Command; + +#[derive(Serialize, Clone)] +pub struct SnapshotRow { + number: String, + date: String, + description: String, +} + +/// `Err` carries snapper's trimmed stderr — distinct from `Ok(vec![])` +/// (snapper works fine, there just aren't any snapshots yet). +#[tauri::command] +pub async fn get_snapshots() -> Result, String> { + // NOTE: the real flag is --columns, not --output-cols (snapper rejects + // that outright) — confirmed against snapper 0.13's own --help. + let output = Command::new("snapper") + .args(["list", "--columns", "number,date,description"]) + .output() + .await + .map_err(|e| e.to_string())?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + eprintln!("bos-settings: snapper list failed: {stderr}"); + return Err(stderr); + } + + let text = String::from_utf8_lossy(&output.stdout); + Ok(text + .lines() + .skip(2) // header + separator + .filter_map(|line| { + let mut cols = line.splitn(3, '|'); + let number = cols.next()?.trim().to_string(); + // Snapshot 0 ("current") always exists, can't be rolled back to + // or deleted, and isn't a real snapshot. + if number == "0" { + return None; + } + Some(SnapshotRow { number, date: cols.next()?.trim().to_string(), description: cols.next()?.trim().to_string() }) + }) + .collect()) +} + +#[tauri::command] +pub async fn delete_snapshot(number: String) -> Result<(), String> { + let output = Command::new("snapper").args(["delete", &number]).status().await.map_err(|e| e.to_string())?; + if output.success() { + Ok(()) + } else { + Err("snapper delete exited with an error — the snapshot wasn't removed.".into()) + } +} + +/// BOS boots with root pinned to a named subvolume (grub emits +/// rootflags=subvol=@), so `snapper rollback`'s usual mechanism has no +/// effect here. The real way back is grub-btrfs, which generates a GRUB +/// submenu entry per snapshot — this just reboots so the user can pick it. +#[tauri::command] +pub fn reboot_system() { + let _ = std::process::Command::new("systemctl").arg("reboot").spawn(); +} diff --git a/src-tauri/src/commands/sound.rs b/src-tauri/src/commands/sound.rs new file mode 100644 index 0000000..4cc2fb4 --- /dev/null +++ b/src-tauri/src/commands/sound.rs @@ -0,0 +1,102 @@ +//! Output/input volume and device selection over PipeWire's pulse +//! compatibility layer (`pactl`) — the same surface hyprland.lua's media +//! keys already use via `wpctl`. `pactl` is used here instead because it +//! can enumerate devices with human-readable descriptions and switch the +//! default in one command; `wpctl` cannot easily do either. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tokio::process::Command; + +#[derive(Deserialize, Serialize, Clone)] +pub struct SoundDevice { + name: String, + description: String, + mute: bool, + percent: f64, +} + +#[derive(Deserialize)] +struct RawDevice { + name: String, + description: String, + mute: bool, + volume: HashMap, +} + +#[derive(Deserialize)] +struct RawVolumeChannel { + value_percent: String, +} + +impl RawDevice { + fn percent(&self) -> f64 { + self.volume + .values() + .next() + .and_then(|v| v.value_percent.trim_end_matches('%').trim().parse::().ok()) + .unwrap_or(0.0) + } +} + +async fn list_devices(kind: &str) -> Vec { + let Ok(output) = Command::new("pactl").args(["-f", "json", "list", kind]).output().await else { + return Vec::new(); + }; + let raw: Vec = serde_json::from_slice(&output.stdout).unwrap_or_default(); + raw.into_iter() + .map(|d| SoundDevice { name: d.name.clone(), description: d.description.clone(), mute: d.mute, percent: d.percent() }) + .collect() +} + +async fn default_device_name(kind: &str) -> Option { + let flag = if kind == "sinks" { "get-default-sink" } else { "get-default-source" }; + Command::new("pactl") + .arg(flag) + .output() + .await + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) +} + +#[derive(Serialize)] +pub struct SoundSection { + devices: Vec, + default_name: Option, +} + +#[tauri::command] +pub async fn get_sound_section(kind: String) -> SoundSection { + let devices = list_devices(&kind).await; + let default_name = default_device_name(&kind).await; + SoundSection { devices, default_name } +} + +#[tauri::command] +pub async fn set_default_sound_device(kind: String, name: String) -> Result<(), String> { + let flag = if kind == "sinks" { "set-default-sink" } else { "set-default-source" }; + Command::new("pactl").args([flag, &name]).status().await.map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub async fn set_sound_volume(kind: String, name: String, percent: i64) -> Result<(), String> { + let flag = if kind == "sinks" { "set-sink-volume" } else { "set-source-volume" }; + let pct = format!("{percent}%"); + Command::new("pactl").args([flag, &name, &pct]).status().await.map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub async fn set_sound_mute(kind: String, name: String, mute: bool) -> Result<(), String> { + let flag = if kind == "sinks" { "set-sink-mute" } else { "set-source-mute" }; + let val = if mute { "1" } else { "0" }; + Command::new("pactl").args([flag, &name, val]).status().await.map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn open_mixer() { + let _ = std::process::Command::new("pavucontrol").spawn(); +} diff --git a/src-tauri/src/commands/streaming.rs b/src-tauri/src/commands/streaming.rs new file mode 100644 index 0000000..d7d6a40 --- /dev/null +++ b/src-tauri/src/commands/streaming.rs @@ -0,0 +1,57 @@ +//! Shared event-streaming command runner for the genuinely long-running +//! operations (package/firmware updates) where the GTK app treated output +//! as "watch the log scroll" — the Tauri-side analog of +//! `stream_command_then`'s async_channel → glib::spawn_future_local +//! pipeline, using Tauri's event bus instead of a GLib main-loop channel. +//! Most other commands are simple request/response (see the other modules) +//! since the operations they wrap finish in well under a second. + +use serde::Serialize; +use std::process::Stdio; +use tauri::{AppHandle, Emitter}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +#[derive(Clone, Serialize)] +struct CmdOutputEvent { + session_id: String, + line: String, +} + +/// Runs `program args...`, emitting one `cmd-output` event per line of +/// stdout/stderr (tagged with `session_id` so the frontend can route +/// concurrent streams), and resolves to whether it exited successfully — +/// the frontend awaits this call directly rather than needing a second +/// "done" event. +#[tauri::command] +pub async fn run_streaming_command(app: AppHandle, session_id: String, program: String, args: Vec) -> bool { + let child = Command::new(&program).args(&args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn(); + let mut child = match child { + Ok(c) => c, + Err(e) => { + let _ = app.emit("cmd-output", CmdOutputEvent { session_id, line: format!("Error: {e}") }); + return false; + } + }; + + let stdout = child.stdout.take().expect("stdout piped"); + let stderr = child.stderr.take().expect("stderr piped"); + + let read_stdout = async { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let _ = app.emit("cmd-output", CmdOutputEvent { session_id: session_id.clone(), line }); + } + }; + let stderr_app = app.clone(); + let stderr_session = session_id.clone(); + let read_stderr = async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let _ = stderr_app.emit("cmd-output", CmdOutputEvent { session_id: stderr_session.clone(), line }); + } + }; + + tokio::join!(read_stdout, read_stderr); + child.wait().await.map(|s| s.success()).unwrap_or(false) +} diff --git a/src-tauri/src/commands/theme.rs b/src-tauri/src/commands/theme.rs new file mode 100644 index 0000000..6aaf367 --- /dev/null +++ b/src-tauri/src/commands/theme.rs @@ -0,0 +1,74 @@ +//! Bridges `bread-theme`'s pywal-derived palette into the webview as CSS +//! custom properties, and keeps it live: `bread-theme`'s generator rewrites +//! its shared stylesheet with a temp-then-rename (atomic replace), which +//! kills a direct file watch (inotify reports DELETE_SELF and never +//! re-arms) — so this watches the *parent directory* and filters by +//! filename instead, the same strategy `bread_theme::gtk::watch_theme_file` +//! uses for the GTK apps. + +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use tauri::{AppHandle, Emitter, Manager}; + +/// Initial theme fetch — called once by the frontend at startup. +#[tauri::command] +pub fn get_theme_css() -> String { + render_theme_css() +} + +fn render_theme_css() -> String { + let palette = bread_theme::load_palette(); + format!( + "{}\n{}", + bread_theme::css_custom_properties(&palette), + bread_theme::css_tokens(), + ) +} + +/// Start watching the shared theme file and emit `theme-changed` with the +/// freshly rendered CSS whenever it's rewritten (palette change from a new +/// wallpaper, or a manual `bread-theme reload`). Call once from `setup`. +pub fn watch_and_emit(app: &AppHandle) { + let target = bread_theme::shared_css_path(); + let Some(dir) = target.parent() else { return }; + let _ = std::fs::create_dir_all(dir); + + let app_for_watcher = app.clone(); + let target_for_watcher = target.clone(); + let mut watcher = match RecommendedWatcher::new( + move |res: notify::Result| { + let Ok(event) = res else { return }; + // Rewrites land as CREATE/MODIFY/RENAME events touching the + // stylesheet's path specifically — the directory watch also + // sees unrelated siblings, so filter to the target file. + let touches_target = matches!( + event.kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) + ) && event.paths.iter().any(|p| p == &target_for_watcher); + if touches_target { + let _ = app_for_watcher.emit("theme-changed", render_theme_css()); + } + }, + notify::Config::default(), + ) { + Ok(w) => w, + Err(e) => { + tracing_or_eprintln(&format!("theme watcher: failed to create: {e}")); + return; + } + }; + + if let Err(e) = watcher.watch(dir, RecursiveMode::NonRecursive) { + tracing_or_eprintln(&format!("theme watcher: failed to watch {}: {e}", dir.display())); + return; + } + + // Leaked to stay alive for the process lifetime — this app has exactly + // one theme watcher, created once at startup, never torn down. + app.manage(WatcherHandle(watcher)); +} + +struct WatcherHandle(RecommendedWatcher); + +fn tracing_or_eprintln(msg: &str) { + eprintln!("{msg}"); +} diff --git a/src-tauri/src/commands/users.rs b/src-tauri/src/commands/users.rs new file mode 100644 index 0000000..d26ff59 --- /dev/null +++ b/src-tauri/src/commands/users.rs @@ -0,0 +1,104 @@ +//! User account management — add/remove users, change passwords. Everything +//! here needs root (useradd/userdel/chpasswd), so every action goes through +//! `pkexec`. + +use serde::Serialize; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +#[derive(Serialize, Clone)] +pub struct Account { + username: String, + full_name: String, +} + +fn list_accounts() -> Vec { + let Ok(text) = std::fs::read_to_string("/etc/passwd") else { + return Vec::new(); + }; + text.lines() + .filter_map(|line| { + let f: Vec<&str> = line.split(':').collect(); + if f.len() < 7 { + return None; + } + let uid: u32 = f[2].parse().ok()?; + let shell = f[6]; + // Real human accounts: normal UID range, a real login shell + // (excludes system/service accounts like greeter, avahi, etc). + if !(1000..60000).contains(&uid) || shell.ends_with("nologin") || shell.ends_with("/false") { + return None; + } + Some(Account { username: f[0].to_string(), full_name: f[4].split(',').next().unwrap_or("").to_string() }) + }) + .collect() +} + +#[derive(Serialize)] +pub struct UsersInfo { + accounts: Vec, + current_user: String, +} + +#[tauri::command] +pub fn get_users_info() -> UsersInfo { + UsersInfo { accounts: list_accounts(), current_user: std::env::var("USER").unwrap_or_default() } +} + +/// Runs a root command that needs a line of input on stdin (chpasswd's own +/// "user:password" format). `pkexec` inherits the spawning process's stdin +/// only when explicitly piped, so this pipes it through. +async fn run_with_stdin(args: &[&str], input: String) -> bool { + let Ok(mut child) = Command::new(args[0]).args(&args[1..]).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() + else { + return false; + }; + if let Some(mut stdin) = child.stdin.take() { + if stdin.write_all(input.as_bytes()).await.is_err() { + return false; + } + } + child.wait().await.map(|s| s.success()).unwrap_or(false) +} + +#[tauri::command] +pub async fn change_password(username: String, password: String) -> Result<(), String> { + let input = format!("{username}:{password}\n"); + if run_with_stdin(&["pkexec", "chpasswd"], input).await { + Ok(()) + } else { + Err("Failed to change password".into()) + } +} + +#[tauri::command] +pub async fn remove_user(username: String) -> Result<(), String> { + let output = Command::new("pkexec").args(["userdel", "-r", &username]).output().await.map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +#[tauri::command] +pub async fn add_user(username: String, full_name: String, password: String) -> Result<(), String> { + let username = username.trim().to_string(); + let mut useradd_args = vec!["pkexec".to_string(), "useradd".to_string(), "-m".to_string(), "-s".to_string(), "/bin/bash".to_string()]; + if !full_name.trim().is_empty() { + useradd_args.push("-c".to_string()); + useradd_args.push(full_name.trim().to_string()); + } + useradd_args.push(username.clone()); + let args_ref: Vec<&str> = useradd_args.iter().map(String::as_str).collect(); + let output = Command::new(args_ref[0]).args(&args_ref[1..]).output().await.map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + let input = format!("{username}:{password}\n"); + if run_with_stdin(&["pkexec", "chpasswd"], input).await { + Ok(()) + } else { + Err("User created, but setting the password failed.".into()) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..c057bfa --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,92 @@ +mod commands; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .setup(|app| { + commands::theme::watch_and_emit(app.handle()); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + commands::theme::get_theme_css, + commands::about::get_system_info, + commands::about::set_hostname, + commands::service::get_service_status, + commands::service::service_action, + commands::service::open_logs, + commands::breadclip::open_breadclip, + commands::bread::get_bread_config, + commands::bread::save_bread_config, + commands::breadpad::get_breadpad_config, + commands::breadpad::save_breadpad_config, + commands::breadsearch::get_breadsearch_config, + commands::breadsearch::save_breadsearch_config, + commands::breadbar::get_breadbar_css, + commands::breadbar::save_breadbar_css, + commands::breadbar::get_breadbar_style, + commands::breadbar::save_breadbar_style, + commands::breadbox::get_breadbox_contexts, + commands::breadbox::save_breadbox_contexts, + commands::breadcrumbs::get_breadcrumbs_config, + commands::breadcrumbs::save_breadcrumbs_config, + commands::breadpaper::get_current_wallpaper, + commands::breadpaper::set_wallpaper, + commands::breadpaper::list_wallpaper_library, + commands::breadpaper::wallpaper_library_dir_display, + commands::appearance::get_appearance, + commands::appearance::save_appearance, + commands::autostart::get_autostart_entries, + commands::autostart::save_autostart_entries, + commands::hyprland::get_live_monitors, + commands::hyprland::get_monitor_rules, + commands::hyprland::save_monitor_rules, + commands::hyprland::open_hyprland_conf, + commands::hyprland::open_keybinds_viewer, + commands::keybinds::get_keybinds, + commands::keybinds::save_keybinds, + commands::sound::get_sound_section, + commands::sound::set_default_sound_device, + commands::sound::set_sound_volume, + commands::sound::set_sound_mute, + commands::sound::open_mixer, + commands::datetime::get_datetime_info, + commands::datetime::set_timezone, + commands::datetime::set_ntp_enabled, + commands::power::get_power_info, + commands::power::set_brightness, + commands::power::set_charge_threshold, + commands::network::get_network_info, + commands::network::set_wifi_radio, + commands::network::scan_wifi, + commands::network::connect_wifi, + commands::network::open_connection_editor, + commands::bluetooth::get_adapter_powered, + commands::bluetooth::set_adapter_powered, + commands::bluetooth::get_paired_devices, + commands::bluetooth::scan_bluetooth, + commands::bluetooth::bt_connect, + commands::bluetooth::bt_disconnect, + commands::bluetooth::bt_forget, + commands::bluetooth::bt_pair, + commands::firewall::get_firewall_status, + commands::firewall::set_firewall_enabled, + commands::firewall::add_firewall_rule, + commands::firewall::remove_firewall_rule, + commands::users::get_users_info, + commands::users::change_password, + commands::users::remove_user, + commands::users::add_user, + commands::streaming::run_streaming_command, + commands::packages::get_installed_packages, + commands::aur::search_aur, + commands::aur::install_aur_package, + commands::firmware::get_updatable_firmware, + commands::snapshots::get_snapshots, + commands::snapshots::delete_snapshot, + commands::snapshots::reboot_system, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..d138433 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + bos_settings_lib::run() +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..b4c5066 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "BOS Settings", + "version": "0.8.0", + "identifier": "dev.breadway.bos-settings", + "build": { + "beforeDevCommand": "npm --prefix frontend run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm --prefix frontend run build", + "frontendDist": "../frontend/build" + }, + "app": { + "windows": [ + { + "title": "BOS Settings", + "width": 960, + "height": 640, + "decorations": false, + "backgroundColor": "#0c0c0c" + } + ], + "security": { + "csp": null, + "assetProtocol": { + "enable": true, + "scope": ["$HOME/Pictures/Backgrounds/**"] + } + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 1c28518..0000000 --- a/src/main.rs +++ /dev/null @@ -1,38 +0,0 @@ -mod config; -mod theme; -mod ui; - -use gtk4::gio::ApplicationFlags; -use gtk4::prelude::*; - -fn main() { - let app = gtk4::Application::builder() - .application_id("com.breadway.bos-settings") - // HANDLES_COMMAND_LINE: without it, GApplication validates argv - // against registered GOptionEntries (none here) and would reject - // `--page ` with "unknown option" before `activate` ever runs. - .flags(ApplicationFlags::HANDLES_COMMAND_LINE) - .build(); - - app.connect_command_line(|app, cmdline| { - let page = parse_page_arg(&cmdline.arguments()); - ui::window::build_ui(app, page); - glib::ExitCode::SUCCESS - }); - - app.run(); -} - -/// Best-effort: like any non-command-line-aware launch of an already-running -/// GApplication, this is only seen by the launch that becomes primary — a -/// `--page` while bos-settings is already open just refocuses the existing -/// window on whatever page it was already showing. -fn parse_page_arg(args: &[std::ffi::OsString]) -> Option { - let mut it = args.iter().skip(1); - while let Some(arg) = it.next() { - if arg == "--page" { - return it.next().and_then(|s| s.to_str()).map(str::to_string); - } - } - None -} diff --git a/src/theme.rs b/src/theme.rs deleted file mode 100644 index 5b57ca8..0000000 --- a/src/theme.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Theming for bos-settings. -//! -//! bos-settings deliberately owns almost no styling: it loads the ecosystem's -//! shared stylesheet (the same one breadbar/breadbox/breadpad use, generated by -//! `bread-theme` from the pywal palette) and adds only the few layout rules -//! specific to this app's sidebar + content shell. This keeps it visually -//! identical to the rest of the bread desktop and live-recolouring for free. - -use gtk4::CssProvider; -use std::cell::RefCell; - -// App-specific layout only — everything visual (colours, buttons, entries, -// switches, sidebar/row styling, cards, scrollbars) comes from the shared sheet. -const APP_CSS: &str = "\ -.view-content { padding: 24px; }\n\ -.view-content > label.title { margin-bottom: 16px; }\n\ -/* Sidebar row sub-labels (the underlying binary/config name under a row's \ - human label) — smaller than the shared sheet's default dim-label size. */\n\ -.caption { font-size: 11px; }\n\ -/* bread-theme's shared sheet only overrides background-color on \ - suggested/destructive buttons, not background-image — so Adwaita's \ - built-in gradient (bright blue/red) paints over our flat colour \ - underneath it. Belongs upstream in bread-theme; patched locally here \ - until that's worth its own release. */\n\ -button.suggested-action, button.destructive-action { background-image: none; }\n\ -/* Same upstream gap for scale (Sound's volume sliders) — the shared sheet \ - has no `scale` rules at all, so they render in Adwaita's default blue. */\n\ -scale trough { background-color: alpha(@on-surface, 0.15); border-radius: 999px; min-height: 6px; background-image: none; }\n\ -scale highlight { background-color: @accent; background-image: none; border-radius: 999px; }\n\ -scale slider { background-color: @on-surface; border-radius: 999px; }\n\ -/* Destructive actions must not follow the wallpaper palette: @red is \ - pywal's color1, which can land on gold/yellow/anything depending on the \ - wallpaper (it did, this session) — making Delete/Remove look like a \ - primary action instead of a dangerous one. Fixed regardless of palette. */\n\ -button.destructive-action { background-color: #c0392b; color: #ffffff; }\n\ -button.destructive-action:hover { background-color: #d64535; }\n\ -/* Adwaita's default switch slider (the knob) carries a box-shadow used for \ - its 3D bevel look — bread-theme's override only sets background-color, \ - so that shadow still renders as a pale ring around the knob on top of \ - our flat colour. */\n\ -switch slider { box-shadow: none; outline: none; border: none; background-image: none; }\n\ -switch { box-shadow: none; outline: none; border: none; background-image: none; }\n\ -"; - -thread_local! { - static APP_PROVIDER: RefCell> = const { RefCell::new(None) }; -} - -pub fn load(_display: >k4::gdk::Display) { - // Shared ecosystem stylesheet (loads the generated file or a rendered - // fallback, and live-reloads when the palette changes). - bread_theme::gtk::apply_shared(); - - // bos-settings layout, layered on top at APPLICATION priority. - APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell)); -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs deleted file mode 100644 index 3867fcd..0000000 --- a/src/ui/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod sidebar; -pub mod views; -pub mod widgets; -pub mod window; diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs deleted file mode 100644 index b4859a7..0000000 --- a/src/ui/sidebar.rs +++ /dev/null @@ -1,157 +0,0 @@ -use gtk4::prelude::*; -use gtk4::{Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation}; - -pub struct SidebarItem { - /// Must match the `Stack` page name registered in `window.rs`. - pub id: &'static str, - pub label: &'static str, - /// Dim second line — the underlying binary/config name, for items whose - /// human label doesn't already make that obvious. - pub sublabel: Option<&'static str>, - /// A `-symbolic` icon name from the system icon theme (Papirus-Dark ships - /// the full Adwaita-compatible symbolic set this app relies on). - pub icon: &'static str, -} - -const fn item(id: &'static str, label: &'static str, icon: &'static str) -> SidebarItem { - SidebarItem { id, label, sublabel: None, icon } -} - -const fn item_sub( - id: &'static str, - label: &'static str, - sublabel: &'static str, - icon: &'static str, -) -> SidebarItem { - SidebarItem { id, label, sublabel: Some(sublabel), icon } -} - -// Grouped by task, not by "app vs system internals" — a user thinks "I want -// to change my Wi-Fi" or "I want to change my wallpaper", not "which of -// these is a bread-ecosystem app". breadcrumbs (Wi-Fi profiles) moves out of -// the old "Apps" bucket into System for the same reason. -pub const SYSTEM_ITEMS: &[SidebarItem] = &[ - item("network", "Network", "network-wireless-symbolic"), - item_sub("breadcrumbs", "Wi-Fi Profiles", "breadcrumbs", "network-workgroup-symbolic"), - item("bluetooth", "Bluetooth", "bluetooth-symbolic"), - item("firewall", "Firewall", "security-high-symbolic"), - item("sound", "Sound", "audio-volume-high-symbolic"), - item("power", "Power", "battery-good-symbolic"), - item("datetime", "Date & Time", "preferences-system-time-symbolic"), - item_sub("hyprland", "Display", "monitors.json", "video-display-symbolic"), - item_sub("keybinds", "Keybinds", "binds.json", "input-keyboard-symbolic"), - item_sub("autostart", "Startup Apps", "autostart.json", "system-run-symbolic"), - item("users", "Users", "system-users-symbolic"), -]; - -pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[ - item_sub("appearance", "Appearance", "settings.json", "applications-graphics-symbolic"), - item_sub("breadpaper", "Wallpaper", "breadpaper", "preferences-desktop-wallpaper-symbolic"), - item_sub("breadbar", "Bar", "breadbar", "view-grid-symbolic"), - item_sub("breadbox", "Launcher", "breadbox", "view-app-grid-symbolic"), - item_sub("breadclip", "Clipboard", "breadclipd", "edit-paste-symbolic"), - item_sub("breadpad", "Notes", "breadpad", "text-editor-symbolic"), - item_sub("breadsearch", "File Search", "breadsearch", "system-search-symbolic"), - item_sub("bread", "Daemon", "breadd", "applications-system-symbolic"), -]; - -pub const MAINTENANCE_ITEMS: &[SidebarItem] = &[ - item("packages", "Packages", "package-x-generic-symbolic"), - item("aur", "AUR", "system-search-symbolic"), - item("firmware", "Firmware", "software-update-available-symbolic"), - item("snapshots", "Snapshots", "document-open-recent-symbolic"), -]; - -pub const ABOUT_ITEMS: &[SidebarItem] = &[item("about", "About", "help-about-symbolic")]; - -/// `default_id` must match whatever page `window.rs` sets as the `Stack`'s -/// initial visible child — previously these were two independent hardcoded -/// "about" literals in different files with no link between them, so -/// changing one without the other silently desynced the sidebar highlight -/// from the actually-displayed page. -pub fn build(default_id: &str) -> (GBox, ListBox) { - let vbox = GBox::new(Orientation::Vertical, 0); - vbox.add_css_class("sidebar"); - vbox.set_width_request(210); - - let list = ListBox::new(); - list.set_selection_mode(gtk4::SelectionMode::Single); - list.add_css_class("sidebar"); - - append_section(&list, "System", SYSTEM_ITEMS); - append_section(&list, "Personalization", PERSONALIZATION_ITEMS); - append_section(&list, "Maintenance", MAINTENANCE_ITEMS); - append_section(&list, None, ABOUT_ITEMS); - - let mut i = 0; - loop { - match list.row_at_index(i) { - None => break, - Some(row) if row.widget_name() == default_id => { - list.select_row(Some(&row)); - break; - } - _ => i += 1, - } - } - - // Unscrolled, this list's ~30 rows (4 sections' worth of items plus - // headers) force a minimum window height that exceeds the *logical* - // screen height at Hyprland scale factors above 1.0 (e.g. a 1200px-tall - // panel becomes 800 logical px at scale 1.5) — GTK can't shrink below a - // widget's natural size, so the window (and anything below the fold, - // like a panel's Save button) gets clipped by the compositor with no way - // to reach it. Scrolling the nav list independently of the content pane - // lets the window's minimum height drop to whatever a single row needs. - let scroll = gtk4::ScrolledWindow::new(); - scroll.set_vexpand(true); - scroll.set_hscrollbar_policy(gtk4::PolicyType::Never); - scroll.set_child(Some(&list)); - vbox.append(&scroll); - (vbox, list) -} - -fn append_section(list: &ListBox, title: impl Into>, items: &[SidebarItem]) { - if let Some(title) = title.into() { - let header_row = ListBoxRow::new(); - header_row.set_selectable(false); - header_row.set_activatable(false); - let header_lbl = Label::new(Some(title)); - header_lbl.add_css_class("section-header"); - header_lbl.set_xalign(0.0); - header_row.set_child(Some(&header_lbl)); - list.append(&header_row); - } - - for entry in items { - let row = ListBoxRow::new(); - row.set_widget_name(entry.id); - - let hbox = GBox::new(Orientation::Horizontal, 10); - hbox.set_margin_top(4); - hbox.set_margin_bottom(4); - - let icon = Image::from_icon_name(entry.icon); - icon.set_pixel_size(16); - hbox.append(&icon); - - let labels = GBox::new(Orientation::Vertical, 0); - let lbl = Label::new(Some(entry.label)); - lbl.set_xalign(0.0); - labels.append(&lbl); - if let Some(sub) = entry.sublabel { - let sub_lbl = Label::new(Some(sub)); - sub_lbl.add_css_class("dim-label"); - sub_lbl.set_xalign(0.0); - // Match the sidebar's smaller "section-header" scale rather than - // the shared sheet's default dim-label size, so it reads as a - // caption under the row label, not a second full-size label. - sub_lbl.add_css_class("caption"); - labels.append(&sub_lbl); - } - hbox.append(&labels); - - row.set_child(Some(&hbox)); - list.append(&row); - } -} diff --git a/src/ui/views/appearance.rs b/src/ui/views/appearance.rs deleted file mode 100644 index 419ac94..0000000 --- a/src/ui/views/appearance.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! hypr/settings.json — Hyprland gaps/borders/blur/shadow/input, read by -//! `scripts/ui/settings.lua` on the Hyprland side (see hyprland.lua). Unlike -//! the TOML-backed views, this has no comments to preserve, so it's modeled -//! as a plain typed struct (round-tripped whole) rather than the -//! `toml_edit`/`Doc` path-based editor the other panels use. -//! -//! `Default` here must stay in sync with `scripts/ui/settings.lua`'s -//! `DEFAULTS` table on the Hyprland side — both independently define "what -//! BOS ships out of the box," the same duplication `binds.json`/ -//! `content/keybinds.rs` already accept for the same reason (two different -//! languages/processes reading the same file, neither able to import the -//! other's defaults). - -use std::cell::RefCell; -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::{ - Adjustment, Box as GBox, ColorDialog, ColorDialogButton, DropDown, Entry, Expression, Orientation, - SpinButton, StringList, Switch, -}; -use serde::{Deserialize, Serialize}; - -use crate::ui::widgets as w; - -#[derive(Clone, Serialize, Deserialize)] -#[serde(default)] -struct Appearance { - gaps_in: i64, - gaps_out: i64, - border_size: i64, - active_border: String, - inactive_border: String, - layout: String, - resize_on_border: bool, - rounding: i64, - blur_enabled: bool, - blur_size: i64, - blur_passes: i64, - shadow_enabled: bool, - shadow_range: i64, - shadow_render_power: i64, - kb_layout: String, - follow_mouse: i64, - natural_scroll: bool, -} - -impl Default for Appearance { - fn default() -> Self { - Self { - gaps_in: 5, - gaps_out: 10, - border_size: 2, - active_border: "rgba(88c0d0ff)".to_string(), - inactive_border: "rgba(4c566aff)".to_string(), - layout: "dwindle".to_string(), - resize_on_border: true, - rounding: 8, - blur_enabled: true, - blur_size: 6, - blur_passes: 2, - shadow_enabled: true, - shadow_range: 12, - shadow_render_power: 3, - kb_layout: "us".to_string(), - follow_mouse: 1, - natural_scroll: true, - } - } -} - -fn config_path() -> std::path::PathBuf { - crate::config::config_dir().join("hypr/settings.json") -} - -/// A missing or malformed file yields defaults — same failsafe posture as -/// the Lua loader reading this same file on the Hyprland side. -fn load() -> Appearance { - std::fs::read_to_string(config_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() -} - -fn save(a: &Appearance) -> std::io::Result<()> { - let path = config_path(); - crate::config::atomic_write(&path, &serde_json::to_string_pretty(a).unwrap_or_default()) -} - -/// "rgba(RRGGBBAA)" (Hyprland's format) <-> gdk::RGBA, so the color fields -/// get a real color-picker button instead of a raw hex text field. -fn parse_hypr_color(s: &str) -> Option { - let inner = s.strip_prefix("rgba(")?.strip_suffix(')')?; - if inner.len() != 8 { - return None; - } - let r = u8::from_str_radix(&inner[0..2], 16).ok()?; - let g = u8::from_str_radix(&inner[2..4], 16).ok()?; - let b = u8::from_str_radix(&inner[4..6], 16).ok()?; - let a = u8::from_str_radix(&inner[6..8], 16).ok()?; - Some(gtk4::gdk::RGBA::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0)) -} - -fn to_hypr_color(c: >k4::gdk::RGBA) -> String { - let clamp = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - format!("rgba({:02x}{:02x}{:02x}{:02x})", clamp(c.red()), clamp(c.green()), clamp(c.blue()), clamp(c.alpha())) -} - -fn color_row(label: &str, model: &Rc>, get: fn(&Appearance) -> &str, set: fn(&mut Appearance, String)) -> GBox { - let cur = parse_hypr_color(get(&model.borrow())).unwrap_or(gtk4::gdk::RGBA::new(0.5, 0.5, 0.5, 1.0)); - let btn = ColorDialogButton::new(Some(ColorDialog::builder().with_alpha(true).build())); - btn.set_rgba(&cur); - let model = model.clone(); - btn.connect_rgba_notify(move |b| { - set(&mut model.borrow_mut(), to_hypr_color(&b.rgba())); - }); - w::row(label, &btn) -} - -fn spin_row(label: &str, model: &Rc>, min: f64, max: f64, get: fn(&Appearance) -> i64, set: fn(&mut Appearance, i64)) -> GBox { - let cur = get(&model.borrow()); - let adj = Adjustment::new(cur as f64, min, max, 1.0, 1.0, 0.0); - let spin = SpinButton::new(Some(&adj), 1.0, 0); - let model = model.clone(); - spin.connect_value_changed(move |s| set(&mut model.borrow_mut(), s.value() as i64)); - w::row(label, &spin) -} - -fn switch_row(label: &str, model: &Rc>, get: fn(&Appearance) -> bool, set: fn(&mut Appearance, bool)) -> GBox { - let sw = Switch::new(); - sw.set_active(get(&model.borrow())); - let model = model.clone(); - sw.connect_active_notify(move |s| set(&mut model.borrow_mut(), s.is_active())); - w::row(label, &sw) -} - -fn entry_row(label: &str, model: &Rc>, get: fn(&Appearance) -> String, set: fn(&mut Appearance, String)) -> GBox { - let entry = Entry::new(); - entry.set_text(&get(&model.borrow())); - entry.set_hexpand(true); - entry.set_width_chars(16); - let model = model.clone(); - entry.connect_changed(move |e| set(&mut model.borrow_mut(), e.text().to_string())); - w::row(label, &entry) -} - -fn dropdown_row(label: &str, model: &Rc>, options: &[&str], get: fn(&Appearance) -> &str, set: fn(&mut Appearance, String)) -> GBox { - let cur = get(&model.borrow()).to_string(); - let dd = DropDown::new(Some(StringList::new(options)), Expression::NONE); - dd.set_selected(options.iter().position(|o| *o == cur).unwrap_or(0) as u32); - let owned: Vec = options.iter().map(|s| s.to_string()).collect(); - let model = model.clone(); - dd.connect_selected_notify(move |dd| { - if let Some(opt) = owned.get(dd.selected() as usize) { - set(&mut model.borrow_mut(), opt.clone()); - } - }); - w::row(label, &dd) -} - -pub fn build() -> GBox { - let (outer, content) = w::view_scaffold("Appearance"); - - content.append(&w::hint( - "Gaps, borders, blur, and input feel for Hyprland — the same \ - settings.json the compositor itself reads at login.", - )); - - let model = Rc::new(RefCell::new(load())); - - content.append(&w::section("Layout & borders")); - content.append(&spin_row("Gaps (inner)", &model, 0.0, 50.0, |a| a.gaps_in, |a, v| a.gaps_in = v)); - content.append(&spin_row("Gaps (outer)", &model, 0.0, 50.0, |a| a.gaps_out, |a, v| a.gaps_out = v)); - content.append(&spin_row("Border width", &model, 0.0, 10.0, |a| a.border_size, |a, v| a.border_size = v)); - content.append(&color_row("Active border color", &model, |a| &a.active_border, |a, v| a.active_border = v)); - content.append(&color_row("Inactive border color", &model, |a| &a.inactive_border, |a, v| a.inactive_border = v)); - content.append(&dropdown_row("Tiling layout", &model, &["dwindle", "master"], |a| &a.layout, |a, v| a.layout = v)); - content.append(&switch_row("Resize by dragging borders", &model, |a| a.resize_on_border, |a, v| a.resize_on_border = v)); - - content.append(&w::section("Effects")); - content.append(&spin_row("Corner rounding", &model, 0.0, 30.0, |a| a.rounding, |a, v| a.rounding = v)); - content.append(&switch_row("Blur", &model, |a| a.blur_enabled, |a, v| a.blur_enabled = v)); - content.append(&spin_row("Blur size", &model, 0.0, 20.0, |a| a.blur_size, |a, v| a.blur_size = v)); - content.append(&spin_row("Blur passes", &model, 1.0, 5.0, |a| a.blur_passes, |a, v| a.blur_passes = v)); - content.append(&switch_row("Window shadows", &model, |a| a.shadow_enabled, |a, v| a.shadow_enabled = v)); - content.append(&spin_row("Shadow range", &model, 0.0, 40.0, |a| a.shadow_range, |a, v| a.shadow_range = v)); - content.append(&spin_row("Shadow render power", &model, 1.0, 4.0, |a| a.shadow_render_power, |a, v| a.shadow_render_power = v)); - - content.append(&w::section("Input")); - content.append(&entry_row("Keyboard layout", &model, |a| a.kb_layout.clone(), |a, v| a.kb_layout = v)); - let follow_mouse_row = spin_row("Focus-follows-mouse mode", &model, 0.0, 3.0, |a| a.follow_mouse, |a, v| a.follow_mouse = v); - content.append(&follow_mouse_row); - content.append(&w::hint("0-3 — see the Hyprland wiki's follow_mouse setting for exact behavior of each value.")); - content.append(&switch_row("Natural scrolling (touchpad)", &model, |a| a.natural_scroll, |a, v| a.natural_scroll = v)); - - content.append(&w::hint("Applies on next login/Hyprland reload — this saves settings.json, it doesn't reload Hyprland live.")); - - let btn_row = GBox::new(Orientation::Horizontal, 12); - btn_row.set_margin_top(16); - let save_btn = gtk4::Button::with_label("Save"); - save_btn.add_css_class("suggested-action"); - let status = gtk4::Label::new(None); - status.add_css_class("dim-label"); - { - let model = model.clone(); - let status = status.clone(); - save_btn.connect_clicked(move |_| match save(&model.borrow()) { - Ok(()) => { - status.set_text("Saved"); - let lbl = status.clone(); - glib::timeout_add_seconds_local(3, move || { - lbl.set_text(""); - glib::ControlFlow::Break - }); - } - Err(e) => status.set_text(&format!("Error: {e}")), - }); - } - btn_row.append(&save_btn); - btn_row.append(&status); - outer.append(&btn_row); - - outer -} diff --git a/src/ui/views/aur.rs b/src/ui/views/aur.rs deleted file mode 100644 index b526224..0000000 --- a/src/ui/views/aur.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! AUR search via yay — graphical discovery for the wider AUR beyond -//! bakery's bread ecosystem and [breadway]'s own republished packages. -//! -//! Search and browsing are fully graphical; actually installing a package -//! opens a terminal running `yay -S ` instead of a silent `--noconfirm` -//! install. That's deliberate, not a shortcut we didn't get around to: -//! AUR packages run arbitrary maintainer-supplied build scripts, and yay's -//! interactive PKGBUILD diff review (plus the sudo password prompt) is the -//! actual safety mechanism against a malicious/compromised AUR package — -//! automating it away in the name of "no terminal" would remove the one -//! step that exists to catch that. - -use gtk4::prelude::*; -use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow}; -use std::process::Command; - -use crate::ui::widgets as w; - -#[derive(Clone)] -struct AurResult { - name: String, - version: String, - description: String, -} - -fn search(query: &str) -> Vec { - let Ok(output) = Command::new("yay").args(["-Ss", "--aur", query]).output() else { - return Vec::new(); - }; - let text = String::from_utf8_lossy(&output.stdout); - let mut results = Vec::new(); - let mut lines = text.lines().peekable(); - while let Some(header) = lines.next() { - // "aur/name version (+votes score) [Orphaned]" — name/version are - // always the first two whitespace-separated fields after the repo/. - let Some(rest) = header.strip_prefix("aur/") else { continue }; - let mut parts = rest.split_whitespace(); - let Some(name) = parts.next() else { continue }; - let version = parts.next().unwrap_or("").to_string(); - let description = lines.next().unwrap_or("").trim().to_string(); - results.push(AurResult { name: name.to_string(), version, description }); - } - results -} - -fn install_in_terminal(pkg: &str) { - let _ = Command::new("kitty").args(["-e", "yay", "-S", pkg]).spawn(); -} - -pub fn build() -> GBox { - let (outer, content) = w::view_scaffold("AUR"); - content.append(&w::hint( - "Search the Arch User Repository via yay. Installing opens a \ - terminal — AUR packages run arbitrary build scripts, and reviewing \ - what yay is about to do (and entering your password) is a real \ - safety step, not just a formality.", - )); - - let search_row = GBox::new(Orientation::Horizontal, 8); - let search_entry = Entry::new(); - search_entry.set_hexpand(true); - search_entry.set_placeholder_text(Some("Search the AUR…")); - let search_btn = Button::with_label("Search"); - search_btn.add_css_class("suggested-action"); - search_row.append(&search_entry); - search_row.append(&search_btn); - content.append(&search_row); - - let status = w::hint("Search for a package to see results here."); - content.append(&status); - - let list = ListBox::new(); - list.set_selection_mode(gtk4::SelectionMode::None); - let scroll = ScrolledWindow::new(); - scroll.set_vexpand(true); - scroll.set_min_content_height(320); - scroll.set_child(Some(&list)); - content.append(&scroll); - - let run_search = { - let list = list.clone(); - let status = status.clone(); - let search_entry = search_entry.clone(); - move |btn: Option + + + {#if cfg} + + + {#if cfg.background_mode === "image"} + + {/if} + + + + + + + + {/if} + + + + {#if examplePath} + + {:else} + + {/if} + + + + diff --git a/frontend/src/lib/views/Breadmon.svelte b/frontend/src/lib/views/Breadmon.svelte new file mode 100644 index 0000000..5002cc0 --- /dev/null +++ b/frontend/src/lib/views/Breadmon.svelte @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/views/Breadshot.svelte b/frontend/src/lib/views/Breadshot.svelte new file mode 100644 index 0000000..116ddbc --- /dev/null +++ b/frontend/src/lib/views/Breadshot.svelte @@ -0,0 +1,78 @@ + + + + + {#if binds && binds.length > 0} + {#each binds as b (`${b.shortcut}:${b.command}`)} + + {/each} + {:else} + + {/if} + + + + {#if cfg} + + + + + + + + + + {/if} + + + diff --git a/frontend/src/lib/views/Firmware.svelte b/frontend/src/lib/views/Firmware.svelte index 36c9fe9..b5317d7 100644 --- a/frontend/src/lib/views/Firmware.svelte +++ b/frontend/src/lib/views/Firmware.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/views/registry.ts b/frontend/src/lib/views/registry.ts index 9c9ed44..ee0e5e4 100644 --- a/frontend/src/lib/views/registry.ts +++ b/frontend/src/lib/views/registry.ts @@ -1,6 +1,5 @@ -// Maps a sidebar page id to its view component. Pages not yet migrated -// fall back to Placeholder (see +page.svelte) — this map only lists pages -// that actually have a real Tauri-backed view. +// Maps a sidebar page id to its view component. Every sidebar id has a +// real view — +page.svelte's Placeholder is only a safety net for typos. import type { Component } from "svelte"; import About from "./About.svelte"; @@ -27,6 +26,10 @@ import Packages from "./Packages.svelte"; import Aur from "./Aur.svelte"; import Firmware from "./Firmware.svelte"; import Snapshots from "./Snapshots.svelte"; +import Breadlock from "./Breadlock.svelte"; +import Breadshot from "./Breadshot.svelte"; +import Breadmon from "./Breadmon.svelte"; +import Breadhelp from "./Breadhelp.svelte"; export const VIEWS: Record = { about: About, @@ -53,4 +56,8 @@ export const VIEWS: Record = { aur: Aur, firmware: Firmware, snapshots: Snapshots, + breadlock: Breadlock, + breadshot: Breadshot, + breadmon: Breadmon, + breadhelp: Breadhelp, }; diff --git a/src/Cargo.lock b/src/Cargo.lock index ddc6b65..64cc1d8 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -291,9 +291,8 @@ name = "bos-settings" version = "0.8.0" dependencies = [ "anyhow", - "bread-screenshots", "bread-theme", - "bread-utils 0.3.1 (git+https://github.com/Breadway/bread-ecosystem?branch=main)", + "bread-utils", "notify", "regex", "serde", @@ -306,20 +305,10 @@ dependencies = [ "toml_edit 0.22.27", ] -[[package]] -name = "bread-screenshots" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" -dependencies = [ - "anyhow", - "bread-utils 0.3.1 (git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main)", - "tracing", -] - [[package]] name = "bread-theme" version = "0.3.1" -source = "git+https://github.com/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ "dirs 5.0.1", "serde", @@ -329,17 +318,7 @@ dependencies = [ [[package]] name = "bread-utils" version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" -dependencies = [ - "dirs 5.0.1", - "serde", - "serde_json", -] - -[[package]] -name = "bread-utils" -version = "0.3.1" -source = "git+https://github.com/Breadway/bread-ecosystem?branch=main#f86e299f4a0ea73ff485cd84923b986ddcc8242e" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" dependencies = [ "dirs 5.0.1", "serde", diff --git a/src/Cargo.toml b/src/Cargo.toml index b4f9bf1..41dc987 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -36,15 +36,7 @@ toml_edit = "0.22" tokio = { version = "1", features = ["process", "io-util", "time", "macros"] } notify = "7" regex = "1" -# TODO(owner): switch to a tag-pinned git dependency once bread-theme cuts a -# release including css_custom_properties/css_tokens (added 2026-07-21 for -# this migration) — pinned to branch = "main" for now rather than a path -# dependency (which broke bakery/CI builds — no sibling bread-ecosystem -# checkout exists on the runner) since no tag has these functions yet. -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", branch = "main" } -bread-utils = { git = "https://github.com/Breadway/bread-ecosystem", branch = "main", features = ["toml"] } -# Capture primitives for `--screenshot` mode — see src/screenshot.rs. On -# "dev", not "main" like the two deps above: it doesn't exist on main yet. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "main" } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["toml"] } anyhow = "1" diff --git a/src/src/commands/breadcrumbs.rs b/src/src/commands/breadcrumbs.rs index 3572f82..7ed5db5 100644 --- a/src/src/commands/breadcrumbs.rs +++ b/src/src/commands/breadcrumbs.rs @@ -1,24 +1,46 @@ //! breadcrumbs.toml — Wi-Fi profile state machine. Schema mirrors //! breadcrumbs/src/config.rs: -//! [settings] scalar tunables -//! [[networks]] saved networks (ssid / password / hidden) +//! [settings] scalar tunables (this file) //! [profiles.] per-location profile (networks, tailscale, …) -//! `[settings]` is edited in place; `networks`/`profiles` are rewritten from -//! their editors on save. Other keys/comments are preserved. +//! +//! Saved networks (SSID + optional local password) live in a *separate* +//! `networks.toml` (0600) next to breadcrumbs.toml. breadcrumbs v2 stores +//! them there so a file people hand-edit / dotfile does not also carry +//! plaintext Wi-Fi credentials. After the first successful connect, +//! breadcrumbs clears the local password and NetworkManager owns the +//! secret; `None` means "NM already has it" or "open network". +//! +//! `[settings]` is edited in place via toml_edit; `profiles` are rewritten +//! from their editor on save. `[[networks]]` is never written back into +//! breadcrumbs.toml — leftover inline blocks from pre-split configs are +//! read once (only if `networks.toml` is missing) and migrated on the +//! next save. Other keys/comments in breadcrumbs.toml are preserved. + +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; use super::config; -fn config_path() -> std::path::PathBuf { +fn settings_path() -> PathBuf { config::config_dir().join("breadcrumbs/breadcrumbs.toml") } -#[derive(Serialize, Deserialize, Clone, Default)] +fn networks_path() -> PathBuf { + config::config_dir().join("breadcrumbs/networks.toml") +} + +#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq, Eq)] pub struct Network { ssid: String, - password: String, + /// Write-only from the UI's point of view. `get_breadcrumbs_config` + /// never returns a stored PSK (always `None`). On save, `None` / empty + /// means "keep whatever is already in networks.toml, or omit — NM + /// remembers." A non-empty value is written only to networks.toml. + #[serde(default, skip_serializing_if = "Option::is_none")] + password: Option, + #[serde(default)] hidden: bool, } @@ -56,24 +78,120 @@ fn read_networks(doc: &DocumentMut) -> Vec { return Vec::new(); }; aot.iter() - .map(|t| Network { - ssid: t.get("ssid").and_then(Item::as_str).unwrap_or("").to_string(), - password: t.get("password").and_then(Item::as_str).unwrap_or("").to_string(), - hidden: t.get("hidden").and_then(Item::as_bool).unwrap_or(false), + .map(|t| { + let password = t + .get("password") + .and_then(Item::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + Network { + ssid: t + .get("ssid") + .and_then(Item::as_str) + .unwrap_or("") + .to_string(), + password, + hidden: t.get("hidden").and_then(Item::as_bool).unwrap_or(false), + } }) .collect() } -fn write_networks(doc: &mut DocumentMut, nets: &[Network]) { +fn networks_document(nets: &[Network]) -> DocumentMut { + let mut doc = DocumentMut::new(); let mut aot = ArrayOfTables::new(); for n in nets { + if n.ssid.trim().is_empty() { + continue; + } let mut t = Table::new(); t.insert("ssid", value(&n.ssid)); - t.insert("password", value(&n.password)); + if let Some(pw) = n + .password + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + t.insert("password", value(pw)); + } t.insert("hidden", value(n.hidden)); aot.push(t); } - doc.as_table_mut().insert("networks", Item::ArrayOfTables(aot)); + doc.as_table_mut() + .insert("networks", Item::ArrayOfTables(aot)); + doc +} + +/// Load saved networks. `networks.toml` wins when present; otherwise fall +/// back to a leftover inline `[[networks]]` block in breadcrumbs.toml so a +/// pre-split config still shows up until the next save migrates it. +fn load_networks(settings_doc: &DocumentMut, net_path: &Path) -> Vec { + if net_path.exists() { + let doc = config::load_doc(net_path); + return read_networks(&doc); + } + read_networks(settings_doc) +} + +fn redact_passwords(nets: Vec) -> Vec { + nets.into_iter() + .map(|n| Network { + password: None, + ..n + }) + .collect() +} + +fn incoming_password(n: &Network) -> Option { + n.password + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +/// Empty / omitted password from the UI means "keep the on-disk secret for +/// this SSID (if any), otherwise let NetworkManager remember." A typed +/// value replaces it. Matching is by SSID; a renamed SSID is a new +/// network and does not inherit the old password. +fn merge_network_passwords(incoming: Vec, existing: &[Network]) -> Vec { + incoming + .into_iter() + .filter(|n| !n.ssid.trim().is_empty()) + .map(|mut n| { + n.password = incoming_password(&n).or_else(|| { + existing + .iter() + .find(|e| e.ssid == n.ssid) + .and_then(|e| e.password.clone()) + }); + n + }) + .collect() +} + +/// Atomic write with mode 0600 set on the temp file *before* any bytes +/// land, so a secrets file is never briefly world-readable. Also re-applies +/// 0600 on the destination in case an older world-readable networks.toml +/// was being replaced (rename keeps the new inode's mode). +fn write_secure(path: &Path, contents: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("creating {}: {e}", parent.display()))?; + } + bread_utils::atomic::write_atomic(path, contents, Some(0o600)) + .map_err(|e| format!("writing {}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +fn save_networks(path: &Path, nets: &[Network]) -> Result<(), String> { + write_secure(path, &networks_document(nets).to_string()) } fn read_profiles(doc: &DocumentMut) -> Vec { @@ -82,7 +200,11 @@ fn read_profiles(doc: &DocumentMut) -> Vec { }; let str_list = |item: Option<&Item>| -> Vec { item.and_then(Item::as_array) - .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) .unwrap_or_default() }; tbl.iter() @@ -92,10 +214,21 @@ fn read_profiles(doc: &DocumentMut) -> Vec { name: name.to_string(), networks: str_list(p.get("networks")), detect_ssids: str_list(p.get("detect_ssids")), - bootstrap: p.get("bootstrap").and_then(Item::as_str).unwrap_or("").to_string(), - exit_node: p.get("exit_node").and_then(Item::as_str).unwrap_or("").to_string(), + bootstrap: p + .get("bootstrap") + .and_then(Item::as_str) + .unwrap_or("") + .to_string(), + exit_node: p + .get("exit_node") + .and_then(Item::as_str) + .unwrap_or("") + .to_string(), tailscale: p.get("tailscale").and_then(Item::as_bool).unwrap_or(false), - include_all_known: p.get("include_all_known").and_then(Item::as_bool).unwrap_or(false), + include_all_known: p + .get("include_all_known") + .and_then(Item::as_bool) + .unwrap_or(false), }) }) .collect() @@ -132,26 +265,74 @@ fn write_profiles(doc: &mut DocumentMut, profiles: &[Profile]) { doc.as_table_mut().insert("profiles", Item::Table(tbl)); } -#[tauri::command] -pub fn get_breadcrumbs_config() -> BreadcrumbsConfig { - let doc = config::load_doc(&config_path()); +fn apply_settings(doc: &mut DocumentMut, settings: &Settings) { + config::set_str( + doc, + &["settings", "default_profile"], + &settings.default_profile, + ); + config::set_str(doc, &["settings", "dns"], &settings.dns); + config::set_str_or_remove(doc, &["settings", "exit_node"], &settings.exit_node); + config::set_str(doc, &["settings", "ping_host"], &settings.ping_host); + config::set_str( + doc, + &["settings", "connectivity_url"], + &settings.connectivity_url, + ); + config::set_i64(doc, &["settings", "nmcli_wait"], settings.nmcli_wait); + config::set_i64( + doc, + &["settings", "watch_interval"], + settings.watch_interval, + ); +} + +fn load_from(settings_path: &Path, net_path: &Path) -> BreadcrumbsConfig { + let doc = config::load_doc(settings_path); BreadcrumbsConfig { settings: Settings { // breadcrumbs' own default_profile_name() is "away", not "home". - default_profile: config::get_str(&doc, &["settings", "default_profile"]).unwrap_or_else(|| "away".into()), + default_profile: config::get_str(&doc, &["settings", "default_profile"]) + .unwrap_or_else(|| "away".into()), dns: config::get_str(&doc, &["settings", "dns"]).unwrap_or_else(|| "1.1.1.1".into()), exit_node: config::get_str(&doc, &["settings", "exit_node"]).unwrap_or_default(), - ping_host: config::get_str(&doc, &["settings", "ping_host"]).unwrap_or_else(|| "1.1.1.1".into()), + ping_host: config::get_str(&doc, &["settings", "ping_host"]) + .unwrap_or_else(|| "1.1.1.1".into()), connectivity_url: config::get_str(&doc, &["settings", "connectivity_url"]) .unwrap_or_else(|| "http://connectivitycheck.gstatic.com/generate_204".into()), nmcli_wait: config::get_i64(&doc, &["settings", "nmcli_wait"]).unwrap_or(8), watch_interval: config::get_i64(&doc, &["settings", "watch_interval"]).unwrap_or(12), }, - networks: read_networks(&doc), + // Never ship a stored PSK to the webview — the password field is + // write-only (empty = keep existing / let NM remember). + networks: redact_passwords(load_networks(&doc, net_path)), profiles: read_profiles(&doc), } } +fn save_to( + settings_path: &Path, + net_path: &Path, + input: SaveBreadcrumbsInput, +) -> Result<(), String> { + let mut doc = config::load_doc(settings_path); + let existing = load_networks(&doc, net_path); + apply_settings(&mut doc, &input.settings); + write_profiles(&mut doc, &input.profiles); + // Completes the pre-split migration: leftover [[networks]] must not + // survive a save, even if the user only edited settings/profiles. + doc.as_table_mut().remove("networks"); + config::save_doc(settings_path, &doc).map_err(|e| e.to_string())?; + + let merged = merge_network_passwords(input.networks, &existing); + save_networks(net_path, &merged) +} + +#[tauri::command] +pub fn get_breadcrumbs_config() -> BreadcrumbsConfig { + load_from(&settings_path(), &networks_path()) +} + #[derive(Deserialize)] pub struct SaveBreadcrumbsInput { settings: Settings, @@ -161,16 +342,232 @@ pub struct SaveBreadcrumbsInput { #[tauri::command] pub fn save_breadcrumbs_config(input: SaveBreadcrumbsInput) -> Result<(), String> { - let path = config_path(); - let mut doc = config::load_doc(&path); - config::set_str(&mut doc, &["settings", "default_profile"], &input.settings.default_profile); - config::set_str(&mut doc, &["settings", "dns"], &input.settings.dns); - config::set_str_or_remove(&mut doc, &["settings", "exit_node"], &input.settings.exit_node); - config::set_str(&mut doc, &["settings", "ping_host"], &input.settings.ping_host); - config::set_str(&mut doc, &["settings", "connectivity_url"], &input.settings.connectivity_url); - config::set_i64(&mut doc, &["settings", "nmcli_wait"], input.settings.nmcli_wait); - config::set_i64(&mut doc, &["settings", "watch_interval"], input.settings.watch_interval); - write_networks(&mut doc, &input.networks); - write_profiles(&mut doc, &input.profiles); - config::save_doc(&path, &doc).map_err(|e| e.to_string()) + save_to(&settings_path(), &networks_path(), input) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "bos-settings-breadcrumbs-{}-{}-{}", + name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn sample_settings() -> Settings { + Settings { + default_profile: "away".into(), + dns: "1.1.1.1".into(), + exit_node: String::new(), + ping_host: "1.1.1.1".into(), + connectivity_url: "http://connectivitycheck.gstatic.com/generate_204".into(), + nmcli_wait: 8, + watch_interval: 12, + } + } + + #[test] + fn networks_document_omits_password_when_none() { + let nets = vec![Network { + ssid: "Cafe".into(), + password: None, + hidden: false, + }]; + let text = networks_document(&nets).to_string(); + assert!(text.contains("ssid")); + assert!(!text.contains("password"), "text: {text}"); + } + + #[test] + fn networks_document_writes_password_when_present() { + let nets = vec![Network { + ssid: "Cafe".into(), + password: Some("hunter2".into()), + hidden: true, + }]; + let text = networks_document(&nets).to_string(); + assert!(text.contains("hunter2")); + assert!(text.contains("hidden = true")); + let back = read_networks(&text.parse().unwrap()); + assert_eq!(back[0].password.as_deref(), Some("hunter2")); + assert!(back[0].hidden); + } + + #[test] + fn merge_keeps_existing_password_when_incoming_empty() { + let existing = vec![Network { + ssid: "Cafe".into(), + password: Some("hunter2".into()), + hidden: false, + }]; + let incoming = vec![Network { + ssid: "Cafe".into(), + password: Some(String::new()), + hidden: true, + }]; + let merged = merge_network_passwords(incoming, &existing); + assert_eq!(merged[0].password.as_deref(), Some("hunter2")); + assert!(merged[0].hidden); + } + + #[test] + fn merge_replaces_password_when_incoming_set() { + let existing = vec![Network { + ssid: "Cafe".into(), + password: Some("old".into()), + hidden: false, + }]; + let incoming = vec![Network { + ssid: "Cafe".into(), + password: Some("new".into()), + hidden: false, + }]; + let merged = merge_network_passwords(incoming, &existing); + assert_eq!(merged[0].password.as_deref(), Some("new")); + } + + #[test] + fn get_never_returns_stored_password() { + let dir = tmp_dir("redact"); + let settings = dir.join("breadcrumbs.toml"); + let nets = dir.join("networks.toml"); + std::fs::write(&settings, "[settings]\ndns = \"9.9.9.9\"\n").unwrap(); + std::fs::write( + &nets, + "[[networks]]\nssid = \"Cafe\"\npassword = \"hunter2\"\nhidden = false\n", + ) + .unwrap(); + + let cfg = load_from(&settings, &nets); + assert_eq!(cfg.settings.dns, "9.9.9.9"); + assert_eq!(cfg.networks.len(), 1); + assert_eq!(cfg.networks[0].ssid, "Cafe"); + assert_eq!(cfg.networks[0].password, None); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn save_writes_networks_toml_not_inline_and_uses_0600() { + let dir = tmp_dir("split"); + let settings = dir.join("breadcrumbs.toml"); + let nets = dir.join("networks.toml"); + std::fs::write( + &settings, + "# keep me\n[settings]\ndns = \"1.1.1.1\"\n\n[[networks]]\nssid = \"Old\"\npassword = \"legacy\"\n", + ) + .unwrap(); + + save_to( + &settings, + &nets, + SaveBreadcrumbsInput { + settings: sample_settings(), + networks: vec![Network { + ssid: "Cafe".into(), + password: Some("hunter2".into()), + hidden: false, + }], + profiles: vec![], + }, + ) + .unwrap(); + + let settings_text = std::fs::read_to_string(&settings).unwrap(); + assert!( + settings_text.contains("# keep me"), + "toml_edit must keep comments" + ); + assert!( + !settings_text.contains("[[networks]]"), + "inline networks must be gone" + ); + assert!( + !settings_text.contains("hunter2"), + "PSK must not land in breadcrumbs.toml" + ); + assert!(!settings_text.contains("legacy")); + + let nets_text = std::fs::read_to_string(&nets).unwrap(); + assert!(nets_text.contains("Cafe")); + assert!(nets_text.contains("hunter2")); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&nets).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "networks.toml must be 0600, got {mode:o}"); + } + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn save_with_empty_password_keeps_existing_secret() { + let dir = tmp_dir("keep-secret"); + let settings = dir.join("breadcrumbs.toml"); + let nets = dir.join("networks.toml"); + std::fs::write(&settings, "[settings]\ndns = \"1.1.1.1\"\n").unwrap(); + std::fs::write( + &nets, + "[[networks]]\nssid = \"Cafe\"\npassword = \"hunter2\"\nhidden = false\n", + ) + .unwrap(); + + save_to( + &settings, + &nets, + SaveBreadcrumbsInput { + settings: sample_settings(), + networks: vec![Network { + ssid: "Cafe".into(), + password: None, + hidden: true, + }], + profiles: vec![], + }, + ) + .unwrap(); + + let nets_text = std::fs::read_to_string(&nets).unwrap(); + assert!( + nets_text.contains("hunter2"), + "empty password must keep existing secret" + ); + assert!(nets_text.contains("hidden = true")); + assert!(!std::fs::read_to_string(&settings) + .unwrap() + .contains("hunter2")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn legacy_inline_networks_load_when_networks_toml_missing() { + let dir = tmp_dir("legacy"); + let settings = dir.join("breadcrumbs.toml"); + let nets = dir.join("networks.toml"); + std::fs::write( + &settings, + "[settings]\ndns = \"8.8.8.8\"\n\n[[networks]]\nssid = \"LegacyNet\"\npassword = \"secret\"\n", + ) + .unwrap(); + + let cfg = load_from(&settings, &nets); + assert_eq!(cfg.networks.len(), 1); + assert_eq!(cfg.networks[0].ssid, "LegacyNet"); + assert_eq!(cfg.networks[0].password, None, "still redacted to the UI"); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/src/commands/breadhelp.rs b/src/src/commands/breadhelp.rs new file mode 100644 index 0000000..efaa85d --- /dev/null +++ b/src/src/commands/breadhelp.rs @@ -0,0 +1,10 @@ +//! breadhelp is the onboarding / help center. This panel launches it; it +//! does not duplicate the help library, keybind tour, or troubleshoot +//! wizard. First-run autostart (`breadhelp --autostart` in +//! `hypr/autostart.json`) is toggled through the existing autostart +//! commands from the frontend. + +#[tauri::command] +pub fn open_breadhelp() { + let _ = std::process::Command::new("breadhelp").spawn(); +} diff --git a/src/src/commands/breadlock.rs b/src/src/commands/breadlock.rs new file mode 100644 index 0000000..6bad05f --- /dev/null +++ b/src/src/commands/breadlock.rs @@ -0,0 +1,132 @@ +//! Lock screen (breadlock) and greeter (breadgreet). +//! +//! Super+L is `loginctl lock-session`; hypridle's lock_cmd / idle listener +//! then starts breadlock. This panel edits `~/.config/breadlock/breadlock.toml` +//! (appearance + fail timeout) — it does not, and cannot, configure PAM. +//! breadgreet is the greetd greeter; its live config is typically +//! `/etc/greetd/breadgreet.toml` (system, owned by the greeter user) and is +//! not written from here. + +use serde::{Deserialize, Serialize}; + +use super::config; + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("breadlock/breadlock.toml") +} + +const EXAMPLE_CANDIDATES: &[&str] = &[ + "/usr/share/doc/breadlock/breadlock.example.toml", + "/usr/share/breadlock/breadlock.example.toml", + "/usr/share/doc/breadlock/examples/breadlock.example.toml", +]; + +#[derive(Serialize, Deserialize)] +pub struct BreadlockConfig { + background_mode: String, + background_path: String, + background_blur: bool, + clock_format: String, + font_family: String, + fail_timeout_ms: i64, +} + +impl Default for BreadlockConfig { + fn default() -> Self { + Self { + background_mode: "color".into(), + background_path: String::new(), + background_blur: false, + clock_format: "%H:%M".into(), + font_family: "Varela Round".into(), + fail_timeout_ms: 800, + } + } +} + +#[tauri::command] +pub fn get_breadlock_config() -> BreadlockConfig { + let doc = config::load_doc(&config_path()); + let mut cfg = BreadlockConfig::default(); + if let Some(mode) = config::get_str(&doc, &["background", "mode"]) { + cfg.background_mode = mode; + } + if let Some(path) = config::get_str(&doc, &["background", "path"]) { + cfg.background_path = path; + } + if let Some(blur) = config::get_bool(&doc, &["background", "blur"]) { + cfg.background_blur = blur; + } + if let Some(fmt) = config::get_str(&doc, &["clock", "format"]) { + cfg.clock_format = fmt; + } + if let Some(family) = config::get_str(&doc, &["font", "family"]) { + cfg.font_family = family; + } + if let Some(ms) = config::get_i64(&doc, &["input", "fail_timeout_ms"]) { + cfg.fail_timeout_ms = ms; + } + cfg +} + +#[tauri::command] +pub fn save_breadlock_config(cfg: BreadlockConfig) -> Result<(), String> { + let path = config_path(); + let mut doc = config::load_doc(&path); + let mode = if cfg.background_mode == "image" { + "image" + } else { + "color" + }; + config::set_str(&mut doc, &["background", "mode"], mode); + config::set_str_or_remove(&mut doc, &["background", "path"], &cfg.background_path); + config::set_bool(&mut doc, &["background", "blur"], cfg.background_blur); + config::set_str(&mut doc, &["clock", "format"], &cfg.clock_format); + config::set_str(&mut doc, &["font", "family"], &cfg.font_family); + config::set_i64( + &mut doc, + &["input", "fail_timeout_ms"], + cfg.fail_timeout_ms.max(0), + ); + config::save_doc(&path, &doc).map_err(|e| e.to_string()) +} + +/// First existing packaged example, if any — the panel links this rather +/// than pretending the in-app editor is the whole schema. +#[tauri::command] +pub fn breadlock_example_path() -> Option { + EXAMPLE_CANDIDATES + .iter() + .find(|p| std::path::Path::new(p).is_file()) + .map(|p| p.to_string()) +} + +fn open_in_editor(path: &std::path::Path) { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); + let _ = std::process::Command::new("kitty") + .args(["-e", &editor]) + .arg(path) + .spawn(); +} + +#[tauri::command] +pub fn open_breadlock_config() { + open_in_editor(&config_path()); +} + +#[tauri::command] +pub fn open_breadlock_example() { + if let Some(path) = breadlock_example_path() { + open_in_editor(std::path::Path::new(&path)); + } +} + +/// Super+L / hypridle path: `loginctl lock-session` → compositor lock +/// protocol → breadlock. Fire-and-forget; locking the live session is the +/// point of the button. +#[tauri::command] +pub fn lock_session() { + let _ = std::process::Command::new("loginctl") + .arg("lock-session") + .spawn(); +} diff --git a/src/src/commands/breadmon.rs b/src/src/commands/breadmon.rs new file mode 100644 index 0000000..06ea09d --- /dev/null +++ b/src/src/commands/breadmon.rs @@ -0,0 +1,9 @@ +//! breadmon is a TUI for live Hyprland monitor layout, mirroring, and +//! named profiles (`~/.config/breadmon/profiles/`). Display (this app) +//! edits `hypr/monitors.json` — the login-time layout Hyprland itself +//! reads. This module only launches the TUI; it does not write profiles. + +#[tauri::command] +pub fn open_breadmon() { + let _ = std::process::Command::new("breadmon").spawn(); +} diff --git a/src/src/commands/breadshot.rs b/src/src/commands/breadshot.rs new file mode 100644 index 0000000..51f75be --- /dev/null +++ b/src/src/commands/breadshot.rs @@ -0,0 +1,139 @@ +//! Screenshots (breadshot). Binds are read-only here — edit them on the +//! Keybinds panel. Config lives at `~/.config/breadshot/config.toml` and +//! matches breadshot's own `Config` (every field optional). + +use serde::{Deserialize, Serialize}; + +use super::config; +use super::keybinds; + +fn config_path() -> std::path::PathBuf { + config::config_dir().join("breadshot/config.toml") +} + +#[derive(Serialize, Deserialize)] +pub struct BreadshotConfig { + save_dir: String, + silent: bool, + freeze: bool, + notif_timeout: i64, + date_format: String, +} + +impl Default for BreadshotConfig { + fn default() -> Self { + Self { + save_dir: "~/Pictures/Screenshots".into(), + silent: false, + freeze: false, + notif_timeout: 5000, + date_format: "%Y-%m-%d-%H%M%S".into(), + } + } +} + +#[derive(Serialize)] +pub struct ShotBind { + shortcut: String, + command: String, +} + +#[tauri::command] +pub fn get_breadshot_config() -> BreadshotConfig { + let doc = config::load_doc(&config_path()); + let mut cfg = BreadshotConfig::default(); + if let Some(dir) = config::get_str(&doc, &["save_dir"]) { + cfg.save_dir = dir; + } + if let Some(v) = config::get_bool(&doc, &["silent"]) { + cfg.silent = v; + } + if let Some(v) = config::get_bool(&doc, &["freeze"]) { + cfg.freeze = v; + } + if let Some(v) = config::get_i64(&doc, &["notif_timeout"]) { + cfg.notif_timeout = v; + } + if let Some(v) = config::get_str(&doc, &["date_format"]) { + cfg.date_format = v; + } + cfg +} + +#[tauri::command] +pub fn save_breadshot_config(cfg: BreadshotConfig) -> Result<(), String> { + let path = config_path(); + let mut doc = config::load_doc(&path); + config::set_str(&mut doc, &["save_dir"], &cfg.save_dir); + config::set_bool(&mut doc, &["silent"], cfg.silent); + config::set_bool(&mut doc, &["freeze"], cfg.freeze); + config::set_i64(&mut doc, &["notif_timeout"], cfg.notif_timeout.max(0)); + config::set_str(&mut doc, &["date_format"], &cfg.date_format); + config::save_doc(&path, &doc).map_err(|e| e.to_string()) +} + +/// Documented BOS defaults (SUPER+Shift+S/C/P) used when binds.json has no +/// breadshot exec entries — still accurate as a cheatsheet even on a +/// machine whose binds were rewritten. +fn documented_defaults() -> Vec { + vec![ + ShotBind { + shortcut: "Super+Shift+S".into(), + command: "breadshot region".into(), + }, + ShotBind { + shortcut: "Super+Shift+C".into(), + command: "breadshot region --clipboard-only".into(), + }, + ShotBind { + shortcut: "Super+Shift+P".into(), + command: "breadshot active-output".into(), + }, + ] +} + +fn format_shortcut(mods: Option<&[String]>, key: Option<&str>, default_mods: &[String]) -> String { + let mods = mods.unwrap_or(default_mods); + let mut parts: Vec = mods + .iter() + .map(|m| match m.to_ascii_uppercase().as_str() { + "SUPER" | "MOD4" => "Super".into(), + "SHIFT" => "Shift".into(), + "CTRL" | "CONTROL" => "Ctrl".into(), + "ALT" | "MOD1" => "Alt".into(), + other => other.to_string(), + }) + .collect(); + if let Some(k) = key { + if !k.is_empty() { + parts.push(k.to_string()); + } + } + parts.join("+") +} + +/// Read-only: breadshot exec binds from binds.json, or the documented +/// Super+Shift+S/C/P cheatsheet if none are defined. +#[tauri::command] +pub fn get_breadshot_binds() -> Vec { + let from_file = keybinds::breadshot_binds(); + if from_file.is_empty() { + documented_defaults() + } else { + from_file + .into_iter() + .map(|b| ShotBind { + shortcut: format_shortcut(b.mods.as_deref(), b.key.as_deref(), &b.default_mods), + command: b.command, + }) + .collect() + } +} + +/// Interactive region capture, clipboard only — no file written. +#[tauri::command] +pub fn breadshot_region_clipboard() { + let _ = std::process::Command::new("breadshot") + .args(["region", "--clipboard-only"]) + .spawn(); +} diff --git a/src/src/commands/config.rs b/src/src/commands/config.rs index c1e4034..31c140e 100644 --- a/src/src/commands/config.rs +++ b/src/src/commands/config.rs @@ -2,7 +2,8 @@ //! //! Every bread* app owns a TOML config that may contain keys, sections, and //! comments this settings app does not model (e.g. breadpad's calendar -//! credentials, breadcrumbs' saved-network passwords). To edit safely we parse +//! credentials). Saved-network passwords live in breadcrumbs' separate +//! `networks.toml`, not in breadcrumbs.toml. To edit safely we parse //! the file into a `toml_edit::DocumentMut`, mutate only the specific keys the //! UI exposes, and write the document back — preserving everything else, //! formatting and comments included. @@ -18,7 +19,7 @@ use toml_edit::{value, Array, DocumentMut, Item, Table, Value}; /// falling back to an empty document there means the next Save (see /// `save_doc`) overwrites it with only the UI-modelled keys, silently /// destroying anything else in the file (breadpad's calendar credentials, -/// breadcrumbs' saved network passwords, ...). Back up the unparseable file +/// unmodelled keys, ...). Back up the unparseable file /// once before falling back, so a bad edit is always recoverable. pub fn load_doc(path: &Path) -> DocumentMut { bread_utils::tomlcfg::load_doc("bos-settings", path) @@ -69,7 +70,8 @@ pub fn get_i64(doc: &DocumentMut, path: &[&str]) -> Option { } pub fn get_f64(doc: &DocumentMut, path: &[&str]) -> Option { let item = get(doc, path)?; - item.as_float().or_else(|| item.as_integer().map(|i| i as f64)) + item.as_float() + .or_else(|| item.as_integer().map(|i| i as f64)) } /// Read an array of strings (e.g. modules.disable, contexts[].priority). pub fn get_str_list(doc: &DocumentMut, path: &[&str]) -> Vec { @@ -176,7 +178,10 @@ password = \"secret\" # keep me let mut doc = DocumentMut::new(); set_bool(&mut doc, &["adapters", "power", "enabled"], false); set_i64(&mut doc, &["adapters", "power", "poll_interval_secs"], 45); - assert_eq!(get_bool(&doc, &["adapters", "power", "enabled"]), Some(false)); + assert_eq!( + get_bool(&doc, &["adapters", "power", "enabled"]), + Some(false) + ); assert_eq!( get_i64(&doc, &["adapters", "power", "poll_interval_secs"]), Some(45) @@ -200,14 +205,20 @@ password = \"secret\" # keep me #[test] fn atomic_write_backs_up_previous_contents_and_no_tmp_file_left_behind() { - let dir = std::env::temp_dir().join(format!("bos-settings-atomic-write-test-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!( + "bos-settings-atomic-write-test-{}", + std::process::id() + )); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("config.toml"); let backup = dir.join("config.toml.bak"); atomic_write(&path, "first").unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "first"); - assert!(!backup.exists(), "no backup should be made when there's nothing to back up yet"); + assert!( + !backup.exists(), + "no backup should be made when there's nothing to back up yet" + ); atomic_write(&path, "second").unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "second"); @@ -219,7 +230,10 @@ password = \"secret\" # keep me .map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|n| n.contains(".tmp.")) .collect(); - assert!(leftover_tmp.is_empty(), "temp file should be renamed away, not left behind: {leftover_tmp:?}"); + assert!( + leftover_tmp.is_empty(), + "temp file should be renamed away, not left behind: {leftover_tmp:?}" + ); let _ = std::fs::remove_dir_all(&dir); } diff --git a/src/src/commands/keybinds.rs b/src/src/commands/keybinds.rs index f46cf7a..946ea6b 100644 --- a/src/src/commands/keybinds.rs +++ b/src/src/commands/keybinds.rs @@ -201,6 +201,51 @@ fn save(f: &BindsFile, kind: SchemaKind) -> std::io::Result<()> { save_to(&config_path(), f, kind) } +/// One breadshot `exec` bind as binds.json stored it. Used by the +/// Screenshots panel (read-only); editing still happens here. +pub(crate) struct ShotBindRaw { + pub mods: Option>, + pub key: Option, + pub command: String, + pub default_mods: Vec, +} + +pub(crate) fn breadshot_binds() -> Vec { + let (file, _kind) = load(); + let default_mods = file.default_mods.clone(); + let mut out = Vec::new(); + let mut push = |binds: &[Bind]| { + for b in binds { + if b.action != "exec" { + continue; + } + let Some(cmd) = b.extra.get("command").and_then(|v| v.as_str()) else { + continue; + }; + if !cmd + .split_whitespace() + .next() + .is_some_and(|bin| bin == "breadshot" || bin.ends_with("/breadshot")) + { + continue; + } + out.push(ShotBindRaw { + mods: b.mods.clone(), + key: b.key.clone(), + command: cmd.to_string(), + default_mods: default_mods.clone(), + }); + } + }; + push(&file.bindings); + push(&file.globals); + push(&file.common); + for binds in file.layouts.values() { + push(binds); + } + out +} + #[tauri::command] pub fn get_keybinds() -> BindsPayload { let (file, kind) = load(); @@ -265,8 +310,14 @@ mod tests { // active_layout/globals/common/layouts keys leaking in. let saved_obj = saved.as_object().expect("flat save must be a JSON object"); assert_eq!( - saved_obj.keys().cloned().collect::>(), - ["default_mods", "bindings"].into_iter().map(String::from).collect(), + saved_obj + .keys() + .cloned() + .collect::>(), + ["default_mods", "bindings"] + .into_iter() + .map(String::from) + .collect(), "Flat schema must round-trip as exactly {{default_mods, bindings}}" ); @@ -279,7 +330,8 @@ mod tests { #[test] fn round_trip_via_files_preserves_bindings_key_and_extras() { - let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-test-{}", std::process::id())); + let dir = + std::env::temp_dir().join(format!("bos-settings-keybinds-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("binds.json"); std::fs::write(&path, REAL_BOS_FLAT_FIXTURE).unwrap(); @@ -292,7 +344,10 @@ mod tests { let saved: Value = serde_json::from_str(&saved_text).unwrap(); let original: Value = serde_json::from_str(REAL_BOS_FLAT_FIXTURE).unwrap(); - assert!(saved.get("bindings").is_some(), "bindings key must survive a load -> save round trip"); + assert!( + saved.get("bindings").is_some(), + "bindings key must survive a load -> save round trip" + ); assert_eq!(saved["bindings"], original["bindings"]); assert_eq!(saved["default_mods"], original["default_mods"]); @@ -301,7 +356,8 @@ mod tests { save_to(&path, &file, kind).unwrap(); let backup_path = dir.join("binds.json.bak"); assert!(backup_path.exists(), "save must back up the previous file"); - let backup: Value = serde_json::from_str(&std::fs::read_to_string(&backup_path).unwrap()).unwrap(); + let backup: Value = + serde_json::from_str(&std::fs::read_to_string(&backup_path).unwrap()).unwrap(); assert_eq!(backup["bindings"], original["bindings"]); let _ = std::fs::remove_dir_all(&dir); @@ -320,7 +376,10 @@ mod tests { assert_eq!(kind, SchemaKind::MultiLayout); let saved = to_json(&file, kind); - assert!(saved.get("bindings").is_none(), "MultiLayout save must not emit a flat `bindings` key"); + assert!( + saved.get("bindings").is_none(), + "MultiLayout save must not emit a flat `bindings` key" + ); assert_eq!(saved["active_layout"], "qwerty"); assert_eq!(saved["layouts"]["qwerty"][0]["action"], "close"); assert_eq!(saved["globals"][0]["command"], "kitty"); @@ -332,20 +391,32 @@ mod tests { let (file, kind) = parse(text); assert_eq!(kind, SchemaKind::Unknown); - let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-unknown-test-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!( + "bos-settings-keybinds-unknown-test-{}", + std::process::id() + )); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("binds.json"); let result = save_to(&path, &file, kind); - assert!(result.is_err(), "save() must refuse when schema kind is Unknown"); - assert!(!path.exists(), "refusing to save must not create/touch the target file"); + assert!( + result.is_err(), + "save() must refuse when schema kind is Unknown" + ); + assert!( + !path.exists(), + "refusing to save must not create/touch the target file" + ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn missing_file_defaults_to_flat_not_multi_layout() { - let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-missing-test-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!( + "bos-settings-keybinds-missing-test-{}", + std::process::id() + )); // Don't create the file at all. let path = dir.join("binds.json"); let (_, kind) = load_from(&path); diff --git a/src/src/commands/mod.rs b/src/src/commands/mod.rs index e26b415..ccc0e2e 100644 --- a/src/src/commands/mod.rs +++ b/src/src/commands/mod.rs @@ -8,9 +8,13 @@ pub mod breadbar; pub mod breadbox; pub mod breadclip; pub mod breadcrumbs; +pub mod breadhelp; +pub mod breadlock; +pub mod breadmon; pub mod breadpad; pub mod breadpaper; pub mod breadsearch; +pub mod breadshot; pub mod config; pub mod datetime; pub mod firewall; diff --git a/src/src/commands/streaming.rs b/src/src/commands/streaming.rs index d7d6a40..5b87c2b 100644 --- a/src/src/commands/streaming.rs +++ b/src/src/commands/streaming.rs @@ -1,10 +1,12 @@ -//! Shared event-streaming command runner for the genuinely long-running -//! operations (package/firmware updates) where the GTK app treated output -//! as "watch the log scroll" — the Tauri-side analog of -//! `stream_command_then`'s async_channel → glib::spawn_future_local -//! pipeline, using Tauri's event bus instead of a GLib main-loop channel. -//! Most other commands are simple request/response (see the other modules) -//! since the operations they wrap finish in well under a second. +//! Shared event-streaming runner for the genuinely long-running operations +//! (package/firmware updates) where the GTK app treated output as "watch +//! the log scroll" — the Tauri-side analog of `stream_command_then`'s +//! async_channel → glib::spawn_future_local pipeline, using Tauri's event +//! bus instead of a GLib main-loop channel. +//! +//! The runner itself is *not* a Tauri command. A generic argv runner was +//! an arbitrary-command primitive; each public command below hardcodes the +//! program and the allowed argument shape. use serde::Serialize; use std::process::Stdio; @@ -18,18 +20,25 @@ struct CmdOutputEvent { line: String, } -/// Runs `program args...`, emitting one `cmd-output` event per line of -/// stdout/stderr (tagged with `session_id` so the frontend can route -/// concurrent streams), and resolves to whether it exited successfully — -/// the frontend awaits this call directly rather than needing a second -/// "done" event. -#[tauri::command] -pub async fn run_streaming_command(app: AppHandle, session_id: String, program: String, args: Vec) -> bool { - let child = Command::new(&program).args(&args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn(); +/// Runs a hardcoded `program args...`, emitting one `cmd-output` event per +/// line of stdout/stderr (tagged with `session_id` so the frontend can route +/// concurrent streams), and resolves to whether it exited successfully. +async fn run_hardcoded(app: AppHandle, session_id: String, program: &str, args: &[&str]) -> bool { + let child = Command::new(program) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); let mut child = match child { Ok(c) => c, Err(e) => { - let _ = app.emit("cmd-output", CmdOutputEvent { session_id, line: format!("Error: {e}") }); + let _ = app.emit( + "cmd-output", + CmdOutputEvent { + session_id, + line: format!("Error: {e}"), + }, + ); return false; } }; @@ -40,7 +49,13 @@ pub async fn run_streaming_command(app: AppHandle, session_id: String, program: let read_stdout = async { let mut lines = BufReader::new(stdout).lines(); while let Ok(Some(line)) = lines.next_line().await { - let _ = app.emit("cmd-output", CmdOutputEvent { session_id: session_id.clone(), line }); + let _ = app.emit( + "cmd-output", + CmdOutputEvent { + session_id: session_id.clone(), + line, + }, + ); } }; let stderr_app = app.clone(); @@ -48,10 +63,96 @@ pub async fn run_streaming_command(app: AppHandle, session_id: String, program: let read_stderr = async move { let mut lines = BufReader::new(stderr).lines(); while let Ok(Some(line)) = lines.next_line().await { - let _ = stderr_app.emit("cmd-output", CmdOutputEvent { session_id: stderr_session.clone(), line }); + let _ = stderr_app.emit( + "cmd-output", + CmdOutputEvent { + session_id: stderr_session.clone(), + line, + }, + ); } }; tokio::join!(read_stdout, read_stderr); child.wait().await.map(|s| s.success()).unwrap_or(false) } + +/// bakery package names are `foo`, `foo-bar`, `foo_bar` — reject flags, +/// paths, and anything else that would change `bakery update`'s shape. +fn valid_bakery_pkg(name: &str) -> bool { + let bytes = name.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 128 + && bytes[0].is_ascii_alphanumeric() + && bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || *b == b'-' || *b == b'_') +} + +#[tauri::command] +pub async fn bakery_update(app: AppHandle, session_id: String, name: String) -> bool { + if !valid_bakery_pkg(&name) { + let _ = app.emit( + "cmd-output", + CmdOutputEvent { + session_id, + line: format!("Error: invalid bakery package name '{name}'"), + }, + ); + return false; + } + run_hardcoded(app, session_id, "bakery", &["update", &name]).await +} + +#[tauri::command] +pub async fn bakery_list(app: AppHandle, session_id: String) -> bool { + run_hardcoded(app, session_id, "bakery", &["list"]).await +} + +#[tauri::command] +pub async fn bakery_update_all(app: AppHandle, session_id: String) -> bool { + run_hardcoded(app, session_id, "bakery", &["update", "--all"]).await +} + +#[tauri::command] +pub async fn pacman_system_update(app: AppHandle, session_id: String) -> bool { + run_hardcoded( + app, + session_id, + "pkexec", + &["pacman", "-Syu", "--noconfirm"], + ) + .await +} + +#[tauri::command] +pub async fn fwupd_refresh(app: AppHandle, session_id: String) -> bool { + run_hardcoded(app, session_id, "fwupdmgr", &["refresh"]).await +} + +#[tauri::command] +pub async fn fwupd_update(app: AppHandle, session_id: String) -> bool { + run_hardcoded(app, session_id, "fwupdmgr", &["update", "-y"]).await +} + +#[cfg(test)] +mod tests { + use super::valid_bakery_pkg; + + #[test] + fn bakery_pkg_accepts_real_names() { + assert!(valid_bakery_pkg("breadbar")); + assert!(valid_bakery_pkg("bos-settings")); + assert!(valid_bakery_pkg("bread_theme")); + } + + #[test] + fn bakery_pkg_rejects_flags_and_paths() { + assert!(!valid_bakery_pkg("")); + assert!(!valid_bakery_pkg("--all")); + assert!(!valid_bakery_pkg("-S")); + assert!(!valid_bakery_pkg("../evil")); + assert!(!valid_bakery_pkg("foo bar")); + assert!(!valid_bakery_pkg("foo;rm")); + } +} diff --git a/src/src/commands/theme.rs b/src/src/commands/theme.rs index 6aaf367..cb290e6 100644 --- a/src/src/commands/theme.rs +++ b/src/src/commands/theme.rs @@ -17,10 +17,68 @@ pub fn get_theme_css() -> String { fn render_theme_css() -> String { let palette = bread_theme::load_palette(); + // bread-theme v0.7.1 exposes Palette + ink_on + tokens, but not the + // later css_custom_properties / css_tokens helpers (those landed after + // the tag). Emit the same :root custom-property names the Svelte app + // already uses so a tag pin doesn't require a web-side rename. + format!("{}\n{}", css_custom_properties(&palette), css_tokens()) +} + +fn css_custom_properties(p: &bread_theme::Palette) -> String { + let pairs = [ + ("bg", p.background.as_str()), + ("fg", p.foreground.as_str()), + ("surface", p.color0.as_str()), + ("overlay", p.color7.as_str()), + ("accent", p.color4.as_str()), + ("red", p.color1.as_str()), + ("green", p.color2.as_str()), + ("yellow", p.color3.as_str()), + ("blue", p.color4.as_str()), + ("pink", p.color5.as_str()), + ("teal", p.color6.as_str()), + ("on-bg", bread_theme::ink_on(&p.background)), + ("on-surface", bread_theme::ink_on(&p.color0)), + ("on-accent", bread_theme::ink_on(&p.color4)), + ("on-red", bread_theme::ink_on(&p.color1)), + ("on-overlay", bread_theme::ink_on(&p.color7)), + ]; + let vars: String = pairs + .iter() + .map(|(name, value)| format!(" --{name}: {value};\n")) + .collect(); + format!(":root {{\n{vars}}}\n") +} + +fn css_tokens() -> String { + use bread_theme::tokens::*; format!( - "{}\n{}", - bread_theme::css_custom_properties(&palette), - bread_theme::css_tokens(), + ":root {{\n\ + \x20\x20--font-family: '{font}';\n\ + \x20\x20--font-size-base: {base}px;\n\ + \x20\x20--font-size-secondary: {sec}px;\n\ + \x20\x20--space-xs: {xs}px;\n\ + \x20\x20--space-sm: {sm}px;\n\ + \x20\x20--space-md: {md}px;\n\ + \x20\x20--space-lg: {lg}px;\n\ + \x20\x20--space-xl: {xl}px;\n\ + \x20\x20--radius-primary: {r1}px;\n\ + \x20\x20--radius-secondary: {r2}px;\n\ + \x20\x20--radius-tertiary: {r3}px;\n\ + \x20\x20--radius-pill: {pill}px;\n\ + }}\n", + font = FONT_FAMILY, + base = FONT_SIZE_BASE, + sec = FONT_SIZE_SECONDARY, + xs = SPACE_XS, + sm = SPACE_SM, + md = SPACE_MD, + lg = SPACE_LG, + xl = SPACE_XL, + r1 = RADIUS_PRIMARY, + r2 = RADIUS_SECONDARY, + r3 = RADIUS_TERTIARY, + pill = RADIUS_PILL, ) } @@ -58,7 +116,10 @@ pub fn watch_and_emit(app: &AppHandle) { }; if let Err(e) = watcher.watch(dir, RecursiveMode::NonRecursive) { - tracing_or_eprintln(&format!("theme watcher: failed to watch {}: {e}", dir.display())); + tracing_or_eprintln(&format!( + "theme watcher: failed to watch {}: {e}", + dir.display() + )); return; } diff --git a/src/src/lib.rs b/src/src/lib.rs index f6a7be3..d022889 100644 --- a/src/src/lib.rs +++ b/src/src/lib.rs @@ -86,7 +86,12 @@ pub fn run() { commands::users::change_password, commands::users::remove_user, commands::users::add_user, - commands::streaming::run_streaming_command, + commands::streaming::bakery_update, + commands::streaming::bakery_list, + commands::streaming::bakery_update_all, + commands::streaming::pacman_system_update, + commands::streaming::fwupd_refresh, + commands::streaming::fwupd_update, commands::packages::get_installed_packages, commands::aur::search_aur, commands::aur::install_aur_package, @@ -94,6 +99,18 @@ pub fn run() { commands::snapshots::get_snapshots, commands::snapshots::delete_snapshot, commands::snapshots::reboot_system, + commands::breadlock::get_breadlock_config, + commands::breadlock::save_breadlock_config, + commands::breadlock::breadlock_example_path, + commands::breadlock::open_breadlock_config, + commands::breadlock::open_breadlock_example, + commands::breadlock::lock_session, + commands::breadshot::get_breadshot_config, + commands::breadshot::save_breadshot_config, + commands::breadshot::get_breadshot_binds, + commands::breadshot::breadshot_region_clipboard, + commands::breadmon::open_breadmon, + commands::breadhelp::open_breadhelp, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/src/screenshot.rs b/src/src/screenshot.rs index bb432a6..c24b6dc 100644 --- a/src/src/screenshot.rs +++ b/src/src/screenshot.rs @@ -1,5 +1,5 @@ //! `--screenshot` CLI mode: switch the Svelte SPA to the named sidebar -//! section, capture it via `bread-screenshots`, then exit — driven by +//! section, capture it via grim, then exit — driven by //! `bread-ecosystem`'s `bread-capture` orchestrator, or run standalone for //! one-off captures. //! @@ -59,6 +59,10 @@ const KNOWN_VIEWS: &[&str] = &[ "aur", "firmware", "snapshots", + "breadlock", + "breadshot", + "breadmon", + "breadhelp", "about", ]; @@ -107,7 +111,12 @@ pub fn parse(args: &[String]) -> Option { eprintln!("bos-settings: --screenshot requires --output"); std::process::exit(1); }; - Some(ScreenshotRequest { view, output: output.into(), width, height }) + Some(ScreenshotRequest { + view, + output: output.into(), + width, + height, + }) } /// Schedule the switch-view-then-capture-then-exit sequence. Called once @@ -121,7 +130,7 @@ pub fn dispatch(req: ScreenshotRequest, app: tauri::AppHandle) { std::process::exit(1); } tokio::time::sleep(VIEW_SETTLE_DELAY).await; - finish(bread_screenshots::capture_region( + finish(capture_region( 0, 0, req.width as i32, @@ -131,6 +140,28 @@ pub fn dispatch(req: ScreenshotRequest, app: tauri::AppHandle) { }); } +/// Same contract as bread-screenshots::capture_region. That crate is not +/// on bread-ecosystem v0.7.1 (it landed after the tag), so this stays a +/// local grim -g call rather than a branch-pinned git dep. +fn capture_region(x: i32, y: i32, w: i32, h: i32, out: &std::path::Path) -> anyhow::Result<()> { + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent)?; + } + let out_str = out + .to_str() + .ok_or_else(|| anyhow::anyhow!("output path is not valid UTF-8"))?; + let geometry = format!("{x},{y} {w}x{h}"); + let result = + bread_utils::proc::run("grim", &["-g", &geometry, out_str], Duration::from_secs(5)); + if !result.success { + anyhow::bail!( + "grim failed for geometry {geometry}: {}", + result.stderr.trim() + ); + } + Ok(()) +} + fn finish(result: anyhow::Result<()>) { match result { Ok(()) => std::process::exit(0), diff --git a/src/tauri.conf.json b/src/tauri.conf.json index b4c5066..004728d 100644 --- a/src/tauri.conf.json +++ b/src/tauri.conf.json @@ -20,7 +20,7 @@ } ], "security": { - "csp": null, + "csp": "default-src 'self'; connect-src ipc: http://ipc.localhost https://ipc.localhost; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'; object-src 'none'; base-uri 'self'; frame-src 'none'", "assetProtocol": { "enable": true, "scope": ["$HOME/Pictures/Backgrounds/**"] From c1f620c1faba8f9cb90dae0807d4454de32efbd3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:03:30 +0800 Subject: [PATCH 17/31] Use Arch package name webkit2gtk-4.1; track AGENTS.md --- AGENTS.md | 33 +++++++++++++++++++++++++++++++++ bakery.toml | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b61a76f --- /dev/null +++ b/AGENTS.md @@ -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_*`). diff --git a/bakery.toml b/bakery.toml index 961a4f5..9b83e9a 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,7 +1,7 @@ name = "bos-settings" description = "System settings app for Bread OS" binaries = ["bos-settings"] -system_deps = ["webkitgtk-4.1", "gtk3", "libsoup3", "librsvg", "hicolor-icon-theme"] +system_deps = ["webkit2gtk-4.1", "gtk3", "libsoup3", "librsvg", "hicolor-icon-theme"] optional_system_deps = ["snapper"] bread_deps = [] license_file = "LICENSE" From 68db82dee1c7d9a9376814d9ee8d910757af9f4c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:04:00 +0800 Subject: [PATCH 18/31] Remove CLAUDE.md (renamed to AGENTS.md) --- CLAUDE.md | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 2c92bd6..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,33 +0,0 @@ -# CLAUDE.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_*`). From 4741dff61b5bf6e8194c0668eeaafdeccea11cef Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 6 Aug 2026 08:46:39 +0800 Subject: [PATCH 19/31] Fix clippy warnings: needless lifetime, sort_by_key, dead_code guard field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/src/commands/breadbar.rs | 2 +- src/src/commands/network.rs | 2 +- src/src/commands/theme.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/src/commands/breadbar.rs b/src/src/commands/breadbar.rs index 51fdefa..8bdbecb 100644 --- a/src/src/commands/breadbar.rs +++ b/src/src/commands/breadbar.rs @@ -33,7 +33,7 @@ pub struct BreadbarStyle { pub notification_border_radius: u32, } -fn find_block<'a>(css: &'a str, selector: &str) -> Option<(usize, usize)> { +fn find_block(css: &str, selector: &str) -> Option<(usize, usize)> { let pat = format!(r"(?m)^\s*{}\s*\{{", regex::escape(selector)); let re = Regex::new(&pat).ok()?; let m = re.find(css)?; diff --git a/src/src/commands/network.rs b/src/src/commands/network.rs index cf0254f..80be783 100644 --- a/src/src/commands/network.rs +++ b/src/src/commands/network.rs @@ -89,7 +89,7 @@ pub async fn scan_wifi() -> Vec { by_ssid.entry(ssid.to_string()).and_modify(|existing| if net.signal > existing.signal { *existing = net.clone() }).or_insert(net); } let mut list: Vec<_> = by_ssid.into_values().collect(); - list.sort_by(|a, b| b.signal.cmp(&a.signal)); + list.sort_by_key(|n| std::cmp::Reverse(n.signal)); list } diff --git a/src/src/commands/theme.rs b/src/src/commands/theme.rs index cb290e6..d9dcc91 100644 --- a/src/src/commands/theme.rs +++ b/src/src/commands/theme.rs @@ -128,7 +128,7 @@ pub fn watch_and_emit(app: &AppHandle) { app.manage(WatcherHandle(watcher)); } -struct WatcherHandle(RecommendedWatcher); +struct WatcherHandle(#[allow(dead_code)] RecommendedWatcher); fn tracing_or_eprintln(msg: &str) { eprintln!("{msg}"); From 32604b492b5804b38041c9443d2afabd287153c2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 22:53:46 +0800 Subject: [PATCH 20/31] Pin bread-theme and bread-utils to bread-ecosystem v0.7.2 --- src/Cargo.lock | 8 ++++---- src/Cargo.toml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 64cc1d8..020c388 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -307,8 +307,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "dirs 5.0.1", "serde", @@ -317,8 +317,8 @@ dependencies = [ [[package]] name = "bread-utils" -version = "0.3.1" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +version = "0.7.2" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" dependencies = [ "dirs 5.0.1", "serde", diff --git a/src/Cargo.toml b/src/Cargo.toml index 41dc987..bbd9ae4 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -36,7 +36,7 @@ toml_edit = "0.22" tokio = { version = "1", features = ["process", "io-util", "time", "macros"] } notify = "7" regex = "1" -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1" } -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["toml"] } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml"] } anyhow = "1" From 60a473fff01d1f5738203e414709b7093368368e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:07:04 +0800 Subject: [PATCH 21/31] Add OS settings panels for updates, printing, VPN, and related surfaces 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. --- frontend/src/lib/sidebar.ts | 20 + frontend/src/lib/views/Accessibility.svelte | 197 +++++++++ frontend/src/lib/views/Backup.svelte | 205 ++++++++++ frontend/src/lib/views/Channel.svelte | 127 ++++++ frontend/src/lib/views/Defaults.svelte | 104 +++++ frontend/src/lib/views/InputMethod.svelte | 166 ++++++++ frontend/src/lib/views/NightLight.svelte | 145 +++++++ frontend/src/lib/views/Optional.svelte | 149 +++++++ frontend/src/lib/views/Printing.svelte | 147 +++++++ frontend/src/lib/views/Updates.svelte | 243 +++++++++++ frontend/src/lib/views/Vpn.svelte | 148 +++++++ frontend/src/lib/views/registry.ts | 20 + src/src/commands/a11y.rs | 116 ++++++ src/src/commands/backup.rs | 358 ++++++++++++++++ src/src/commands/channel.rs | 91 +++++ src/src/commands/defaults.rs | 431 ++++++++++++++++++++ src/src/commands/ime.rs | 177 ++++++++ src/src/commands/mod.rs | 12 + src/src/commands/nightlight.rs | 216 ++++++++++ src/src/commands/nvidia.rs | 106 +++++ src/src/commands/optional.rs | 112 +++++ src/src/commands/packages.rs | 11 +- src/src/commands/printing.rs | 204 +++++++++ src/src/commands/streaming.rs | 68 ++- src/src/commands/updates.rs | 184 +++++++++ src/src/commands/util.rs | 258 ++++++++++++ src/src/commands/vpn.rs | 179 ++++++++ src/src/lib.rs | 33 ++ src/src/screenshot.rs | 10 + 29 files changed, 4229 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/views/Accessibility.svelte create mode 100644 frontend/src/lib/views/Backup.svelte create mode 100644 frontend/src/lib/views/Channel.svelte create mode 100644 frontend/src/lib/views/Defaults.svelte create mode 100644 frontend/src/lib/views/InputMethod.svelte create mode 100644 frontend/src/lib/views/NightLight.svelte create mode 100644 frontend/src/lib/views/Optional.svelte create mode 100644 frontend/src/lib/views/Printing.svelte create mode 100644 frontend/src/lib/views/Updates.svelte create mode 100644 frontend/src/lib/views/Vpn.svelte create mode 100644 src/src/commands/a11y.rs create mode 100644 src/src/commands/backup.rs create mode 100644 src/src/commands/channel.rs create mode 100644 src/src/commands/defaults.rs create mode 100644 src/src/commands/ime.rs create mode 100644 src/src/commands/nightlight.rs create mode 100644 src/src/commands/nvidia.rs create mode 100644 src/src/commands/optional.rs create mode 100644 src/src/commands/printing.rs create mode 100644 src/src/commands/updates.rs create mode 100644 src/src/commands/util.rs create mode 100644 src/src/commands/vpn.rs diff --git a/frontend/src/lib/sidebar.ts b/frontend/src/lib/sidebar.ts index 7d98ce6..c9f6740 100644 --- a/frontend/src/lib/sidebar.ts +++ b/frontend/src/lib/sidebar.ts @@ -31,6 +31,16 @@ import Lock from "@lucide/svelte/icons/lock"; import Camera from "@lucide/svelte/icons/camera"; import AppWindow from "@lucide/svelte/icons/app-window"; import CircleHelp from "@lucide/svelte/icons/circle-help"; +import Download from "@lucide/svelte/icons/download"; +import Printer from "@lucide/svelte/icons/printer"; +import ShieldEllipsis from "@lucide/svelte/icons/shield-ellipsis"; +import Moon from "@lucide/svelte/icons/moon"; +import Languages from "@lucide/svelte/icons/languages"; +import Accessibility from "@lucide/svelte/icons/accessibility"; +import AppWindowMac from "@lucide/svelte/icons/app-window-mac"; +import GitBranch from "@lucide/svelte/icons/git-branch"; +import Archive from "@lucide/svelte/icons/archive"; +import Boxes from "@lucide/svelte/icons/boxes"; export interface SidebarItem { /** Must match a key in the view component map (see routing in +page.svelte). */ @@ -45,15 +55,20 @@ export interface SidebarItem { export const SYSTEM_ITEMS: SidebarItem[] = [ { id: "network", label: "Network", icon: Wifi }, { id: "breadcrumbs", label: "Wi-Fi Profiles", sublabel: "breadcrumbs", icon: Network }, + { id: "vpn", label: "VPN / WireGuard", sublabel: "NetworkManager", icon: ShieldEllipsis }, { id: "bluetooth", label: "Bluetooth", icon: Bluetooth }, + { id: "printing", label: "Printing", sublabel: "CUPS", icon: Printer }, { id: "firewall", label: "Firewall", icon: Shield }, { id: "sound", label: "Sound", icon: Volume2 }, { id: "power", label: "Power", icon: BatteryFull }, { id: "datetime", label: "Date & Time", icon: Clock }, { id: "hyprland", label: "Display", sublabel: "monitors.json", icon: Monitor }, + { id: "nightlight", label: "Night light", sublabel: "hyprsunset", icon: Moon }, { id: "breadmon", label: "Monitors", sublabel: "breadmon", icon: AppWindow }, { id: "breadlock", label: "Lock & greet", sublabel: "breadlock", icon: Lock }, { id: "keybinds", label: "Keybinds", sublabel: "binds.json", icon: Keyboard }, + { id: "ime", label: "Input method", sublabel: "fcitx5", icon: Languages }, + { id: "accessibility", label: "Accessibility", icon: Accessibility }, { id: "breadshot", label: "Screenshots", sublabel: "breadshot", icon: Camera }, { id: "autostart", label: "Startup Apps", sublabel: "autostart.json", icon: Rocket }, { id: "users", label: "Users", icon: Users }, @@ -67,14 +82,19 @@ export const PERSONALIZATION_ITEMS: SidebarItem[] = [ { id: "breadclip", label: "Clipboard", sublabel: "breadclipd", icon: Clipboard }, { id: "breadpad", label: "Notes", sublabel: "breadpad", icon: NotebookPen }, { id: "breadsearch", label: "File Search", sublabel: "breadsearch", icon: Search }, + { id: "defaults", label: "Default apps", sublabel: "mimeapps.list", icon: AppWindowMac }, { id: "bread", label: "Daemon", sublabel: "breadd", icon: Cog }, ]; export const MAINTENANCE_ITEMS: SidebarItem[] = [ + { id: "updates", label: "Updates", icon: Download }, { id: "packages", label: "Packages", icon: Package }, { id: "aur", label: "AUR", icon: Search }, { id: "firmware", label: "Firmware", icon: RefreshCw }, { id: "snapshots", label: "Snapshots", icon: History }, + { id: "channel", label: "Bakery channel", sublabel: "track", icon: GitBranch }, + { id: "backup", label: "Backup", sublabel: "restic", icon: Archive }, + { id: "optional", label: "Optional software", icon: Boxes }, ]; export const ABOUT_ITEMS: SidebarItem[] = [ diff --git a/frontend/src/lib/views/Accessibility.svelte b/frontend/src/lib/views/Accessibility.svelte new file mode 100644 index 0000000..02bb427 --- /dev/null +++ b/frontend/src/lib/views/Accessibility.svelte @@ -0,0 +1,197 @@ + + + + + {#if !st} + + {:else if !st.orca_installed} + + + {:else} +

+ {/if} + + + + {#if st} + + + + {#if !st.kmag_installed} + + + {:else} + + {/if} + {/if} + + + + {#if st} +
+ Sticky keys + +
+
+ Slow keys + +
+ + {/if} +
+ {#if message}{/if} + + + + diff --git a/frontend/src/lib/views/Backup.svelte b/frontend/src/lib/views/Backup.svelte new file mode 100644 index 0000000..45da508 --- /dev/null +++ b/frontend/src/lib/views/Backup.svelte @@ -0,0 +1,205 @@ + + + + + {#if !st} + + {:else if !st.restic_installed} + + + {:else} + + + + + {/if} + + + +
+ + + + +
+ {#if message}{/if} +
+ + + {#if snapshots.length === 0} + + {:else} +
+ {#each snapshots as s (s.id)} + + {/each} +
+ {/if} +
+ +
+ + diff --git a/frontend/src/lib/views/Channel.svelte b/frontend/src/lib/views/Channel.svelte new file mode 100644 index 0000000..a49c439 --- /dev/null +++ b/frontend/src/lib/views/Channel.svelte @@ -0,0 +1,127 @@ + + + + + {#if !track} + + {:else} +
+ {#each track.tracks as name (name)} + + {/each} +
+ + {/if} + + {#if message}{/if} +
+ +
+ + diff --git a/frontend/src/lib/views/Defaults.svelte b/frontend/src/lib/views/Defaults.svelte new file mode 100644 index 0000000..0fd2927 --- /dev/null +++ b/frontend/src/lib/views/Defaults.svelte @@ -0,0 +1,104 @@ + + + + + {#if st} + {#each CATEGORIES as cat (cat.id)} +
+ {cat.label} + +
+ {/each} + + {:else} + + {/if} +
+
+ + diff --git a/frontend/src/lib/views/InputMethod.svelte b/frontend/src/lib/views/InputMethod.svelte new file mode 100644 index 0000000..ef99f54 --- /dev/null +++ b/frontend/src/lib/views/InputMethod.svelte @@ -0,0 +1,166 @@ + + + + + {#if !st} + + {:else} +
+ Enable fcitx5 for this session + +
+ + + {/if} + {#if message}{/if} +
+ + + {#if st} +
    + {#each st.packages as p (p.name)} +
  • {p.name}{p.installed ? "" : " — not installed"}
  • + {/each} +
+ {/if} + {#if missing.length} + + {/if} +
+ +
+ + diff --git a/frontend/src/lib/views/NightLight.svelte b/frontend/src/lib/views/NightLight.svelte new file mode 100644 index 0000000..13ec93f --- /dev/null +++ b/frontend/src/lib/views/NightLight.svelte @@ -0,0 +1,145 @@ + + + + + {#if !st} + + {:else if !st.installed} + + + {:else} +
+ Night light + +
+ + + + {/if} + {#if message}{/if} +
+ +
+ + diff --git a/frontend/src/lib/views/Optional.svelte b/frontend/src/lib/views/Optional.svelte new file mode 100644 index 0000000..3fbfcf0 --- /dev/null +++ b/frontend/src/lib/views/Optional.svelte @@ -0,0 +1,149 @@ + + + + + {#if !st} + + {:else} + {#each st.items as item (item.id)} +
+
+ {item.title} +

{item.detail}

+ {item.via}{item.installed ? " · installed" : ""} +
+ {#if item.installed} + Installed + {:else} + + {/if} +
+ {/each} + {/if} + {#if message}{/if} +
+ +
+ + diff --git a/frontend/src/lib/views/Printing.svelte b/frontend/src/lib/views/Printing.svelte new file mode 100644 index 0000000..2fc3ac5 --- /dev/null +++ b/frontend/src/lib/views/Printing.svelte @@ -0,0 +1,147 @@ + + + + + {#if !status} + + {:else if status.error} + + {:else if status.printers.length === 0} + + {:else} +
+ {#each status.printers as p (p.name)} +
+
+ {p.name}{p.is_default ? " (default)" : ""} + {p.enabled ? p.status : "disabled"} +
+ +
+ {/each} +
+ {/if} +
+ + +
+ {#if message}{/if} +
+ + + + + + +
+ + diff --git a/frontend/src/lib/views/Updates.svelte b/frontend/src/lib/views/Updates.svelte new file mode 100644 index 0000000..4b48203 --- /dev/null +++ b/frontend/src/lib/views/Updates.svelte @@ -0,0 +1,243 @@ + + + + {#if status?.nvidia} + +
+ +
+ {status.nvidia.gpu} +

{status.nvidia.reason}

+ +
+ +
+
+ {/if} + + + {#if !status} + + {:else if status.pacman_error} + + {:else if status.pacman.length === 0} + + {:else} +
+ {#each status.pacman as pkg (pkg.name)} +
+ {pkg.name} + {pkg.current} → {pkg.latest} +
+ {/each} +
+ {/if} +
+ + +
+
+ + + {#if !status} + + {:else if status.bakery_error} + + {:else if status.bakery.length === 0} + + {:else} +
+ {#each status.bakery as pkg (pkg.name)} +
+ {pkg.name} + {pkg.current ? `${pkg.current} → ` : ""}{pkg.latest} + +
+ {/each} +
+ {/if} +
+ +
+
+ + + {#if !status} + + {:else if status.firmware.length === 0} + + {:else} +
+ {#each status.firmware as dev (dev.name)} +
+ {dev.name} + {dev.version} +
+ {/each} +
+ {/if} +
+ + +
+
+ + + + + + +
+ + diff --git a/frontend/src/lib/views/Vpn.svelte b/frontend/src/lib/views/Vpn.svelte new file mode 100644 index 0000000..7bf4102 --- /dev/null +++ b/frontend/src/lib/views/Vpn.svelte @@ -0,0 +1,148 @@ + + + + + {#if !status} + + {:else if status.error} + + {:else if status.connections.length === 0} + + {:else} +
+ {#each status.connections as c (c.name)} +
+
+ {c.name} + {c.kind}{c.active ? " · connected" : ""}{c.autoconnect ? " · autoconnect" : ""} +
+ {#if c.active} + + {:else} + + {/if} +
+ {/each} +
+ {/if} + + {#if message}{/if} +
+ + + + + +
+ + diff --git a/frontend/src/lib/views/registry.ts b/frontend/src/lib/views/registry.ts index ee0e5e4..6a87bbf 100644 --- a/frontend/src/lib/views/registry.ts +++ b/frontend/src/lib/views/registry.ts @@ -30,6 +30,16 @@ import Breadlock from "./Breadlock.svelte"; import Breadshot from "./Breadshot.svelte"; import Breadmon from "./Breadmon.svelte"; import Breadhelp from "./Breadhelp.svelte"; +import Updates from "./Updates.svelte"; +import Printing from "./Printing.svelte"; +import Vpn from "./Vpn.svelte"; +import NightLight from "./NightLight.svelte"; +import InputMethod from "./InputMethod.svelte"; +import Accessibility from "./Accessibility.svelte"; +import Defaults from "./Defaults.svelte"; +import Channel from "./Channel.svelte"; +import Backup from "./Backup.svelte"; +import Optional from "./Optional.svelte"; export const VIEWS: Record = { about: About, @@ -60,4 +70,14 @@ export const VIEWS: Record = { breadshot: Breadshot, breadmon: Breadmon, breadhelp: Breadhelp, + updates: Updates, + printing: Printing, + vpn: Vpn, + nightlight: NightLight, + ime: InputMethod, + accessibility: Accessibility, + defaults: Defaults, + channel: Channel, + backup: Backup, + optional: Optional, }; diff --git a/src/src/commands/a11y.rs b/src/src/commands/a11y.rs new file mode 100644 index 0000000..807bc1d --- /dev/null +++ b/src/src/commands/a11y.rs @@ -0,0 +1,116 @@ +//! Accessibility toggles that actually do something on Hyprland. +//! Orca launches. Magnifier is Hyprland `cursor:zoom_factor`. Sticky/slow +//! keys are not exposed by Hyprland or xkeyboard-config rules — the UI +//! must show that honestly rather than a dead switch. + +use serde::Serialize; +use tokio::process::Command; + +use super::util::{command_exists, fail_output, pacman_installed}; + +#[derive(Serialize)] +pub struct A11yStatus { + orca_installed: bool, + orca_running: bool, + zoom_factor: f64, + sticky_keys_supported: bool, + slow_keys_supported: bool, + kmag_installed: bool, + note: String, +} + +async fn orca_running() -> bool { + Command::new("pgrep") + .args(["-x", "orca"]) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) +} + +async fn read_zoom() -> f64 { + let output = Command::new("hyprctl") + .args(["getoption", "cursor:zoom_factor", "-j"]) + .output() + .await; + let Ok(output) = output else { + return 1.0; + }; + let Ok(v) = serde_json::from_slice::(&output.stdout) else { + return 1.0; + }; + v.get("float") + .and_then(|x| x.as_f64()) + .or_else(|| v.get("int").and_then(|x| x.as_i64()).map(|i| i as f64)) + .unwrap_or(1.0) +} + +#[tauri::command] +pub async fn get_a11y_status() -> A11yStatus { + A11yStatus { + orca_installed: command_exists("orca") || pacman_installed("orca"), + orca_running: orca_running().await, + zoom_factor: read_zoom().await, + sticky_keys_supported: false, + slow_keys_supported: false, + kmag_installed: command_exists("kmag") || pacman_installed("kmag"), + note: "Hyprland does not expose XKB AccessX (sticky keys / slow keys). Those toggles stay off because they would not do anything.".into(), + } +} + +#[tauri::command] +pub async fn set_cursor_zoom(factor: f64) -> Result { + let factor = factor.clamp(1.0, 8.0); + let value = format!("{factor:.2}"); + let output = Command::new("hyprctl") + .args(["keyword", "cursor:zoom_factor", &value]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(factor) + } else { + Err(fail_output(&output, "hyprctl keyword cursor:zoom_factor")) + } +} + +#[tauri::command] +pub async fn set_orca_running(running: bool) -> Result<(), String> { + if running { + if !command_exists("orca") { + return Err("orca is not installed".into()); + } + std::process::Command::new("orca") + .arg("--replace") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| format!("couldn't start orca: {e}"))?; + Ok(()) + } else { + let _ = Command::new("pkill").args(["-x", "orca"]).status().await; + Ok(()) + } +} + +#[tauri::command] +pub fn open_kmag() -> Result<(), String> { + if !command_exists("kmag") { + return Err("kmag is not installed".into()); + } + std::process::Command::new("kmag") + .spawn() + .map_err(|e| format!("couldn't start kmag: {e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + #[test] + fn zoom_clamp_bounds() { + let f = 0.2_f64.clamp(1.0, 8.0); + assert_eq!(f, 1.0); + assert_eq!(12.0_f64.clamp(1.0, 8.0), 8.0); + } +} diff --git a/src/src/commands/backup.rs b/src/src/commands/backup.rs new file mode 100644 index 0000000..19a6a13 --- /dev/null +++ b/src/src/commands/backup.rs @@ -0,0 +1,358 @@ +//! restic backups of `$HOME`. Repo path + password live in +//! `~/.config/bos-settings/backup.toml` (0600). The password is write-only +//! to the webview — empty on save keeps the stored secret. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use tauri::AppHandle; +use tokio::process::Command; + +use super::config; +use super::streaming; +use super::util::{self, command_exists, fail_output}; + +fn backup_toml() -> PathBuf { + util::bos_settings_dir().join("backup.toml") +} + +#[derive(Clone)] +pub struct BackupSecrets { + pub repo: String, + pub password: Option, +} + +impl BackupSecrets { + fn empty() -> Self { + Self { + repo: String::new(), + password: None, + } + } +} + +pub fn load_secrets() -> BackupSecrets { + load_secrets_from(&backup_toml()) +} + +fn load_secrets_from(path: &Path) -> BackupSecrets { + let Ok(text) = std::fs::read_to_string(path) else { + return BackupSecrets::empty(); + }; + let doc = text.parse::().unwrap_or_default(); + BackupSecrets { + repo: config::get_str(&doc, &["repo"]).unwrap_or_default(), + password: config::get_str(&doc, &["password"]).filter(|s| !s.is_empty()), + } +} + +fn save_secrets_to(path: &Path, repo: &str, password: Option<&str>) -> Result<(), String> { + let existing = load_secrets_from(path); + let password = match password.map(str::trim).filter(|s| !s.is_empty()) { + Some(p) => Some(p.to_string()), + None => existing.password, + }; + let mut doc = toml_edit::DocumentMut::new(); + config::set_str(&mut doc, &["repo"], repo.trim()); + if let Some(p) = password.as_deref() { + config::set_str(&mut doc, &["password"], p); + } + util::write_secure(path, &doc.to_string()) +} + +#[derive(Serialize)] +pub struct BackupStatus { + restic_installed: bool, + repo: String, + has_password: bool, + snapshots: Vec, + error: Option, +} + +#[derive(Serialize, Clone)] +pub struct ResticSnapshot { + id: String, + time: String, + paths: Vec, +} + +#[tauri::command] +pub fn get_backup_config() -> BackupStatus { + let s = load_secrets(); + BackupStatus { + restic_installed: command_exists("restic"), + repo: s.repo, + has_password: s.password.is_some(), + snapshots: Vec::new(), + error: None, + } +} + +#[derive(Deserialize)] +pub struct SaveBackupInput { + repo: String, + #[serde(default)] + password: Option, +} + +#[tauri::command] +pub fn save_backup_config(input: SaveBackupInput) -> Result<(), String> { + if !valid_repo(&input.repo) { + return Err("repo must be an absolute path or sftp:user@host:path".into()); + } + save_secrets_to(&backup_toml(), &input.repo, input.password.as_deref()) +} + +pub fn valid_repo(repo: &str) -> bool { + let repo = repo.trim(); + if repo.is_empty() || repo.len() > 512 || repo.contains('\n') || repo.contains('\0') { + return false; + } + if let Some(rest) = repo.strip_prefix("sftp:") { + return !rest.is_empty() && rest.contains('@') && rest.contains(':') && !rest.contains(' '); + } + std::path::Path::new(repo).is_absolute() +} + +fn require_ready() -> Result { + if !command_exists("restic") { + return Err("restic is not installed".into()); + } + let s = load_secrets(); + if !valid_repo(&s.repo) { + return Err("set a repository path first".into()); + } + if s.password.is_none() { + return Err("set a repository password first".into()); + } + Ok(s) +} + +fn restic_args<'a>(repo: &'a str, extra: &'a [&'a str]) -> Vec<&'a str> { + let mut args = vec!["--repo", repo]; + args.extend_from_slice(extra); + args +} + +#[tauri::command] +pub async fn restic_init(app: AppHandle, session_id: String) -> bool { + let Ok(s) = require_ready() else { + streaming::emit_line( + &app, + &session_id, + "Error: configure repo and password first", + ); + return false; + }; + let password = s.password.clone().unwrap_or_default(); + let extra = ["init"]; + let args = restic_args(&s.repo, &extra); + streaming::run_hardcoded_env( + app, + session_id, + "restic", + &args, + &[("RESTIC_PASSWORD", password)], + ) + .await +} + +fn exclude_args(home: &str) -> Vec { + let extras = [ + ".cache", + ".local/share/Trash", + ".local/share/Steam", + ".npm", + ".cargo/registry", + ".cargo/git", + ".rustup", + ".var/app", + ]; + let mut args = vec![ + "--exclude-caches".into(), + "--exclude".into(), + "node_modules".into(), + "--exclude".into(), + "target".into(), + "--exclude".into(), + ".git".into(), + ]; + for rel in extras { + args.push("--exclude".into()); + args.push(format!("{home}/{rel}")); + } + args +} + +#[tauri::command] +pub async fn restic_backup(app: AppHandle, session_id: String) -> bool { + let s = match require_ready() { + Ok(s) => s, + Err(e) => { + streaming::emit_line(&app, &session_id, &format!("Error: {e}")); + return false; + } + }; + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + let password = s.password.clone().unwrap_or_default(); + let excludes = exclude_args(&home); + let mut args = vec!["--repo".to_string(), s.repo.clone()]; + args.extend(excludes); + args.push("backup".into()); + args.push(home); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + streaming::run_hardcoded_env( + app, + session_id, + "restic", + &arg_refs, + &[("RESTIC_PASSWORD", password)], + ) + .await +} + +#[tauri::command] +pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot: String) -> bool { + let s = match require_ready() { + Ok(s) => s, + Err(e) => { + streaming::emit_line(&app, &session_id, &format!("Error: {e}")); + return false; + } + }; + let snap = snapshot.trim(); + if !valid_snapshot_id(snap) { + streaming::emit_line(&app, &session_id, "Error: invalid snapshot id"); + return false; + } + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + let password = s.password.clone().unwrap_or_default(); + let extra = ["restore", snap, "--target", home.as_str(), "--dry-run"]; + let args = restic_args(&s.repo, &extra); + streaming::run_hardcoded_env( + app, + session_id, + "restic", + &args, + &[("RESTIC_PASSWORD", password)], + ) + .await +} + +fn valid_snapshot_id(id: &str) -> bool { + if id == "latest" { + return true; + } + let bytes = id.as_bytes(); + !bytes.is_empty() && bytes.len() <= 64 && bytes.iter().all(|b| b.is_ascii_hexdigit()) +} + +#[tauri::command] +pub async fn list_restic_snapshots() -> Result, String> { + let s = require_ready()?; + let password = s.password.clone().unwrap_or_default(); + let output = Command::new("restic") + .args(["--repo", &s.repo, "snapshots", "--json"]) + .env("RESTIC_PASSWORD", password) + .output() + .await + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(fail_output(&output, "restic snapshots")); + } + parse_snapshots(&output.stdout) +} + +fn parse_snapshots(bytes: &[u8]) -> Result, String> { + let v: serde_json::Value = + serde_json::from_slice(bytes).map_err(|e| format!("restic json: {e}"))?; + let Some(arr) = v.as_array() else { + return Ok(Vec::new()); + }; + Ok(arr + .iter() + .filter_map(|s| { + let id = s + .get("short_id") + .or_else(|| s.get("id")) + .and_then(|x| x.as_str())? + .to_string(); + let time = s + .get("time") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let paths = s + .get("paths") + .and_then(|x| x.as_array()) + .map(|a| { + a.iter() + .filter_map(|p| p.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + Some(ResticSnapshot { id, time, paths }) + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repo_accepts_abs_and_sftp() { + assert!(valid_repo("/mnt/backup/bos")); + assert!(valid_repo("sftp:user@host:/backups/bos")); + assert!(!valid_repo("relative/path")); + assert!(!valid_repo("sftp:nocolon")); + assert!(!valid_repo("sftp:user host:/x")); + assert!(!valid_repo("")); + } + + #[test] + fn snapshot_id_hex_or_latest() { + assert!(valid_snapshot_id("latest")); + assert!(valid_snapshot_id("a1b2c3d4")); + assert!(!valid_snapshot_id("../x")); + assert!(!valid_snapshot_id("latest;rm")); + } + + #[test] + fn write_secure_is_0600_and_keeps_password() { + let dir = std::env::temp_dir().join(format!( + "bos-settings-backup-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("backup.toml"); + save_secrets_to(&path, "/tmp/repo", Some("hunter2")).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("hunter2")); + assert!(text.contains("/tmp/repo")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "backup.toml must be 0600, got {mode:o}"); + } + save_secrets_to(&path, "/tmp/repo2", Some("")).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("hunter2"), "empty password keeps secret"); + assert!(text.contains("/tmp/repo2")); + let loaded = load_secrets_from(&path); + assert_eq!(loaded.password.as_deref(), Some("hunter2")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn parse_restic_json() { + let json = br#"[{"short_id":"abc123","time":"2026-08-15T01:00:00Z","paths":["/home/a"]}]"#; + let v = parse_snapshots(json).unwrap(); + assert_eq!(v[0].id, "abc123"); + assert_eq!(v[0].paths[0], "/home/a"); + } +} diff --git a/src/src/commands/channel.rs b/src/src/commands/channel.rs new file mode 100644 index 0000000..bc33a17 --- /dev/null +++ b/src/src/commands/channel.rs @@ -0,0 +1,91 @@ +//! Bakery track (stable / beta / dev). Preference only — `bakery update +//! --all` afterwards actually installs the new track's builds. + +use serde::Serialize; +use tokio::process::Command; + +use super::util::{fail_output, strip_ansi}; + +const TRACKS: &[&str] = &["stable", "beta", "dev"]; + +#[derive(Serialize)] +pub struct BakeryTrack { + current: String, + tracks: Vec, +} + +fn parse_track_show(text: &str) -> String { + let text = strip_ansi(text); + for line in text.lines() { + let line = line.trim(); + let lower = line.to_ascii_lowercase(); + if let Some(rest) = lower.strip_prefix("current track:") { + let raw = line[line.len() - rest.len()..].trim(); + return raw.to_ascii_lowercase(); + } + if TRACKS.contains(&line) { + return line.to_string(); + } + } + let lower = text.to_ascii_lowercase(); + for track in TRACKS { + if lower.contains(track) { + return (*track).to_string(); + } + } + "stable".into() +} + +#[tauri::command] +pub async fn get_bakery_track() -> Result { + let output = Command::new("bakery") + .args(["track", "show"]) + .output() + .await + .map_err(|e| format!("couldn't run bakery: {e}"))?; + if !output.status.success() { + return Err(fail_output(&output, "bakery track show")); + } + let text = String::from_utf8_lossy(&output.stdout); + Ok(BakeryTrack { + current: parse_track_show(&text), + tracks: TRACKS.iter().map(|s| (*s).to_string()).collect(), + }) +} + +#[tauri::command] +pub async fn set_bakery_track(track: String) -> Result { + let track = track.trim().to_ascii_lowercase(); + if !TRACKS.contains(&track.as_str()) { + return Err(format!( + "unknown track '{track}' — expected stable, beta, or dev" + )); + } + let output = Command::new("bakery") + .args(["track", "set", &track]) + .output() + .await + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(fail_output(&output, "bakery track set")); + } + get_bakery_track().await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_current_track_line() { + assert_eq!(parse_track_show("current track: dev\n"), "dev"); + assert_eq!(parse_track_show("current track: stable"), "stable"); + assert_eq!(parse_track_show("beta"), "beta"); + } + + #[test] + fn rejects_unknown_in_set_guard() { + assert!(!TRACKS.contains(&"nightly")); + assert!(TRACKS.contains(&"stable")); + } +} diff --git a/src/src/commands/defaults.rs b/src/src/commands/defaults.rs new file mode 100644 index 0000000..b6386a4 --- /dev/null +++ b/src/src/commands/defaults.rs @@ -0,0 +1,431 @@ +//! Default applications via `~/.config/mimeapps.list`. Categories cover +//! the associations BOS already ships in skel (browser, files, images, +//! PDF, editor) plus a terminal entry. + +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; + +use serde::Serialize; + +use super::config; + +const CATEGORIES: &[(&str, &[&str])] = &[ + ( + "browser", + &[ + "x-scheme-handler/http", + "x-scheme-handler/https", + "text/html", + ], + ), + ("files", &["inode/directory"]), + ("terminal", &["x-scheme-handler/terminal"]), + ( + "image", + &[ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "image/svg+xml", + ], + ), + ("pdf", &["application/pdf"]), + ("editor", &["text/plain", "text/markdown"]), +]; + +#[derive(Serialize, Clone)] +pub struct DesktopApp { + id: String, + name: String, +} + +#[derive(Serialize)] +pub struct DefaultsStatus { + path: String, + current: HashMap, + options: HashMap>, +} + +fn mimeapps_path() -> PathBuf { + config::config_dir().join("mimeapps.list") +} + +fn xdg_terminals_path() -> PathBuf { + config::config_dir().join("xdg-terminals.list") +} + +fn applications_dirs() -> Vec { + let mut dirs = vec![ + PathBuf::from("/usr/share/applications"), + PathBuf::from("/usr/local/share/applications"), + ]; + if let Ok(home) = std::env::var("HOME") { + dirs.push(PathBuf::from(home).join(".local/share/applications")); + } + dirs +} + +#[derive(Clone)] +struct DesktopMeta { + id: String, + name: String, + mimes: Vec, + terminal: bool, +} + +fn parse_desktop(id: &str, text: &str) -> Option { + let mut in_entry = false; + let mut name = String::new(); + let mut mimes = Vec::new(); + let mut terminal = false; + let mut hidden = false; + for line in text.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_entry = line.eq_ignore_ascii_case("[Desktop Entry]"); + continue; + } + if !in_entry { + continue; + } + if let Some(v) = line.strip_prefix("Name=") { + if name.is_empty() { + name = v.to_string(); + } + } else if let Some(v) = line.strip_prefix("MimeType=") { + mimes = v + .split(';') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + } else if let Some(v) = line.strip_prefix("Categories=") { + terminal |= v.split(';').any(|c| c.trim() == "TerminalEmulator"); + } else if line == "Hidden=true" || line == "NoDisplay=true" { + hidden = true; + } + } + if hidden || name.is_empty() { + return None; + } + Some(DesktopMeta { + id: id.to_string(), + name, + mimes, + terminal, + }) +} + +fn scan_desktops() -> Vec { + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for dir in applications_dirs() { + let Ok(entries) = std::fs::read_dir(dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("desktop") { + continue; + } + let Some(id) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !seen.insert(id.to_string()) { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + if let Some(meta) = parse_desktop(id, &text) { + out.push(meta); + } + } + } + out.sort_by_key(|a| a.name.to_lowercase()); + out +} + +fn parse_default_applications(text: &str) -> BTreeMap { + let mut map = BTreeMap::new(); + let mut in_defaults = false; + for line in text.lines() { + let t = line.trim(); + if t.starts_with('[') { + in_defaults = t.eq_ignore_ascii_case("[Default Applications]"); + continue; + } + if !in_defaults || t.is_empty() || t.starts_with('#') { + continue; + } + if let Some((k, v)) = t.split_once('=') { + let desktop = v.split(';').next().unwrap_or("").trim(); + if !desktop.is_empty() { + map.insert(k.trim().to_string(), desktop.to_string()); + } + } + } + map +} + +fn current_for_category(defaults: &BTreeMap, mimes: &[&str]) -> String { + for mime in mimes { + if let Some(v) = defaults.get(*mime) { + return v.clone(); + } + } + String::new() +} + +fn options_for( + apps: &[DesktopMeta], + category: &str, + mimes: &[&str], + current: &str, +) -> Vec { + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for app in apps { + let matches = if category == "terminal" { + app.terminal || app.mimes.iter().any(|m| mimes.contains(&m.as_str())) + } else { + app.mimes.iter().any(|m| mimes.contains(&m.as_str())) + }; + if matches && seen.insert(app.id.clone()) { + out.push(DesktopApp { + id: app.id.clone(), + name: app.name.clone(), + }); + } + } + if !current.is_empty() && !seen.contains(current) { + out.insert( + 0, + DesktopApp { + id: current.to_string(), + name: current.trim_end_matches(".desktop").to_string(), + }, + ); + } + out +} + +#[tauri::command] +pub fn get_default_apps() -> DefaultsStatus { + let path = mimeapps_path(); + let text = std::fs::read_to_string(&path).unwrap_or_default(); + let defaults = parse_default_applications(&text); + let apps = scan_desktops(); + let mut current = HashMap::new(); + let mut options = HashMap::new(); + for (cat, mimes) in CATEGORIES { + let cur = if *cat == "terminal" { + read_terminal_default(&defaults) + } else { + current_for_category(&defaults, mimes) + }; + options.insert((*cat).to_string(), options_for(&apps, cat, mimes, &cur)); + current.insert((*cat).to_string(), cur); + } + DefaultsStatus { + path: path.display().to_string(), + current, + options, + } +} + +fn read_terminal_default(defaults: &BTreeMap) -> String { + if let Ok(text) = std::fs::read_to_string(xdg_terminals_path()) { + if let Some(id) = text + .lines() + .map(str::trim) + .find(|l| !l.is_empty() && !l.starts_with('#')) + { + return id.to_string(); + } + } + current_for_category(defaults, &["x-scheme-handler/terminal"]) +} + +#[derive(serde::Deserialize)] +pub struct SaveDefaultsInput { + current: HashMap, +} + +#[tauri::command] +pub fn save_default_apps(input: SaveDefaultsInput) -> Result<(), String> { + let path = mimeapps_path(); + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + let mut replacements = BTreeMap::new(); + for (cat, mimes) in CATEGORIES { + let Some(desktop) = input.current.get(*cat).map(|s| s.trim()) else { + continue; + }; + if desktop.is_empty() { + continue; + } + if !valid_desktop_id(desktop) { + return Err(format!("invalid desktop id '{desktop}'")); + } + for mime in *mimes { + replacements.insert((*mime).to_string(), desktop.to_string()); + } + if *cat == "terminal" { + write_terminal_list(desktop)?; + } + } + let text = upsert_defaults(&existing, &replacements); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + config::atomic_write(&path, &text).map_err(|e| e.to_string()) +} + +fn write_terminal_list(desktop: &str) -> Result<(), String> { + let path = xdg_terminals_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + config::atomic_write(&path, &format!("{desktop}\n")).map_err(|e| e.to_string()) +} + +fn valid_desktop_id(id: &str) -> bool { + let bytes = id.as_bytes(); + bytes.ends_with(b".desktop") + && bytes.len() > ".desktop".len() + && bytes.len() <= 128 + && bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.' | b'+')) +} + +fn upsert_defaults(existing: &str, replacements: &BTreeMap) -> String { + if existing.trim().is_empty() { + let mut out = String::from("[Default Applications]\n"); + for (mime, desktop) in replacements { + out.push_str(&format!("{mime}={desktop}\n")); + } + return out; + } + let mut out = String::new(); + let mut in_defaults = false; + let mut seen = std::collections::HashSet::new(); + let mut wrote_header = false; + for line in existing.lines() { + let t = line.trim(); + if t.starts_with('[') { + if in_defaults { + for (mime, desktop) in replacements { + if seen.insert(mime.clone()) { + out.push_str(&format!("{mime}={desktop}\n")); + } + } + } + in_defaults = t.eq_ignore_ascii_case("[Default Applications]"); + if in_defaults { + wrote_header = true; + } + out.push_str(line); + out.push('\n'); + continue; + } + if in_defaults { + if let Some((k, _)) = t.split_once('=') { + let key = k.trim(); + if let Some(desktop) = replacements.get(key) { + out.push_str(&format!("{key}={desktop}\n")); + seen.insert(key.to_string()); + continue; + } + } + } + out.push_str(line); + out.push('\n'); + } + if in_defaults { + for (mime, desktop) in replacements { + if seen.insert(mime.clone()) { + out.push_str(&format!("{mime}={desktop}\n")); + } + } + } else if !wrote_header { + if !out.ends_with('\n') && !out.is_empty() { + out.push('\n'); + } + out.push_str("\n[Default Applications]\n"); + for (mime, desktop) in replacements { + out.push_str(&format!("{mime}={desktop}\n")); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_skel_defaults() { + let text = "\ +[Default Applications] +text/html=zen.desktop +x-scheme-handler/http=zen.desktop +inode/directory=org.gnome.Nautilus.desktop +"; + let map = parse_default_applications(text); + assert_eq!(map.get("text/html").unwrap(), "zen.desktop"); + assert_eq!( + current_for_category(&map, &["x-scheme-handler/http", "text/html"]), + "zen.desktop" + ); + } + + #[test] + fn upsert_replaces_only_named_keys() { + let existing = "\ +# keep +[Default Applications] +text/html=old.desktop +image/png=org.gnome.Loupe.desktop + +[Added Associations] +text/html=extra.desktop; +"; + let mut rep = BTreeMap::new(); + rep.insert("text/html".into(), "zen.desktop".into()); + rep.insert("x-scheme-handler/http".into(), "zen.desktop".into()); + let out = upsert_defaults(existing, &rep); + assert!(out.contains("# keep")); + assert!(out.contains("text/html=zen.desktop")); + assert!(out.contains("x-scheme-handler/http=zen.desktop")); + assert!(out.contains("image/png=org.gnome.Loupe.desktop")); + assert!(out.contains("[Added Associations]")); + assert!(out.contains("text/html=extra.desktop;")); + assert_eq!(out.matches("text/html=zen.desktop").count(), 1); + } + + #[test] + fn desktop_id_check() { + assert!(valid_desktop_id("zen.desktop")); + assert!(valid_desktop_id("org.gnome.Nautilus.desktop")); + assert!(!valid_desktop_id("zen")); + assert!(!valid_desktop_id("../evil.desktop")); + } + + #[test] + fn parse_desktop_skips_hidden() { + let hidden = parse_desktop( + "x.desktop", + "[Desktop Entry]\nName=X\nNoDisplay=true\nMimeType=text/plain;\n", + ); + assert!(hidden.is_none()); + let ok = parse_desktop( + "ed.desktop", + "[Desktop Entry]\nName=Editor\nMimeType=text/plain;\nCategories=Utility;\n", + ) + .unwrap(); + assert_eq!(ok.name, "Editor"); + assert!(ok.mimes.contains(&"text/plain".into())); + } +} diff --git a/src/src/commands/ime.rs b/src/src/commands/ime.rs new file mode 100644 index 0000000..d0c40e5 --- /dev/null +++ b/src/src/commands/ime.rs @@ -0,0 +1,177 @@ +//! fcitx5 input method for this session: environment.d + Hyprland env + +//! systemd --user / `fcitx5 -d`. Missing packages are offered via the +//! allowlisted pacman installer, not installed on page load. + +use serde::Serialize; +use tokio::process::Command; + +use super::config; +use super::util::{self, command_exists, pacman_installed}; + +const FRAGMENT: &str = "fcitx5.conf"; +const ENV_FILE: &str = "90-fcitx5.conf"; + +const ENV_LINES_SYSTEMD: &str = "\ +GTK_IM_MODULE=fcitx +QT_IM_MODULE=fcitx +XMODIFIERS=@im=fcitx +SDL_IM_MODULE=fcitx +"; + +const ENV_LINES_HYPR: &str = "\ +env = GTK_IM_MODULE,fcitx +env = QT_IM_MODULE,fcitx +env = XMODIFIERS,@im=fcitx +env = SDL_IM_MODULE,fcitx +exec-once = fcitx5 -d +"; + +#[derive(Serialize, Clone)] +pub struct ImePackage { + name: String, + installed: bool, +} + +#[derive(Serialize)] +pub struct ImeStatus { + enabled: bool, + running: bool, + packages: Vec, + error: Option, +} + +fn env_path() -> std::path::PathBuf { + config::config_dir().join("environment.d").join(ENV_FILE) +} + +fn wanted_packages() -> &'static [&'static str] { + &[ + "fcitx5", + "fcitx5-gtk", + "fcitx5-qt", + "fcitx5-configtool", + "fcitx5-chinese-addons", + ] +} + +fn packages_status() -> Vec { + wanted_packages() + .iter() + .map(|name| ImePackage { + name: (*name).to_string(), + installed: pacman_installed(name), + }) + .collect() +} + +fn env_file_present() -> bool { + env_path().is_file() +} + +async fn fcitx_running() -> bool { + Command::new("pgrep") + .args(["-x", "fcitx5"]) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) +} + +#[tauri::command] +pub async fn get_ime_status() -> ImeStatus { + ImeStatus { + enabled: env_file_present(), + running: fcitx_running().await, + packages: packages_status(), + error: None, + } +} + +#[tauri::command] +pub async fn set_ime_enabled(enabled: bool) -> Result { + if enabled { + enable_ime().await?; + } else { + disable_ime().await?; + } + Ok(ImeStatus { + enabled: env_file_present(), + running: fcitx_running().await, + packages: packages_status(), + error: None, + }) +} + +async fn enable_ime() -> Result<(), String> { + if !command_exists("fcitx5") { + return Err("fcitx5 is not installed".into()); + } + let env = env_path(); + if let Some(parent) = env.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + config::atomic_write(&env, ENV_LINES_SYSTEMD).map_err(|e| e.to_string())?; + + let hypr = util::hypr_dir().join(FRAGMENT); + std::fs::create_dir_all(util::hypr_dir()).map_err(|e| e.to_string())?; + config::atomic_write(&hypr, ENV_LINES_HYPR).map_err(|e| e.to_string())?; + util::ensure_hypr_source(FRAGMENT)?; + + let _ = Command::new("systemctl") + .args([ + "--user", + "import-environment", + "GTK_IM_MODULE", + "QT_IM_MODULE", + "XMODIFIERS", + "SDL_IM_MODULE", + ]) + .status() + .await; + + let enabled_unit = Command::new("systemctl") + .args(["--user", "enable", "--now", "fcitx5.service"]) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false); + if !enabled_unit && !fcitx_running().await { + std::process::Command::new("fcitx5") + .arg("-d") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| format!("couldn't start fcitx5: {e}"))?; + } + Ok(()) +} + +async fn disable_ime() -> Result<(), String> { + let _ = std::fs::remove_file(env_path()); + let _ = std::fs::remove_file(util::hypr_dir().join(FRAGMENT)); + util::remove_hypr_source(FRAGMENT)?; + let _ = Command::new("systemctl") + .args(["--user", "disable", "--now", "fcitx5.service"]) + .status() + .await; + let _ = Command::new("pkill").args(["-x", "fcitx5"]).status().await; + Ok(()) +} + +#[tauri::command] +pub fn open_fcitx_config() { + let _ = std::process::Command::new("fcitx5-configtool").spawn(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn env_files_use_fcitx_module_name() { + assert!(ENV_LINES_SYSTEMD.contains("GTK_IM_MODULE=fcitx")); + assert!(ENV_LINES_HYPR.contains("XMODIFIERS,@im=fcitx")); + assert!(ENV_LINES_HYPR.contains("exec-once = fcitx5 -d")); + } +} diff --git a/src/src/commands/mod.rs b/src/src/commands/mod.rs index ccc0e2e..e1c5a29 100644 --- a/src/src/commands/mod.rs +++ b/src/src/commands/mod.rs @@ -1,7 +1,9 @@ +pub mod a11y; pub mod about; pub mod appearance; pub mod aur; pub mod autostart; +pub mod backup; pub mod bluetooth; pub mod bread; pub mod breadbar; @@ -15,18 +17,28 @@ pub mod breadpad; pub mod breadpaper; pub mod breadsearch; pub mod breadshot; +pub mod channel; pub mod config; pub mod datetime; +pub mod defaults; pub mod firewall; pub mod firmware; pub mod hyprland; +pub mod ime; pub mod keybinds; pub mod network; +pub mod nightlight; +pub mod nvidia; +pub mod optional; pub mod packages; pub mod power; +pub mod printing; pub mod service; pub mod snapshots; pub mod sound; pub mod streaming; pub mod theme; +pub mod updates; pub mod users; +pub mod util; +pub mod vpn; diff --git a/src/src/commands/nightlight.rs b/src/src/commands/nightlight.rs new file mode 100644 index 0000000..dc36bf5 --- /dev/null +++ b/src/src/commands/nightlight.rs @@ -0,0 +1,216 @@ +//! Night light via hyprsunset (Hyprland twilight IPC). The compositor +//! talks to a hyprsunset daemon socket; if the binary is missing we offer +//! a pacman install rather than pretending the toggle works. + +use serde::{Deserialize, Serialize}; +use tokio::process::Command; + +use super::config; +use super::util::{self, command_exists, fail_output}; + +const FRAGMENT: &str = "nightlight.conf"; +const DEFAULT_TEMP: u32 = 3500; +const MIN_TEMP: u32 = 2000; +const MAX_TEMP: u32 = 6500; + +#[derive(Serialize, Deserialize, Clone)] +pub struct NightlightConfig { + enabled: bool, + temperature: u32, +} + +impl Default for NightlightConfig { + fn default() -> Self { + Self { + enabled: false, + temperature: DEFAULT_TEMP, + } + } +} + +#[derive(Serialize)] +pub struct NightlightStatus { + installed: bool, + running: bool, + enabled: bool, + temperature: u32, + error: Option, +} + +fn persist_path() -> std::path::PathBuf { + util::bos_settings_dir().join("nightlight.toml") +} + +fn load_persist() -> NightlightConfig { + let Ok(text) = std::fs::read_to_string(persist_path()) else { + return NightlightConfig::default(); + }; + let doc = text.parse::().unwrap_or_default(); + NightlightConfig { + enabled: config::get_bool(&doc, &["enabled"]).unwrap_or(false), + temperature: config::get_i64(&doc, &["temperature"]) + .unwrap_or(DEFAULT_TEMP as i64) + .clamp(MIN_TEMP as i64, MAX_TEMP as i64) as u32, + } +} + +fn save_persist(cfg: &NightlightConfig) -> Result<(), String> { + let mut doc = toml_edit::DocumentMut::new(); + config::set_bool(&mut doc, &["enabled"], cfg.enabled); + config::set_i64(&mut doc, &["temperature"], cfg.temperature as i64); + let path = persist_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + config::atomic_write(&path, &doc.to_string()).map_err(|e| e.to_string()) +} + +fn clamp_temp(t: u32) -> u32 { + t.clamp(MIN_TEMP, MAX_TEMP) +} + +async fn hyprsunset_running() -> bool { + Command::new("hyprctl") + .args(["hyprsunset", "gamma", "1.0"]) + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +async fn start_daemon() -> Result<(), String> { + if hyprsunset_running().await { + return Ok(()); + } + if !command_exists("hyprsunset") { + return Err("hyprsunset is not installed".into()); + } + std::process::Command::new("hyprsunset") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| format!("couldn't start hyprsunset: {e}"))?; + for _ in 0..15 { + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + if hyprsunset_running().await { + return Ok(()); + } + } + Err("hyprsunset started but Hyprland twilight socket never came up".into()) +} + +async fn apply_temperature(temp: u32) -> Result<(), String> { + start_daemon().await?; + let t = clamp_temp(temp).to_string(); + let output = Command::new("hyprctl") + .args(["hyprsunset", "temperature", &t]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(fail_output(&output, "hyprctl hyprsunset")) + } +} + +async fn apply_identity() -> Result<(), String> { + if !hyprsunset_running().await { + return Ok(()); + } + let output = Command::new("hyprctl") + .args(["hyprsunset", "identity"]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(fail_output(&output, "hyprctl hyprsunset")) + } +} + +fn write_autostart() -> Result<(), String> { + let path = util::hypr_dir().join(FRAGMENT); + std::fs::create_dir_all(util::hypr_dir()).map_err(|e| e.to_string())?; + config::atomic_write(&path, "exec-once = hyprsunset\n").map_err(|e| e.to_string())?; + util::ensure_hypr_source(FRAGMENT) +} + +fn clear_autostart() -> Result<(), String> { + let path = util::hypr_dir().join(FRAGMENT); + let _ = std::fs::remove_file(path); + util::remove_hypr_source(FRAGMENT) +} + +#[tauri::command] +pub async fn get_nightlight() -> NightlightStatus { + let persist = load_persist(); + let installed = command_exists("hyprsunset"); + let running = if installed { + hyprsunset_running().await + } else { + false + }; + NightlightStatus { + installed, + running, + enabled: persist.enabled && running, + temperature: persist.temperature, + error: None, + } +} + +#[tauri::command] +pub async fn set_nightlight(enabled: bool, temperature: u32) -> Result { + if !command_exists("hyprsunset") { + return Ok(NightlightStatus { + installed: false, + running: false, + enabled: false, + temperature: clamp_temp(temperature), + error: Some("hyprsunset is not installed".into()), + }); + } + let mut cfg = NightlightConfig { + enabled, + temperature: clamp_temp(temperature), + }; + let mut error = None; + if enabled { + if let Err(e) = apply_temperature(cfg.temperature).await { + error = Some(e); + cfg.enabled = false; + } else if let Err(e) = write_autostart() { + error = Some(e); + } + } else { + if let Err(e) = apply_identity().await { + error = Some(e); + } + if let Err(e) = clear_autostart() { + error = Some(error.unwrap_or(e)); + } + } + save_persist(&cfg)?; + Ok(NightlightStatus { + installed: true, + running: hyprsunset_running().await, + enabled: cfg.enabled, + temperature: cfg.temperature, + error, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn temp_clamps() { + assert_eq!(clamp_temp(100), MIN_TEMP); + assert_eq!(clamp_temp(9000), MAX_TEMP); + assert_eq!(clamp_temp(3500), 3500); + } +} diff --git a/src/src/commands/nvidia.rs b/src/src/commands/nvidia.rs new file mode 100644 index 0000000..cf59830 --- /dev/null +++ b/src/src/commands/nvidia.rs @@ -0,0 +1,106 @@ +//! NVIDIA driver offer. BOS writes a probe file when it sees a discrete +//! NVIDIA GPU; Settings only shows the card if that file exists and does +//! not install anything until the user clicks. + +use serde::Serialize; + +#[derive(Serialize, Clone)] +pub struct NvidiaOffer { + gpu: String, + reason: String, + packages: Vec, +} + +fn offer_paths() -> Vec { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + let state = std::path::Path::new(&home).join(".local/state/bos"); + vec![ + state.join("nvidia-offer.json"), + state.join("nvidia-probe.json"), + ] +} + +pub fn read_nvidia_offer() -> Option { + for path in offer_paths() { + if !path.is_file() { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + return Some(generic_offer()); + }; + if let Ok(v) = serde_json::from_str::(&text) { + if v.get("offer").and_then(|x| x.as_bool()) == Some(false) + || v.get("dismissed").and_then(|x| x.as_bool()) == Some(true) + { + return None; + } + let gpu = v + .get("gpu") + .or_else(|| v.get("name")) + .or_else(|| v.get("device")) + .and_then(|x| x.as_str()) + .unwrap_or("NVIDIA GPU") + .to_string(); + let reason = v + .get("reason") + .or_else(|| v.get("message")) + .and_then(|x| x.as_str()) + .unwrap_or("A discrete NVIDIA GPU was detected. The proprietary driver is not installed until you choose it.") + .to_string(); + let packages = v + .get("packages") + .and_then(|x| x.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect::>() + }) + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| vec!["nvidia".into(), "nvidia-utils".into()]); + return Some(NvidiaOffer { + gpu, + reason, + packages, + }); + } + return Some(generic_offer()); + } + None +} + +fn generic_offer() -> NvidiaOffer { + NvidiaOffer { + gpu: "NVIDIA GPU".into(), + reason: "BOS found an NVIDIA device. Install the proprietary driver only if you want it — nouveau stays otherwise.".into(), + packages: vec!["nvidia".into(), "nvidia-utils".into()], + } +} + +#[tauri::command] +pub fn get_nvidia_offer() -> Option { + read_nvidia_offer() +} + +#[cfg(test)] +mod tests { + #[test] + fn missing_file_is_none() { + // This machine's real probe path is not something the unit test + // should depend on; the helper is covered via parse cases below. + let parsed = serde_json::from_str::("{\"offer\":false}").unwrap(); + assert_eq!(parsed["offer"], false); + } + + #[test] + fn dismissed_or_offer_false_hides() { + // Inlined copies of the hide conditions so a schema change is obvious. + let hide = |v: &str| { + let v: serde_json::Value = serde_json::from_str(v).unwrap(); + v.get("offer").and_then(|x| x.as_bool()) == Some(false) + || v.get("dismissed").and_then(|x| x.as_bool()) == Some(true) + }; + assert!(hide(r#"{"offer":false}"#)); + assert!(hide(r#"{"dismissed":true}"#)); + assert!(!hide(r#"{"gpu":"RTX 4060"}"#)); + } +} diff --git a/src/src/commands/optional.rs b/src/src/commands/optional.rs new file mode 100644 index 0000000..835ec8e --- /dev/null +++ b/src/src/commands/optional.rs @@ -0,0 +1,112 @@ +//! Curated optional software. Not an AUR dump — four explicit offers, +//! each installed through a typed command (bakery or allowlisted pacman). + +use serde::Serialize; +use tokio::process::Command; + +use super::packages::get_installed_packages; +use super::util::{command_exists, fail_output, pacman_installed}; + +#[derive(Serialize, Clone)] +pub struct OptionalItem { + id: String, + title: String, + detail: String, + installed: bool, + via: String, +} + +#[derive(Serialize)] +pub struct OptionalStatus { + items: Vec, + flathub: bool, +} + +fn bakery_has(name: &str) -> bool { + get_installed_packages().iter().any(|p| p.name == name) +} + +fn flathub_enabled() -> bool { + if !command_exists("flatpak") { + return false; + } + std::process::Command::new("flatpak") + .args(["remotes"]) + .output() + .ok() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .to_ascii_lowercase() + .contains("flathub") + }) + .unwrap_or(false) +} + +#[tauri::command] +pub fn get_optional_software() -> OptionalStatus { + let breadcast = bakery_has("breadcast") || command_exists("breadcast"); + let flatpak = pacman_installed("flatpak") || command_exists("flatpak"); + let office = pacman_installed("libreoffice-fresh") + && (pacman_installed("papers") || pacman_installed("evince")); + let steam = pacman_installed("steam") || command_exists("steam"); + OptionalStatus { + items: vec![ + OptionalItem { + id: "breadcast".into(), + title: "breadcast".into(), + detail: + "Optional bread-ecosystem app. Installed through bakery — it is not on the ISO." + .into(), + installed: breadcast, + via: "bakery".into(), + }, + OptionalItem { + id: "flatpak".into(), + title: "Flatpak + Flathub".into(), + detail: "Enables the Flatpak runtime and the Flathub user remote.".into(), + installed: flatpak && flathub_enabled(), + via: "pacman".into(), + }, + OptionalItem { + id: "office".into(), + title: "LibreOffice + PDF".into(), + detail: "libreoffice-fresh and papers (GNOME document viewer).".into(), + installed: office, + via: "pacman".into(), + }, + OptionalItem { + id: "steam".into(), + title: "Steam".into(), + detail: "Valve Steam from the multilib repo.".into(), + installed: steam, + via: "pacman".into(), + }, + ], + flathub: flathub_enabled(), + } +} + +/// User Flathub remote — no root. Flatpak itself is installed separately +/// via the allowlisted pacman command when missing. +#[tauri::command] +pub async fn enable_flathub() -> Result<(), String> { + if !command_exists("flatpak") { + return Err("flatpak is not installed".into()); + } + let output = Command::new("flatpak") + .args([ + "remote-add", + "--if-not-exists", + "--user", + "flathub", + "https://dl.flathub.org/repo/flathub.flatpakrepo", + ]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(fail_output(&output, "flatpak remote-add")) + } +} diff --git a/src/src/commands/packages.rs b/src/src/commands/packages.rs index 29e522b..53556fb 100644 --- a/src/src/commands/packages.rs +++ b/src/src/commands/packages.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; #[derive(Serialize, Clone)] pub struct InstalledPackage { - name: String, + pub name: String, version: String, } @@ -23,14 +23,19 @@ pub fn get_installed_packages() -> Vec { let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else { return Vec::new(); }; - let Ok(packages) = serde_json::from_value::>(packages) else { + let Ok(packages) = serde_json::from_value::>(packages) + else { return Vec::new(); }; let mut list: Vec = packages .into_iter() .map(|(name, val)| { - let version = val.get("version").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); + let version = val + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); InstalledPackage { name, version } }) .collect(); diff --git a/src/src/commands/printing.rs b/src/src/commands/printing.rs new file mode 100644 index 0000000..0f2918b --- /dev/null +++ b/src/src/commands/printing.rs @@ -0,0 +1,204 @@ +//! CUPS printers via lpstat / lpadmin. Adding a printer through the full +//! device wizard is `system-config-printer`; a simple IPP Everywhere queue +//! can be created here when the user has a URI. + +use serde::Serialize; +use tokio::process::Command; + +use super::util::{fail_output, valid_printer_name}; + +#[derive(Serialize, Clone)] +pub struct Printer { + name: String, + status: String, + enabled: bool, + is_default: bool, +} + +#[derive(Serialize)] +pub struct PrintingStatus { + printers: Vec, + default: Option, + cups_ok: bool, + error: Option, +} + +#[tauri::command] +pub async fn get_printers() -> PrintingStatus { + let output = match Command::new("lpstat").args(["-p", "-d"]).output().await { + Ok(o) => o, + Err(e) => { + return PrintingStatus { + printers: Vec::new(), + default: None, + cups_ok: false, + error: Some(format!("couldn't run lpstat: {e}")), + }; + } + }; + if !output.status.success() { + return PrintingStatus { + printers: Vec::new(), + default: None, + cups_ok: false, + error: Some(fail_output(&output, "lpstat")), + }; + } + let text = String::from_utf8_lossy(&output.stdout); + parse_lpstat(&text) +} + +fn parse_lpstat(text: &str) -> PrintingStatus { + let mut printers = Vec::new(); + let mut default = None; + for line in text.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("printer ") { + let mut parts = rest.splitn(2, ' '); + let name = parts.next().unwrap_or("").to_string(); + let rest = parts.next().unwrap_or(""); + if name.is_empty() { + continue; + } + let enabled = !rest.contains("disabled"); + let status = rest + .strip_prefix("is ") + .unwrap_or(rest) + .split(". ") + .next() + .unwrap_or(rest) + .trim() + .to_string(); + printers.push(Printer { + name, + status, + enabled, + is_default: false, + }); + } else if let Some(name) = line.strip_prefix("system default destination: ") { + default = Some(name.trim().to_string()); + } else if line == "no system default destination" { + default = None; + } + } + if let Some(def) = default.as_deref() { + for p in &mut printers { + p.is_default = p.name == def; + } + } + PrintingStatus { + printers, + default, + cups_ok: true, + error: None, + } +} + +#[tauri::command] +pub async fn set_default_printer(name: String) -> Result<(), String> { + if !valid_printer_name(&name) { + return Err(format!("invalid printer name '{name}'")); + } + let output = Command::new("lpadmin") + .args(["-d", &name]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + return Ok(()); + } + let output = Command::new("pkexec") + .args(["lpadmin", "-d", &name]) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(fail_output(&output, "lpadmin")) + } +} + +#[tauri::command] +pub async fn add_ipp_printer(name: String, uri: String) -> Result<(), String> { + if !valid_printer_name(&name) { + return Err(format!("invalid printer name '{name}'")); + } + if !valid_printer_uri(&uri) { + return Err("URI must be ipp://, ipps://, socket://, usb://, or dnssd://".into()); + } + let args_owned = [ + "-p".into(), + name.clone(), + "-E".into(), + "-v".into(), + uri, + "-m".into(), + "everywhere".into(), + ]; + let output = Command::new("lpadmin") + .args(&args_owned) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + return Ok(()); + } + let mut pk = vec!["lpadmin".to_string()]; + pk.extend(args_owned); + let output = Command::new("pkexec") + .args(&pk) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(fail_output(&output, "lpadmin")) + } +} + +fn valid_printer_uri(uri: &str) -> bool { + let u = uri.trim(); + !u.is_empty() + && u.len() <= 512 + && !u.contains(char::is_whitespace) + && (u.starts_with("ipp://") + || u.starts_with("ipps://") + || u.starts_with("socket://") + || u.starts_with("usb://") + || u.starts_with("dnssd://")) +} + +#[tauri::command] +pub fn open_printer_settings() { + let _ = std::process::Command::new("system-config-printer").spawn(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lpstat_parses_idle_and_default() { + let text = "\ +printer Canon-TS6360a is idle. enabled since Mon 10 Aug 2026 +printer Hall is disabled since yesterday +system default destination: Canon-TS6360a +"; + let st = parse_lpstat(text); + assert_eq!(st.printers.len(), 2); + assert!(st.printers[0].is_default); + assert!(st.printers[0].enabled); + assert!(!st.printers[1].enabled); + assert_eq!(st.default.as_deref(), Some("Canon-TS6360a")); + } + + #[test] + fn uri_schemes() { + assert!(valid_printer_uri("ipp://192.168.1.5/ipp/print")); + assert!(valid_printer_uri("ipps://printer.local/ipp")); + assert!(!valid_printer_uri("http://evil")); + assert!(!valid_printer_uri("ipp://x y")); + } +} diff --git a/src/src/commands/streaming.rs b/src/src/commands/streaming.rs index 5b87c2b..3c683a3 100644 --- a/src/src/commands/streaming.rs +++ b/src/src/commands/streaming.rs @@ -15,20 +15,49 @@ use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; #[derive(Clone, Serialize)] -struct CmdOutputEvent { +pub(crate) struct CmdOutputEvent { session_id: String, line: String, } +pub(crate) fn emit_line(app: &AppHandle, session_id: &str, line: &str) { + let _ = app.emit( + "cmd-output", + CmdOutputEvent { + session_id: session_id.to_string(), + line: line.to_string(), + }, + ); +} + /// Runs a hardcoded `program args...`, emitting one `cmd-output` event per /// line of stdout/stderr (tagged with `session_id` so the frontend can route /// concurrent streams), and resolves to whether it exited successfully. -async fn run_hardcoded(app: AppHandle, session_id: String, program: &str, args: &[&str]) -> bool { - let child = Command::new(program) - .args(args) +pub(crate) async fn run_hardcoded( + app: AppHandle, + session_id: String, + program: &str, + args: &[&str], +) -> bool { + run_hardcoded_env(app, session_id, program, args, &[]).await +} + +pub(crate) async fn run_hardcoded_env( + app: AppHandle, + session_id: String, + program: &str, + args: &[&str], + envs: &[(&str, String)], +) -> bool { + let mut cmd = Command::new(program); + cmd.args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .spawn(); + .kill_on_drop(true); + for (k, v) in envs { + cmd.env(k, v); + } + let child = cmd.spawn(); let mut child = match child { Ok(c) => c, Err(e) => { @@ -135,6 +164,35 @@ pub async fn fwupd_update(app: AppHandle, session_id: String) -> bool { run_hardcoded(app, session_id, "fwupdmgr", &["update", "-y"]).await } +#[tauri::command] +pub async fn bakery_install(app: AppHandle, session_id: String, name: String) -> bool { + if let Err(e) = super::util::allowed_bakery_install(&name) { + emit_line(&app, &session_id, &format!("Error: {e}")); + return false; + } + run_hardcoded(app, session_id, "bakery", &["-y", "install", &name]).await +} + +#[tauri::command] +pub async fn pacman_install(app: AppHandle, session_id: String, packages: Vec) -> bool { + let names = match super::util::allowed_pacman_packages(&packages) { + Ok(n) => n, + Err(e) => { + emit_line(&app, &session_id, &format!("Error: {e}")); + return false; + } + }; + let mut args: Vec = vec![ + "pacman".into(), + "-S".into(), + "--noconfirm".into(), + "--".into(), + ]; + args.extend(names); + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + run_hardcoded(app, session_id, "pkexec", &refs).await +} + #[cfg(test)] mod tests { use super::valid_bakery_pkg; diff --git a/src/src/commands/updates.rs b/src/src/commands/updates.rs new file mode 100644 index 0000000..0b4da74 --- /dev/null +++ b/src/src/commands/updates.rs @@ -0,0 +1,184 @@ +//! Aggregated Updates page: pacman -Qu, bakery dry-run, fwupd devices. +//! Rollback is Snapshots / grub-btrfs — not `snapper rollback`. + +use serde::Serialize; +use tokio::process::Command; + +use super::firmware::{get_updatable_firmware, FwDevice}; +use super::nvidia::{read_nvidia_offer, NvidiaOffer}; +use super::util::strip_ansi; + +#[derive(Serialize, Clone)] +pub struct PendingUpdate { + name: String, + current: String, + latest: String, +} + +#[derive(Serialize)] +pub struct UpdatesStatus { + pacman: Vec, + pacman_error: Option, + bakery: Vec, + bakery_error: Option, + firmware: Vec, + nvidia: Option, +} + +#[tauri::command] +pub async fn get_updates_status() -> UpdatesStatus { + let (pacman, bakery, firmware) = tokio::join!( + list_pacman_upgrades(), + list_bakery_outdated(), + get_updatable_firmware() + ); + let (pacman, pacman_error) = match pacman { + Ok(v) => (v, None), + Err(e) => (Vec::new(), Some(e)), + }; + let (bakery, bakery_error) = match bakery { + Ok(v) => (v, None), + Err(e) => (Vec::new(), Some(e)), + }; + UpdatesStatus { + pacman, + pacman_error, + bakery, + bakery_error, + firmware, + nvidia: read_nvidia_offer(), + } +} + +async fn list_pacman_upgrades() -> Result, String> { + let output = Command::new("pacman") + .args(["-Qu"]) + .output() + .await + .map_err(|e| format!("couldn't run pacman: {e}"))?; + // pacman -Qu exits 1 when there is nothing to upgrade. + let text = String::from_utf8_lossy(&output.stdout); + Ok(parse_pacman_qu(&text)) +} + +fn parse_pacman_qu(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + // "name old -> new" — extra fields after new are ignored. + let mut parts = line.split_whitespace(); + let name = parts.next()?.to_string(); + let current = parts.next()?.to_string(); + let arrow = parts.next()?; + if arrow != "->" { + return None; + } + let latest = parts.next()?.to_string(); + Some(PendingUpdate { + name, + current, + latest, + }) + }) + .collect() +} + +async fn list_bakery_outdated() -> Result, String> { + let output = Command::new("bakery") + .args(["--dry-run", "update", "--all"]) + .output() + .await + .map_err(|e| format!("couldn't run bakery: {e}"))?; + let text = strip_ansi(&String::from_utf8_lossy(&output.stdout)); + let err = strip_ansi(&String::from_utf8_lossy(&output.stderr)); + let combined = format!("{text}\n{err}"); + Ok(parse_bakery_outdated(&combined)) +} + +/// bakery has no `outdated` subcommand. `--dry-run update --all` is the +/// CLI's own preview of what a track-aware update would change. +fn parse_bakery_outdated(text: &str) -> Vec { + let mut out = Vec::new(); + for raw in text.lines() { + let line = raw.trim(); + if let Some(pkg) = parse_would_update(line).or_else(|| parse_updating_arrow(line)) { + if !out.iter().any(|p: &PendingUpdate| p.name == pkg.name) { + out.push(pkg); + } + } + } + out +} + +fn parse_would_update(line: &str) -> Option { + // "dry-run: would update bakery to 0.7.3-dev.…" + // "Would update bakery 0.7.3-dev.…" + let lower = line.to_ascii_lowercase(); + let i = lower.find("would update")?; + let rest = line[i + "would update".len()..].trim(); + let rest = rest.strip_prefix(':').unwrap_or(rest).trim(); + let rest = rest.strip_prefix("to ").unwrap_or(rest); + let mut parts = rest.split_whitespace(); + let name = parts.next()?.to_string(); + let mut latest = parts.next().unwrap_or("").to_string(); + if latest.eq_ignore_ascii_case("to") { + latest = parts.next().unwrap_or("").to_string(); + } + if name.is_empty() { + return None; + } + Some(PendingUpdate { + name, + current: String::new(), + latest, + }) +} + +fn parse_updating_arrow(line: &str) -> Option { + // "updating bakery 0.7.2 → 0.7.3" + let line = line.strip_prefix("updating ")?; + let (name, rest) = line.split_once(' ')?; + let (current, latest) = rest.split_once('→')?; + Some(PendingUpdate { + name: name.trim().to_string(), + current: current.trim().to_string(), + latest: latest.trim().to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pacman_qu_parses_arrow_lines() { + let text = + "linux 6.15.1-1 -> 6.15.2-1\nextra-note\nbos-settings 0.8.0-1 -> 0.8.1-1 [ignored]\n"; + let v = parse_pacman_qu(text); + assert_eq!(v.len(), 2); + assert_eq!(v[0].name, "linux"); + assert_eq!(v[0].current, "6.15.1-1"); + assert_eq!(v[0].latest, "6.15.2-1"); + assert_eq!(v[1].name, "bos-settings"); + } + + #[test] + fn bakery_dry_run_parses_would_update_and_arrow() { + let text = "\ + · breadbar is already at 0.3.2 +updating bakery 0.7.2-dev.1 → 0.7.3-dev.2 + dry-run: would update bakery to 0.7.3-dev.2 +Would update breadcast 1.2.3 +1 updated, 14 already up to date +"; + let v = parse_bakery_outdated(text); + assert_eq!(v.len(), 2); + assert_eq!(v[0].name, "bakery"); + assert_eq!(v[0].latest, "0.7.3-dev.2"); + assert_eq!(v[1].name, "breadcast"); + assert_eq!(v[1].latest, "1.2.3"); + } +} diff --git a/src/src/commands/util.rs b/src/src/commands/util.rs new file mode 100644 index 0000000..1acb283 --- /dev/null +++ b/src/src/commands/util.rs @@ -0,0 +1,258 @@ +//! Shared helpers for the OS-panel commands: PATH lookups, tight name +//! checks, 0600 writes, and the Hyprland `source =` fragment convention. + +use std::path::{Path, PathBuf}; + +use super::config; + +/// Pacman packages these panels may install. A generic `pacman -S` runner +/// is an arbitrary-package primitive; every name must be on this list. +pub const PACMAN_ALLOWLIST: &[&str] = &[ + "hyprsunset", + "fcitx5", + "fcitx5-configtool", + "fcitx5-gtk", + "fcitx5-qt", + "fcitx5-im", + "fcitx5-chinese-addons", + "fcitx5-table-extra", + "orca", + "kmag", + "restic", + "flatpak", + "libreoffice-fresh", + "papers", + "evince", + "steam", + "nvidia", + "nvidia-utils", +]; + +/// Bakery packages these panels may `bakery install`. breadcast is optional +/// software and is not on the ISO; do not add breadarr. +pub const BAKERY_INSTALL_ALLOWLIST: &[&str] = &["breadcast"]; + +pub fn command_exists(name: &str) -> bool { + let Some(paths) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&paths).any(|dir| { + let candidate = dir.join(name); + candidate.is_file() + }) +} + +pub fn pacman_installed(pkg: &str) -> bool { + std::process::Command::new("pacman") + .args(["-Q", pkg]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Arch package / bakery name: starts alphanumeric, then `[A-Za-z0-9+._-]`. +pub fn valid_pkg_name(name: &str) -> bool { + let bytes = name.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 128 + && bytes[0].is_ascii_alphanumeric() + && bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'+' | b'.')) +} + +pub fn allowed_pacman_packages(names: &[String]) -> Result, String> { + if names.is_empty() { + return Err("no packages given".into()); + } + let mut out = Vec::with_capacity(names.len()); + for name in names { + if !valid_pkg_name(name) || !PACMAN_ALLOWLIST.contains(&name.as_str()) { + return Err(format!("refusing to install '{name}'")); + } + if !out.iter().any(|e| e == name) { + out.push(name.clone()); + } + } + Ok(out) +} + +pub fn allowed_bakery_install(name: &str) -> Result<(), String> { + if !valid_pkg_name(name) || !BAKERY_INSTALL_ALLOWLIST.contains(&name) { + return Err(format!("refusing to bakery-install '{name}'")); + } + Ok(()) +} + +pub fn bos_settings_dir() -> PathBuf { + config::config_dir().join("bos-settings") +} + +/// Atomic write with mode 0600 set on the new inode before/after replace, +/// matching breadcrumbs' `networks.toml` care. +pub fn write_secure(path: &Path, contents: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("creating {}: {e}", parent.display()))?; + } + bread_utils::atomic::write_atomic(path, contents, Some(0o600)) + .map_err(|e| format!("writing {}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +pub fn strip_ansi(s: &str) -> String { + let re = regex::Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").expect("ansi regex"); + re.replace_all(s, "").into_owned() +} + +pub fn fail_output(output: &std::process::Output, what: &str) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let msg = stderr.trim(); + if !msg.is_empty() { + return msg.to_string(); + } + let msg = stdout.trim(); + if !msg.is_empty() { + return msg.to_string(); + } + format!("{what} failed") +} + +pub fn hypr_dir() -> PathBuf { + config::config_dir().join("hypr") +} + +pub fn hyprland_conf() -> PathBuf { + hypr_dir().join("hyprland.conf") +} + +/// Ensure `hyprland.conf` sources `~/.config/hypr/{fragment}`. Appends a +/// single source line when missing; does not rewrite the rest of the file. +pub fn ensure_hypr_source(fragment: &str) -> Result<(), String> { + if !valid_fragment(fragment) { + return Err(format!("invalid hypr fragment '{fragment}'")); + } + let dir = hypr_dir(); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let path = hyprland_conf(); + let marker = format!("hypr/{fragment}"); + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + if existing.lines().any(|l| l.contains(&marker)) { + return Ok(()); + } + let mut text = existing; + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + text.push_str(&format!("source = ~/.config/hypr/{fragment}\n")); + config::atomic_write(&path, &text).map_err(|e| e.to_string()) +} + +pub fn remove_hypr_source(fragment: &str) -> Result<(), String> { + if !valid_fragment(fragment) { + return Err(format!("invalid hypr fragment '{fragment}'")); + } + let path = hyprland_conf(); + let Ok(existing) = std::fs::read_to_string(&path) else { + return Ok(()); + }; + let marker = format!("hypr/{fragment}"); + let filtered: String = + existing + .lines() + .filter(|l| !l.contains(&marker)) + .fold(String::new(), |mut acc, l| { + acc.push_str(l); + acc.push('\n'); + acc + }); + if filtered != existing { + config::atomic_write(&path, &filtered).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn valid_fragment(name: &str) -> bool { + let bytes = name.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 64 + && bytes[0].is_ascii_alphanumeric() + && bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.')) +} + +/// Connection / printer names: no flags, no newlines. Spaces are allowed +/// (NetworkManager connection ids often have them). +pub fn valid_nm_id(name: &str) -> bool { + let t = name.trim(); + !t.is_empty() + && t.len() <= 256 + && !t.starts_with('-') + && !t.contains('\n') + && !t.contains('\0') + && !t.contains(';') +} + +pub fn valid_printer_name(name: &str) -> bool { + let bytes = name.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 127 + && bytes[0].is_ascii_alphanumeric() + && bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'-' | b'_' | b'.')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkg_name_accepts_arch_names() { + assert!(valid_pkg_name("hyprsunset")); + assert!(valid_pkg_name("fcitx5-chinese-addons")); + assert!(valid_pkg_name("libreoffice-fresh")); + assert!(valid_pkg_name("nvidia-utils")); + } + + #[test] + fn pkg_name_rejects_flags() { + assert!(!valid_pkg_name("")); + assert!(!valid_pkg_name("-S")); + assert!(!valid_pkg_name("--noconfirm")); + assert!(!valid_pkg_name("foo;rm")); + assert!(!valid_pkg_name("foo bar")); + } + + #[test] + fn allowlist_rejects_unknown() { + assert!(allowed_pacman_packages(&["steam".into()]).is_ok()); + assert!(allowed_pacman_packages(&["evil".into()]).is_err()); + assert!(allowed_bakery_install("breadcast").is_ok()); + assert!(allowed_bakery_install("breadarr").is_err()); + } + + #[test] + fn nm_id_allows_spaces_not_flags() { + assert!(valid_nm_id("Home VPN")); + assert!(!valid_nm_id("-evil")); + assert!(!valid_nm_id("a\nb")); + assert!(!valid_nm_id("")); + } + + #[test] + fn printer_name_is_tight() { + assert!(valid_printer_name("Canon-TS6360a")); + assert!(!valid_printer_name("foo bar")); + assert!(!valid_printer_name("-d")); + } +} diff --git a/src/src/commands/vpn.rs b/src/src/commands/vpn.rs new file mode 100644 index 0000000..c09d039 --- /dev/null +++ b/src/src/commands/vpn.rs @@ -0,0 +1,179 @@ +//! NetworkManager VPN / WireGuard connections. breadcrumbs stays Wi-Fi +//! profiles; this panel only lists `vpn` and `wireguard` connection types. + +use serde::Serialize; +use tokio::process::Command; + +use super::util::{fail_output, valid_nm_id}; + +#[derive(Serialize, Clone)] +pub struct VpnConnection { + name: String, + kind: String, + active: bool, + autoconnect: bool, +} + +#[derive(Serialize)] +pub struct VpnStatus { + connections: Vec, + error: Option, +} + +#[tauri::command] +pub async fn get_vpn_connections() -> VpnStatus { + let output = match Command::new("nmcli") + .args([ + "-t", + "-f", + "NAME,TYPE,STATE,AUTOCONNECT", + "connection", + "show", + ]) + .output() + .await + { + Ok(o) => o, + Err(e) => { + return VpnStatus { + connections: Vec::new(), + error: Some(format!("couldn't run nmcli: {e}")), + }; + } + }; + if !output.status.success() { + return VpnStatus { + connections: Vec::new(), + error: Some(fail_output(&output, "nmcli")), + }; + } + let text = String::from_utf8_lossy(&output.stdout); + VpnStatus { + connections: parse_nm_connections(&text), + error: None, + } +} + +fn parse_nm_connections(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + // nmcli -t escapes ":" in names as "\:". + let cols = split_nmcli(line); + if cols.len() < 3 { + return None; + } + let kind = cols[1].as_str(); + if kind != "vpn" && kind != "wireguard" { + return None; + } + let state = cols[2].as_str(); + let autoconnect = cols.get(3).map(|s| s == "yes").unwrap_or(false); + Some(VpnConnection { + name: cols[0].clone(), + kind: kind.to_string(), + active: state == "activated" || state == "activating", + autoconnect, + }) + }) + .collect() +} + +fn split_nmcli(line: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(n) = chars.next() { + cur.push(n); + } + } else if c == ':' { + out.push(std::mem::take(&mut cur)); + } else { + cur.push(c); + } + } + out.push(cur); + out +} + +#[tauri::command] +pub async fn vpn_connect(name: String) -> Result<(), String> { + nmcli_con(&["connection", "up", "id", &checked_id(&name)?]).await +} + +#[tauri::command] +pub async fn vpn_disconnect(name: String) -> Result<(), String> { + nmcli_con(&["connection", "down", "id", &checked_id(&name)?]).await +} + +fn checked_id(name: &str) -> Result { + if !valid_nm_id(name) { + return Err(format!("invalid connection name '{name}'")); + } + Ok(name.trim().to_string()) +} + +async fn nmcli_con(args: &[&str]) -> Result<(), String> { + let output = Command::new("nmcli") + .args(args) + .output() + .await + .map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(fail_output(&output, "nmcli")) + } +} + +#[tauri::command] +pub async fn vpn_import(path: String) -> Result<(), String> { + let path = path.trim(); + if path.is_empty() || path.contains('\0') || path.contains('\n') { + return Err("invalid path".into()); + } + let p = std::path::Path::new(path); + if !p.is_absolute() || !p.is_file() { + return Err("pick an existing .conf or .ovpn file".into()); + } + let kind = match p + .extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_ascii_lowercase()) + .as_deref() + { + Some("ovpn") => "openvpn", + Some("conf") => "wireguard", + _ => return Err("import a WireGuard .conf or OpenVPN .ovpn file".into()), + }; + nmcli_con(&["connection", "import", "type", kind, "file", path]).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_wireguard_and_skips_wifi() { + let text = "\ +Home WG:wireguard:activated:yes +Office:vpn: +NetComm:802-11-wireless:activated +tailscale0:tun:activated:yes +"; + let v = parse_nm_connections(text); + assert_eq!(v.len(), 2); + assert_eq!(v[0].name, "Home WG"); + assert!(v[0].active); + assert_eq!(v[1].kind, "vpn"); + assert!(!v[1].active); + } + + #[test] + fn unescapes_colon_in_name() { + let text = r"Work\:VPN:vpn:activated:no"; + let v = parse_nm_connections(text); + assert_eq!(v[0].name, "Work:VPN"); + } +} diff --git a/src/src/lib.rs b/src/src/lib.rs index d022889..3fe5646 100644 --- a/src/src/lib.rs +++ b/src/src/lib.rs @@ -89,7 +89,9 @@ pub fn run() { commands::streaming::bakery_update, commands::streaming::bakery_list, commands::streaming::bakery_update_all, + commands::streaming::bakery_install, commands::streaming::pacman_system_update, + commands::streaming::pacman_install, commands::streaming::fwupd_refresh, commands::streaming::fwupd_update, commands::packages::get_installed_packages, @@ -111,6 +113,37 @@ pub fn run() { commands::breadshot::breadshot_region_clipboard, commands::breadmon::open_breadmon, commands::breadhelp::open_breadhelp, + commands::updates::get_updates_status, + commands::nvidia::get_nvidia_offer, + commands::printing::get_printers, + commands::printing::set_default_printer, + commands::printing::add_ipp_printer, + commands::printing::open_printer_settings, + commands::vpn::get_vpn_connections, + commands::vpn::vpn_connect, + commands::vpn::vpn_disconnect, + commands::vpn::vpn_import, + commands::nightlight::get_nightlight, + commands::nightlight::set_nightlight, + commands::ime::get_ime_status, + commands::ime::set_ime_enabled, + commands::ime::open_fcitx_config, + commands::a11y::get_a11y_status, + commands::a11y::set_cursor_zoom, + commands::a11y::set_orca_running, + commands::a11y::open_kmag, + commands::defaults::get_default_apps, + commands::defaults::save_default_apps, + commands::channel::get_bakery_track, + commands::channel::set_bakery_track, + commands::backup::get_backup_config, + commands::backup::save_backup_config, + commands::backup::restic_init, + commands::backup::restic_backup, + commands::backup::restic_restore_dry_run, + commands::backup::list_restic_snapshots, + commands::optional::get_optional_software, + commands::optional::enable_flathub, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/src/screenshot.rs b/src/src/screenshot.rs index c24b6dc..0e7388e 100644 --- a/src/src/screenshot.rs +++ b/src/src/screenshot.rs @@ -59,6 +59,16 @@ const KNOWN_VIEWS: &[&str] = &[ "aur", "firmware", "snapshots", + "updates", + "printing", + "vpn", + "nightlight", + "ime", + "accessibility", + "defaults", + "channel", + "backup", + "optional", "breadlock", "breadshot", "breadmon", From 0798fad697141f9c670ecccdf9d6ddd03b66a176 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:26:06 +0800 Subject: [PATCH 22/31] Adopt bread_utils::screenshot_cli for --screenshot flags Use the crate for pair validation and canvas defaults. Webview settle delays stay local; Tauri still does not have a GTK map signal. --- src/src/screenshot.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/src/screenshot.rs b/src/src/screenshot.rs index 0e7388e..6c8b963 100644 --- a/src/src/screenshot.rs +++ b/src/src/screenshot.rs @@ -23,7 +23,8 @@ //! `KNOWN_VIEWS`) — every one of them has a real registered component (see //! `frontend/src/lib/views/registry.ts`), no Placeholder fallbacks to skip. -use std::path::PathBuf; +use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH}; +use std::path::{Path, PathBuf}; use std::time::Duration; use tauri::Emitter; @@ -84,13 +85,13 @@ pub struct ScreenshotRequest { } /// `None` for a normal run. Exits the process with an error for an unknown -/// view, or if `--screenshot` was given without `--output` — before any -/// Tauri setup happens. +/// view, or if the `--screenshot` / `--output` pair is incomplete — before +/// any Tauri setup happens. pub fn parse(args: &[String]) -> Option { let mut view = None; let mut output = None; - let mut width = 1920u32; - let mut height = 1080u32; + let mut width = DEFAULT_WIDTH; + let mut height = DEFAULT_HEIGHT; let mut it = args.iter().skip(1); while let Some(arg) = it.next() { match arg.as_str() { @@ -109,6 +110,10 @@ pub fn parse(args: &[String]) -> Option { _ => {} } } + if let Err(e) = validate_pair(view.as_deref(), output.as_deref().map(Path::new)) { + eprintln!("bos-settings: {e}"); + std::process::exit(1); + } let view = view?; if !KNOWN_VIEWS.contains(&view.as_str()) { eprintln!( @@ -117,13 +122,9 @@ pub fn parse(args: &[String]) -> Option { ); std::process::exit(1); } - let Some(output) = output else { - eprintln!("bos-settings: --screenshot requires --output"); - std::process::exit(1); - }; Some(ScreenshotRequest { view, - output: output.into(), + output: output?.into(), width, height, }) From 3aad09c9efdffa3a206fe8519f3e0e5e00cae1d4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:48:25 +0800 Subject: [PATCH 23/31] Make rollback a GRUB snapshot reboot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/lib/nav.ts | 3 +++ frontend/src/lib/views/Snapshots.svelte | 24 +++++++++++++++++++----- frontend/src/lib/views/Updates.svelte | 11 +++++++++-- frontend/src/routes/+page.svelte | 6 +++++- 4 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/nav.ts diff --git a/frontend/src/lib/nav.ts b/frontend/src/lib/nav.ts new file mode 100644 index 0000000..bced4f0 --- /dev/null +++ b/frontend/src/lib/nav.ts @@ -0,0 +1,3 @@ +/** Sidebar page switch. Set from +page.svelte; views call it to jump. */ +export type Navigate = (page: string) => void; +export const NAVIGATE_KEY = "bos-settings-navigate"; diff --git a/frontend/src/lib/views/Snapshots.svelte b/frontend/src/lib/views/Snapshots.svelte index 8687a00..6834326 100644 --- a/frontend/src/lib/views/Snapshots.svelte +++ b/frontend/src/lib/views/Snapshots.svelte @@ -42,7 +42,7 @@ if (!selected) return; if ( confirm( - `Boot into snapshot #${selected}? Snapshots on BOS are booted directly from the GRUB menu (under "BOS snapshots"), not rolled back in place. Reboot now and pick this snapshot there.`, + `Reboot to pick snapshot #${selected} in GRUB? BOS boots snapshots from the GRUB “BOS snapshots” submenu. This does not run snapper rollback.`, ) ) { invoke("reboot_system"); @@ -62,9 +62,10 @@ +
@@ -75,6 +76,11 @@ {:else if snapshots.length === 0} {:else} + {#each snapshots as snap (snap.number)} - +
@@ -121,7 +127,15 @@ min-width: 0; } - .row:hover { + .row.header { + background: transparent; + cursor: default; + opacity: 0.55; + font-size: var(--font-size-secondary, 12px); + padding-top: 0; + } + + .row:hover:not(.header) { background-color: color-mix(in srgb, var(--surface), var(--on-surface) 8%); } diff --git a/frontend/src/lib/views/Updates.svelte b/frontend/src/lib/views/Updates.svelte index 4b48203..ec4c865 100644 --- a/frontend/src/lib/views/Updates.svelte +++ b/frontend/src/lib/views/Updates.svelte @@ -1,5 +1,5 @@ @@ -100,18 +138,29 @@ {/if} - +
-
{#if message}{/if}
+ + +
+ + +
+
+ {#if snapshots.length === 0} diff --git a/src/src/commands/backup.rs b/src/src/commands/backup.rs index 19a6a13..ed4bf12 100644 --- a/src/src/commands/backup.rs +++ b/src/src/commands/backup.rs @@ -11,6 +11,12 @@ use super::config; use super::streaming; use super::util::{self, command_exists, fail_output}; +fn home_dir() -> PathBuf { + std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/root")) +} + fn backup_toml() -> PathBuf { util::bos_settings_dir().join("backup.toml") } @@ -66,6 +72,7 @@ pub struct BackupStatus { has_password: bool, snapshots: Vec, error: Option, + home: String, } #[derive(Serialize, Clone)] @@ -84,6 +91,7 @@ pub fn get_backup_config() -> BackupStatus { has_password: s.password.is_some(), snapshots: Vec::new(), error: None, + home: home_dir().to_string_lossy().into_owned(), } } @@ -161,6 +169,7 @@ fn exclude_args(home: &str) -> Vec { ".cache", ".local/share/Trash", ".local/share/Steam", + ".local/share/containers", ".npm", ".cargo/registry", ".cargo/git", @@ -210,8 +219,56 @@ pub async fn restic_backup(app: AppHandle, session_id: String) -> bool { .await } -#[tauri::command] -pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot: String) -> bool { +/// `~/bos-restore-`. Never `$HOME` itself — restore writes into a new +/// directory so a bad snapshot cannot clobber the live home. +pub fn default_restore_dir(snapshot: &str) -> PathBuf { + home_dir().join(format!("bos-restore-{snapshot}")) +} + +fn normalize_abs(path: &Path) -> PathBuf { + path.components().collect() +} + +/// Absolute path, not `$HOME` and not `/`. Empty target means the default. +pub fn valid_restore_target(path: &Path) -> bool { + if !path.is_absolute() { + return false; + } + let s = path.to_string_lossy(); + if s.is_empty() || s.len() > 512 || s.contains('\n') || s.contains('\0') { + return false; + } + let normalized = normalize_abs(path); + if normalized == PathBuf::from("/") { + return false; + } + normalized != normalize_abs(&home_dir()) +} + +fn resolve_restore_target(snapshot: &str, target: Option<&str>) -> Result { + if !valid_snapshot_id(snapshot) { + return Err("invalid snapshot id".into()); + } + let dest = match target.map(str::trim).filter(|s| !s.is_empty()) { + Some(t) => PathBuf::from(t), + None => default_restore_dir(snapshot), + }; + if !valid_restore_target(&dest) { + return Err( + "restore target must be an absolute path that is not $HOME (default is ~/bos-restore-)" + .into(), + ); + } + Ok(dest) +} + +async fn run_restic_restore( + app: AppHandle, + session_id: String, + snapshot: String, + target: Option, + dry_run: bool, +) -> bool { let s = match require_ready() { Ok(s) => s, Err(e) => { @@ -220,14 +277,38 @@ pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot } }; let snap = snapshot.trim(); - if !valid_snapshot_id(snap) { - streaming::emit_line(&app, &session_id, "Error: invalid snapshot id"); - return false; - } - let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + let dest = match resolve_restore_target(snap, target.as_deref()) { + Ok(p) => p, + Err(e) => { + streaming::emit_line(&app, &session_id, &format!("Error: {e}")); + return false; + } + }; + let dest_s = dest.to_string_lossy().into_owned(); let password = s.password.clone().unwrap_or_default(); - let extra = ["restore", snap, "--target", home.as_str(), "--dry-run"]; - let args = restic_args(&s.repo, &extra); + let mut extra = vec![ + "restore".to_string(), + snap.to_string(), + "--target".into(), + dest_s.clone(), + ]; + if dry_run { + extra.push("--dry-run".into()); + } + streaming::emit_line( + &app, + &session_id, + &format!( + "{} {snap} → {dest_s}", + if dry_run { + "Dry-run restore" + } else { + "Restoring" + } + ), + ); + let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect(); + let args = restic_args(&s.repo, &extra_refs); streaming::run_hardcoded_env( app, session_id, @@ -238,6 +319,26 @@ pub async fn restic_restore_dry_run(app: AppHandle, session_id: String, snapshot .await } +#[tauri::command] +pub async fn restic_restore_dry_run( + app: AppHandle, + session_id: String, + snapshot: String, + target: Option, +) -> bool { + run_restic_restore(app, session_id, snapshot, target, true).await +} + +#[tauri::command] +pub async fn restic_restore( + app: AppHandle, + session_id: String, + snapshot: String, + target: Option, +) -> bool { + run_restic_restore(app, session_id, snapshot, target, false).await +} + fn valid_snapshot_id(id: &str) -> bool { if id == "latest" { return true; @@ -355,4 +456,33 @@ mod tests { assert_eq!(v[0].id, "abc123"); assert_eq!(v[0].paths[0], "/home/a"); } + + #[test] + fn restore_defaults_to_bos_restore_id_not_home() { + let dest = default_restore_dir("a1b2c3d4"); + let home = home_dir(); + assert_eq!(dest, home.join("bos-restore-a1b2c3d4")); + assert_ne!(dest, home); + assert!(valid_restore_target(&dest)); + assert!(!valid_restore_target(&home)); + assert!(!valid_restore_target(Path::new("/"))); + assert!(!valid_restore_target(Path::new("relative/path"))); + assert!(valid_restore_target(Path::new("/tmp/bos-restore-custom"))); + let resolved = resolve_restore_target("latest", None).unwrap(); + assert_eq!(resolved, home.join("bos-restore-latest")); + assert!(resolve_restore_target("latest", Some(home.to_str().unwrap())).is_err()); + } + + #[test] + fn exclude_covers_caches_and_containers() { + let args = exclude_args("/home/a"); + let joined = args.join(" "); + assert!(joined.contains("/home/a/.cache")); + assert!(joined.contains("/home/a/.local/share/Trash")); + assert!(joined.contains("/home/a/.local/share/Steam")); + assert!(joined.contains("/home/a/.local/share/containers")); + assert!(joined.contains("node_modules")); + assert!(joined.contains("target")); + assert!(joined.contains(".git")); + } } diff --git a/src/src/lib.rs b/src/src/lib.rs index da39441..a21d6a0 100644 --- a/src/src/lib.rs +++ b/src/src/lib.rs @@ -142,6 +142,7 @@ pub fn run() { commands::backup::restic_init, commands::backup::restic_backup, commands::backup::restic_restore_dry_run, + commands::backup::restic_restore, commands::backup::list_restic_snapshots, commands::optional::get_optional_software, commands::optional::enable_flathub, From a6cb245ae4cee37548b5afee2ed9cd09f1f681a1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 13:23:21 +0800 Subject: [PATCH 26/31] Load bread-theme CSS from the window's monitor palette Pin bread-theme to v0.7.4. --- src/Cargo.lock | 454 +++++++++++++++++++++++--------------- src/Cargo.toml | 2 +- src/src/commands/theme.rs | 25 ++- 3 files changed, 302 insertions(+), 179 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 020c388..1dfe71d 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -34,9 +34,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -169,13 +169,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -307,8 +307,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" +version = "0.7.4" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" dependencies = [ "dirs 5.0.1", "serde", @@ -410,9 +410,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -437,7 +437,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -452,9 +452,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.3.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "shlex", @@ -526,9 +526,9 @@ dependencies = [ [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "time", "version_check", @@ -701,6 +701,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "deranged" version = "0.5.8" @@ -797,13 +828,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -904,7 +935,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "vswhom", "winreg", ] @@ -971,11 +1002,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1027,9 +1057,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flate2" @@ -1065,13 +1095,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1100,24 +1130,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1126,9 +1156,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -1145,32 +1175,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -1406,9 +1436,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "gobject-sys" @@ -1521,9 +1551,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1541,9 +1571,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1643,9 +1673,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1657,9 +1687,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1670,9 +1700,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1684,16 +1714,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1704,15 +1735,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -1813,9 +1844,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is-docker" @@ -1865,6 +1896,59 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -1911,9 +1995,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -1955,9 +2039,9 @@ dependencies = [ [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -1999,9 +2083,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.188" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libdbus-sys" @@ -2024,9 +2108,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -2039,9 +2123,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -2129,7 +2213,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -2432,9 +2516,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" dependencies = [ "dunce", "is-wsl", @@ -2589,9 +2673,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plist" @@ -2647,10 +2731,25 @@ dependencies = [ ] [[package]] -name = "potential_utf" -version = "0.1.5" +name = "portable-atomic" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2792,7 +2891,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2812,7 +2911,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -2829,9 +2928,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2974,9 +3073,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -3070,7 +3169,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -3105,7 +3204,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -3128,9 +3227,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", "bs58", @@ -3138,8 +3237,9 @@ dependencies = [ "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -3148,9 +3248,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling", "proc-macro2", @@ -3368,9 +3468,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -3452,9 +3552,9 @@ dependencies = [ [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", @@ -3509,7 +3609,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tray-icon", "url", @@ -3560,7 +3660,7 @@ dependencies = [ "sha2", "syn 2.0.119", "tauri-utils", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "url", "uuid", @@ -3611,7 +3711,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", ] @@ -3634,8 +3734,8 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.19", - "toml 1.1.3+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "url", ] @@ -3655,7 +3755,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", "windows", "zbus", @@ -3679,7 +3779,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", "webkit2gtk", "webview2-com", @@ -3742,8 +3842,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.19", - "toml 1.1.3+spec-1.1.0", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", "url", "urlpattern", "uuid", @@ -3758,7 +3858,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] @@ -3794,11 +3894,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -3814,20 +3914,20 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -3855,9 +3955,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -3896,13 +3996,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -3947,9 +4047,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -4037,9 +4137,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -4134,9 +4234,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs 6.0.0", @@ -4150,7 +4250,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -4269,9 +4369,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4347,9 +4447,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -4360,9 +4460,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -4370,9 +4470,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4380,9 +4480,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -4393,9 +4493,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -4415,9 +4515,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -4425,9 +4525,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" dependencies = [ "phf", "phf_codegen", @@ -4510,7 +4610,7 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", "windows", "windows-core 0.61.2", ] @@ -5060,9 +5160,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" @@ -5097,7 +5197,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", "webkit2gtk", "webkit2gtk-sys", @@ -5154,9 +5254,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -5189,14 +5289,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zbus_names", "zvariant", "zvariant_utils", @@ -5213,6 +5313,15 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5236,9 +5345,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -5247,9 +5356,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -5258,13 +5367,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -5275,40 +5384,41 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" dependencies = [ "endi", "enumflags2", "serde", "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.5.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.119", + "syn 3.0.3", "winnow 1.0.4", ] diff --git a/src/Cargo.toml b/src/Cargo.toml index bbd9ae4..bb8b2d3 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -36,7 +36,7 @@ toml_edit = "0.22" tokio = { version = "1", features = ["process", "io-util", "time", "macros"] } notify = "7" regex = "1" -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } +bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml"] } anyhow = "1" diff --git a/src/src/commands/theme.rs b/src/src/commands/theme.rs index d9dcc91..1e5e4b0 100644 --- a/src/src/commands/theme.rs +++ b/src/src/commands/theme.rs @@ -11,17 +11,26 @@ use tauri::{AppHandle, Emitter, Manager}; /// Initial theme fetch — called once by the frontend at startup. #[tauri::command] -pub fn get_theme_css() -> String { - render_theme_css() +pub fn get_theme_css(window: tauri::WebviewWindow) -> String { + render_theme_css(&palette_for_window(&window)) } -fn render_theme_css() -> String { - let palette = bread_theme::load_palette(); +fn palette_for_window(window: &tauri::WebviewWindow) -> bread_theme::Palette { + window + .current_monitor() + .ok() + .flatten() + .and_then(|m| m.name().map(|s| s.to_string())) + .map(|name| bread_theme::load_palette_for(&name)) + .unwrap_or_else(bread_theme::load_palette) +} + +fn render_theme_css(palette: &bread_theme::Palette) -> String { // bread-theme v0.7.1 exposes Palette + ink_on + tokens, but not the // later css_custom_properties / css_tokens helpers (those landed after // the tag). Emit the same :root custom-property names the Svelte app // already uses so a tag pin doesn't require a web-side rename. - format!("{}\n{}", css_custom_properties(&palette), css_tokens()) + format!("{}\n{}", css_custom_properties(palette), css_tokens()) } fn css_custom_properties(p: &bread_theme::Palette) -> String { @@ -103,7 +112,11 @@ pub fn watch_and_emit(app: &AppHandle) { EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) ) && event.paths.iter().any(|p| p == &target_for_watcher); if touches_target { - let _ = app_for_watcher.emit("theme-changed", render_theme_css()); + let css = app_for_watcher + .get_webview_window("main") + .map(|w| render_theme_css(&palette_for_window(&w))) + .unwrap_or_else(|| render_theme_css(&bread_theme::load_palette())); + let _ = app_for_watcher.emit("theme-changed", css); } }, notify::Config::default(), From 77b5a223d8b22cc9daeeb70f51302d4ca2ab59ab Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 14:09:46 +0800 Subject: [PATCH 27/31] Bump version to v0.8.2 --- src/Cargo.lock | 2 +- src/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 1dfe71d..bf1adc3 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -288,7 +288,7 @@ dependencies = [ [[package]] name = "bos-settings" -version = "0.8.0" +version = "0.8.2" dependencies = [ "anyhow", "bread-theme", diff --git a/src/Cargo.toml b/src/Cargo.toml index bb8b2d3..c74d506 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bos-settings" -version = "0.8.0" +version = "0.8.2" description = "System settings app for BOS (Bread Operating System)" authors = ["Breadway"] edition = "2021" From f7b114f77847b49078fc309bc8f2d9a06fbdb503 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:40:03 +0800 Subject: [PATCH 28/31] Harden privileged command operands against injection 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. --- src/capabilities/default.json | 1 - src/src/commands/about.rs | 85 +++++++++++++++---- src/src/commands/aur.rs | 29 +++++-- src/src/commands/breadpad.rs | 100 +++++++++++++++++++---- src/src/commands/datetime.rs | 55 ++++++++++++- src/src/commands/firewall.rs | 107 ++++++++++++++++++++++-- src/src/commands/power.rs | 90 +++++++++++++++++--- src/src/commands/service.rs | 54 ++++++++++-- src/src/commands/users.rs | 149 ++++++++++++++++++++++++++++------ src/src/commands/util.rs | 24 ++++++ 10 files changed, 605 insertions(+), 89 deletions(-) diff --git a/src/capabilities/default.json b/src/capabilities/default.json index 778bfb5..e895c6b 100644 --- a/src/capabilities/default.json +++ b/src/capabilities/default.json @@ -5,7 +5,6 @@ "windows": ["main"], "permissions": [ "core:default", - "opener:default", "dialog:default" ] } diff --git a/src/src/commands/about.rs b/src/src/commands/about.rs index 7ec9f56..b847a85 100644 --- a/src/src/commands/about.rs +++ b/src/src/commands/about.rs @@ -23,8 +23,10 @@ fn os_pretty_name() -> String { fs::read_to_string("/etc/os-release") .ok() .and_then(|s| { - s.lines() - .find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string())) + s.lines().find_map(|l| { + l.strip_prefix("PRETTY_NAME=") + .map(|v| v.trim_matches('"').to_string()) + }) }) .unwrap_or_else(|| "BOS".to_string()) } @@ -49,11 +51,15 @@ fn cpu() -> String { let model = fs::read_to_string("/proc/cpuinfo") .ok() .and_then(|s| { - s.lines() - .find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string())) + s.lines().find_map(|l| { + l.strip_prefix("model name") + .map(|v| v.trim_start_matches([':', ' ', '\t']).to_string()) + }) }) .unwrap_or_else(|| "unknown".to_string()); - let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0); + let cores = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(0); if cores > 0 { format!("{model} ({cores} threads)") } else { @@ -62,14 +68,12 @@ fn cpu() -> String { } fn memory() -> String { - let kb = fs::read_to_string("/proc/meminfo") - .ok() - .and_then(|s| { - s.lines() - .find(|l| l.starts_with("MemTotal:")) - .and_then(|l| l.split_whitespace().nth(1)) - .and_then(|v| v.parse::().ok()) - }); + let kb = fs::read_to_string("/proc/meminfo").ok().and_then(|s| { + s.lines() + .find(|l| l.starts_with("MemTotal:")) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|v| v.parse::().ok()) + }); match kb { Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0), None => "unknown".to_string(), @@ -96,7 +100,11 @@ async fn gpu() -> String { } async fn disk_usage() -> String { - let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output().await else { + let Ok(output) = Command::new("df") + .args(["-h", "--output=used,size,pcent", "/"]) + .output() + .await + else { return "unknown".to_string(); }; let text = String::from_utf8_lossy(&output.stdout); @@ -136,11 +144,35 @@ pub async fn get_system_info() -> SystemInfo { } } +/// RFC 1123 labels (digit start allowed), no leading `-`. Linux static +/// hostnames are also capped at `HOST_NAME_MAX` (64). +fn valid_hostname(name: &str) -> bool { + let name = name.trim(); + if name.is_empty() || name.len() > 64 || name.starts_with('-') { + return false; + } + if name.contains('\n') || name.contains('\r') || name.contains('\0') { + return false; + } + name.split('.').all(valid_dns_label) +} + +fn valid_dns_label(label: &str) -> bool { + let b = label.as_bytes(); + if b.is_empty() || b.len() > 63 { + return false; + } + if !b[0].is_ascii_alphanumeric() || !b[b.len() - 1].is_ascii_alphanumeric() { + return false; + } + b.iter().all(|c| c.is_ascii_alphanumeric() || *c == b'-') +} + #[tauri::command] pub async fn set_hostname(name: String) -> Result<(), String> { let name = name.trim(); - if name.is_empty() { - return Err("Hostname can't be empty".into()); + if !valid_hostname(name) { + return Err("invalid hostname".into()); } let output = Command::new("pkexec") .args(["hostnamectl", "set-hostname", name]) @@ -153,3 +185,24 @@ pub async fn set_hostname(name: String) -> Result<(), String> { Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hostname_rfc1123() { + assert!(valid_hostname("bos")); + assert!(valid_hostname("bos.local")); + assert!(valid_hostname("a1-b")); + assert!(valid_hostname("1host")); + assert!(!valid_hostname("")); + assert!(!valid_hostname("-bos")); + assert!(!valid_hostname("bos-")); + assert!(!valid_hostname("-foo.bar")); + assert!(!valid_hostname("foo_bar")); + assert!(!valid_hostname("bos\n-set-hostname evil")); + assert!(!valid_hostname("--help")); + assert!(!valid_hostname(&"a".repeat(65))); + } +} diff --git a/src/src/commands/aur.rs b/src/src/commands/aur.rs index 226af63..6c72b64 100644 --- a/src/src/commands/aur.rs +++ b/src/src/commands/aur.rs @@ -10,6 +10,8 @@ use serde::Serialize; +use super::util; + #[derive(Serialize, Clone)] pub struct AurResult { name: String, @@ -19,7 +21,11 @@ pub struct AurResult { #[tauri::command] pub async fn search_aur(query: String) -> Vec { - let Ok(output) = tokio::process::Command::new("yay").args(["-Ss", "--aur", &query]).output().await else { + let Ok(output) = tokio::process::Command::new("yay") + .args(["-Ss", "--aur", &query]) + .output() + .await + else { return Vec::new(); }; let text = String::from_utf8_lossy(&output.stdout); @@ -28,12 +34,18 @@ pub async fn search_aur(query: String) -> Vec { while let Some(header) = lines.next() { // "aur/name version (+votes score) [Orphaned]" — name/version are // always the first two whitespace-separated fields after "aur/". - let Some(rest) = header.strip_prefix("aur/") else { continue }; + let Some(rest) = header.strip_prefix("aur/") else { + continue; + }; let mut parts = rest.split_whitespace(); let Some(name) = parts.next() else { continue }; let version = parts.next().unwrap_or("").to_string(); let description = lines.next().unwrap_or("").trim().to_string(); - results.push(AurResult { name: name.to_string(), version, description }); + results.push(AurResult { + name: name.to_string(), + version, + description, + }); if results.len() >= 50 { break; } @@ -42,6 +54,13 @@ pub async fn search_aur(query: String) -> Vec { } #[tauri::command] -pub fn install_aur_package(pkg: String) { - let _ = std::process::Command::new("kitty").args(["-e", "yay", "-S", &pkg]).spawn(); +pub fn install_aur_package(pkg: String) -> Result<(), String> { + if !util::valid_pkg_name(&pkg) { + return Err(format!("refusing to install '{pkg}'")); + } + std::process::Command::new("kitty") + .args(["-e", "yay", "-S", &pkg]) + .spawn() + .map_err(|e| e.to_string())?; + Ok(()) } diff --git a/src/src/commands/breadpad.rs b/src/src/commands/breadpad.rs index 5f0fb5d..19db123 100644 --- a/src/src/commands/breadpad.rs +++ b/src/src/commands/breadpad.rs @@ -35,22 +35,35 @@ pub struct BreadpadConfig { pub fn get_breadpad_config() -> BreadpadConfig { let doc = config::load_doc(&config_path()); BreadpadConfig { - default_type: config::get_str(&doc, &["settings", "default_type"]).unwrap_or_else(|| "note".into()), + default_type: config::get_str(&doc, &["settings", "default_type"]) + .unwrap_or_else(|| "note".into()), workspace_tag: config::get_bool(&doc, &["settings", "workspace_tag"]).unwrap_or(true), snooze_options: config::get_str_list(&doc, &["settings", "snooze_options"]), - archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"]).unwrap_or(30), + archive_after_days: config::get_i64(&doc, &["settings", "archive_after_days"]) + .unwrap_or(30), model_path: config::get_str(&doc, &["model", "path"]).unwrap_or_default(), tokenizer_path: config::get_str(&doc, &["model", "tokenizer"]).unwrap_or_default(), ollama_enabled: config::get_bool(&doc, &["model", "ollama", "enabled"]).unwrap_or(true), - ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"]).unwrap_or_default(), + ollama_endpoint: config::get_str(&doc, &["model", "ollama", "endpoint"]) + .unwrap_or_default(), ollama_model: config::get_str(&doc, &["model", "ollama", "model"]).unwrap_or_default(), - ollama_confidence_threshold: config::get_f64(&doc, &["model", "ollama", "confidence_threshold"]).unwrap_or(0.6), - reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"]).unwrap_or_else(|| "7:00".into()), - reminders_missed_grace_minutes: config::get_i64(&doc, &["reminders", "missed_grace_minutes"]).unwrap_or(60), + ollama_confidence_threshold: config::get_f64( + &doc, + &["model", "ollama", "confidence_threshold"], + ) + .unwrap_or(0.6), + reminders_default_morning: config::get_str(&doc, &["reminders", "default_morning"]) + .unwrap_or_else(|| "7:00".into()), + reminders_missed_grace_minutes: config::get_i64( + &doc, + &["reminders", "missed_grace_minutes"], + ) + .unwrap_or(60), calendar_enabled: config::get_bool(&doc, &["calendar", "enabled"]).unwrap_or(false), calendar_url: config::get_str(&doc, &["calendar", "url"]).unwrap_or_default(), calendar_username: config::get_str(&doc, &["calendar", "username"]).unwrap_or_default(), - calendar_password: config::get_str(&doc, &["calendar", "password"]).unwrap_or_default(), + // Write-only to the webview, same as restic — never round-trip the secret. + calendar_password: String::new(), } } @@ -60,19 +73,76 @@ pub fn save_breadpad_config(cfg: BreadpadConfig) -> Result<(), String> { let mut doc = config::load_doc(&path); config::set_str(&mut doc, &["settings", "default_type"], &cfg.default_type); config::set_bool(&mut doc, &["settings", "workspace_tag"], cfg.workspace_tag); - config::set_str_list(&mut doc, &["settings", "snooze_options"], &cfg.snooze_options); - config::set_i64(&mut doc, &["settings", "archive_after_days"], cfg.archive_after_days); + config::set_str_list( + &mut doc, + &["settings", "snooze_options"], + &cfg.snooze_options, + ); + config::set_i64( + &mut doc, + &["settings", "archive_after_days"], + cfg.archive_after_days, + ); config::set_str_or_remove(&mut doc, &["model", "path"], &cfg.model_path); config::set_str_or_remove(&mut doc, &["model", "tokenizer"], &cfg.tokenizer_path); - config::set_bool(&mut doc, &["model", "ollama", "enabled"], cfg.ollama_enabled); - config::set_str_or_remove(&mut doc, &["model", "ollama", "endpoint"], &cfg.ollama_endpoint); + config::set_bool( + &mut doc, + &["model", "ollama", "enabled"], + cfg.ollama_enabled, + ); + config::set_str_or_remove( + &mut doc, + &["model", "ollama", "endpoint"], + &cfg.ollama_endpoint, + ); config::set_str_or_remove(&mut doc, &["model", "ollama", "model"], &cfg.ollama_model); - config::set_f64(&mut doc, &["model", "ollama", "confidence_threshold"], cfg.ollama_confidence_threshold); - config::set_str_or_remove(&mut doc, &["reminders", "default_morning"], &cfg.reminders_default_morning); - config::set_i64(&mut doc, &["reminders", "missed_grace_minutes"], cfg.reminders_missed_grace_minutes); + config::set_f64( + &mut doc, + &["model", "ollama", "confidence_threshold"], + cfg.ollama_confidence_threshold, + ); + config::set_str_or_remove( + &mut doc, + &["reminders", "default_morning"], + &cfg.reminders_default_morning, + ); + config::set_i64( + &mut doc, + &["reminders", "missed_grace_minutes"], + cfg.reminders_missed_grace_minutes, + ); config::set_bool(&mut doc, &["calendar", "enabled"], cfg.calendar_enabled); config::set_str_or_remove(&mut doc, &["calendar", "url"], &cfg.calendar_url); config::set_str_or_remove(&mut doc, &["calendar", "username"], &cfg.calendar_username); - config::set_str_or_remove(&mut doc, &["calendar", "password"], &cfg.calendar_password); + apply_calendar_password(&mut doc, &cfg.calendar_password); config::save_doc(&path, &doc).map_err(|e| e.to_string()) } + +/// Empty incoming password keeps the existing secret (PasswordField is write-only). +fn apply_calendar_password(doc: &mut toml_edit::DocumentMut, incoming: &str) { + if incoming.is_empty() { + return; + } + config::set_str(doc, &["calendar", "password"], incoming); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_password_keeps_existing_secret() { + let mut doc: toml_edit::DocumentMut = + "[calendar]\npassword = \"secret\"\n".parse().unwrap(); + apply_calendar_password(&mut doc, ""); + assert_eq!( + config::get_str(&doc, &["calendar", "password"]).as_deref(), + Some("secret") + ); + apply_calendar_password(&mut doc, "newpass"); + assert_eq!( + config::get_str(&doc, &["calendar", "password"]).as_deref(), + Some("newpass") + ); + } +} diff --git a/src/src/commands/datetime.rs b/src/src/commands/datetime.rs index 2e8660b..5413cc2 100644 --- a/src/src/commands/datetime.rs +++ b/src/src/commands/datetime.rs @@ -26,7 +26,12 @@ async fn list_timezones() -> Vec { .output() .await .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect()) + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .map(str::to_string) + .collect() + }) .unwrap_or_default() } @@ -60,10 +65,32 @@ pub async fn get_datetime_info() -> DateTimeInfo { } } +/// Reject flags, path traversal, and newlines before we ever exec. Charset +/// matches IANA names (`Area/City`, `UTC`, `Etc/GMT+6`). +fn timezone_looks_safe(tz: &str) -> bool { + let tz = tz.trim(); + if tz.is_empty() || tz.len() > 64 || tz.starts_with('-') { + return false; + } + if tz.contains('\n') || tz.contains('\r') || tz.contains('\0') || tz.contains("..") { + return false; + } + tz.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-')) +} + #[tauri::command] pub async fn set_timezone(tz: String) -> Result<(), String> { + let tz = tz.trim(); + if !timezone_looks_safe(tz) { + return Err("invalid timezone".into()); + } + let listed = list_timezones().await; + if !listed.is_empty() && !listed.iter().any(|t| t == tz) { + return Err("unknown timezone".into()); + } let output = Command::new("pkexec") - .args(["timedatectl", "set-timezone", &tz]) + .args(["timedatectl", "set-timezone", tz]) .output() .await .map_err(|e| e.to_string())?; @@ -77,6 +104,28 @@ pub async fn set_timezone(tz: String) -> Result<(), String> { #[tauri::command] pub async fn set_ntp_enabled(enabled: bool) -> Result<(), String> { let val = if enabled { "true" } else { "false" }; - Command::new("pkexec").args(["timedatectl", "set-ntp", val]).status().await.map_err(|e| e.to_string())?; + Command::new("pkexec") + .args(["timedatectl", "set-ntp", val]) + .status() + .await + .map_err(|e| e.to_string())?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn timezone_rejects_flags_and_traversal() { + assert!(timezone_looks_safe("UTC")); + assert!(timezone_looks_safe("America/New_York")); + assert!(timezone_looks_safe("Etc/GMT+6")); + assert!(!timezone_looks_safe("")); + assert!(!timezone_looks_safe("-UTC")); + assert!(!timezone_looks_safe("--help")); + assert!(!timezone_looks_safe("America/../UTC")); + assert!(!timezone_looks_safe("UTC\n--adjust")); + assert!(!timezone_looks_safe("UTC;reboot")); + } +} diff --git a/src/src/commands/firewall.rs b/src/src/commands/firewall.rs index 0841d7b..bfc6471 100644 --- a/src/src/commands/firewall.rs +++ b/src/src/commands/firewall.rs @@ -32,7 +32,9 @@ pub async fn get_firewall_status() -> Result { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(if stderr.is_empty() { match output.status.code() { - Some(127) => "no polkit authentication agent is available in this session".to_string(), + Some(127) => { + "no polkit authentication agent is available in this session".to_string() + } Some(code) => format!("pkexec exited with status {code}"), None => "pkexec was terminated by a signal".to_string(), } @@ -41,7 +43,10 @@ pub async fn get_firewall_status() -> Result { }); } let text = String::from_utf8_lossy(&output.stdout); - let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active"); + let active = text + .lines() + .next() + .is_some_and(|l| l.trim() == "Status: active"); let rules = text .lines() .filter_map(|l| { @@ -51,7 +56,10 @@ pub async fn get_firewall_status() -> Result { } let (num, rest) = l.split_once(']')?; let number = num.trim_start_matches('[').trim().to_string(); - Some(FirewallRule { number, text: rest.trim().to_string() }) + Some(FirewallRule { + number, + text: rest.trim().to_string(), + }) }) .collect(); Ok(FirewallStatus { active, rules }) @@ -60,7 +68,11 @@ pub async fn get_firewall_status() -> Result { #[tauri::command] pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> { let verb = if enabled { "enable" } else { "disable" }; - let output = Command::new("pkexec").args(["ufw", "--force", verb]).output().await.map_err(|e| e.to_string())?; + let output = Command::new("pkexec") + .args(["ufw", "--force", verb]) + .output() + .await + .map_err(|e| e.to_string())?; if output.status.success() { Ok(()) } else { @@ -68,9 +80,54 @@ pub async fn set_firewall_enabled(enabled: bool) -> Result<(), String> { } } +/// Port, optional `/tcp`/`/udp`, or optional space-separated proto. No +/// service names, IPs, or flags — those become extra `ufw allow` operands. +fn valid_firewall_rule(rule: &str) -> bool { + let rule = rule.trim(); + if rule.is_empty() || rule.len() > 16 || rule.starts_with('-') { + return false; + } + if rule.contains('\n') || rule.contains('\r') || rule.contains('\0') { + return false; + } + let (port, proto) = if let Some((p, rest)) = rule.split_once('/') { + (p, Some(rest)) + } else if let Some((p, rest)) = rule.split_once(' ') { + (p, Some(rest.trim())) + } else { + (rule, None) + }; + let Ok(n) = port.parse::() else { + return false; + }; + if n == 0 { + return false; + } + match proto { + None => true, + Some(p) => p == "tcp" || p == "udp", + } +} + +fn valid_rule_number(number: &str) -> bool { + let t = number.trim(); + !t.is_empty() + && t.len() <= 8 + && t.bytes().all(|b| b.is_ascii_digit()) + && t.parse::().is_ok_and(|n| n > 0) +} + #[tauri::command] pub async fn add_firewall_rule(rule: String) -> Result<(), String> { - let output = Command::new("pkexec").args(["ufw", "allow", rule.trim()]).output().await.map_err(|e| e.to_string())?; + let rule = rule.trim(); + if !valid_firewall_rule(rule) { + return Err("invalid firewall rule".into()); + } + let output = Command::new("pkexec") + .args(["ufw", "allow", rule]) + .output() + .await + .map_err(|e| e.to_string())?; if output.status.success() { Ok(()) } else { @@ -80,10 +137,48 @@ pub async fn add_firewall_rule(rule: String) -> Result<(), String> { #[tauri::command] pub async fn remove_firewall_rule(number: String) -> Result<(), String> { - let output = Command::new("pkexec").args(["ufw", "--force", "delete", &number]).output().await.map_err(|e| e.to_string())?; + if !valid_rule_number(&number) { + return Err("invalid rule number".into()); + } + let output = Command::new("pkexec") + .args(["ufw", "--force", "delete", number.trim()]) + .output() + .await + .map_err(|e| e.to_string())?; if output.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn firewall_rule_is_port_and_optional_proto() { + assert!(valid_firewall_rule("22")); + assert!(valid_firewall_rule("8080/tcp")); + assert!(valid_firewall_rule("53/udp")); + assert!(valid_firewall_rule("80 tcp")); + assert!(!valid_firewall_rule("OpenSSH")); + assert!(!valid_firewall_rule("-f")); + assert!(!valid_firewall_rule("22;id")); + assert!(!valid_firewall_rule("22/tcp\nallow 23")); + assert!(!valid_firewall_rule("0")); + assert!(!valid_firewall_rule("65536")); + assert!(!valid_firewall_rule("22/all")); + } + + #[test] + fn firewall_delete_is_positive_int() { + assert!(valid_rule_number("1")); + assert!(valid_rule_number("12")); + assert!(!valid_rule_number("0")); + assert!(!valid_rule_number("-1")); + assert!(!valid_rule_number("1;2")); + assert!(!valid_rule_number("1\n2")); + assert!(!valid_rule_number("")); + } +} diff --git a/src/src/commands/power.rs b/src/src/commands/power.rs index 3d82def..d1d6820 100644 --- a/src/src/commands/power.rs +++ b/src/src/commands/power.rs @@ -7,13 +7,22 @@ use serde::Serialize; use tokio::process::Command; +use super::util; + async fn upower_device(kind: &str) -> Option { let out = Command::new("upower").arg("-e").output().await.ok()?; - String::from_utf8_lossy(&out.stdout).lines().find(|l| l.to_lowercase().contains(kind)).map(str::to_string) + String::from_utf8_lossy(&out.stdout) + .lines() + .find(|l| l.to_lowercase().contains(kind)) + .map(str::to_string) } async fn upower_field(device: &str, field: &str) -> Option { - let out = Command::new("upower").args(["-i", device]).output().await.ok()?; + let out = Command::new("upower") + .args(["-i", device]) + .output() + .await + .ok()?; let text = String::from_utf8_lossy(&out.stdout); text.lines() .find(|l| l.trim_start().starts_with(field)) @@ -39,12 +48,18 @@ async fn battery_summary() -> Vec<(String, String)> { if let Some(t) = t { rows.push(("Time remaining".to_string(), t)); } - let full: Option = upower_field(&bat, "energy-full").await.and_then(|v| v.split_whitespace().next()?.parse().ok()); - let design: Option = - upower_field(&bat, "energy-full-design").await.and_then(|v| v.split_whitespace().next()?.parse().ok()); + let full: Option = upower_field(&bat, "energy-full") + .await + .and_then(|v| v.split_whitespace().next()?.parse().ok()); + let design: Option = upower_field(&bat, "energy-full-design") + .await + .and_then(|v| v.split_whitespace().next()?.parse().ok()); if let (Some(full), Some(design)) = (full, design) { if design > 0.0 { - rows.push(("Battery health".to_string(), format!("{:.0}% of design capacity", full / design * 100.0))); + rows.push(( + "Battery health".to_string(), + format!("{:.0}% of design capacity", full / design * 100.0), + )); } } rows @@ -64,12 +79,19 @@ async fn power_source() -> String { async fn tlp_profile() -> Option { let out = Command::new("tlp-stat").arg("-s").output().await.ok()?; let text = String::from_utf8_lossy(&out.stdout); - text.lines().find(|l| l.trim_start().starts_with("TLP profile")).and_then(|l| l.split('=').nth(1)).map(|v| v.trim().to_string()) + text.lines() + .find(|l| l.trim_start().starts_with("TLP profile")) + .and_then(|l| l.split('=').nth(1)) + .map(|v| v.trim().to_string()) } async fn brightness_device() -> Option { let out = Command::new("brightnessctl").output().await.ok()?; - String::from_utf8_lossy(&out.stdout).lines().find(|l| l.starts_with("Device")).and_then(|l| l.split('\'').nth(1)).map(str::to_string) + String::from_utf8_lossy(&out.stdout) + .lines() + .find(|l| l.starts_with("Device")) + .and_then(|l| l.split('\'').nth(1)) + .map(str::to_string) } async fn brightness_pct() -> Option { @@ -98,7 +120,10 @@ fn charge_threshold_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)> } fn read_threshold(path: &std::path::Path) -> i64 { - std::fs::read_to_string(path).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(100) + std::fs::read_to_string(path) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(100) } #[derive(Serialize)] @@ -130,16 +155,57 @@ pub async fn set_brightness(percent: i64) -> Result<(), String> { return Err("No controllable backlight found".into()); }; let pct = format!("{percent}%"); - Command::new("brightnessctl").args(["--device", &device, "set", &pct]).status().await.map_err(|e| e.to_string())?; + Command::new("brightnessctl") + .args(["--device", &device, "set", &pct]) + .status() + .await + .map_err(|e| e.to_string())?; Ok(()) } +fn charge_threshold_write(which: &str, percent: i64) -> Result<(String, i64), String> { + if which != "start" && which != "end" { + return Err("threshold must be start or end".into()); + } + Ok((which.to_string(), percent.clamp(0, 100))) +} + #[tauri::command] pub async fn set_charge_threshold(which: String, percent: i64) -> Result<(), String> { + let (which, percent) = charge_threshold_write(&which, percent)?; let Some((start, end)) = charge_threshold_paths() else { return Err("No charge threshold support on this hardware".into()); }; let path = if which == "start" { start } else { end }; - Command::new("pkexec").args(["tee", &path.display().to_string()]).arg(percent.to_string()).output().await.map_err(|e| e.to_string())?; - Ok(()) + // GNU tee writes stdin to its path operands — the percent must be piped, + // not passed as a second path argument. + let input = format!("{percent}\n"); + if util::run_with_stdin(&["pkexec", "tee", &path.display().to_string()], &input).await { + Ok(()) + } else { + Err("Failed to set charge threshold".into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn charge_threshold_clamps_and_restricts_which() { + assert_eq!( + charge_threshold_write("start", 80).unwrap(), + ("start".into(), 80) + ); + assert_eq!( + charge_threshold_write("end", 150).unwrap(), + ("end".into(), 100) + ); + assert_eq!( + charge_threshold_write("start", -5).unwrap(), + ("start".into(), 0) + ); + assert!(charge_threshold_write("both", 50).is_err()); + assert!(charge_threshold_write("-start", 50).is_err()); + } } diff --git a/src/src/commands/service.rs b/src/src/commands/service.rs index c99fee4..17a1540 100644 --- a/src/src/commands/service.rs +++ b/src/src/commands/service.rs @@ -21,6 +21,19 @@ pub enum ServiceAction { Restart, } +/// Units the frontend already hardcodes in ServiceControl call sites. +const ALLOWED_UNITS: &[&str] = &[ + "breadd.service", + "breadclipd.service", + "breadcrumbs.service", + "breadmill.service", + "breadbox-sync.service", +]; + +fn allowed_unit(unit: &str) -> bool { + ALLOWED_UNITS.contains(&unit) +} + async fn systemctl_active(unit: &str) -> bool { Command::new("systemctl") .args(["--user", "is-active", "--quiet", unit]) @@ -40,15 +53,21 @@ async fn systemctl_enabled(unit: &str) -> bool { } #[tauri::command] -pub async fn get_service_status(unit: String) -> ServiceStatus { - ServiceStatus { +pub async fn get_service_status(unit: String) -> Result { + if !allowed_unit(&unit) { + return Err("unknown service".into()); + } + Ok(ServiceStatus { active: systemctl_active(&unit).await, enabled: systemctl_enabled(&unit).await, - } + }) } #[tauri::command] pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), String> { + if !allowed_unit(&unit) { + return Err("unknown service".into()); + } let verb = match action { ServiceAction::Start => "start", ServiceAction::Stop => "stop", @@ -70,8 +89,31 @@ pub async fn service_action(unit: String, action: ServiceAction) -> Result<(), S /// panel, no reason to pull an open-ended `journalctl -f` tail into the /// webview. #[tauri::command] -pub fn open_logs(unit: String) { - let _ = std::process::Command::new("kitty") +pub fn open_logs(unit: String) -> Result<(), String> { + if !allowed_unit(&unit) { + return Err("unknown service".into()); + } + std::process::Command::new("kitty") .args(["-e", "journalctl", "--user", "-u", &unit, "-f"]) - .spawn(); + .spawn() + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn units_match_frontend_hardcoded_list() { + assert!(allowed_unit("breadd.service")); + assert!(allowed_unit("breadclipd.service")); + assert!(allowed_unit("breadcrumbs.service")); + assert!(allowed_unit("breadmill.service")); + assert!(allowed_unit("breadbox-sync.service")); + assert!(!allowed_unit("sshd.service")); + assert!(!allowed_unit("breadd.service;reboot")); + assert!(!allowed_unit("../sshd.service")); + assert!(!allowed_unit("-u sshd")); + } } diff --git a/src/src/commands/users.rs b/src/src/commands/users.rs index d26ff59..8b0db82 100644 --- a/src/src/commands/users.rs +++ b/src/src/commands/users.rs @@ -3,9 +3,10 @@ //! `pkexec`. use serde::Serialize; -use tokio::io::AsyncWriteExt; use tokio::process::Command; +use super::util; + #[derive(Serialize, Clone)] pub struct Account { username: String, @@ -26,10 +27,16 @@ fn list_accounts() -> Vec { let shell = f[6]; // Real human accounts: normal UID range, a real login shell // (excludes system/service accounts like greeter, avahi, etc). - if !(1000..60000).contains(&uid) || shell.ends_with("nologin") || shell.ends_with("/false") { + if !(1000..60000).contains(&uid) + || shell.ends_with("nologin") + || shell.ends_with("/false") + { return None; } - Some(Account { username: f[0].to_string(), full_name: f[4].split(',').next().unwrap_or("").to_string() }) + Some(Account { + username: f[0].to_string(), + full_name: f[4].split(',').next().unwrap_or("").to_string(), + }) }) .collect() } @@ -42,29 +49,69 @@ pub struct UsersInfo { #[tauri::command] pub fn get_users_info() -> UsersInfo { - UsersInfo { accounts: list_accounts(), current_user: std::env::var("USER").unwrap_or_default() } + UsersInfo { + accounts: list_accounts(), + current_user: std::env::var("USER").unwrap_or_default(), + } } -/// Runs a root command that needs a line of input on stdin (chpasswd's own -/// "user:password" format). `pkexec` inherits the spawning process's stdin -/// only when explicitly piped, so this pipes it through. -async fn run_with_stdin(args: &[&str], input: String) -> bool { - let Ok(mut child) = Command::new(args[0]).args(&args[1..]).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() - else { +/// shadow-utils `USER_NAME_MAX` is 32; keep chpasswd/useradd operands inside it. +const USERNAME_MAX: usize = 32; + +/// `[a-z_][a-z0-9_-]*`, length-capped, no leading `-`. Also rejects `:`, +/// newlines, and other chpasswd field/line separators. +fn valid_username(name: &str) -> bool { + let bytes = name.as_bytes(); + if bytes.is_empty() || bytes.len() > USERNAME_MAX { return false; - }; - if let Some(mut stdin) = child.stdin.take() { - if stdin.write_all(input.as_bytes()).await.is_err() { - return false; - } } - child.wait().await.map(|s| s.success()).unwrap_or(false) + let first = bytes[0]; + if first != b'_' && !first.is_ascii_lowercase() { + return false; + } + bytes[1..] + .iter() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(*b, b'_' | b'-')) +} + +/// chpasswd reads `user:password` lines — a `:`, `\n`, or `\r` in either +/// field injects extra passwd entries or shifts columns. +fn valid_chpasswd_password(password: &str) -> bool { + !password.is_empty() + && password.len() <= 512 + && !password.contains('\n') + && !password.contains('\r') + && !password.contains(':') + && !password.contains('\0') +} + +fn chpasswd_input(username: &str, password: &str) -> Result { + if !valid_username(username) { + return Err("invalid username".into()); + } + if !valid_chpasswd_password(password) { + return Err("invalid password".into()); + } + Ok(format!("{username}:{password}\n")) +} + +fn may_delete_user(username: &str, current: &str) -> Result<(), String> { + if !valid_username(username) { + return Err("invalid username".into()); + } + if username == "root" { + return Err("refusing to remove root".into()); + } + if !current.is_empty() && username == current { + return Err("refusing to remove the current user".into()); + } + Ok(()) } #[tauri::command] pub async fn change_password(username: String, password: String) -> Result<(), String> { - let input = format!("{username}:{password}\n"); - if run_with_stdin(&["pkexec", "chpasswd"], input).await { + let input = chpasswd_input(&username, &password)?; + if util::run_with_stdin(&["pkexec", "chpasswd"], &input).await { Ok(()) } else { Err("Failed to change password".into()) @@ -73,7 +120,13 @@ pub async fn change_password(username: String, password: String) -> Result<(), S #[tauri::command] pub async fn remove_user(username: String) -> Result<(), String> { - let output = Command::new("pkexec").args(["userdel", "-r", &username]).output().await.map_err(|e| e.to_string())?; + let current = std::env::var("USER").unwrap_or_default(); + may_delete_user(&username, ¤t)?; + let output = Command::new("pkexec") + .args(["userdel", "-r", &username]) + .output() + .await + .map_err(|e| e.to_string())?; if output.status.success() { Ok(()) } else { @@ -83,22 +136,68 @@ pub async fn remove_user(username: String) -> Result<(), String> { #[tauri::command] pub async fn add_user(username: String, full_name: String, password: String) -> Result<(), String> { - let username = username.trim().to_string(); - let mut useradd_args = vec!["pkexec".to_string(), "useradd".to_string(), "-m".to_string(), "-s".to_string(), "/bin/bash".to_string()]; + let username = username.trim(); + let input = chpasswd_input(username, &password)?; + let mut useradd_args = vec![ + "pkexec".to_string(), + "useradd".to_string(), + "-m".to_string(), + "-s".to_string(), + "/bin/bash".to_string(), + ]; if !full_name.trim().is_empty() { useradd_args.push("-c".to_string()); useradd_args.push(full_name.trim().to_string()); } - useradd_args.push(username.clone()); + useradd_args.push(username.to_string()); let args_ref: Vec<&str> = useradd_args.iter().map(String::as_str).collect(); - let output = Command::new(args_ref[0]).args(&args_ref[1..]).output().await.map_err(|e| e.to_string())?; + let output = Command::new(args_ref[0]) + .args(&args_ref[1..]) + .output() + .await + .map_err(|e| e.to_string())?; if !output.status.success() { return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); } - let input = format!("{username}:{password}\n"); - if run_with_stdin(&["pkexec", "chpasswd"], input).await { + if util::run_with_stdin(&["pkexec", "chpasswd"], &input).await { Ok(()) } else { Err("User created, but setting the password failed.".into()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chpasswd_rejects_newline_injection() { + assert!(chpasswd_input("alice", "pw\nroot:evil").is_err()); + assert!(chpasswd_input("alice\nroot", "pw").is_err()); + assert!(chpasswd_input("alice\rroot", "pw").is_err()); + assert!(chpasswd_input("alice", "pw\rroot:x").is_err()); + assert!(chpasswd_input("al:ice", "pw").is_err()); + assert!(chpasswd_input("alice", "p:w").is_err()); + assert_eq!(chpasswd_input("alice", "secret").unwrap(), "alice:secret\n"); + } + + #[test] + fn username_grammar() { + assert!(valid_username("alice")); + assert!(valid_username("_svc")); + assert!(valid_username("a1-b_c")); + assert!(!valid_username("")); + assert!(!valid_username("-alice")); + assert!(!valid_username("Alice")); + assert!(!valid_username("root user")); + assert!(!valid_username(&"a".repeat(USERNAME_MAX + 1))); + } + + #[test] + fn remove_user_refuses_root_and_self() { + assert!(may_delete_user("root", "alice").is_err()); + assert!(may_delete_user("alice", "alice").is_err()); + assert!(may_delete_user("root\n", "alice").is_err()); + assert!(may_delete_user("bob", "alice").is_ok()); + } +} diff --git a/src/src/commands/util.rs b/src/src/commands/util.rs index 1acb283..d7a40d6 100644 --- a/src/src/commands/util.rs +++ b/src/src/commands/util.rs @@ -90,6 +90,30 @@ pub fn bos_settings_dir() -> PathBuf { config::config_dir().join("bos-settings") } +/// Pipe `input` to a command's stdin (`pkexec` does not inherit a piped +/// stdin unless we set it). Used by chpasswd and `pkexec tee`. +pub async fn run_with_stdin(args: &[&str], input: &str) -> bool { + if args.is_empty() { + return false; + } + let Ok(mut child) = tokio::process::Command::new(args[0]) + .args(&args[1..]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + else { + return false; + }; + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + if stdin.write_all(input.as_bytes()).await.is_err() { + return false; + } + } + child.wait().await.map(|s| s.success()).unwrap_or(false) +} + /// Atomic write with mode 0600 set on the new inode before/after replace, /// matching breadcrumbs' `networks.toml` care. pub fn write_secure(path: &Path, contents: &str) -> Result<(), String> { From dce203174334ef8e0b368f5b0da3c377d74786e0 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 30 Aug 2026 18:37:21 +0800 Subject: [PATCH 29/31] 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. --- frontend/package-lock.json | 18 + frontend/package.json | 3 +- frontend/src/lib/components/Embed.svelte | 10 + frontend/src/lib/components/EmptyState.svelte | 4 +- frontend/src/lib/components/Group.svelte | 22 +- frontend/src/lib/components/Hint.svelte | 10 +- frontend/src/lib/components/Hub.svelte | 51 ++ .../src/lib/components/HyprColorField.svelte | 2 +- .../src/lib/components/NumberField.svelte | 4 +- frontend/src/lib/components/Row.svelte | 46 +- frontend/src/lib/components/SaveButton.svelte | 23 +- .../src/lib/components/SelectField.svelte | 22 +- .../src/lib/components/ServiceControl.svelte | 12 +- frontend/src/lib/components/Sidebar.svelte | 144 ++++-- .../src/lib/components/SliderField.svelte | 44 ++ frontend/src/lib/components/Subnav.svelte | 40 ++ frontend/src/lib/components/Switch.svelte | 27 +- .../src/lib/components/SwitchField.svelte | 8 +- frontend/src/lib/components/TextField.svelte | 4 +- frontend/src/lib/components/Titlebar.svelte | 75 +++ .../src/lib/components/ViewScaffold.svelte | 70 ++- frontend/src/lib/debounce.ts | 7 + frontend/src/lib/embed.ts | 2 + frontend/src/lib/nav.svelte.ts | 17 + frontend/src/lib/nav.ts | 3 - frontend/src/lib/search.ts | 120 +++++ frontend/src/lib/sidebar.ts | 183 ++++---- frontend/src/lib/streaming.ts | 2 +- frontend/src/lib/styles/app.css | 121 +++++ frontend/src/lib/theme/index.ts | 2 +- frontend/src/lib/views/About.svelte | 2 +- frontend/src/lib/views/AboutHub.svelte | 14 + frontend/src/lib/views/Accessibility.svelte | 2 +- frontend/src/lib/views/Appearance.svelte | 152 +++--- frontend/src/lib/views/AppearanceHub.svelte | 14 + frontend/src/lib/views/AppsHub.svelte | 16 + frontend/src/lib/views/Aur.svelte | 2 +- frontend/src/lib/views/Autostart.svelte | 2 +- frontend/src/lib/views/Backup.svelte | 6 +- frontend/src/lib/views/Bluetooth.svelte | 19 +- frontend/src/lib/views/Bread.svelte | 2 +- frontend/src/lib/views/Breadbar.svelte | 80 ++-- frontend/src/lib/views/Breadbox.svelte | 2 +- frontend/src/lib/views/Breadclip.svelte | 2 +- frontend/src/lib/views/Breadcrumbs.svelte | 6 +- frontend/src/lib/views/Breadhelp.svelte | 6 +- frontend/src/lib/views/Breadlock.svelte | 15 +- frontend/src/lib/views/Breadmon.svelte | 9 +- frontend/src/lib/views/Breadpad.svelte | 2 +- frontend/src/lib/views/Breadpaper.svelte | 170 +++---- frontend/src/lib/views/Breadsearch.svelte | 4 +- frontend/src/lib/views/Breadshot.svelte | 4 +- frontend/src/lib/views/Channel.svelte | 2 +- frontend/src/lib/views/Defaults.svelte | 53 +-- frontend/src/lib/views/DesktopHub.svelte | 22 + frontend/src/lib/views/DesktopMore.svelte | 29 ++ frontend/src/lib/views/Display.svelte | 434 +++++++++++++----- frontend/src/lib/views/DisplaysHub.svelte | 16 + frontend/src/lib/views/Firewall.svelte | 26 +- frontend/src/lib/views/Firmware.svelte | 2 +- frontend/src/lib/views/Home.svelte | 315 +++++++++++++ frontend/src/lib/views/InputHub.svelte | 16 + frontend/src/lib/views/InputMethod.svelte | 4 +- frontend/src/lib/views/Keybinds.svelte | 255 +++++++--- frontend/src/lib/views/Network.svelte | 234 +++++----- frontend/src/lib/views/NetworkHub.svelte | 16 + frontend/src/lib/views/NightLight.svelte | 110 +---- frontend/src/lib/views/Optional.svelte | 2 +- frontend/src/lib/views/Packages.svelte | 2 +- frontend/src/lib/views/Power.svelte | 28 +- frontend/src/lib/views/PrivacyHub.svelte | 14 + frontend/src/lib/views/Snapshots.svelte | 6 +- frontend/src/lib/views/Sound.svelte | 143 ++++-- frontend/src/lib/views/SystemHub.svelte | 24 + frontend/src/lib/views/Updates.svelte | 13 +- frontend/src/lib/views/Users.svelte | 2 +- frontend/src/lib/views/Vpn.svelte | 2 +- frontend/src/lib/views/registry.ts | 101 +--- frontend/src/routes/+page.svelte | 77 ++-- frontend/vite.config.js | 1 - src/capabilities/default.json | 4 + src/src/commands/appearance.rs | 4 +- src/src/commands/backup.rs | 2 +- src/src/commands/firewall.rs | 2 + src/src/commands/hyprland.rs | 130 +++++- src/src/commands/keybinds.rs | 4 +- src/src/commands/network.rs | 5 + src/src/commands/nvidia.rs | 2 +- src/src/commands/power.rs | 3 +- src/src/commands/snapshots.rs | 5 + src/src/commands/streaming.rs | 6 + src/src/commands/theme.rs | 4 +- src/src/commands/users.rs | 19 +- src/src/commands/util.rs | 81 ++++ src/src/lib.rs | 1 + src/src/screenshot.rs | 7 + src/tauri.conf.json | 10 +- 97 files changed, 2723 insertions(+), 1142 deletions(-) create mode 100644 frontend/src/lib/components/Embed.svelte create mode 100644 frontend/src/lib/components/Hub.svelte create mode 100644 frontend/src/lib/components/SliderField.svelte create mode 100644 frontend/src/lib/components/Subnav.svelte create mode 100644 frontend/src/lib/components/Titlebar.svelte create mode 100644 frontend/src/lib/debounce.ts create mode 100644 frontend/src/lib/embed.ts create mode 100644 frontend/src/lib/nav.svelte.ts delete mode 100644 frontend/src/lib/nav.ts create mode 100644 frontend/src/lib/search.ts create mode 100644 frontend/src/lib/styles/app.css create mode 100644 frontend/src/lib/views/AboutHub.svelte create mode 100644 frontend/src/lib/views/AppearanceHub.svelte create mode 100644 frontend/src/lib/views/AppsHub.svelte create mode 100644 frontend/src/lib/views/DesktopHub.svelte create mode 100644 frontend/src/lib/views/DesktopMore.svelte create mode 100644 frontend/src/lib/views/DisplaysHub.svelte create mode 100644 frontend/src/lib/views/Home.svelte create mode 100644 frontend/src/lib/views/InputHub.svelte create mode 100644 frontend/src/lib/views/NetworkHub.svelte create mode 100644 frontend/src/lib/views/PrivacyHub.svelte create mode 100644 frontend/src/lib/views/SystemHub.svelte diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6f1479c..03950c8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@sveltejs/kit": "^2.9.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tauri-apps/cli": "^2", + "@types/node": "^26.4.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "~5.6.2", @@ -1308,6 +1309,16 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1837,6 +1848,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 08d2563..62b0eb1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "bos-settings-frontend", - "version": "0.8.0", + "version": "0.8.2", "description": "Frontend for BOS Settings", "type": "module", "scripts": { @@ -23,6 +23,7 @@ "@sveltejs/kit": "^2.9.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tauri-apps/cli": "^2", + "@types/node": "^26.4.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "~5.6.2", diff --git a/frontend/src/lib/components/Embed.svelte b/frontend/src/lib/components/Embed.svelte new file mode 100644 index 0000000..40090e5 --- /dev/null +++ b/frontend/src/lib/components/Embed.svelte @@ -0,0 +1,10 @@ + + +{@render children()} diff --git a/frontend/src/lib/components/EmptyState.svelte b/frontend/src/lib/components/EmptyState.svelte index 357d48f..aba4339 100644 --- a/frontend/src/lib/components/EmptyState.svelte +++ b/frontend/src/lib/components/EmptyState.svelte @@ -16,8 +16,8 @@ flex-direction: column; align-items: center; gap: 6px; - padding: var(--space-xl, 20px) 0; - opacity: 0.7; + padding: 28px 8px; + color: var(--muted); text-align: center; } diff --git a/frontend/src/lib/components/Group.svelte b/frontend/src/lib/components/Group.svelte index 5a6caae..fd11cb0 100644 --- a/frontend/src/lib/components/Group.svelte +++ b/frontend/src/lib/components/Group.svelte @@ -22,33 +22,35 @@ display: flex; flex-direction: column; min-width: 0; + background: var(--surface); + border: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent)); + border-radius: var(--radius, 14px); + padding: 16px 18px; } .group.wide { - grid-column: 1 / -1; + width: 100%; } .title { font-weight: 600; - font-size: 1.05em; - margin: 0 0 var(--space-sm, 8px); + font-size: 13px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent)); + margin: 0 0 12px; } .hint { - opacity: 0.75; + color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent)); font-size: var(--font-size-secondary, 12px); line-height: 1.4; - margin: 0 0 var(--space-sm, 8px); + margin: -6px 0 12px; } .rows { display: flex; flex-direction: column; - /* Flex children default to refusing to shrink below their content's - natural width (min-width: auto) — without this, a button/input - with enough text overflows past the grid column's actual pixel - width instead of wrapping, visually spilling into the next - column. */ min-width: 0; } diff --git a/frontend/src/lib/components/Hint.svelte b/frontend/src/lib/components/Hint.svelte index 450e2ad..e86d531 100644 --- a/frontend/src/lib/components/Hint.svelte +++ b/frontend/src/lib/components/Hint.svelte @@ -6,13 +6,9 @@ diff --git a/frontend/src/lib/components/Hub.svelte b/frontend/src/lib/components/Hub.svelte new file mode 100644 index 0000000..dd404a0 --- /dev/null +++ b/frontend/src/lib/components/Hub.svelte @@ -0,0 +1,51 @@ + + + + {#if tabs.length > 1} + ({ id, label }))} bind:value={tab} /> + {/if} + + {#key tab} + {#if Active} + + {/if} + {/key} + + diff --git a/frontend/src/lib/components/HyprColorField.svelte b/frontend/src/lib/components/HyprColorField.svelte index 115d4d7..cc9f430 100644 --- a/frontend/src/lib/components/HyprColorField.svelte +++ b/frontend/src/lib/components/HyprColorField.svelte @@ -1,7 +1,7 @@
- {label} +
+ {label} + {#if hint}{hint}{/if} +
{@render children()}
diff --git a/frontend/src/lib/components/SaveButton.svelte b/frontend/src/lib/components/SaveButton.svelte index 5dcd916..faadab1 100644 --- a/frontend/src/lib/components/SaveButton.svelte +++ b/frontend/src/lib/components/SaveButton.svelte @@ -19,7 +19,7 @@
- + {status}
@@ -28,28 +28,11 @@ display: flex; align-items: center; gap: var(--space-md, 12px); - margin-top: var(--space-lg, 16px); - /* Always spans the full grid width, regardless of how many - Group columns the rest of the page laid out above it. */ - grid-column: 1 / -1; - } - - button { - background-color: var(--accent); - color: var(--on-accent); - border: none; - border-radius: var(--radius-primary, 8px); - padding: var(--space-sm, 8px) var(--space-lg, 16px); - cursor: pointer; - } - - button:disabled { - opacity: 0.5; - cursor: default; + margin-top: 4px; } .status { - opacity: 0.6; + color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent)); font-size: var(--font-size-secondary, 12px); } diff --git a/frontend/src/lib/components/SelectField.svelte b/frontend/src/lib/components/SelectField.svelte index 2dc50c6..feba79e 100644 --- a/frontend/src/lib/components/SelectField.svelte +++ b/frontend/src/lib/components/SelectField.svelte @@ -1,13 +1,25 @@ - + @@ -18,8 +30,8 @@ background-color: var(--bg); color: var(--on-surface); border: 1px solid transparent; - border-radius: var(--radius-secondary, 6px); - padding: var(--space-xs, 4px) var(--space-sm, 8px); + border-radius: var(--radius-sm, 10px); + padding: 6px 10px; } option { diff --git a/frontend/src/lib/components/ServiceControl.svelte b/frontend/src/lib/components/ServiceControl.svelte index 1c81d29..9f3e8c0 100644 --- a/frontend/src/lib/components/ServiceControl.svelte +++ b/frontend/src/lib/components/ServiceControl.svelte @@ -30,7 +30,7 @@ function toggle() { if (active && critical) { - if (!confirm(`Stop ${unit}? This is a core part of the desktop's event handling — stopping it may affect other bread apps until it's restarted.`)) { + if (!confirm(`Stop ${unit}? Other bread apps may stall until it is restarted.`)) { return; } } @@ -57,7 +57,7 @@ {#if hasConfig} - + {/if}
@@ -74,16 +74,16 @@ } button { - background-color: var(--surface); + background-color: var(--surface-2, var(--bg)); color: var(--on-surface); - border: none; - border-radius: var(--radius-primary, 8px); + border: 1px solid var(--line, transparent); + border-radius: var(--radius-sm, 10px); padding: var(--space-sm, 8px) var(--space-lg, 16px); cursor: pointer; } button:hover { - background-color: color-mix(in srgb, var(--on-surface) 14%, transparent); + background-color: color-mix(in srgb, var(--on-surface) 10%, transparent); } button:disabled { diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 95fb43e..d7c67e8 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -1,59 +1,130 @@ diff --git a/frontend/src/lib/components/SliderField.svelte b/frontend/src/lib/components/SliderField.svelte new file mode 100644 index 0000000..e3ee9a9 --- /dev/null +++ b/frontend/src/lib/components/SliderField.svelte @@ -0,0 +1,44 @@ + + + + onChange?.()} /> + {shown}{suffix} + + + diff --git a/frontend/src/lib/components/Subnav.svelte b/frontend/src/lib/components/Subnav.svelte new file mode 100644 index 0000000..acc782b --- /dev/null +++ b/frontend/src/lib/components/Subnav.svelte @@ -0,0 +1,40 @@ + + + + + diff --git a/frontend/src/lib/components/Switch.svelte b/frontend/src/lib/components/Switch.svelte index e2504d3..c62300d 100644 --- a/frontend/src/lib/components/Switch.svelte +++ b/frontend/src/lib/components/Switch.svelte @@ -11,33 +11,40 @@ aria-label={ariaLabel} onclick={() => (value = !value)} > - + diff --git a/frontend/src/lib/components/SwitchField.svelte b/frontend/src/lib/components/SwitchField.svelte index a49caaf..a8e2b19 100644 --- a/frontend/src/lib/components/SwitchField.svelte +++ b/frontend/src/lib/components/SwitchField.svelte @@ -2,9 +2,13 @@ import Row from "./Row.svelte"; import Switch from "./Switch.svelte"; - let { label, value = $bindable() }: { label: string; value: boolean } = $props(); + let { + label, + hint, + value = $bindable(), + }: { label: string; hint?: string; value: boolean } = $props(); - + diff --git a/frontend/src/lib/components/TextField.svelte b/frontend/src/lib/components/TextField.svelte index 62fa918..1711e56 100644 --- a/frontend/src/lib/components/TextField.svelte +++ b/frontend/src/lib/components/TextField.svelte @@ -18,8 +18,8 @@ background-color: var(--bg); color: var(--on-surface); border: 1px solid transparent; - border-radius: var(--radius-secondary, 6px); - padding: var(--space-xs, 4px) var(--space-sm, 8px); + border-radius: var(--radius-sm, 10px); + padding: 6px 10px; } input:focus { diff --git a/frontend/src/lib/components/Titlebar.svelte b/frontend/src/lib/components/Titlebar.svelte new file mode 100644 index 0000000..78fe93a --- /dev/null +++ b/frontend/src/lib/components/Titlebar.svelte @@ -0,0 +1,75 @@ + + +
+
+ + + +
+
Settings
+
+ Ctrl K +
+ + diff --git a/frontend/src/lib/components/ViewScaffold.svelte b/frontend/src/lib/components/ViewScaffold.svelte index bb27e82..1eef417 100644 --- a/frontend/src/lib/components/ViewScaffold.svelte +++ b/frontend/src/lib/components/ViewScaffold.svelte @@ -1,40 +1,64 @@ -
-

{title}

-
+{#if embedded} +
{@render children()}
-
+{:else} +
+

{title}

+ {#if lede} +

{lede}

+ {/if} +
+ {@render children()} +
+
+{/if} diff --git a/frontend/src/lib/debounce.ts b/frontend/src/lib/debounce.ts new file mode 100644 index 0000000..463b59b --- /dev/null +++ b/frontend/src/lib/debounce.ts @@ -0,0 +1,7 @@ +export function debounce(fn: () => void, ms = 280): () => void { + let t: ReturnType | undefined; + return () => { + clearTimeout(t); + t = setTimeout(fn, ms); + }; +} diff --git a/frontend/src/lib/embed.ts b/frontend/src/lib/embed.ts new file mode 100644 index 0000000..86ff3ab --- /dev/null +++ b/frontend/src/lib/embed.ts @@ -0,0 +1,2 @@ +/** When set, ViewScaffold renders inner groups only - used by hub tab panes. */ +export const EMBEDDED_KEY = "bos-settings-embedded"; diff --git a/frontend/src/lib/nav.svelte.ts b/frontend/src/lib/nav.svelte.ts new file mode 100644 index 0000000..b7382c9 --- /dev/null +++ b/frontend/src/lib/nav.svelte.ts @@ -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"; diff --git a/frontend/src/lib/nav.ts b/frontend/src/lib/nav.ts deleted file mode 100644 index bced4f0..0000000 --- a/frontend/src/lib/nav.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** Sidebar page switch. Set from +page.svelte; views call it to jump. */ -export type Navigate = (page: string) => void; -export const NAVIGATE_KEY = "bos-settings-navigate"; diff --git a/frontend/src/lib/search.ts b/frontend/src/lib/search.ts new file mode 100644 index 0000000..2b5f4e6 --- /dev/null +++ b/frontend/src/lib/search.ts @@ -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); +} diff --git a/frontend/src/lib/sidebar.ts b/frontend/src/lib/sidebar.ts index c9f6740..f7d9d50 100644 --- a/frontend/src/lib/sidebar.ts +++ b/frontend/src/lib/sidebar.ts @@ -1,117 +1,112 @@ -// Ports src/ui/sidebar.rs's declarative item lists verbatim. Grouped by task, -// not "app vs system internals" — a user thinks "I want to change my Wi-Fi", -// not "which of these is a bread-ecosystem app" (why breadcrumbs/Wi-Fi -// Profiles lives in System, not Personalization). - import type { Component } from "svelte"; +import House from "@lucide/svelte/icons/house"; import Wifi from "@lucide/svelte/icons/wifi"; -import Network from "@lucide/svelte/icons/network"; import Bluetooth from "@lucide/svelte/icons/bluetooth"; -import Shield from "@lucide/svelte/icons/shield"; +import Monitor from "@lucide/svelte/icons/monitor"; import Volume2 from "@lucide/svelte/icons/volume-2"; import BatteryFull from "@lucide/svelte/icons/battery-full"; -import Clock from "@lucide/svelte/icons/clock"; -import Monitor from "@lucide/svelte/icons/monitor"; -import Keyboard from "@lucide/svelte/icons/keyboard"; -import Rocket from "@lucide/svelte/icons/rocket"; -import Users from "@lucide/svelte/icons/users"; import Palette from "@lucide/svelte/icons/palette"; -import Image from "@lucide/svelte/icons/image"; -import LayoutGrid from "@lucide/svelte/icons/layout-grid"; -import Grid3x3 from "@lucide/svelte/icons/grid-3x3"; -import Clipboard from "@lucide/svelte/icons/clipboard"; -import NotebookPen from "@lucide/svelte/icons/notebook-pen"; -import Search from "@lucide/svelte/icons/search"; -import Cog from "@lucide/svelte/icons/cog"; -import Package from "@lucide/svelte/icons/package"; -import RefreshCw from "@lucide/svelte/icons/refresh-cw"; -import History from "@lucide/svelte/icons/history"; -import Info from "@lucide/svelte/icons/info"; -import Lock from "@lucide/svelte/icons/lock"; -import Camera from "@lucide/svelte/icons/camera"; import AppWindow from "@lucide/svelte/icons/app-window"; -import CircleHelp from "@lucide/svelte/icons/circle-help"; +import Keyboard from "@lucide/svelte/icons/keyboard"; +import LayoutGrid from "@lucide/svelte/icons/layout-grid"; +import Shield from "@lucide/svelte/icons/shield"; import Download from "@lucide/svelte/icons/download"; -import Printer from "@lucide/svelte/icons/printer"; -import ShieldEllipsis from "@lucide/svelte/icons/shield-ellipsis"; -import Moon from "@lucide/svelte/icons/moon"; -import Languages from "@lucide/svelte/icons/languages"; -import Accessibility from "@lucide/svelte/icons/accessibility"; -import AppWindowMac from "@lucide/svelte/icons/app-window-mac"; -import GitBranch from "@lucide/svelte/icons/git-branch"; -import Archive from "@lucide/svelte/icons/archive"; -import Boxes from "@lucide/svelte/icons/boxes"; +import Info from "@lucide/svelte/icons/info"; export interface SidebarItem { - /** Must match a key in the view component map (see routing in +page.svelte). */ id: string; label: string; - /** Dim second line — the underlying binary/config name, for items whose - * human label doesn't already make that obvious. */ - sublabel?: string; icon: Component; } -export const SYSTEM_ITEMS: SidebarItem[] = [ - { id: "network", label: "Network", icon: Wifi }, - { id: "breadcrumbs", label: "Wi-Fi Profiles", sublabel: "breadcrumbs", icon: Network }, - { id: "vpn", label: "VPN / WireGuard", sublabel: "NetworkManager", icon: ShieldEllipsis }, - { id: "bluetooth", label: "Bluetooth", icon: Bluetooth }, - { id: "printing", label: "Printing", sublabel: "CUPS", icon: Printer }, - { id: "firewall", label: "Firewall", icon: Shield }, - { id: "sound", label: "Sound", icon: Volume2 }, - { id: "power", label: "Power", icon: BatteryFull }, - { id: "datetime", label: "Date & Time", icon: Clock }, - { id: "hyprland", label: "Display", sublabel: "monitors.json", icon: Monitor }, - { id: "nightlight", label: "Night light", sublabel: "hyprsunset", icon: Moon }, - { id: "breadmon", label: "Monitors", sublabel: "breadmon", icon: AppWindow }, - { id: "breadlock", label: "Lock & greet", sublabel: "breadlock", icon: Lock }, - { id: "keybinds", label: "Keybinds", sublabel: "binds.json", icon: Keyboard }, - { id: "ime", label: "Input method", sublabel: "fcitx5", icon: Languages }, - { id: "accessibility", label: "Accessibility", icon: Accessibility }, - { id: "breadshot", label: "Screenshots", sublabel: "breadshot", icon: Camera }, - { id: "autostart", label: "Startup Apps", sublabel: "autostart.json", icon: Rocket }, - { id: "users", label: "Users", icon: Users }, -]; - -export const PERSONALIZATION_ITEMS: SidebarItem[] = [ - { id: "appearance", label: "Appearance", sublabel: "settings.json", icon: Palette }, - { id: "breadpaper", label: "Wallpaper", sublabel: "breadpaper", icon: Image }, - { id: "breadbar", label: "Bar", sublabel: "breadbar", icon: LayoutGrid }, - { id: "breadbox", label: "Launcher", sublabel: "breadbox", icon: Grid3x3 }, - { id: "breadclip", label: "Clipboard", sublabel: "breadclipd", icon: Clipboard }, - { id: "breadpad", label: "Notes", sublabel: "breadpad", icon: NotebookPen }, - { id: "breadsearch", label: "File Search", sublabel: "breadsearch", icon: Search }, - { id: "defaults", label: "Default apps", sublabel: "mimeapps.list", icon: AppWindowMac }, - { id: "bread", label: "Daemon", sublabel: "breadd", icon: Cog }, -]; - -export const MAINTENANCE_ITEMS: SidebarItem[] = [ - { id: "updates", label: "Updates", icon: Download }, - { id: "packages", label: "Packages", icon: Package }, - { id: "aur", label: "AUR", icon: Search }, - { id: "firmware", label: "Firmware", icon: RefreshCw }, - { id: "snapshots", label: "Snapshots", icon: History }, - { id: "channel", label: "Bakery channel", sublabel: "track", icon: GitBranch }, - { id: "backup", label: "Backup", sublabel: "restic", icon: Archive }, - { id: "optional", label: "Optional software", icon: Boxes }, -]; - -export const ABOUT_ITEMS: SidebarItem[] = [ - { id: "breadhelp", label: "Help", sublabel: "breadhelp", icon: CircleHelp }, - { id: "about", label: "About", icon: Info }, -]; - export interface SidebarSection { title: string | null; items: SidebarItem[]; } export const SIDEBAR_SECTIONS: SidebarSection[] = [ - { title: "System", items: SYSTEM_ITEMS }, - { title: "Personalization", items: PERSONALIZATION_ITEMS }, - { title: "Maintenance", items: MAINTENANCE_ITEMS }, - { title: null, items: ABOUT_ITEMS }, + { title: "Overview", items: [{ id: "home", label: "Home", icon: House }] }, + { + title: "Devices", + items: [ + { id: "network", label: "Wi-Fi & internet", icon: Wifi }, + { id: "bluetooth", label: "Bluetooth", icon: Bluetooth }, + { id: "displays", label: "Displays", icon: Monitor }, + { id: "sound", label: "Sound", icon: Volume2 }, + { id: "power", label: "Power & battery", icon: BatteryFull }, + ], + }, + { + title: "Desktop", + items: [ + { id: "appearance", label: "Appearance", icon: Palette }, + { id: "desktop", label: "Bar & apps", icon: AppWindow }, + { id: "input", label: "Keyboard & mouse", icon: Keyboard }, + { id: "apps", label: "Default apps", icon: LayoutGrid }, + ], + }, + { + title: "System", + items: [ + { id: "privacy", label: "Privacy & users", icon: Shield }, + { id: "system", label: "Updates & backup", icon: Download }, + { id: "about", label: "About", icon: Info }, + ], + }, ]; -export const DEFAULT_PAGE = "about"; +/** Old sidebar ids (screenshot CLI, in-app jumps) → hub page + optional tab. */ +export const PAGE_ALIASES: Record = { + 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 }; +} diff --git a/frontend/src/lib/streaming.ts b/frontend/src/lib/streaming.ts index daaeee4..bf78f05 100644 --- a/frontend/src/lib/streaming.ts +++ b/frontend/src/lib/streaming.ts @@ -1,5 +1,5 @@ // Frontend half of the event-streaming command pattern (see -// src/src/commands/streaming.rs) — listens for `cmd-output` lines from a +// src/src/commands/streaming.rs) - listens for `cmd-output` lines from a // typed Tauri command that runs a hardcoded program, then resolves once // the process exits. diff --git a/frontend/src/lib/styles/app.css b/frontend/src/lib/styles/app.css new file mode 100644 index 0000000..a626e8e --- /dev/null +++ b/frontend/src/lib/styles/app.css @@ -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; +} diff --git a/frontend/src/lib/theme/index.ts b/frontend/src/lib/theme/index.ts index a358759..87e4b17 100644 --- a/frontend/src/lib/theme/index.ts +++ b/frontend/src/lib/theme/index.ts @@ -1,6 +1,6 @@ // Bridges bread-theme's pywal-derived palette into the webview. Mirrors // bread_theme::gtk::apply_shared()'s two-phase pattern: fetch once at -// startup, then keep it live via a backend-pushed event — see +// startup, then keep it live via a backend-pushed event - see // src-tauri/src/commands/theme.rs for the file-watch side of this. import { invoke } from "@tauri-apps/api/core"; diff --git a/frontend/src/lib/views/About.svelte b/frontend/src/lib/views/About.svelte index 894aca2..43c0e56 100644 --- a/frontend/src/lib/views/About.svelte +++ b/frontend/src/lib/views/About.svelte @@ -24,7 +24,7 @@ // The backend reports the GPU as a raw lspci device string, e.g. // "Advanced Micro Devices, Inc. [AMD/ATI] Krackan [Radeon 840M / 860M Graphics] (rev c2)". // That's too technical for a general settings page, so extract just the - // marketing model name. Heuristic (not exhaustive — just needs to read + // marketing model name. Heuristic (not exhaustive - just needs to read // well for common vendors): drop the trailing "(rev ..)", prefer the // text inside the last [...] bracket group (usually the model name), // collapse "840M / 860M" style multi-model lists to the last variant, diff --git a/frontend/src/lib/views/AboutHub.svelte b/frontend/src/lib/views/AboutHub.svelte new file mode 100644 index 0000000..b4f7a2e --- /dev/null +++ b/frontend/src/lib/views/AboutHub.svelte @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/lib/views/Accessibility.svelte b/frontend/src/lib/views/Accessibility.svelte index 02bb427..ed4094b 100644 --- a/frontend/src/lib/views/Accessibility.svelte +++ b/frontend/src/lib/views/Accessibility.svelte @@ -94,7 +94,7 @@ {/if} - + {#if st} diff --git a/frontend/src/lib/views/Appearance.svelte b/frontend/src/lib/views/Appearance.svelte index b9d7541..2ef5fe2 100644 --- a/frontend/src/lib/views/Appearance.svelte +++ b/frontend/src/lib/views/Appearance.svelte @@ -5,13 +5,12 @@ import Group from "$lib/components/Group.svelte"; import SwitchField from "$lib/components/SwitchField.svelte"; import SelectField from "$lib/components/SelectField.svelte"; - import NumberField from "$lib/components/NumberField.svelte"; + import SliderField from "$lib/components/SliderField.svelte"; import HyprColorField from "$lib/components/HyprColorField.svelte"; - import Row from "$lib/components/Row.svelte"; import Hint from "$lib/components/Hint.svelte"; - import SaveButton from "$lib/components/SaveButton.svelte"; + import { debounce } from "$lib/debounce"; - // Common XKB layout codes. Not exhaustive — if the config holds + // Common XKB layout codes. Not exhaustive - if the config holds // something else, it's merged in below so it's never dropped from the // dropdown. const COMMON_KB_LAYOUTS = [ @@ -25,13 +24,51 @@ // window under the cursor; 2 = focus follows the cursor, but clicking a // window keeps keyboard focus there until the mouse moves again; 3 = // cursor and keyboard focus are fully independent. - const FOLLOW_MOUSE_MODES: { value: number; label: string }[] = [ - { value: 0, label: "Off — click to focus" }, - { value: 1, label: "Follow mouse" }, - { value: 2, label: "Follow mouse, detach on click" }, - { value: 3, label: "Fully detached from keyboard focus" }, + const FOLLOW_MOUSE_MODES: { value: string; label: string }[] = [ + { value: "0", label: "Click to focus" }, + { value: "1", label: "Follow mouse" }, + { value: "2", label: "Follow, keep focus on click" }, + { value: "3", label: "Mouse and keys independent" }, ]; + const KB_LABELS: Record = { + 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 = { + dwindle: "Tiling", + master: "Master stack", + }; + interface Appearance { gaps_in: number; gaps_out: number; @@ -53,76 +90,73 @@ } let cfg = $state(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("get_appearance"); + followMouse = String(cfg.follow_mouse); + loaded = true; }); - async function save() { - await invoke("save_appearance", { appearance: cfg }); - } + let primed = false; + $effect(() => { + if (!loaded || !cfg) return; + JSON.stringify(cfg); + void followMouse; + if (!primed) { + primed = true; + return; + } + persist(); + }); {#if cfg} - - - - - - - - + + + + + + + + - + - - - - - + {#if cfg.blur_enabled} + + + {/if} + + {#if cfg.shadow_enabled} + + {/if} - - - - - - + + + m.value)} + labels={Object.fromEntries(FOLLOW_MOUSE_MODES.map((m) => [m.value, m.label]))} + /> + - - + {/if} - - diff --git a/frontend/src/lib/views/AppearanceHub.svelte b/frontend/src/lib/views/AppearanceHub.svelte new file mode 100644 index 0000000..692f3ea --- /dev/null +++ b/frontend/src/lib/views/AppearanceHub.svelte @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/lib/views/AppsHub.svelte b/frontend/src/lib/views/AppsHub.svelte new file mode 100644 index 0000000..5b5c900 --- /dev/null +++ b/frontend/src/lib/views/AppsHub.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/views/Aur.svelte b/frontend/src/lib/views/Aur.svelte index 11c035f..ecf2ae0 100644 --- a/frontend/src/lib/views/Aur.svelte +++ b/frontend/src/lib/views/Aur.svelte @@ -34,7 +34,7 @@
diff --git a/frontend/src/lib/views/Autostart.svelte b/frontend/src/lib/views/Autostart.svelte index 5bc5248..914bf12 100644 --- a/frontend/src/lib/views/Autostart.svelte +++ b/frontend/src/lib/views/Autostart.svelte @@ -41,7 +41,7 @@ {#if entries} {#each entries as entry, i (i)} diff --git a/frontend/src/lib/views/Backup.svelte b/frontend/src/lib/views/Backup.svelte index b650c30..32cc11b 100644 --- a/frontend/src/lib/views/Backup.svelte +++ b/frontend/src/lib/views/Backup.svelte @@ -84,7 +84,7 @@ log = [...log, line]; }); busy = false; - if (!ok) message = "Command failed — see the log."; + if (!ok) message = "Failed. See the log."; } async function listSnaps() { @@ -122,7 +122,7 @@ {#if !st} @@ -140,7 +140,7 @@
diff --git a/frontend/src/lib/views/Bluetooth.svelte b/frontend/src/lib/views/Bluetooth.svelte index 49d5962..73bad27 100644 --- a/frontend/src/lib/views/Bluetooth.svelte +++ b/frontend/src/lib/views/Bluetooth.svelte @@ -75,7 +75,10 @@ } - + {#if powered === null} {dev.name}{dev.connected ? " (connected)" : ""} - +
{/each} @@ -107,7 +110,7 @@
{#if scanResults === null} @@ -144,9 +147,13 @@ display: flex; align-items: center; gap: var(--space-sm, 8px); - background-color: var(--surface); - border-radius: var(--radius-secondary, 6px); - padding: var(--space-sm, 8px) var(--space-md, 12px); + padding: 10px 2px; + border-top: 1px solid var(--line); + } + + .row:first-child { + border-top: none; + padding-top: 0; } .name { diff --git a/frontend/src/lib/views/Bread.svelte b/frontend/src/lib/views/Bread.svelte index 15fa6a9..a0de5cf 100644 --- a/frontend/src/lib/views/Bread.svelte +++ b/frontend/src/lib/views/Bread.svelte @@ -34,7 +34,7 @@ let cfg = $state(null); // The daemon's 4 compiled-in modules plus every *.lua file actually - // sitting in the configured module directory — real installed modules, + // sitting in the configured module directory - real installed modules, // not a guess, so picking one to disable is a click. let knownModules = $state([]); diff --git a/frontend/src/lib/views/Breadbar.svelte b/frontend/src/lib/views/Breadbar.svelte index ee844ef..7748928 100644 --- a/frontend/src/lib/views/Breadbar.svelte +++ b/frontend/src/lib/views/Breadbar.svelte @@ -3,10 +3,10 @@ import { invoke } from "@tauri-apps/api/core"; import ViewScaffold from "$lib/components/ViewScaffold.svelte"; import Group from "$lib/components/Group.svelte"; - import Row from "$lib/components/Row.svelte"; import TextField from "$lib/components/TextField.svelte"; - import NumberField from "$lib/components/NumberField.svelte"; - import SaveButton from "$lib/components/SaveButton.svelte"; + import SliderField from "$lib/components/SliderField.svelte"; + import Hint from "$lib/components/Hint.svelte"; + import { debounce } from "$lib/debounce"; import ChevronDown from "@lucide/svelte/icons/chevron-down"; import ChevronRight from "@lucide/svelte/icons/chevron-right"; @@ -23,20 +23,33 @@ } let style = $state(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("get_breadbar_css").then((c) => (css = c))); + }, 320); + onMount(async () => { style = await invoke("get_breadbar_style"); css = await invoke("get_breadbar_css"); + loaded = true; }); - async function saveStyle() { - await invoke("save_breadbar_style", { style }); - css = await invoke("get_breadbar_css"); - } + let primed = false; + $effect(() => { + if (!loaded || !style) return; + JSON.stringify(style); + if (!primed) { + primed = true; + return; + } + persist(); + }); async function saveCss() { cssSaving = true; @@ -55,36 +68,37 @@ {#if style} - + - + - - + + - - - -
- - {Math.round(style.workspace_inactive_opacity * 100)}% -
-
+ + + - - - - + + + + - + {/if} - +
+ {/each} - + {:else} {/if} @@ -60,45 +58,18 @@
diff --git a/frontend/src/lib/views/DesktopHub.svelte b/frontend/src/lib/views/DesktopHub.svelte new file mode 100644 index 0000000..a2f7aa0 --- /dev/null +++ b/frontend/src/lib/views/DesktopHub.svelte @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/lib/views/DesktopMore.svelte b/frontend/src/lib/views/DesktopMore.svelte new file mode 100644 index 0000000..3c34441 --- /dev/null +++ b/frontend/src/lib/views/DesktopMore.svelte @@ -0,0 +1,29 @@ + + + ({ id, label }))} bind:value={tab} /> + + {#key tab} + {#if Active} + + {/if} + {/key} + diff --git a/frontend/src/lib/views/Display.svelte b/frontend/src/lib/views/Display.svelte index 296bc3d..e121a6f 100644 --- a/frontend/src/lib/views/Display.svelte +++ b/frontend/src/lib/views/Display.svelte @@ -2,151 +2,369 @@ import { onMount } from "svelte"; import { invoke } from "@tauri-apps/api/core"; import ViewScaffold from "$lib/components/ViewScaffold.svelte"; - import InfoRow from "$lib/components/InfoRow.svelte"; import Group from "$lib/components/Group.svelte"; import Hint from "$lib/components/Hint.svelte"; - import SaveButton from "$lib/components/SaveButton.svelte"; + import Row from "$lib/components/Row.svelte"; interface LiveMonitor { name: string; mode: string; - } - interface MonitorRule { - output: string; - mode: string; - position: string; - scale: string; + x: number; + y: number; + width: number; + height: number; + refresh: number; + scale: number; + transform: number; + available_modes: string[]; } - let liveMonitors = $state(null); - let rules = $state(null); + const SCALES = [ + { label: "100%", value: 1 }, + { label: "125%", value: 1.25 }, + { label: "150%", value: 1.5 }, + { label: "200%", value: 2 }, + ]; - onMount(async () => { - liveMonitors = await invoke("get_live_monitors"); - rules = await invoke("get_monitor_rules"); + const TURNS = [ + { label: "Landscape", value: 0 }, + { label: "Right", value: 1 }, + { label: "Upside down", value: 2 }, + { label: "Left", value: 3 }, + ]; + + const PAD = 28; + const STAGE_H = 300; + + let monitors = $state([]); + let selected = $state(null); + let stageEl: HTMLDivElement | undefined = $state(); + let stageW = $state(640); + let message = $state(""); + let applying = $state(false); + + let live = $derived(monitors.find((m) => m.name === selected) ?? null); + + let view = $derived.by(() => { + if (monitors.length === 0) return { minX: 0, minY: 0, scale: 0.1 }; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const m of monitors) { + const w = boxW(m); + const h = boxH(m); + minX = Math.min(minX, m.x); + minY = Math.min(minY, m.y); + maxX = Math.max(maxX, m.x + w); + maxY = Math.max(maxY, m.y + h); + } + const bw = Math.max(maxX - minX, 1); + const bh = Math.max(maxY - minY, 1); + const scale = Math.min((stageW - PAD * 2) / bw, (STAGE_H - PAD * 2) / bh); + return { minX, minY, scale }; }); - function addRule() { - rules = [...(rules ?? []), { output: "", mode: "preferred", position: "auto", scale: "auto" }]; + type Drag = { i: number; grabX: number; grabY: number; pointer: number }; + let drag = $state(null); + + function rotated(m: LiveMonitor): boolean { + return m.transform === 1 || m.transform === 3 || m.transform === 5 || m.transform === 7; + } + function boxW(m: LiveMonitor): number { + return rotated(m) ? m.height : m.width; + } + function boxH(m: LiveMonitor): number { + return rotated(m) ? m.width : m.height; } - function removeRule(i: number) { - rules = rules!.filter((_, idx) => idx !== i); - if (rules.length === 0) { - rules = [{ output: "", mode: "preferred", position: "auto", scale: "auto" }]; + function screenX(m: LiveMonitor): number { + return PAD + (m.x - view.minX) * view.scale; + } + function screenY(m: LiveMonitor): number { + return PAD + (m.y - view.minY) * view.scale; + } + function screenW(m: LiveMonitor): number { + return Math.max(48, boxW(m) * view.scale); + } + function screenH(m: LiveMonitor): number { + return Math.max(32, boxH(m) * view.scale); + } + + function nearestScale(v: number): number { + return SCALES.reduce((best, s) => (Math.abs(s.value - v) < Math.abs(best - v) ? s.value : best), 1); + } + + async function refresh() { + monitors = await invoke("get_live_monitors"); + if (!selected || !monitors.some((m) => m.name === selected)) { + selected = monitors[0]?.name ?? null; } } - async function save() { - await invoke("save_monitor_rules", { rules }); + onMount(refresh); + + $effect(() => { + const el = stageEl; + if (!el) return; + stageW = el.clientWidth; + const ro = new ResizeObserver(() => { + stageW = el.clientWidth; + }); + ro.observe(el); + return () => ro.disconnect(); + }); + + function snap(idx: number, x: number, y: number): { x: number; y: number } { + const moving = monitors[idx]; + if (!moving) return { x, y }; + const mw = boxW(moving); + const mh = boxH(moving); + const thresh = Math.max(16, 18 / Math.max(view.scale, 0.01)); + let bestX = x; + let bestY = y; + let bestXd = thresh + 1; + let bestYd = thresh + 1; + for (let i = 0; i < monitors.length; i++) { + if (i === idx) continue; + const o = monitors[i]; + const ow = boxW(o); + const oh = boxH(o); + const xs = [x - o.x, x - (o.x + ow), x + mw - o.x, x + mw - (o.x + ow)]; + for (const d of xs) { + const ad = Math.abs(d); + if (ad < bestXd) { + bestXd = ad; + bestX = x - d; + } + } + const ys = [y - o.y, y - (o.y + oh), y + mh - o.y, y + mh - (o.y + oh)]; + for (const d of ys) { + const ad = Math.abs(d); + if (ad < bestYd) { + bestYd = ad; + bestY = y - d; + } + } + } + return { x: Math.round(bestX), y: Math.round(bestY) }; + } + + function onDown(e: PointerEvent, i: number) { + const m = monitors[i]; + if (!m || !stageEl) return; + selected = m.name; + const rect = stageEl.getBoundingClientRect(); + const px = e.clientX - rect.left; + const py = e.clientY - rect.top; + drag = { i, grabX: px - screenX(m), grabY: py - screenY(m), pointer: e.pointerId }; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + e.preventDefault(); + } + + function onMove(e: PointerEvent) { + if (!drag || !stageEl) return; + const rect = stageEl.getBoundingClientRect(); + const px = e.clientX - rect.left - drag.grabX; + const py = e.clientY - rect.top - drag.grabY; + const wx = view.minX + (px - PAD) / view.scale; + const wy = view.minY + (py - PAD) / view.scale; + const snapped = snap(drag.i, wx, wy); + monitors[drag.i].x = snapped.x; + monitors[drag.i].y = snapped.y; + monitors = [...monitors]; + } + + async function onUp(e: PointerEvent) { + if (!drag) return; + if (e.pointerId !== drag.pointer && e.type !== "pointerleave") return; + drag = null; + await apply(); + } + + async function apply() { + if (monitors.length === 0) return; + applying = true; + message = ""; + try { + await invoke("apply_monitor_layout", { monitors }); + await refresh(); + } catch (err) { + message = `${err}`; + } finally { + applying = false; + } + } + + async function setScale(value: number) { + if (!live) return; + live.scale = value; + monitors = [...monitors]; + await apply(); + } + + async function setTransform(value: number) { + if (!live) return; + live.transform = value; + monitors = [...monitors]; + await apply(); + } + + async function setMode(modeStr: string) { + if (!live) return; + const cleaned = modeStr.replace(/Hz$/i, ""); + const [res, hz] = cleaned.split("@"); + const [w, h] = (res ?? "").split("x"); + const width = Number(w); + const height = Number(h); + const refresh = Number(hz); + if (!width || !height) return; + live.width = width; + live.height = height; + if (refresh) live.refresh = refresh; + live.mode = `${width}x${height} @ ${Math.round(live.refresh)}Hz`; + monitors = [...monitors]; + await apply(); } - - - {#if liveMonitors && liveMonitors.length > 0} - {#each liveMonitors as m (m.name)} - - {/each} + + + {#if monitors.length === 0} + {:else} - +
+ {#each monitors as m, i (m.name)} + + {/each} +
+ {/if}
- - - - - - {#if rules} - - {#each rules as rule, i (i)} -
- - - - - + {#if live} + + + {#if live.available_modes.length > 0} + + {:else} + {live.mode} + {/if} + + +
+ {#each SCALES as s (s.value)} + + {/each}
- {/each} - - - +
+ +
+ {#each TURNS as t (t.value)} + + {/each} +
+
+ + {live.x}, {live.y} + + {#if message} + + {/if}
{/if} diff --git a/frontend/src/lib/views/DisplaysHub.svelte b/frontend/src/lib/views/DisplaysHub.svelte new file mode 100644 index 0000000..3e86d77 --- /dev/null +++ b/frontend/src/lib/views/DisplaysHub.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/views/Firewall.svelte b/frontend/src/lib/views/Firewall.svelte index 9bbdca0..aba18bf 100644 --- a/frontend/src/lib/views/Firewall.svelte +++ b/frontend/src/lib/views/Firewall.svelte @@ -1,4 +1,5 @@ - + + {/each} +
+ {:else} +
+ + + +
+ +
+ {#each tiles as tile (tile.id + (tile.tab ?? ""))} + + {/each} +
+ {/if} +
+ + diff --git a/frontend/src/lib/views/InputHub.svelte b/frontend/src/lib/views/InputHub.svelte new file mode 100644 index 0000000..e9a7664 --- /dev/null +++ b/frontend/src/lib/views/InputHub.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/views/InputMethod.svelte b/frontend/src/lib/views/InputMethod.svelte index ef99f54..c485eb5 100644 --- a/frontend/src/lib/views/InputMethod.svelte +++ b/frontend/src/lib/views/InputMethod.svelte @@ -59,7 +59,7 @@ {#if !st} @@ -87,7 +87,7 @@ {#if st}
    {#each st.packages as p (p.name)} -
  • {p.name}{p.installed ? "" : " — not installed"}
  • +
  • {p.name}{p.installed ? "" : " (missing)"}
  • {/each}
{/if} diff --git a/frontend/src/lib/views/Keybinds.svelte b/frontend/src/lib/views/Keybinds.svelte index dc710cf..07b7b70 100644 --- a/frontend/src/lib/views/Keybinds.svelte +++ b/frontend/src/lib/views/Keybinds.svelte @@ -9,7 +9,7 @@ import SaveButton from "$lib/components/SaveButton.svelte"; import Switch from "$lib/components/Switch.svelte"; - // Mirrors src-tauri/src/commands/keybinds.rs's `Bind` — `action`/`key`/ + // Mirrors src-tauri/src/commands/keybinds.rs's `Bind` - `action`/`key`/ // `mods` are the only fields every bind has; everything else (`command`, // `direction`, `workspace`, `options`, breadhelp's `label`/`category`/ // `demo_cmd`, ...) round-trips through serde's `#[serde(flatten)]` as @@ -35,7 +35,7 @@ } // Every action actually seen in real binds.json files (BOS's shipped - // flat schema and this app's own dev MultiLayout config) — an `action` + // flat schema and this app's own dev MultiLayout config) - an `action` // dropdown beats free-typing a dispatcher name from memory. "Custom…" // keeps anything not in this list reachable without blocking on it. const KNOWN_ACTIONS = [ @@ -56,7 +56,7 @@ ] as const; // The editable, per-row UI state a bind gets flattened into on load and - // reconstructed back into a `Bind` on save — same trade-off the GTK + // reconstructed back into a `Bind` on save - same trade-off the GTK // version made with individual `Entry` widgets per field. interface EditRow { action: string; @@ -64,7 +64,7 @@ mods: string; // Whether `mods` has been explicitly set (present in the loaded JSON, // or touched by the user since). `None`/absent means "fall back to - // default_mods"; `Some([])` — an explicitly *empty* mods list — means + // default_mods"; `Some([])` - an explicitly *empty* mods list - means // "use no modifiers at all, even though default_mods exists" (real // BOS binds rely on this for e.g. bare media keys). Collapsing both // cases to "omit when empty" would silently turn an explicit @@ -77,12 +77,123 @@ // key they don't know about (label/category/demo_cmd, or an action // shape this editor has no dedicated fields for) survives untouched. extraValue: Record; - // Raw-JSON view of `extraValue`, kept in sync both directions — + // Raw-JSON view of `extraValue`, kept in sync both directions - // only actually shown when `advancedOpen` is true, or for an action // not in `KNOWN_ACTIONS` (nothing dedicated to show instead). extraText: string; extraError: boolean; advancedOpen: boolean; + capturing: boolean; + } + + const ACTION_LABELS: Record = { + 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 = { + 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 = { + Space: "SPACE", + Enter: "RETURN", + Escape: "ESCAPE", + Backspace: "BACKSPACE", + Tab: "TAB", + AudioVolumeUp: "XF86AudioRaiseVolume", + AudioVolumeDown: "XF86AudioLowerVolume", + AudioVolumeMute: "XF86AudioMute", + AudioMicMute: "XF86AudioMicMute", + BrightnessUp: "XF86MonBrightnessUp", + BrightnessDown: "XF86MonBrightnessDown", + MediaTrackNext: "XF86AudioNext", + MediaTrackPrevious: "XF86AudioPrev", + MediaPlayPause: "XF86AudioPlay", + PrintScreen: "Print", + }; + + function keyLabel(k: string): string { + return KEY_LABELS[k] ?? k; + } + + function bindTitle(row: EditRow): string { + if (row.action === "exec") { + const cmd = String(row.extraValue.command ?? ""); + if (cmd.includes("set-volume") && cmd.includes("+")) return "Volume up"; + if (cmd.includes("set-volume") && cmd.includes("-")) return "Volume down"; + if (cmd.includes("set-mute") && cmd.includes("SINK")) return "Mute"; + if (cmd.includes("set-mute") && cmd.includes("SOURCE")) return "Mute mic"; + if (cmd.includes("brightnessctl") && cmd.includes("+")) return "Brightness up"; + if (cmd.includes("brightnessctl") && cmd.includes("-")) return "Brightness down"; + if (cmd.includes("playerctl next")) return "Next track"; + if (cmd.includes("playerctl previous")) return "Previous track"; + if (cmd.includes("playerctl play-pause")) return "Play/pause"; + if (cmd) return cmd.split(/\s+/)[0].split("/").pop() ?? "Command"; + } + return ACTION_LABELS[row.action] ?? row.action; + } + + function keyChips(row: EditRow): string[] { + const mods = textToMods(row.mods); + const key = row.key.trim(); + return [...mods, key].filter(Boolean).map(keyLabel); + } + + function hyprFromEvent(e: KeyboardEvent): { mods: string[]; key: string } | null { + if (["Shift", "Control", "Alt", "Meta"].includes(e.key)) return null; + const mods: string[] = []; + if (e.metaKey) mods.push("SUPER"); + if (e.ctrlKey) mods.push("CTRL"); + if (e.altKey) mods.push("ALT"); + if (e.shiftKey) mods.push("SHIFT"); + let key = CODE_TO_HYPR[e.code] ?? CODE_TO_HYPR[e.key]; + if (!key) { + if (e.code.startsWith("Key") && e.code.length === 4) key = e.code.slice(3); + else if (e.code.startsWith("Digit")) key = e.code.slice(5); + else if (e.code.startsWith("F") && /^F\d+$/.test(e.code)) key = e.code; + else key = e.key.length === 1 ? e.key.toUpperCase() : e.key; + } + return { mods, key }; + } + + function allRows(): EditRow[] { + return [...globalsRows, ...commonRows, ...bindingsRows, ...Object.values(layoutRows).flat()]; + } + + function beginCapture(row: EditRow) { + for (const r of allRows()) r.capturing = false; + row.capturing = true; } function syncExtraText(row: EditRow) { @@ -114,7 +225,7 @@ } // Hyprland workspace refs mix bare integers ("1") and relative tokens - // ("e+1", "e-1") in the same field — keep whichever shape the user typed + // ("e+1", "e-1") in the same field - keep whichever shape the user typed // instead of forcing everything through one type. function parseWorkspaceValue(s: string): string | number | undefined { const trimmed = s.trim(); @@ -143,13 +254,14 @@ extraText: "", extraError: false, advancedOpen: false, + capturing: false, }; syncExtraText(row); return row; } function rowToBind(r: EditRow): Bind { - // `drag` binds are only meaningful as a mouse bind — there's no + // `drag` binds are only meaningful as a mouse bind - there's no // dedicated field for `options.mouse` (nothing to configure, it's // always true), so pin it here rather than exposing a checkbox // whose only correct state is "on". @@ -193,6 +305,7 @@ extraText: "", extraError: false, advancedOpen: false, + capturing: false, }; } @@ -208,13 +321,32 @@ let newLayoutName = $state(""); let loadError = $state(""); - onMount(async () => { + onMount(() => { + const onKey = (e: KeyboardEvent) => { + const row = allRows().find((r) => r.capturing); + if (!row) return; + const mapped = hyprFromEvent(e); + if (!mapped) return; + e.preventDefault(); + row.mods = mapped.mods.join(", "); + row.modsTouched = true; + row.key = mapped.key; + row.capturing = false; + }; + window.addEventListener("keydown", onKey); + void loadKeybinds(); + // Register cleanup synchronously so Svelte types the onMount + // callback as returning a function (not a Promise-of-function). + return () => window.removeEventListener("keydown", onKey); + }); + + async function loadKeybinds() { try { const p = await invoke("get_keybinds"); kind = p.kind; // Rust's `#[serde(skip_serializing_if = ...)]` on every one of // these fields means an empty one is OMITTED from the JSON - // entirely, not sent as `[]`/`{}`/`""` — every field here needs + // entirely, not sent as `[]`/`{}`/`""` - every field here needs // a `??` fallback, not just the ones that are "usually" empty. activeLayout = p.file.active_layout ?? ""; defaultMods = modsToText(p.file.default_mods ?? []); @@ -229,7 +361,7 @@ } catch (e) { loadError = String(e); } - }); + } function addLayout() { const name = newLayoutName.trim(); @@ -248,7 +380,7 @@ // A row whose "extra" column currently holds text that doesn't parse as // JSON keeps its *last successfully parsed* value in `extraValue` (see // `onExtraInput`) rather than losing it on every keystroke while the - // user is mid-edit — but that means saving while such a row is still + // user is mid-edit - but that means saving while such a row is still // showing invalid/incomplete text would silently write that stale (or, // for a brand new row, empty) value instead of what's actually on // screen. Block save entirely until every row's extra JSON is valid, so @@ -349,7 +481,7 @@ oninput={(e) => setExtra(row, "layout", e.currentTarget.value)} /> {:else if row.action === "drag"} - Mouse-drag bind — nothing else to set. + Mouse-drag bind. {:else if row.action === "close" || row.action === "fullscreen" || row.action === "float" || row.action === "pseudo" || row.action === "resize" || row.action === "focus_last" || row.action === "exit"} No extra options for this action. {:else} @@ -375,30 +507,19 @@ {#each rows as row, i (i)}
- (row.modsTouched = true)} - /> - - - {#if !KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number])} - - {/if} + {bindTitle(row)} +
@@ -436,7 +557,7 @@ {:else if kind === "flat"} @@ -453,7 +574,7 @@ {:else} {#if layoutOrder.length > 0} @@ -512,9 +633,13 @@ display: flex; flex-direction: column; gap: var(--space-xs, 4px); - background-color: var(--surface); - border-radius: var(--radius-secondary, 6px); - padding: var(--space-sm, 8px) var(--space-md, 12px); + padding: 10px 2px; + border-top: 1px solid var(--line); + } + + .bind-card:first-child { + border-top: none; + padding-top: 0; } .bind-top, @@ -525,6 +650,34 @@ flex-wrap: wrap; } + .what { + flex: 1; + min-width: 12ch; + } + + .capture { + display: flex; + align-items: center; + gap: 4px; + background: color-mix(in srgb, var(--fg) 6%, transparent); + border: 1px solid var(--line); + border-radius: 10px; + padding: 6px 10px; + color: inherit; + cursor: pointer; + min-height: 32px; + } + + .capture.listening { + border-color: var(--accent); + color: var(--accent); + } + + .plus { + opacity: 0.4; + padding: 0 1px; + } + .bind-card input, .bind-card select { background-color: var(--bg); @@ -544,26 +697,6 @@ border-color: var(--red); } - .mods { - width: 14ch; - flex-shrink: 0; - } - - .key { - width: 9ch; - flex-shrink: 0; - } - - .action-select { - width: 11ch; - flex-shrink: 0; - } - - .action-custom { - width: 11ch; - flex-shrink: 0; - } - .extra-wide { flex: 1; min-width: 16ch; diff --git a/frontend/src/lib/views/Network.svelte b/frontend/src/lib/views/Network.svelte index 0a8e788..b8e2f1b 100644 --- a/frontend/src/lib/views/Network.svelte +++ b/frontend/src/lib/views/Network.svelte @@ -3,9 +3,8 @@ import { invoke } from "@tauri-apps/api/core"; import ViewScaffold from "$lib/components/ViewScaffold.svelte"; import Group from "$lib/components/Group.svelte"; - import Row from "$lib/components/Row.svelte"; + import SwitchField from "$lib/components/SwitchField.svelte"; import InfoRow from "$lib/components/InfoRow.svelte"; - import Hint from "$lib/components/Hint.svelte"; import Wifi from "@lucide/svelte/icons/wifi"; import WifiOff from "@lucide/svelte/icons/wifi-off"; import Lock from "@lucide/svelte/icons/lock"; @@ -26,12 +25,6 @@ let pendingSsid = $state(null); let password = $state(""); - // The backend reports the first `nmcli`-visible device of TYPE=ethernet, - // which on a machine running containers/VMs can be a virtual interface - // (docker/podman veth pairs, libvirt bridges, VPN tuns) rather than a - // real NIC — nmcli doesn't distinguish these from physical ethernet. - // Names like "vethYCF3ZA: unmanaged" are meaningless to a non-technical - // user, so hide the card rather than show raw interface jargon. const VIRTUAL_IFACE_PREFIXES = ["veth", "docker", "br-", "virbr", "vnet", "tun", "tap", "vmnet", "podman"]; const ETHERNET_STATE_LABELS: Record = { connected: "Connected", @@ -47,11 +40,14 @@ if (!iface || VIRTUAL_IFACE_PREFIXES.some((prefix) => iface.startsWith(prefix))) return null; return ETHERNET_STATE_LABELS[rawState ?? ""] ?? "Not connected"; }); + let activeNet = $derived(networks?.find((n) => n.active) ?? null); + let nearby = $derived(networks?.filter((n) => !n.active) ?? []); onMount(async () => { const info = await invoke<{ radio_enabled: boolean; ethernet: string | null }>("get_network_info"); radioEnabled = info.radio_enabled; ethernet = info.ethernet; + if (info.radio_enabled) await scan(); }); async function toggleRadio(enabled: boolean) { @@ -61,10 +57,12 @@ async function scan() { scanning = true; - status = "Scanning…"; - networks = await invoke("scan_wifi"); - status = `Found ${networks.length} network(s)`; - scanning = false; + try { + networks = await invoke("scan_wifi"); + } finally { + scanning = false; + status = ""; + } } async function connect(ssid: string, secured: boolean, known: boolean) { @@ -76,8 +74,9 @@ try { await invoke("connect_wifi", { ssid, password: null }); status = `Connected to ${ssid}`; + await scan(); } catch (e) { - status = `Failed to connect to ${ssid}: ${e}`; + status = `Failed: ${e}`; } } @@ -90,54 +89,76 @@ try { await invoke("connect_wifi", { ssid, password: pw }); status = `Connected to ${ssid}`; + await scan(); } catch { - status = `Failed to connect to ${ssid}: wrong password?`; + status = "Wrong password?"; } } - function signalLabel(signal: number): string { - return `${Math.min(100, Math.max(0, signal))}%`; + function bars(signal: number): number { + if (signal >= 75) return 4; + if (signal >= 50) return 3; + if (signal >= 25) return 2; + return 1; } - - - - - + + + radioEnabled, (v) => toggleRadio(v)} /> + {#if ethernetLabel} + + {/if} - {#if ethernetLabel} - - + {#if activeNet} + +
+
+ {#each [1, 2, 3, 4] as n (n)} + = n}> + {/each} +
+
+ {activeNet.ssid} + {activeNet.secured ? "Secured" : "Open"} · {activeNet.signal}% +
+ Connected +
{/if} - +
- {#if networks === null} + {#if scanning && networks === null}
- - Not scanned yet - Press Scan to see nearby networks. + + Scanning…
- {:else if networks.length === 0} + {:else if networks === null}
- + + No scan yet +
+ {:else if nearby.length === 0 && !activeNet} +
+ No networks found - Try Scan again, or check Wi-Fi radio is on.
{:else} - {#each networks as net (net.ssid)} -
- {net.ssid}{net.active ? " (connected)" : ""} + {#each nearby as net (net.ssid)} +
+
+ {#each [1, 2, 3, 4] as n (n)} + = n}> + {/each} +
+
+ {net.ssid} + {net.known ? "Saved" : net.secured ? "Secured" : "Open"} +
{#if net.secured}{/if} - {signalLabel(net.signal)} - {#if !net.active} - - {/if} +
{/each} {/if} @@ -145,52 +166,28 @@ {#if pendingSsid}
- - + +
{/if} {#if status} {status} {/if} - - + - - + + diff --git a/frontend/src/lib/views/NetworkHub.svelte b/frontend/src/lib/views/NetworkHub.svelte new file mode 100644 index 0000000..da2b0fd --- /dev/null +++ b/frontend/src/lib/views/NetworkHub.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/views/NightLight.svelte b/frontend/src/lib/views/NightLight.svelte index 13ec93f..8f07737 100644 --- a/frontend/src/lib/views/NightLight.svelte +++ b/frontend/src/lib/views/NightLight.svelte @@ -5,8 +5,10 @@ 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 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; @@ -20,19 +22,18 @@ let log = $state([]); let busy = $state(false); let message = $state(""); - async function refresh() { st = await invoke("get_nightlight"); } onMount(refresh); - async function apply(enabled: boolean) { + async function applyNow() { if (!st) return; message = ""; try { st = await invoke("set_nightlight", { - enabled, + enabled: st.enabled, temperature: st.temperature, }); if (st.error) message = st.error; @@ -41,6 +42,8 @@ } } + const persistTemp = debounce(applyNow, 200); + async function install() { log = []; busy = true; @@ -53,93 +56,30 @@ - + {#if !st} {:else if !st.installed} - - + + {:else} -
- Night light - -
- - - + st!.enabled, (v) => { st!.enabled = v; applyNow(); }} + /> + {#if st.enabled} + + {/if} {/if} {#if message}{/if}
- - diff --git a/frontend/src/lib/views/Optional.svelte b/frontend/src/lib/views/Optional.svelte index 3fbfcf0..882d537 100644 --- a/frontend/src/lib/views/Optional.svelte +++ b/frontend/src/lib/views/Optional.svelte @@ -38,7 +38,7 @@ log = [...log, line]; }); busy = false; - if (!ok) message = "Install failed — see the log."; + if (!ok) message = "Install failed. See the log."; await refresh(); return ok; } diff --git a/frontend/src/lib/views/Packages.svelte b/frontend/src/lib/views/Packages.svelte index d28509f..ec20f80 100644 --- a/frontend/src/lib/views/Packages.svelte +++ b/frontend/src/lib/views/Packages.svelte @@ -85,7 +85,7 @@ diff --git a/frontend/src/lib/views/Power.svelte b/frontend/src/lib/views/Power.svelte index d81ea9c..dcb50dd 100644 --- a/frontend/src/lib/views/Power.svelte +++ b/frontend/src/lib/views/Power.svelte @@ -40,7 +40,10 @@ } - + {#if info} {#each info.battery as [label, value] (label)} @@ -63,20 +66,22 @@ {#if info.charge_start !== null && info.charge_end !== null} - - setChargeThreshold("start", chargeStart)} /> + + setChargeThreshold("start", chargeStart)} /> + {chargeStart}% - - setChargeThreshold("end", chargeEnd)} /> + + setChargeThreshold("end", chargeEnd)} /> + {chargeEnd}% {/if} @@ -88,15 +93,6 @@ width: 180px; } - input[type="number"] { - width: 8ch; - background-color: var(--bg); - color: var(--on-surface); - border: 1px solid transparent; - border-radius: var(--radius-secondary, 6px); - padding: var(--space-xs, 4px) var(--space-sm, 8px); - } - .pct { margin-left: var(--space-sm, 8px); font-size: var(--font-size-secondary, 12px); diff --git a/frontend/src/lib/views/PrivacyHub.svelte b/frontend/src/lib/views/PrivacyHub.svelte new file mode 100644 index 0000000..e0f7ef1 --- /dev/null +++ b/frontend/src/lib/views/PrivacyHub.svelte @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/lib/views/Snapshots.svelte b/frontend/src/lib/views/Snapshots.svelte index 6834326..592f82a 100644 --- a/frontend/src/lib/views/Snapshots.svelte +++ b/frontend/src/lib/views/Snapshots.svelte @@ -25,7 +25,7 @@ } catch (e) { const msg = `${e}`.toLowerCase(); if (msg.includes("no permission")) { - errorHint = "This user isn't allowed to run snapper. Check ALLOW_USERS in /etc/snapper/configs/root — it should list your username."; + errorHint = "This user is not in ALLOW_USERS for snapper."; } else if (msg.includes("unknown config") || msg.includes("no such file")) { errorHint = "No snapper config exists for root yet, so nothing is being snapshotted. This should be set up automatically at install."; } else { @@ -62,10 +62,10 @@ - +
diff --git a/frontend/src/lib/views/Sound.svelte b/frontend/src/lib/views/Sound.svelte index b863d45..9c887a0 100644 --- a/frontend/src/lib/views/Sound.svelte +++ b/frontend/src/lib/views/Sound.svelte @@ -24,107 +24,154 @@ let output = $state(null); let input = $state(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 { - const section = await invoke<{ devices: SoundDevice[]; default_name: string | null }>("get_sound_section", { kind }); - const selected = Math.max(0, section.devices.findIndex((d) => d.name === section.default_name)); - return { kind, title, devices: section.devices, selected }; + const section = await invoke<{ devices: SoundDevice[]; default_name: string | null }>("get_sound_section", { + kind, + }); + return { kind, title, devices: section.devices, selected: pick(section.devices, section.default_name) }; } onMount(async () => { - output = await loadSection("sinks", "Output"); - input = await loadSection("sources", "Input"); + try { + output = await loadSection("sinks", "Output"); + input = await loadSection("sources", "Input"); + } catch (e) { + console.error(e); + output = { kind: "sinks", title: "Output", devices: [], selected: 0 }; + input = { kind: "sources", title: "Input", devices: [], selected: 0 }; + } }); + function current(section: DeviceSection): SoundDevice | null { + return section.devices[section.selected] ?? section.devices[0] ?? null; + } + async function selectDevice(section: DeviceSection, index: number) { + const device = section.devices[index]; + if (!device) return; section.selected = index; - await invoke("set_default_sound_device", { kind: section.kind, name: section.devices[index].name }); + await invoke("set_default_sound_device", { kind: section.kind, name: device.name }); } async function setVolume(section: DeviceSection, percent: number) { - const device = section.devices[section.selected]; + const device = current(section); + if (!device) return; device.percent = percent; - await invoke("set_sound_volume", { kind: section.kind, name: device.name, percent }); + await invoke("set_sound_volume", { kind: section.kind, name: device.name, percent: Math.round(percent) }); } async function setMute(section: DeviceSection, mute: boolean) { - const device = section.devices[section.selected]; + const device = current(section); + if (!device) return; device.mute = mute; await invoke("set_sound_mute", { kind: section.kind, name: device.name, mute }); } -{#snippet deviceSection(section: DeviceSection | null)} - {#if section} - - {#if section.devices.length === 0} - - {:else} - - - +{#snippet sectionCard(section: DeviceSection)} + + {#if section.devices.length === 0} + + {:else} + {#each section.devices as d, i (d.name + i)} + + {/each} + {#if current(section)} setVolume(section, Number(e.currentTarget.value))} /> - {section.devices[section.selected].percent}% + {Math.round(current(section)!.percent)}% section.devices[section.selected].mute, - (v) => setMute(section, v) - } + bind:value={() => current(section)!.mute, (v) => setMute(section, v)} /> {/if} - - {/if} + {/if} + {/snippet} - - {@render deviceSection(output)} - {@render deviceSection(input)} + + {#if output} + {@render sectionCard(output)} + {/if} + {#if input} + {@render sectionCard(input)} + {/if} - - + +