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 @@
+
+
+
+
+
+ {#each value as name (name)}
+
+ {name}
+ remove(name)} aria-label={`Remove ${name}`}>×
+
+ {/each}
+ {#if adding}
+ {#if available.length > 0}
+
+ add(e.currentTarget.value)}>
+ Choose…
+ {#each available as opt (opt)}
+ {opt}
+ {/each}
+
+ {:else}
+ {emptyOptionsHint}
+ {/if}
+ {:else if available.length > 0}
+ (adding = true)}>+
+ {/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 @@
+
+
+
+
+
+ (reveal = !reveal)}>
+ {#if reveal} {:else} {/if}
+
+
+
+
+
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 @@
+
+
+
+
+ {#if hint}
{hint}
{/if}
+ {#if value.length === 0}
+
None added.
+ {:else}
+
+ {#each value as path (path)}
+
+ {shorten(path)}
+ remove(path)} aria-label={`Remove ${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 @@
+
+
+
+ Save
+ {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 @@
+
+
+
+
+ {#each options as opt (opt)}
+ {opt}
+ {/each}
+
+
+
+
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"}
+
+
+ {active ? "Stop" : "Start"}
+ run("Restart")}>Restart
+ View logs
+
+ {#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 @@
+
+
+ (value = !value)}
+>
+
+
+
+
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 @@
+
+
+
+
+
+ {#each value as name (name)}
+
+ {name}
+ remove(name)} aria-label={`Remove ${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}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Apply
+
+ {#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}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#each FOLLOW_MOUSE_MODES as mode (mode.value)}
+ {mode.label}
+ {/each}
+
+
+
+
+
+
+
+ {/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()} />
+ Search
+
+
+
+
+
+ {#if results && results.length === 0}
+
+ {:else if results}
+ {#each results as r (r.name)}
+
+
+ {r.name}
+ {r.version}
+ install(r.name)}>Install
+
+
{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)}
+
+
(entry.enabled = !entry.enabled)}
+ >
+
+
+
+
+
+
+
browseFor(entry)} aria-label="Browse for program">
+
+
+
removeEntry(i)}>Remove
+
+ {/each}
+ Add app
+
+
+
+ {/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)" : ""}
+ toggleConnect(dev)}>{dev.connected ? "Disconnect" : "Connect"}
+ forget(dev)}>Forget
+
+ {/each}
+ {/if}
+
+
+
+
+
+ {#if scanResults === null}
+
+ {:else if scanResults.length === 0}
+
+ {:else}
+ {#each scanResults as dev (dev.address)}
+
+ {dev.name}
+ pair(dev)}>Pair
+
+ {/each}
+ {/if}
+
+ {scanning ? "Scanning…" : "Scan"}
+
+
+
+ {/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}
+
+
+ (advancedOpen = !advancedOpen)}>
+ {#if advancedOpen} {:else} {/if}
+ Edit raw CSS
+
+
+ {#if advancedOpen}
+
+
+ Save CSS
+ {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"
+ />
+ removeContext(i)}>Remove
+
+ {/each}
+ Add context
+
+
+
+ {/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 @@
+
+
+
+
+ Open history (SUPER+V)
+
+
+
+
+
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)}
+
+
+
+
+
+ (net.hidden = !net.hidden)}>Hidden network
+
+
removeNetwork(i)}>Remove
+
+ {/each}
+
+ Add network
+
+
+
+ {#each cfg.profiles as profile, i (i)}
+
+
+
+
+
+
+
+ Exit node
+
+
+
+ Run on connect
+
+
+
+
+ (profile.tailscale = !profile.tailscale)}>Enable Tailscale
+
+
+
+ (profile.include_all_known = !profile.include_all_known)}
+ >
+ Also allow any other saved network, not just the ones listed above
+
+
+
+ {/each}
+ Add profile
+
+
+
+ {/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}
+
+
{currentPath.split("/").pop()}
+ {:else}
+
No wallpaper set
+ {/if}
+
+
+
+ Choose image…
+ {status}
+
+
+
+
+ {#if library === null}
+
+ {scanning ? "Scanning…" : `Browse ${libraryDir}`}
+
+ {:else if library.length === 0}
+ Nothing under {libraryDir} — use Choose image… above instead.
+ {:else}
+
+ {#each library as item (item.path)}
+
apply(item.path)} title={item.path}>
+
+ {item.name}
+
+ {/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}
+
+
+
+
+
+
+
+
+ {#each Object.entries(BACKEND_LABELS) as [code, label] (code)}
+ {label}
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/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}
+
+ applyTimezone(e.currentTarget.value)}>
+ {#each info.timezones as tz (tz)}
+ {tz}
+ {/each}
+
+
+ {#if tzStatus}
+ {tzStatus}
+ {/if}
+ {/if}
+
+
+
+
+ toggleNtp(!ntpEnabled)}
+ >
+
+
+
+
+ {/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}
+
+
+
+ invoke("open_hyprland_conf")}>Open hyprland.lua in editor
+ invoke("open_keybinds_viewer")}>View keybinds (breadhelp)
+
+
+ {#if rules}
+
+ {#each rules as rule, i (i)}
+
+ Output
+ Mode
+ Position
+ Scale
+ removeRule(i)}>Remove
+
+ {/each}
+ Add monitor rule
+
+
+
+ {/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 @@
+
+
+
+
+
+ toggleEnabled(!(typeof status === "object" && "active" in status && status.active))}
+ >
+
+
+
+
+
+
+
+
+ Allow
+
+
+
+
+
+ {#if status === "unloaded"}
+
+ {:else if "error" in status}
+
+ {:else if status.rules.length === 0}
+
+ {:else}
+ {#each status.rules as rule (rule.number)}
+
+ {rule.text}
+ removeRule(rule.number)}>Remove
+
+ {/each}
+ {/if}
+
+
+ Refresh status
+
+
+
+
+
+
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}
+
+
+
+ Check for updates
+ Update all
+ Refresh list
+
+
+
+
+
+
+
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)}
+ />
+
+ getOption(row, "repeating"), (v) => setOption(row, "repeating", v)}
+ />
+ Repeat while held
+
+
+ getOption(row, "locked"), (v) => setOption(row, "locked", v)}
+ />
+ Works when locked
+
+ {: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))}
+ />
+
+ getOption(row, "repeating"), (v) => setOption(row, "repeating", v)}
+ />
+ Repeat while held
+
+ {:else if row.action === "layout"}
+ setExtra(row, "layout", e.currentTarget.value)}
+ />
+ {:else if row.action === "drag"}
+
+ {:else if row.action === "close" || row.action === "fullscreen" || row.action === "float" || row.action === "pseudo" || row.action === "resize" || row.action === "focus_last" || row.action === "exit"}
+
+ {:else}
+ onExtraInput(row, e.currentTarget.value)}
+ />
+ {/if}
+
+ {#if KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number]) && row.action !== "drag"}
+ (row.advancedOpen = !row.advancedOpen)}>
+ {row.advancedOpen ? "Hide raw JSON" : "Advanced"}
+
+ {/if}
+{/snippet}
+
+{#snippet section(rows: EditRow[], onAdd: () => void, onRemove: (i: number) => void)}
+
+ {#each rows as row, i (i)}
+
+ {/each}
+
+ Add bind
+{/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)}
+
+ removeLayout(name)}>Remove layout
+ {@render section(
+ layoutRows[name] ?? [],
+ () => (layoutRows[name] ?? (layoutRows[name] = [])).push(emptyRow()),
+ (i) => layoutRows[name]?.splice(i, 1)
+ )}
+
+ {/each}
+
+
+
+
+ Add layout
+
+
+
+
+ {/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 @@
+
+
+
+
+
+ toggleRadio(!radioEnabled)}>
+
+
+
+
+
+ {#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}
+ connect(net.ssid, net.secured, net.known)}>Connect
+ {/if}
+
+ {/each}
+ {/if}
+
+
+ {#if pendingSsid}
+
+
+ Connect
+
+ {/if}
+ {#if status}
+ {status}
+ {/if}
+
+ {scanning ? "Scanning…" : "Scan"}
+
+
+
+ invoke("open_connection_editor")}>Open connection editor
+
+
+
+
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}
+ updatePackage(pkg.name)}>Update
+
+ {/each}
+ {/if}
+
+
+
+ List installed
+ Update all
+
+
+
+
+ Update system (pacman -Syu)
+
+
+
+
+
+
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)}
+ (selected = snap.number)}
+ >
+ {snap.number}
+ {snap.date}
+ {snap.description}
+
+ {/each}
+ {/if}
+
+
+
+ Refresh
+ Boot into selected…
+ Delete selected
+
+
+
+
+
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}
+
+ selectDevice(section, Number(e.currentTarget.value))}
+ >
+ {#each section.devices as d, i (d.name)}
+ {d.description}
+ {/each}
+
+
+
+ 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)}
+
+
+ invoke("open_mixer")}>Open advanced mixer (pavucontrol)
+
+
+
+
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})` : ""}
+ (openPasswordFor = openPasswordFor === acc.username ? null : acc.username)}>
+ Change password
+
+ removeAccount(acc.username)}>Remove
+
+ {#if openPasswordFor === acc.username}
+
+
+ applyPassword(acc.username)}>Apply
+
+ {/if}
+ {#if rowStatus[acc.username]}
+
{rowStatus[acc.username]}
+ {/if}
+
+ {/each}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add user
+ {#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 0000000..825b9e6
Binary files /dev/null and b/frontend/static/favicon.png differ
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js
new file mode 100644
index 0000000..a7830ea
--- /dev/null
+++ b/frontend/svelte.config.js
@@ -0,0 +1,18 @@
+// Tauri doesn't have a Node.js server to do proper SSR
+// so we use adapter-static with a fallback to index.html to put the site in SPA mode
+// See: https://svelte.dev/docs/kit/single-page-apps
+// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
+import adapter from "@sveltejs/adapter-static";
+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter({
+ fallback: "index.html",
+ }),
+ },
+};
+
+export default config;
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..f4d0a0e
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
+ //
+ // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
+ // from the referenced tsconfig.json - TypeScript does not merge them in
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..3ecfa0a
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,32 @@
+import { defineConfig } from "vite";
+import { sveltekit } from "@sveltejs/kit/vite";
+
+// @ts-expect-error process is a nodejs global
+const host = process.env.TAURI_DEV_HOST;
+
+// https://vite.dev/config/
+export default defineConfig(async () => ({
+ plugins: [sveltekit()],
+
+ // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
+ //
+ // 1. prevent Vite from obscuring rust errors
+ clearScreen: false,
+ // 2. tauri expects a fixed port, fail if that port is not available
+ server: {
+ port: 1420,
+ strictPort: true,
+ host: host || false,
+ hmr: host
+ ? {
+ protocol: "ws",
+ host,
+ port: 1421,
+ }
+ : undefined,
+ watch: {
+ // 3. tell Vite to ignore watching `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 0000000..6be5e50
Binary files /dev/null and b/src-tauri/icons/128x128.png differ
diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png
new file mode 100644
index 0000000..e81bece
Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ
diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png
new file mode 100644
index 0000000..a437dd5
Binary files /dev/null and b/src-tauri/icons/32x32.png differ
diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png
new file mode 100644
index 0000000..0ca4f27
Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ
diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png
new file mode 100644
index 0000000..b81f820
Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ
diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png
new file mode 100644
index 0000000..624c7bf
Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ
diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png
new file mode 100644
index 0000000..c021d2b
Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ
diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png
new file mode 100644
index 0000000..6219700
Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ
diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png
new file mode 100644
index 0000000..f9bc048
Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ
diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png
new file mode 100644
index 0000000..d5fbfb2
Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ
diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png
new file mode 100644
index 0000000..63440d7
Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ
diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png
new file mode 100644
index 0000000..f3f705a
Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ
diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png
new file mode 100644
index 0000000..4556388
Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ
diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns
new file mode 100644
index 0000000..12a5bce
Binary files /dev/null and b/src-tauri/icons/icon.icns differ
diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico
new file mode 100644
index 0000000..b3636e4
Binary files /dev/null and b/src-tauri/icons/icon.ico differ
diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png
new file mode 100644
index 0000000..e1cd261
Binary files /dev/null and b/src-tauri/icons/icon.png differ
diff --git a/src/ui/views/about.rs b/src-tauri/src/commands/about.rs
similarity index 57%
rename from src/ui/views/about.rs
rename to src-tauri/src/commands/about.rs
index 8f4cdcb..7ec9f56 100644
--- a/src/ui/views/about.rs
+++ b/src-tauri/src/commands/about.rs
@@ -3,12 +3,21 @@
//! ships `BUILD_ID=rolling` on purpose), so there's no "BOS 1.2.3" readout
//! here the way a point-release distro's About panel would have one.
-use gtk4::prelude::*;
-use gtk4::{Box as GBox, Button, Entry, Label, Orientation};
+use serde::Serialize;
use std::fs;
-use std::process::Command;
+use tokio::process::Command;
-use crate::ui::widgets as w;
+#[derive(Serialize)]
+pub struct SystemInfo {
+ os: String,
+ kernel: String,
+ cpu: String,
+ gpu: String,
+ memory: String,
+ disk: String,
+ uptime: String,
+ hostname: String,
+}
fn os_pretty_name() -> String {
fs::read_to_string("/etc/os-release")
@@ -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| {
- let query = search_entry.text().to_string();
- if query.trim().is_empty() {
- return;
- }
- if let Some(b) = &btn {
- b.set_sensitive(false);
- }
- status.set_text("Searching…");
-
- let (tx, rx) = async_channel::bounded::>(1);
- std::thread::spawn(move || {
- let _ = tx.send_blocking(search(&query));
- });
-
- let list = list.clone();
- let status = status.clone();
- let btn = btn.clone();
- glib::spawn_future_local(async move {
- if let Ok(results) = rx.recv().await {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
- if results.is_empty() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- row.set_child(Some(&w::empty_state(
- "system-search-symbolic",
- "No results",
- "Try a different search term.",
- )));
- list.append(&row);
- status.set_text("No results.");
- } else {
- status.set_text(&format!("{} result(s)", results.len()));
- for r in results.into_iter().take(50) {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- let vbox = GBox::new(Orientation::Vertical, 2);
- vbox.add_css_class("card");
- vbox.set_margin_top(3);
- vbox.set_margin_bottom(3);
-
- let top = GBox::new(Orientation::Horizontal, 12);
- let name_lbl = Label::new(Some(&format!("{} {}", r.name, r.version)));
- name_lbl.set_hexpand(true);
- name_lbl.set_xalign(0.0);
- let install_btn = Button::with_label("Install");
- {
- let pkg = r.name.clone();
- install_btn.connect_clicked(move |_| install_in_terminal(&pkg));
- }
- top.append(&name_lbl);
- top.append(&install_btn);
- vbox.append(&top);
-
- let desc_lbl = Label::new(Some(&r.description));
- desc_lbl.add_css_class("dim-label");
- desc_lbl.set_xalign(0.0);
- desc_lbl.set_wrap(true);
- desc_lbl.set_max_width_chars(64);
- vbox.append(&desc_lbl);
-
- row.set_child(Some(&vbox));
- list.append(&row);
- }
- }
- }
- if let Some(b) = &btn {
- b.set_sensitive(true);
- }
- });
- }
- };
-
- {
- let run_search = run_search.clone();
- search_btn.connect_clicked(move |btn| run_search(Some(btn.clone())));
- }
- {
- let run_search = run_search.clone();
- search_entry.connect_activate(move |_| run_search(None));
- }
-
- outer
-}
diff --git a/src/ui/views/autostart.rs b/src/ui/views/autostart.rs
deleted file mode 100644
index fe2870c..0000000
--- a/src/ui/views/autostart.rs
+++ /dev/null
@@ -1,198 +0,0 @@
-//! hypr/autostart.json — the *extra*, user-toggleable autostart apps
-//! (breadbar, hypridle, bos-netcheck, breadhelp, plus anything a user adds).
-//! The core bootstrap sequence (theme generation, dark-mode gsettings,
-//! polkit agent, wallpaper daemon, breadd's Wayland-env fix, breadclipd)
-//! stays hardcoded in hyprland.lua on purpose — it's timing/order-sensitive
-//! infrastructure, not something this panel exposes.
-
-use std::cell::RefCell;
-use std::rc::Rc;
-
-use gtk4::prelude::*;
-use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, Switch};
-use serde::{Deserialize, Serialize};
-
-use crate::ui::widgets as w;
-
-#[derive(Clone, Serialize, Deserialize)]
-struct Entry_ {
- 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![
- Entry_ { command: "breadbar".to_string(), label: "Bar (breadbar)".to_string(), enabled: true },
- Entry_ { command: "hypridle".to_string(), label: "Idle / lock daemon (hypridle)".to_string(), enabled: true },
- Entry_ { command: "bos-netcheck".to_string(), label: "Network connectivity check".to_string(), enabled: true },
- Entry_ { command: "breadhelp --autostart".to_string(), label: "BOS Help (first-run onboarding)".to_string(), enabled: true },
- ]
-}
-
-fn config_path() -> std::path::PathBuf {
- crate::config::config_dir().join("hypr/autostart.json")
-}
-
-fn load() -> 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)
-}
-
-fn save(entries: &[Entry_]) -> std::io::Result<()> {
- let path = config_path();
- let file = AutostartFile { extra: entries.to_vec() };
- crate::config::atomic_write(&path, &serde_json::to_string_pretty(&file).unwrap_or_default())
-}
-
-fn rebuild(list: &ListBox, model: &Rc>>) {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
- for (i, entry) in model.borrow().iter().enumerate() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- let hbox = GBox::new(Orientation::Horizontal, 8);
- hbox.set_margin_top(6);
- hbox.set_margin_bottom(6);
- hbox.set_margin_start(8);
- hbox.set_margin_end(8);
-
- let enabled = Switch::new();
- enabled.set_active(entry.enabled);
- enabled.set_valign(gtk4::Align::Center);
-
- let label = Entry::new();
- label.set_text(&entry.label);
- label.set_placeholder_text(Some("Label"));
- label.set_width_chars(20);
-
- let command = Entry::new();
- command.set_text(&entry.command);
- command.set_placeholder_text(Some("command"));
- command.set_hexpand(true);
-
- let remove = Button::with_label("Remove");
- remove.add_css_class("destructive-action");
-
- {
- let model = model.clone();
- enabled.connect_active_notify(move |s| {
- if let Some(e) = model.borrow_mut().get_mut(i) {
- e.enabled = s.is_active();
- }
- });
- }
- {
- let model = model.clone();
- label.connect_changed(move |e| {
- if let Some(entry) = model.borrow_mut().get_mut(i) {
- entry.label = e.text().to_string();
- }
- });
- }
- {
- let model = model.clone();
- command.connect_changed(move |e| {
- if let Some(entry) = model.borrow_mut().get_mut(i) {
- entry.command = e.text().to_string();
- }
- });
- }
- {
- let model = model.clone();
- let list = list.clone();
- remove.connect_clicked(move |_| {
- model.borrow_mut().remove(i);
- rebuild(&list, &model);
- });
- }
-
- hbox.append(&enabled);
- hbox.append(&label);
- hbox.append(&command);
- hbox.append(&remove);
- row.set_child(Some(&hbox));
- list.append(&row);
- }
-}
-
-pub fn build() -> GBox {
- let (outer, content) = w::view_scaffold("Startup Apps");
-
- content.append(&w::hint(
- "What launches after login, beyond the core desktop (bar, theme, \
- clipboard, etc. always start regardless). Toggle off, edit, or add \
- your own — each row is one command run at login.",
- ));
-
- let model = Rc::new(RefCell::new(load()));
-
- content.append(&w::section("Extra autostart apps"));
- let list = ListBox::new();
- list.set_selection_mode(gtk4::SelectionMode::None);
- list.add_css_class("boxed-list");
- rebuild(&list, &model);
- content.append(&list);
-
- let add_btn = Button::with_label("Add app");
- add_btn.set_halign(gtk4::Align::Start);
- add_btn.set_margin_top(6);
- {
- let model = model.clone();
- let list = list.clone();
- add_btn.connect_clicked(move |_| {
- model.borrow_mut().push(Entry_ { command: String::new(), label: String::new(), enabled: true });
- rebuild(&list, &model);
- });
- }
- content.append(&add_btn);
-
- let btn_row = GBox::new(Orientation::Horizontal, 12);
- btn_row.set_margin_top(16);
- let save_btn = Button::with_label("Save");
- save_btn.add_css_class("suggested-action");
- let status = Label::new(None);
- status.add_css_class("dim-label");
- {
- let model = model.clone();
- let status = status.clone();
- save_btn.connect_clicked(move |_| {
- // 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.
- let entries: Vec = model.borrow().iter().filter(|e| !e.command.trim().is_empty()).cloned().collect();
- match save(&entries) {
- 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/bluetooth.rs b/src/ui/views/bluetooth.rs
deleted file mode 100644
index f646ae1..0000000
--- a/src/ui/views/bluetooth.rs
+++ /dev/null
@@ -1,384 +0,0 @@
-//! Bluetooth over `bluetoothctl`'s non-interactive mode (BlueZ's own CLI has
-//! supported single-shot argv commands since 5.48 — no D-Bus dependency
-//! needed here, same "shell out to the standard CLI" choice as Network
-//! (nmcli) and Snapshots (snapper). Unlike Firewall, 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 with
-//! no prompt. A device that requires PIN/passkey confirmation isn't
-//! supported: that would need this app to register its own bluetoothd
-//! agent, a bigger lift no bread-ecosystem app takes on today. Pairing such
-//! a device just fails, surfaced via the log view below like any other
-//! failure here.
-
-use gtk4::prelude::*;
-use gtk4::{
- AlertDialog, Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow,
- Switch, TextView,
-};
-use std::collections::HashSet;
-use std::process::Command;
-
-use crate::ui::widgets as w;
-
-#[derive(Clone)]
-struct BtDevice {
- address: String,
- name: String,
- connected: bool,
-}
-
-/// `None` means no controller present at all (no Bluetooth hardware, or the
-/// kernel module isn't loaded) — distinct from "present but powered off",
-/// which still has plenty to show (the power switch itself).
-fn adapter_powered() -> Option {
- let output = Command::new("bluetoothctl").arg("show").output().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"))
-}
-
-fn set_powered(on: bool) {
- let val = if on { "on" } else { "off" };
- let _ = Command::new("bluetoothctl").args(["power", val]).spawn();
-}
-
-/// Parses `bluetoothctl devices[...]` output — one `Device ` per
-/// line, name may contain spaces.
-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()
-}
-
-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() else {
- return Vec::new();
- };
- parse_device_lines(&String::from_utf8_lossy(&out.stdout))
-}
-
-fn list_paired() -> Vec {
- let mut devices = run_devices(Some("Paired"));
- let connected: HashSet =
- run_devices(Some("Connected")).into_iter().map(|d| d.address).collect();
- for d in &mut devices {
- d.connected = connected.contains(&d.address);
- }
- devices
-}
-
-/// Scans for a few seconds (blocking — call this on a background thread) and
-/// returns every device BlueZ has seen that isn't already paired. `devices`
-/// with no filter returns BlueZ's whole device cache (previously-seen plus
-/// anything the scan just found), so filtering out the paired set gives
-/// exactly "new, not-yet-paired" devices.
-fn scan_unpaired() -> Vec {
- let _ = Command::new("bluetoothctl").args(["--timeout", "5", "scan", "on"]).output();
- let paired: HashSet = run_devices(Some("Paired")).into_iter().map(|d| d.address).collect();
- run_devices(None).into_iter().filter(|d| !paired.contains(&d.address)).collect()
-}
-
-fn populate_paired(list: &ListBox, log_buf: >k4::TextBuffer, log_view: &TextView) {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
- let devices = list_paired();
- if devices.is_empty() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- row.set_child(Some(&w::empty_state(
- "bluetooth-symbolic",
- "No paired devices",
- "Scan below and pair a device to see it here.",
- )));
- list.append(&row);
- return;
- }
- for dev in devices {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- let hbox = GBox::new(Orientation::Horizontal, 16);
- hbox.add_css_class("card");
- hbox.set_margin_top(3);
- hbox.set_margin_bottom(3);
-
- let name = if dev.connected {
- format!("{} (connected)", dev.name)
- } else {
- dev.name.clone()
- };
- let name_lbl = Label::new(Some(&name));
- name_lbl.set_hexpand(true);
- name_lbl.set_xalign(0.0);
- if dev.connected {
- name_lbl.add_css_class("heading");
- }
-
- let toggle_btn = Button::with_label(if dev.connected { "Disconnect" } else { "Connect" });
- {
- let list = list.clone();
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- let addr = dev.address.clone();
- let connected = dev.connected;
- toggle_btn.connect_clicked(move |_| {
- log_buf.set_text("");
- log_view.set_visible(true);
- let verb = if connected { "disconnect" } else { "connect" };
- let list2 = list.clone();
- let log_buf2 = log_buf.clone();
- let log_view2 = log_view.clone();
- w::stream_command_then(&["bluetoothctl", verb, &addr], log_buf.clone(), move || {
- populate_paired(&list2, &log_buf2, &log_view2);
- });
- });
- }
-
- let forget_btn = Button::with_label("Forget");
- forget_btn.add_css_class("destructive-action");
- {
- let list = list.clone();
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- let addr = dev.address.clone();
- let name_for_dialog = dev.name.clone();
- forget_btn.connect_clicked(move |btn| {
- let window = btn.root().and_then(|r| r.downcast::().ok());
- let dialog = AlertDialog::builder()
- .message(format!("Forget \"{name_for_dialog}\"?"))
- .detail("You'll need to pair it again to reconnect.")
- .buttons(["Cancel", "Forget"])
- .cancel_button(0)
- .default_button(0)
- .build();
- let list = list.clone();
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- let addr = addr.clone();
- dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
- if result != Ok(1) {
- return;
- }
- log_buf.set_text("");
- log_view.set_visible(true);
- let list2 = list.clone();
- let log_buf2 = log_buf.clone();
- let log_view2 = log_view.clone();
- w::stream_command_then(
- &["bluetoothctl", "remove", &addr],
- log_buf.clone(),
- move || populate_paired(&list2, &log_buf2, &log_view2),
- );
- });
- });
- }
-
- hbox.append(&name_lbl);
- hbox.append(&toggle_btn);
- hbox.append(&forget_btn);
- row.set_child(Some(&hbox));
- list.append(&row);
- }
-}
-
-fn populate_scan(
- list: &ListBox,
- devices: Vec,
- paired_list: &ListBox,
- log_buf: >k4::TextBuffer,
- log_view: &TextView,
-) {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
- if devices.is_empty() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- row.set_child(Some(&w::empty_state(
- "bluetooth-symbolic",
- "No new devices found",
- "Make sure the device is powered on and in pairing mode, then Scan again.",
- )));
- list.append(&row);
- return;
- }
- for dev in devices {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- let hbox = GBox::new(Orientation::Horizontal, 16);
- hbox.add_css_class("card");
- hbox.set_margin_top(3);
- hbox.set_margin_bottom(3);
-
- let name_lbl = Label::new(Some(&dev.name));
- name_lbl.set_hexpand(true);
- name_lbl.set_xalign(0.0);
-
- let pair_btn = Button::with_label("Pair");
- {
- let list = list.clone();
- let paired_list = paired_list.clone();
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- let addr = dev.address.clone();
- // A weak ref to just this row: on success we remove only it
- // (cheap and correct) rather than re-running the 5s scan on the
- // main thread — `stream_command_then`'s completion callback runs
- // on the GTK main loop, so anything blocking in it would freeze
- // the whole window for the scan's duration.
- let row_weak = row.downgrade();
- pair_btn.connect_clicked(move |_| {
- log_buf.set_text("");
- log_view.set_visible(true);
- let list2 = list.clone();
- let paired_list2 = paired_list.clone();
- let log_buf2 = log_buf.clone();
- let log_view2 = log_view.clone();
- let addr2 = addr.clone();
- let row_weak2 = row_weak.clone();
- w::stream_command_then(&["bluetoothctl", "pair", &addr], log_buf.clone(), move || {
- // Both of these are one-shot device-cache lookups, not a
- // scan, so they return in well under a second.
- populate_paired(&paired_list2, &log_buf2, &log_view2);
- let now_paired = run_devices(Some("Paired")).iter().any(|d| d.address == addr2);
- if !now_paired {
- return; // pairing failed — leave the row so the user can retry, error is in the log view above
- }
- if let Some(row) = row_weak2.upgrade() {
- list2.remove(&row);
- }
- if list2.first_child().is_none() {
- let empty_row = ListBoxRow::new();
- empty_row.set_selectable(false);
- empty_row.set_child(Some(&w::empty_state(
- "bluetooth-symbolic",
- "No new devices found",
- "Make sure the device is powered on and in pairing mode, then Scan again.",
- )));
- list2.append(&empty_row);
- }
- });
- });
- }
-
- hbox.append(&name_lbl);
- hbox.append(&pair_btn);
- row.set_child(Some(&hbox));
- list.append(&row);
- }
-}
-
-pub fn build() -> GBox {
- let (outer, content) = w::view_scaffold("Bluetooth");
-
- let Some(powered) = adapter_powered() else {
- content.append(&w::empty_state(
- "bluetooth-disabled-symbolic",
- "No Bluetooth adapter found",
- "This machine doesn't have Bluetooth hardware, or the kernel module isn't loaded.",
- ));
- return outer;
- };
-
- content.append(&w::section("Adapter"));
- let power_sw = Switch::new();
- power_sw.set_active(powered);
- power_sw.connect_active_notify(|s| set_powered(s.is_active()));
- content.append(&w::row("Bluetooth", &power_sw));
-
- content.append(&w::section("Paired devices"));
- let paired_list = ListBox::new();
- paired_list.set_selection_mode(gtk4::SelectionMode::None);
- let paired_scroll = ScrolledWindow::new();
- paired_scroll.set_min_content_height(160);
- paired_scroll.set_child(Some(&paired_list));
- content.append(&paired_scroll);
-
- content.append(&w::section("Available devices"));
- content.append(&w::hint(
- "Scanning takes a few seconds. Devices that need a PIN to pair \
- aren't supported here — only \"just works\" pairing (most \
- headphones, speakers, keyboards, and mice).",
- ));
- let scan_list = ListBox::new();
- scan_list.set_selection_mode(gtk4::SelectionMode::None);
- {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- row.set_child(Some(&w::empty_state(
- "bluetooth-symbolic",
- "Not scanned yet",
- "Press Scan to look for nearby devices.",
- )));
- scan_list.append(&row);
- }
- let scan_scroll = ScrolledWindow::new();
- scan_scroll.set_min_content_height(160);
- scan_scroll.set_child(Some(&scan_list));
- content.append(&scan_scroll);
-
- // Shared log view for every connect/disconnect/pair/forget action on
- // this page — same "don't discard the reason for a failure" pattern
- // Firewall uses, since a failed pair (PIN required, device out of
- // range, ...) is exactly the case a user needs an explanation for.
- let log_buf = gtk4::TextBuffer::new(None);
- let log_view = TextView::with_buffer(&log_buf);
- log_view.set_editable(false);
- log_view.set_monospace(true);
- log_view.set_height_request(120);
- log_view.set_margin_top(8);
- log_view.set_visible(false);
-
- populate_paired(&paired_list, &log_buf, &log_view);
-
- let scan_btn = Button::with_label("Scan");
- scan_btn.set_halign(gtk4::Align::Start);
- scan_btn.set_margin_top(6);
- {
- let scan_list = scan_list.clone();
- let paired_list = paired_list.clone();
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- scan_btn.connect_clicked(move |btn| {
- btn.set_sensitive(false);
- let (tx, rx) = async_channel::bounded::>(1);
- std::thread::spawn(move || {
- let _ = tx.send_blocking(scan_unpaired());
- });
- let btn = btn.clone();
- let scan_list = scan_list.clone();
- let paired_list = paired_list.clone();
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- glib::spawn_future_local(async move {
- if let Ok(devices) = rx.recv().await {
- populate_scan(&scan_list, devices, &paired_list, &log_buf, &log_view);
- }
- btn.set_sensitive(true);
- });
- });
- }
- content.append(&scan_btn);
- content.append(&log_view);
-
- outer
-}
diff --git a/src/ui/views/bread.rs b/src/ui/views/bread.rs
deleted file mode 100644
index 77d3294..0000000
--- a/src/ui/views/bread.rs
+++ /dev/null
@@ -1,160 +0,0 @@
-//! breadd.toml — the bread daemon config.
-//! Schema mirrors breadd/src/core/config.rs (daemon, lua, modules, adapters,
-//! events, notifications). Edited non-destructively via the shared document.
-
-use std::cell::RefCell;
-use std::rc::Rc;
-
-use gtk4::prelude::*;
-use gtk4::Box as GBox;
-
-use crate::config;
-use crate::ui::widgets as w;
-
-fn config_path() -> std::path::PathBuf {
- config::config_dir().join("bread/breadd.toml")
-}
-
-pub fn build() -> GBox {
- let path = config_path();
- let doc = Rc::new(RefCell::new(config::load_doc(&path)));
-
- let (outer, c) = w::view_scaffold("Daemon");
-
- c.append(&w::service_control("breadd.service", true, true));
-
- c.append(&w::section("Daemon"));
- c.append(&w::dropdown_row(
- "Log level",
- &doc,
- &["daemon", "log_level"],
- &["error", "warn", "info", "debug", "trace"],
- "info",
- ));
- c.append(&w::entry_row(
- "Socket path",
- &doc,
- &["daemon", "socket_path"],
- "default (XDG runtime dir)",
- "",
- ));
-
- c.append(&w::section("Lua"));
- c.append(&w::entry_row(
- "Entry point",
- &doc,
- &["lua", "entry_point"],
- "~/.config/bread/init.lua",
- "",
- ));
- c.append(&w::entry_row(
- "Module path",
- &doc,
- &["lua", "module_path"],
- "~/.config/bread/modules",
- "",
- ));
-
- c.append(&w::section("Modules"));
- c.append(&w::switch_row(
- "Load built-in modules",
- &doc,
- &["modules", "builtin"],
- true,
- ));
- c.append(&w::csv_row(
- "Disabled modules",
- &doc,
- &["modules", "disable"],
- "module-a, module-b",
- ));
-
- c.append(&w::section("Adapters"));
- c.append(&w::hint(
- "Sources breadd normalises into events. Disable any you don't use.",
- ));
- c.append(&w::switch_row(
- "Hyprland",
- &doc,
- &["adapters", "hyprland", "enabled"],
- true,
- ));
- c.append(&w::switch_row(
- "udev (devices)",
- &doc,
- &["adapters", "udev", "enabled"],
- true,
- ));
- c.append(&w::csv_row(
- "udev subsystems",
- &doc,
- &["adapters", "udev", "subsystems"],
- "usb, input, power_supply",
- ));
- c.append(&w::switch_row(
- "Power",
- &doc,
- &["adapters", "power", "enabled"],
- true,
- ));
- c.append(&w::spin_row(
- "Power poll interval (s)",
- &doc,
- &["adapters", "power", "poll_interval_secs"],
- 1.0,
- 3600.0,
- 1.0,
- 30,
- ));
- c.append(&w::switch_row(
- "Network",
- &doc,
- &["adapters", "network", "enabled"],
- true,
- ));
- c.append(&w::switch_row(
- "Bluetooth",
- &doc,
- &["adapters", "bluetooth", "enabled"],
- true,
- ));
-
- c.append(&w::section("Events"));
- c.append(&w::spin_row(
- "Dedup window (ms)",
- &doc,
- &["events", "dedup_window_ms"],
- 0.0,
- 10000.0,
- 50.0,
- 250,
- ));
-
- c.append(&w::section("Notifications"));
- c.append(&w::spin_row(
- "Default timeout (ms)",
- &doc,
- &["notifications", "default_timeout_ms"],
- 0.0,
- 60000.0,
- 500.0,
- 5000,
- ));
- c.append(&w::dropdown_row(
- "Default urgency",
- &doc,
- &["notifications", "default_urgency"],
- &["low", "normal", "critical"],
- "normal",
- ));
- c.append(&w::entry_row(
- "notify-send path",
- &doc,
- &["notifications", "notify_send_path"],
- "auto-detected",
- "",
- ));
-
- outer.append(&w::save_button(&doc, path));
- outer
-}
diff --git a/src/ui/views/breadbar.rs b/src/ui/views/breadbar.rs
deleted file mode 100644
index 3caaeea..0000000
--- a/src/ui/views/breadbar.rs
+++ /dev/null
@@ -1,90 +0,0 @@
-use gtk4::prelude::*;
-use gtk4::{Box as GBox, Button, Label, Orientation, ScrolledWindow, TextView};
-use std::path::PathBuf;
-
-use crate::ui::widgets as w;
-
-fn css_path() -> PathBuf {
- crate::config::config_dir().join("breadbar/style.css")
-}
-
-pub fn build() -> GBox {
- let path = css_path();
- let existing_css = std::fs::read_to_string(&path).unwrap_or_default();
-
- let (outer, content) = w::view_scaffold("Bar");
- content.append(&w::hint(
- "CSS overrides for breadbar. Leave empty to use the default bread theme. \
- Reloads live on save — no need to restart the bar.",
- ));
-
- let buf = gtk4::TextBuffer::new(None);
- buf.set_text(&existing_css);
-
- let text_view = TextView::with_buffer(&buf);
- text_view.set_monospace(true);
- text_view.set_top_margin(12);
- text_view.set_bottom_margin(12);
- text_view.set_left_margin(12);
- text_view.set_right_margin(12);
-
- // A borderless full-bleed textview reads as a rendering bug, not an
- // editor — give it the same card framing every other panel's content
- // gets, and cap its height so it doesn't compete with the button row
- // for the one screen's worth of space.
- let editor_card = GBox::new(Orientation::Vertical, 0);
- editor_card.add_css_class("card");
- editor_card.set_vexpand(true);
-
- let scroll = ScrolledWindow::new();
- scroll.set_vexpand(true);
- scroll.set_min_content_height(360);
- scroll.set_child(Some(&text_view));
- editor_card.append(&scroll);
- content.append(&editor_card);
-
- let btn_row = GBox::new(Orientation::Horizontal, 12);
- btn_row.set_margin_top(12);
-
- let save_btn = Button::with_label("Save");
- save_btn.add_css_class("suggested-action");
- let status_lbl = Label::new(None);
- status_lbl.add_css_class("dim-label");
-
- {
- let path = path.clone();
- let status_lbl = status_lbl.clone();
- save_btn.connect_clicked(move |_| {
- let (start, end) = buf.bounds();
- let text = buf.text(&start, &end, false);
- match crate::config::atomic_write(&path, text.as_str()) {
- Ok(()) => {
- // breadbar has no systemd unit (it's launched directly by
- // hyprland.lua's exec-once) — SIGHUP is its own documented
- // live-reload mechanism (see this file's own header
- // comment: "reload live: kill -HUP $(pidof breadbar)").
- // -x pkill exits non-zero if breadbar isn't running,
- // which is fine — the file's still saved either way.
- let reloaded = std::process::Command::new("pkill")
- .args(["-HUP", "-x", "breadbar"])
- .status()
- .map(|s| s.success())
- .unwrap_or(false);
- status_lbl.set_text(if reloaded { "Saved & reloaded" } else { "Saved" });
- let lbl = status_lbl.clone();
- glib::timeout_add_seconds_local(3, move || {
- lbl.set_text("");
- glib::ControlFlow::Break
- });
- }
- Err(e) => status_lbl.set_text(&format!("Error: {e}")),
- }
- });
- }
-
- btn_row.append(&save_btn);
- btn_row.append(&status_lbl);
- outer.append(&btn_row);
-
- outer
-}
diff --git a/src/ui/views/breadbox.rs b/src/ui/views/breadbox.rs
deleted file mode 100644
index 52b0321..0000000
--- a/src/ui/views/breadbox.rs
+++ /dev/null
@@ -1,210 +0,0 @@
-//! 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 in
-//! the file are preserved.
-
-use std::cell::RefCell;
-use std::rc::Rc;
-
-use gtk4::prelude::*;
-use gtk4::{
- Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow,
-};
-use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
-
-use crate::config;
-use crate::ui::widgets as w;
-
-#[derive(Clone, Default)]
-struct Context {
- name: String,
- priority: Vec,
-}
-
-fn config_path() -> std::path::PathBuf {
- config::config_dir().join("breadbox/config.toml")
-}
-
-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()
-}
-
-/// Rewrite only the `contexts` array-of-tables, leaving the rest of the doc.
-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));
-}
-
-fn rebuild_list(list: &ListBox, model: &Rc>>) {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
- if model.borrow().is_empty() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- row.set_child(Some(&w::empty_state(
- "view-app-grid-symbolic",
- "No launcher contexts yet",
- "Add one to control which apps/categories breadbox surfaces first.",
- )));
- list.append(&row);
- return;
- }
- for (i, ctx) in model.borrow().iter().enumerate() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
-
- let hbox = GBox::new(Orientation::Horizontal, 8);
- hbox.set_margin_top(6);
- hbox.set_margin_bottom(6);
- hbox.set_margin_start(8);
- hbox.set_margin_end(8);
-
- let name_entry = Entry::new();
- name_entry.set_text(&ctx.name);
- name_entry.set_width_chars(14);
- name_entry.set_placeholder_text(Some("name"));
-
- let prio_entry = Entry::new();
- prio_entry.set_text(&ctx.priority.join(", "));
- prio_entry.set_hexpand(true);
- prio_entry.set_placeholder_text(Some("firefox, code, Development, ..."));
-
- let remove_btn = Button::with_label("Remove");
- remove_btn.add_css_class("destructive-action");
-
- {
- let model = model.clone();
- name_entry.connect_changed(move |e| {
- if let Some(c) = model.borrow_mut().get_mut(i) {
- c.name = e.text().to_string();
- }
- });
- }
- {
- let model = model.clone();
- prio_entry.connect_changed(move |e| {
- if let Some(c) = model.borrow_mut().get_mut(i) {
- c.priority = e
- .text()
- .split(',')
- .map(|s| s.trim().to_string())
- .filter(|s| !s.is_empty())
- .collect();
- }
- });
- }
- {
- let model = model.clone();
- let list = list.clone();
- remove_btn.connect_clicked(move |_| {
- model.borrow_mut().remove(i);
- rebuild_list(&list, &model);
- });
- }
-
- hbox.append(&name_entry);
- hbox.append(&prio_entry);
- hbox.append(&remove_btn);
- row.set_child(Some(&hbox));
- list.append(&row);
- }
-}
-
-pub fn build() -> GBox {
- let path = config_path();
- let doc = Rc::new(RefCell::new(config::load_doc(&path)));
- let model = Rc::new(RefCell::new(read_contexts(&doc.borrow())));
-
- let (outer, content) = w::view_scaffold("Launcher");
- content.append(&w::service_control("breadbox-sync.service", false, true));
-
- content.append(&w::section("Contexts"));
- content.append(&w::hint(
- "Launcher contexts — each lists, in priority order, the apps/categories surfaced first.",
- ));
-
- let list = ListBox::new();
- list.set_selection_mode(gtk4::SelectionMode::None);
- rebuild_list(&list, &model);
-
- let scroll = ScrolledWindow::new();
- scroll.set_vexpand(true);
- scroll.set_child(Some(&list));
- content.append(&scroll);
-
- // Add-row button lives with the list it adds to, halign Start like
- // breadcrumbs' "Add network"/"Add profile" — Save stays alone in its
- // own row at the bottom, consistent with every other panel.
- let add_btn = Button::with_label("Add context");
- add_btn.set_halign(gtk4::Align::Start);
- add_btn.set_margin_top(8);
- {
- let model = model.clone();
- let list = list.clone();
- add_btn.connect_clicked(move |_| {
- model.borrow_mut().push(Context {
- name: "new".to_string(),
- priority: Vec::new(),
- });
- rebuild_list(&list, &model);
- });
- }
- content.append(&add_btn);
-
- let btn_row = GBox::new(Orientation::Horizontal, 12);
- btn_row.set_margin_top(16);
- let save_btn = Button::with_label("Save");
- save_btn.add_css_class("suggested-action");
- let status_lbl = Label::new(None);
- status_lbl.add_css_class("dim-label");
- {
- let doc = doc.clone();
- let model = model.clone();
- let path = path.clone();
- let status_lbl = status_lbl.clone();
- save_btn.connect_clicked(move |_| {
- write_contexts(&mut doc.borrow_mut(), &model.borrow());
- match config::save_doc(&path, &doc.borrow()) {
- Ok(()) => {
- status_lbl.set_text("Saved");
- let lbl = status_lbl.clone();
- glib::timeout_add_seconds_local(3, move || {
- lbl.set_text("");
- glib::ControlFlow::Break
- });
- }
- Err(e) => status_lbl.set_text(&format!("Error: {e}")),
- }
- });
- }
- btn_row.append(&save_btn);
- btn_row.append(&status_lbl);
- outer.append(&btn_row);
-
- outer
-}
diff --git a/src/ui/views/breadclip.rs b/src/ui/views/breadclip.rs
deleted file mode 100644
index 896a2f1..0000000
--- a/src/ui/views/breadclip.rs
+++ /dev/null
@@ -1,33 +0,0 @@
-//! breadclip — clipboard history popup + its background daemon (breadclipd).
-//! No config file to edit: breadclip takes no persistent settings. This
-//! panel exists purely so the daemon backing it is visible/controllable
-//! from bos-settings instead of only from a terminal — previously breadclip
-//! had no presence in Settings at all despite running its own service.
-
-use gtk4::prelude::*;
-use gtk4::{Box as GBox, Button};
-
-use crate::ui::widgets as w;
-
-pub fn build() -> GBox {
- let (outer, c) = w::view_scaffold("Clipboard");
-
- c.append(&w::hint(
- "Keeps a history of copied text/images and shows it as a popup. \
- breadclipd (below) is the background daemon that watches the \
- clipboard — breadclip itself is just the popup UI, launched on \
- demand by the keybind or the button below.",
- ));
-
- let open_btn = Button::with_label("Open history (SUPER+V)");
- open_btn.add_css_class("suggested-action");
- open_btn.set_halign(gtk4::Align::Start);
- open_btn.connect_clicked(|_| {
- let _ = std::process::Command::new("breadclip").spawn();
- });
- c.append(&open_btn);
-
- c.append(&w::service_control("breadclipd.service", false, false));
-
- outer
-}
diff --git a/src/ui/views/breadcrumbs.rs b/src/ui/views/breadcrumbs.rs
deleted file mode 100644
index 78318cb..0000000
--- a/src/ui/views/breadcrumbs.rs
+++ /dev/null
@@ -1,466 +0,0 @@
-//! 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; the `networks` array and `profiles` table
-//! are rewritten from their editors on save. Other keys/comments are preserved.
-
-use std::cell::RefCell;
-use std::rc::Rc;
-
-use gtk4::prelude::*;
-use gtk4::{
- Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, PasswordEntry, Switch,
-};
-use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
-
-use crate::config;
-use crate::ui::widgets as w;
-
-fn config_path() -> std::path::PathBuf {
- config::config_dir().join("breadcrumbs/breadcrumbs.toml")
-}
-
-// --- networks ---------------------------------------------------------------
-
-#[derive(Clone, Default)]
-struct Network {
- ssid: String,
- password: String,
- hidden: bool,
-}
-
-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 rebuild_networks(list: &ListBox, model: &Rc>>) {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
- for (i, n) in model.borrow().iter().enumerate() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- let hbox = GBox::new(Orientation::Horizontal, 8);
- hbox.set_margin_top(6);
- hbox.set_margin_bottom(6);
- hbox.set_margin_start(8);
- hbox.set_margin_end(8);
-
- let ssid = Entry::new();
- ssid.set_text(&n.ssid);
- ssid.set_width_chars(16);
- ssid.set_placeholder_text(Some("SSID"));
-
- let pass = PasswordEntry::new();
- pass.set_text(&n.password);
- pass.set_hexpand(true);
- pass.set_show_peek_icon(true);
- pass.set_placeholder_text(Some("password"));
-
- let hidden = Switch::new();
- hidden.set_active(n.hidden);
- hidden.set_valign(gtk4::Align::Center);
- hidden.set_tooltip_text(Some("Hidden network"));
-
- let remove = Button::with_label("Remove");
- remove.add_css_class("destructive-action");
-
- {
- let model = model.clone();
- ssid.connect_changed(move |e| {
- if let Some(n) = model.borrow_mut().get_mut(i) {
- n.ssid = e.text().to_string();
- }
- });
- }
- {
- let model = model.clone();
- pass.connect_changed(move |e| {
- if let Some(n) = model.borrow_mut().get_mut(i) {
- n.password = e.text().to_string();
- }
- });
- }
- {
- let model = model.clone();
- hidden.connect_active_notify(move |s| {
- if let Some(n) = model.borrow_mut().get_mut(i) {
- n.hidden = s.is_active();
- }
- });
- }
- {
- let model = model.clone();
- let list = list.clone();
- remove.connect_clicked(move |_| {
- model.borrow_mut().remove(i);
- rebuild_networks(&list, &model);
- });
- }
-
- hbox.append(&ssid);
- hbox.append(&pass);
- hbox.append(&Label::new(Some("hidden")));
- hbox.append(&hidden);
- hbox.append(&remove);
- row.set_child(Some(&hbox));
- list.append(&row);
- }
-}
-
-// --- profiles ---------------------------------------------------------------
-
-#[derive(Clone, Default)]
-struct Profile {
- name: String,
- networks: Vec,
- detect_ssids: Vec,
- bootstrap: String,
- exit_node: String,
- tailscale: bool,
- include_all_known: bool,
-}
-
-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));
-}
-
-fn field(label: &str, control: &impl IsA) -> GBox {
- let row = GBox::new(Orientation::Horizontal, 12);
- let lbl = Label::new(Some(label));
- lbl.set_xalign(0.0);
- lbl.set_width_chars(16);
- row.append(&lbl);
- control.set_hexpand(true);
- row.append(control);
- row
-}
-
-fn rebuild_profiles(container: &GBox, model: &Rc>>) {
- while let Some(child) = container.first_child() {
- container.remove(&child);
- }
- for (i, p) in model.borrow().iter().enumerate() {
- let card = GBox::new(Orientation::Vertical, 6);
- card.add_css_class("card");
- card.set_margin_top(6);
- card.set_margin_bottom(6);
-
- let header = GBox::new(Orientation::Horizontal, 8);
- let name = Entry::new();
- name.set_text(&p.name);
- name.set_hexpand(true);
- name.set_placeholder_text(Some("profile name (e.g. home)"));
- let remove = Button::with_label("Remove");
- remove.add_css_class("destructive-action");
- header.append(&name);
- header.append(&remove);
- card.append(&header);
-
- let networks = Entry::new();
- networks.set_text(&p.networks.join(", "));
- networks.set_placeholder_text(Some("SSID1, SSID2"));
- card.append(&field("Networks", &networks));
-
- let detect = Entry::new();
- detect.set_text(&p.detect_ssids.join(", "));
- detect.set_placeholder_text(Some("SSIDs that auto-select this profile"));
- card.append(&field("Detect SSIDs", &detect));
-
- let exit_node = Entry::new();
- exit_node.set_text(&p.exit_node);
- exit_node.set_placeholder_text(Some("tailscale exit node (optional)"));
- card.append(&field("Exit node", &exit_node));
-
- let bootstrap = Entry::new();
- bootstrap.set_text(&p.bootstrap);
- bootstrap.set_placeholder_text(Some("bootstrap command (optional)"));
- card.append(&field("Bootstrap", &bootstrap));
-
- let tailscale = Switch::new();
- tailscale.set_active(p.tailscale);
- tailscale.set_halign(gtk4::Align::Start);
- card.append(&field("Tailscale", &tailscale));
-
- let include_all = Switch::new();
- include_all.set_active(p.include_all_known);
- include_all.set_halign(gtk4::Align::Start);
- card.append(&field("Include all known", &include_all));
-
- // bind each control to the in-memory model entry
- macro_rules! bind_csv {
- ($entry:ident, $f:ident) => {{
- let model = model.clone();
- $entry.connect_changed(move |e| {
- if let Some(p) = model.borrow_mut().get_mut(i) {
- p.$f = e
- .text()
- .split(',')
- .map(|s| s.trim().to_string())
- .filter(|s| !s.is_empty())
- .collect();
- }
- });
- }};
- }
- macro_rules! bind_str {
- ($entry:ident, $f:ident) => {{
- let model = model.clone();
- $entry.connect_changed(move |e| {
- if let Some(p) = model.borrow_mut().get_mut(i) {
- p.$f = e.text().to_string();
- }
- });
- }};
- }
- macro_rules! bind_bool {
- ($sw:ident, $f:ident) => {{
- let model = model.clone();
- $sw.connect_active_notify(move |s| {
- if let Some(p) = model.borrow_mut().get_mut(i) {
- p.$f = s.is_active();
- }
- });
- }};
- }
- bind_str!(name, name);
- bind_csv!(networks, networks);
- bind_csv!(detect, detect_ssids);
- bind_str!(exit_node, exit_node);
- bind_str!(bootstrap, bootstrap);
- bind_bool!(tailscale, tailscale);
- bind_bool!(include_all, include_all_known);
- {
- let model = model.clone();
- let container = container.clone();
- remove.connect_clicked(move |_| {
- model.borrow_mut().remove(i);
- rebuild_profiles(&container, &model);
- });
- }
-
- container.append(&card);
- }
-}
-
-// --- view -------------------------------------------------------------------
-
-pub fn build() -> GBox {
- let path = config_path();
- let doc = Rc::new(RefCell::new(config::load_doc(&path)));
- let nets = Rc::new(RefCell::new(read_networks(&doc.borrow())));
- let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow())));
-
- let (outer, content) = w::view_scaffold("Wi-Fi Profiles");
- content.append(&w::service_control("breadcrumbs.service", false, true));
-
- // [settings] — edited in place on the shared doc
- content.append(&w::section("Settings"));
- content.append(&w::dropdown_row(
- "Default profile",
- &doc,
- &["settings", "default_profile"],
- &["home", "away"],
- // breadcrumbs' own default_profile_name() is "away", not "home" —
- // this was showing the wrong value for an unset key.
- "away",
- ));
- content.append(&w::entry_row("DNS", &doc, &["settings", "dns"], "1.1.1.1", ""));
- content.append(&w::entry_row(
- "Exit node",
- &doc,
- &["settings", "exit_node"],
- "tailscale exit node",
- "",
- ));
- content.append(&w::entry_row(
- "Ping host",
- &doc,
- &["settings", "ping_host"],
- "1.1.1.1",
- "",
- ));
- content.append(&w::entry_row(
- "Connectivity URL",
- &doc,
- &["settings", "connectivity_url"],
- "http://connectivitycheck.gstatic.com/generate_204",
- "",
- ));
- content.append(&w::spin_row(
- "nmcli wait (s)",
- &doc,
- &["settings", "nmcli_wait"],
- 1.0,
- 120.0,
- 1.0,
- 8,
- ));
- content.append(&w::spin_row(
- "Watch interval (s)",
- &doc,
- &["settings", "watch_interval"],
- 1.0,
- 600.0,
- 1.0,
- 12,
- ));
-
- // [[networks]]
- content.append(&w::section("Saved networks"));
- let net_list = ListBox::new();
- net_list.set_selection_mode(gtk4::SelectionMode::None);
- rebuild_networks(&net_list, &nets);
- content.append(&net_list);
- let add_net = Button::with_label("Add network");
- add_net.set_halign(gtk4::Align::Start);
- {
- let nets = nets.clone();
- let net_list = net_list.clone();
- add_net.connect_clicked(move |_| {
- nets.borrow_mut().push(Network::default());
- rebuild_networks(&net_list, &nets);
- });
- }
- content.append(&add_net);
-
- // [profiles.*]
- content.append(&w::section("Profiles"));
- let prof_box = GBox::new(Orientation::Vertical, 4);
- rebuild_profiles(&prof_box, &profiles);
- content.append(&prof_box);
- let add_prof = Button::with_label("Add profile");
- add_prof.set_halign(gtk4::Align::Start);
- {
- let profiles = profiles.clone();
- let prof_box = prof_box.clone();
- add_prof.connect_clicked(move |_| {
- profiles.borrow_mut().push(Profile {
- name: "new".to_string(),
- ..Default::default()
- });
- rebuild_profiles(&prof_box, &profiles);
- });
- }
- content.append(&add_prof);
-
- // Save — fold the network + profile editors back into the doc, then write.
- let btn_row = GBox::new(Orientation::Horizontal, 12);
- btn_row.set_margin_top(16);
- let save_btn = Button::with_label("Save");
- save_btn.add_css_class("suggested-action");
- let status = Label::new(None);
- status.add_css_class("dim-label");
- {
- let doc = doc.clone();
- let nets = nets.clone();
- let profiles = profiles.clone();
- let path = path.clone();
- let status = status.clone();
- save_btn.connect_clicked(move |_| {
- {
- let mut d = doc.borrow_mut();
- write_networks(&mut d, &nets.borrow());
- write_profiles(&mut d, &profiles.borrow());
- }
- match config::save_doc(&path, &doc.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/breadpad.rs b/src/ui/views/breadpad.rs
deleted file mode 100644
index 25a0519..0000000
--- a/src/ui/views/breadpad.rs
+++ /dev/null
@@ -1,146 +0,0 @@
-//! breadpad.toml — the breadpad notes/reminders config.
-//! Schema mirrors breadpad-shared/src/config.rs (settings, model + model.ollama,
-//! reminders, calendar). Edited non-destructively (the calendar password and
-//! model paths are preserved across saves).
-
-use std::cell::RefCell;
-use std::rc::Rc;
-
-use gtk4::prelude::*;
-use gtk4::Box as GBox;
-
-use crate::config;
-use crate::ui::widgets as w;
-
-fn config_path() -> std::path::PathBuf {
- config::config_dir().join("breadpad/breadpad.toml")
-}
-
-pub fn build() -> GBox {
- let path = config_path();
- let doc = Rc::new(RefCell::new(config::load_doc(&path)));
-
- let (outer, c) = w::view_scaffold("Notes");
-
- c.append(&w::section("Capture"));
- c.append(&w::dropdown_row(
- "Default note type",
- &doc,
- &["settings", "default_type"],
- &["note", "reminder", "task"],
- "note",
- ));
- c.append(&w::switch_row(
- "Tag with active workspace",
- &doc,
- &["settings", "workspace_tag"],
- true,
- ));
- c.append(&w::csv_row(
- "Snooze options",
- &doc,
- &["settings", "snooze_options"],
- "15m, 1h, tomorrow_morning",
- ));
- c.append(&w::spin_row(
- "Archive after (days)",
- &doc,
- &["settings", "archive_after_days"],
- 0.0,
- 3650.0,
- 1.0,
- 30,
- ));
-
- c.append(&w::section("Classifier model"));
- c.append(&w::entry_row(
- "ONNX model path",
- &doc,
- &["model", "path"],
- "~/.local/share/breadpad/model/classifier.onnx",
- "",
- ));
- c.append(&w::entry_row(
- "Tokenizer path",
- &doc,
- &["model", "tokenizer"],
- "~/.local/share/breadpad/model/tokenizer.json",
- "",
- ));
-
- c.append(&w::section("Ollama (LLM classifier)"));
- c.append(&w::switch_row(
- "Use Ollama",
- &doc,
- &["model", "ollama", "enabled"],
- true,
- ));
- c.append(&w::entry_row(
- "Endpoint",
- &doc,
- &["model", "ollama", "endpoint"],
- "http://localhost:11434",
- "",
- ));
- c.append(&w::entry_row(
- "Model",
- &doc,
- &["model", "ollama", "model"],
- "e.g. fastflowlm",
- "",
- ));
- c.append(&w::spin_f64_row(
- "Confidence threshold",
- &doc,
- &["model", "ollama", "confidence_threshold"],
- 0.0,
- 1.0,
- 0.05,
- 2,
- 0.6,
- ));
-
- c.append(&w::section("Reminders"));
- c.append(&w::entry_row(
- "Default morning time",
- &doc,
- &["reminders", "default_morning"],
- "7:00",
- "",
- ));
- c.append(&w::spin_row(
- "Missed grace (minutes)",
- &doc,
- &["reminders", "missed_grace_minutes"],
- 0.0,
- 1440.0,
- 5.0,
- 60,
- ));
-
- c.append(&w::section("Calendar (CalDAV)"));
- c.append(&w::switch_row(
- "Sync to calendar",
- &doc,
- &["calendar", "enabled"],
- false,
- ));
- c.append(&w::entry_row(
- "CalDAV URL",
- &doc,
- &["calendar", "url"],
- "https://host/remote.php/dav/calendars/...",
- "",
- ));
- c.append(&w::entry_row(
- "Username",
- &doc,
- &["calendar", "username"],
- "",
- "",
- ));
- c.append(&w::password_row("Password", &doc, &["calendar", "password"]));
-
- outer.append(&w::save_button(&doc, path));
- outer
-}
diff --git a/src/ui/views/breadpaper.rs b/src/ui/views/breadpaper.rs
deleted file mode 100644
index 012739a..0000000
--- a/src/ui/views/breadpaper.rs
+++ /dev/null
@@ -1,299 +0,0 @@
-//! 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`). This panel is a thin GUI front-end for
-//! that CLI so wallpaper (and the pywal-driven theme it generates) has a
-//! discoverable home in BOS Settings instead of only being reachable from a
-//! terminal.
-
-use std::path::{Path, PathBuf};
-use std::process::Command;
-
-use gtk4::prelude::*;
-use gtk4::{
- Box as GBox, Button, FileDialog, FileFilter, FlowBox, Image, Label, Orientation, Picture,
-};
-
-use crate::ui::widgets as w;
-
-/// Extensions breadpaper's own `validate()` accepts — narrower than what
-/// GdkPixbuf can decode (svg/tiff/etc.), so both the file picker's filter and
-/// the library scan below stay in sync with what `breadpaper set` will
-/// actually take instead of offering images it'll reject.
-const WALLPAPER_EXTS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp"];
-
-/// Caps how many thumbnails the library grid ever builds. Each one decodes a
-/// scaled `Pixbuf` synchronously on the main thread (see `thumbnail`) — fine
-/// for a bounded, explicitly-triggered scan (same "costs seconds, gated
-/// behind a button" posture as network.rs's Wi-Fi scan), not fine as an
-/// unbounded walk of someone's whole Pictures folder.
-const MAX_LIBRARY_ITEMS: usize = 80;
-const MAX_SCAN_DEPTH: usize = 4;
-
-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)
-}
-
-/// Recursively collects image paths under `dir` (sorted, depth- and
-/// count-bounded) — the library is organized in subfolders (e.g. by show/
-/// series), not a flat directory, so a non-recursive scan would find nothing.
-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
-}
-
-/// A clickable thumbnail: scaled-decode preview + filename, wrapped in a
-/// plain `Button` so the whole card is the click target. Returns `None` for
-/// files GdkPixbuf can't decode (corrupt/unsupported) rather than showing a
-/// broken-image placeholder for every miss.
-fn thumbnail(path: &Path, on_pick: impl Fn(PathBuf) + 'static) -> Option {
- let pixbuf = gtk4::gdk_pixbuf::Pixbuf::from_file_at_scale(path, 160, 100, true).ok()?;
- let texture = gtk4::gdk::Texture::for_pixbuf(&pixbuf);
-
- let card = GBox::new(Orientation::Vertical, 4);
- card.set_margin_top(4);
- card.set_margin_bottom(4);
- card.set_margin_start(4);
- card.set_margin_end(4);
-
- let picture = Picture::for_paintable(&texture);
- picture.set_size_request(160, 100);
- picture.set_content_fit(gtk4::ContentFit::Cover);
- card.append(&picture);
-
- let name = path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
- let name_lbl = Label::new(Some(&name));
- name_lbl.add_css_class("caption");
- name_lbl.add_css_class("dim-label");
- name_lbl.set_max_width_chars(20);
- name_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::Middle);
- card.append(&name_lbl);
-
- let btn = Button::new();
- btn.set_child(Some(&card));
- btn.set_tooltip_text(Some(&path.display().to_string()));
- let path = path.to_path_buf();
- btn.connect_clicked(move |_| on_pick(path.clone()));
- Some(btn)
-}
-
-fn current_wallpaper() -> Option {
- let out = Command::new("breadpaper").arg("get").output().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(PathBuf::from(s))
- }
-}
-
-fn refresh_preview(preview: &Image, path_lbl: &Label) {
- match current_wallpaper() {
- Some(path) => {
- let filename = path.file_name().map(|f| f.to_string_lossy().to_string());
- path_lbl.set_text(filename.as_deref().unwrap_or("(unknown filename)"));
- path_lbl.set_tooltip_text(Some(&path.display().to_string()));
- preview.set_from_file(Some(&path));
- }
- None => {
- path_lbl.set_text("No wallpaper set");
- path_lbl.set_tooltip_text(None);
- preview.set_icon_name(Some("image-missing"));
- }
- }
-}
-
-/// Runs `breadpaper set `, which also drives `awww img` + pywal
-/// palette generation (routinely 1-3s — pywal spawns Python + an
-/// ImageMagick backend, not the "sub-second" call this used to assume). GTK
-/// widgets aren't `Send`, so the command runs on its own thread and the
-/// result comes back over a channel (same pattern as snapshots.rs).
-fn set_wallpaper(path: PathBuf, preview: Image, path_lbl: Label, status: Label) {
- status.set_text("Setting...");
- let (tx, rx) = async_channel::bounded::(1);
- std::thread::spawn(move || {
- let ok = Command::new("breadpaper").arg("set").arg(&path).status().map(|s| s.success()).unwrap_or(false);
- let _ = tx.send_blocking(ok);
- });
-
- glib::spawn_future_local(async move {
- let ok = rx.recv().await.unwrap_or(false);
- if ok {
- refresh_preview(&preview, &path_lbl);
- status.set_text("Wallpaper set");
- } else {
- status.set_text("breadpaper failed — see terminal/journal");
- }
- let lbl = status.clone();
- glib::timeout_add_seconds_local(3, move || {
- lbl.set_text("");
- glib::ControlFlow::Break
- });
- });
-}
-
-pub fn build() -> GBox {
- let (outer, c) = w::view_scaffold("Wallpaper");
-
- c.append(&w::hint(
- "Sets the desktop wallpaper, generates a matching pywal palette, and \
- reloads the shared bread-theme stylesheet — the wallpaper drives \
- the whole desktop's accent colors.",
- ));
-
- let preview_card = GBox::new(Orientation::Vertical, 8);
- preview_card.add_css_class("card");
- preview_card.set_halign(gtk4::Align::Center);
- preview_card.set_margin_top(8);
- preview_card.set_margin_bottom(8);
-
- let preview = Image::new();
- preview.set_pixel_size(320);
- preview_card.append(&preview);
-
- let path_lbl = Label::new(None);
- path_lbl.set_wrap(true);
- path_lbl.add_css_class("dim-label");
- preview_card.append(&path_lbl);
- c.append(&preview_card);
-
- refresh_preview(&preview, &path_lbl);
-
- let btn_row = GBox::new(Orientation::Horizontal, 8);
- btn_row.set_margin_top(8);
- btn_row.set_halign(gtk4::Align::Center);
-
- let choose_btn = Button::with_label("Choose image...");
- choose_btn.add_css_class("suggested-action");
- let status = Label::new(None);
- status.add_css_class("dim-label");
-
- {
- let preview = preview.clone();
- let path_lbl = path_lbl.clone();
- let status = status.clone();
- choose_btn.connect_clicked(move |btn| {
- let window = btn.root().and_then(|r| r.downcast::().ok());
- let dialog = FileDialog::new();
- dialog.set_title("Choose a wallpaper");
-
- // Restricted to what breadpaper's own validate() actually
- // accepts — GdkPixbuf (and thus the file dialog's own preview)
- // would happily offer svg/tiff/etc. that breadpaper rejects
- // outright.
- let filter = FileFilter::new();
- for ext in WALLPAPER_EXTS {
- filter.add_suffix(ext);
- }
- filter.set_name(Some("Images"));
- let filters = gtk4::gio::ListStore::new::();
- filters.append(&filter);
- dialog.set_filters(Some(&filters));
- dialog.set_default_filter(Some(&filter));
-
- let preview = preview.clone();
- let path_lbl = path_lbl.clone();
- let status = status.clone();
- dialog.open(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
- if let Ok(file) = result {
- if let Some(path) = file.path() {
- set_wallpaper(path, preview.clone(), path_lbl.clone(), status.clone());
- }
- }
- });
- });
- }
-
- btn_row.append(&choose_btn);
- btn_row.append(&status);
- c.append(&btn_row);
-
- // Library: a thumbnail grid so picking a wallpaper doesn't mean guessing
- // blind from filenames in a file-picker list. Scanned lazily behind a
- // button click, not at panel-build time — every view is built eagerly at
- // app launch (see window.rs), and decoding dozens of scaled thumbnails
- // synchronously would add real latency to every bos-settings launch, not
- // just the first visit to this panel.
- c.append(&w::section("Library"));
- let library_dir = wallpaper_library_dir();
- let flow = FlowBox::new();
- flow.set_selection_mode(gtk4::SelectionMode::None);
- flow.set_max_children_per_line(6);
- flow.set_row_spacing(4);
- flow.set_column_spacing(4);
- flow.set_homogeneous(true);
-
- let flow_wrapper = GBox::new(Orientation::Vertical, 4);
- let browse_btn = Button::with_label(&format!("Browse {}", library_dir.display()));
- browse_btn.set_halign(gtk4::Align::Start);
- flow_wrapper.append(&browse_btn);
- c.append(&flow_wrapper);
-
- {
- let preview = preview.clone();
- let path_lbl = path_lbl.clone();
- let status = status.clone();
- let flow = flow.clone();
- let flow_wrapper = flow_wrapper.clone();
- let library_dir = library_dir.clone();
- browse_btn.connect_clicked(move |btn| {
- btn.set_sensitive(false);
- while let Some(child) = flow.first_child() {
- flow.remove(&child);
- }
- let paths = scan_wallpapers(&library_dir);
- if paths.is_empty() {
- flow_wrapper.append(&w::empty_state(
- "image-missing",
- "No wallpapers found",
- &format!("Nothing under {} — use Choose image... above instead.", library_dir.display()),
- ));
- } else {
- for path in &paths {
- let preview = preview.clone();
- let path_lbl = path_lbl.clone();
- let status = status.clone();
- if let Some(thumb) = thumbnail(path, move |p| {
- set_wallpaper(p, preview.clone(), path_lbl.clone(), status.clone());
- }) {
- flow.insert(&thumb, -1);
- }
- }
- flow_wrapper.append(&flow);
- }
- btn.set_visible(false);
- });
- }
-
- outer
-}
diff --git a/src/ui/views/breadsearch.rs b/src/ui/views/breadsearch.rs
deleted file mode 100644
index 4245339..0000000
--- a/src/ui/views/breadsearch.rs
+++ /dev/null
@@ -1,115 +0,0 @@
-//! breadsearch/config.toml — semantic search indexer (breadmill) + GUI.
-//! Schema mirrors breadsearch-shared::Config ([index], [search], [model], [power]).
-
-use std::cell::RefCell;
-use std::rc::Rc;
-
-use gtk4::prelude::*;
-use gtk4::Box as GBox;
-
-use crate::config;
-use crate::ui::widgets as w;
-
-fn config_path() -> std::path::PathBuf {
- config::config_dir().join("breadsearch/config.toml")
-}
-
-pub fn build() -> GBox {
- let path = config_path();
- let doc = Rc::new(RefCell::new(config::load_doc(&path)));
-
- let (outer, c) = w::view_scaffold("File Search");
- c.append(&w::service_control("breadmill.service", false, true));
-
- c.append(&w::section("Power"));
- c.append(&w::hint(
- "breadmill's embedding step is CPU/NPU/GPU-heavy. Turn it off entirely, \
- or just pause it on battery — it resumes automatically on AC power.",
- ));
- c.append(&w::switch_row("Enabled", &doc, &["power", "enabled"], true));
- c.append(&w::switch_row(
- "Index while on battery",
- &doc,
- &["power", "run_on_battery"],
- false,
- ));
-
- c.append(&w::section("Model"));
- // breadsearch v0.2.3+ publishes breadmill built with --features full
- // (npu + rocm + cuda + openvino all in one binary, ort load-dynamic/
- // dlopen — no separate build needed per backend). Selecting a GPU/NPU
- // backend here only takes effect if the matching ONNX Runtime is
- // actually present on this machine; breadmill logs which EP really
- // registered at startup.
- c.append(&w::dropdown_row(
- "Compute backend",
- &doc,
- &["model", "backend"],
- &["cpu", "npu", "rocm", "cuda", "openvino"],
- "cpu",
- ));
- c.append(&w::hint(
- "npu = AMD Ryzen AI (XDNA), rocm = AMD GPU (via MIGraphX), cuda = \
- NVIDIA GPU, openvino = Intel iGPU/Arc GPU. Each needs the matching \
- ONNX Runtime installed and visible to breadmill (system package, \
- or ORT_DYLIB_PATH) — check \
- `journalctl --user -u breadmill` after restarting for a \
- \"Successfully registered\" line confirming it actually took. \
- ROCm additionally needs ORT_MIGRAPHX_MODEL_CACHE_PATH set (bakery's \
- breadmill.service sets this by default) or the first query after \
- each backend/model change costs a one-time ~1-2 minute GPU compile.",
- ));
-
- c.append(&w::section("Index"));
- c.append(&w::csv_row(
- "Roots",
- &doc,
- &["index", "roots"],
- "~/Documents, ~/Projects",
- ));
- c.append(&w::csv_row(
- "Excludes",
- &doc,
- &["index", "excludes"],
- "~/Projects/some-noisy-repo",
- ));
- c.append(&w::csv_row(
- "Extensions",
- &doc,
- &["index", "extensions"],
- "md, txt, org, pdf, odt, docx",
- ));
- c.append(&w::spin_f64_row(
- "Max file size (MB)",
- &doc,
- &["index", "max_file_mb"],
- 0.1,
- 500.0,
- 0.5,
- 1,
- 10.0,
- ));
-
- c.append(&w::section("Search"));
- c.append(&w::spin_row(
- "Result limit",
- &doc,
- &["search", "limit"],
- 1.0,
- 100.0,
- 1.0,
- 10,
- ));
- c.append(&w::spin_row(
- "Snippet length",
- &doc,
- &["search", "snippet_len"],
- 20.0,
- 2000.0,
- 20.0,
- 200,
- ));
-
- outer.append(&w::save_button(&doc, path));
- outer
-}
diff --git a/src/ui/views/datetime.rs b/src/ui/views/datetime.rs
deleted file mode 100644
index a721b4e..0000000
--- a/src/ui/views/datetime.rs
+++ /dev/null
@@ -1,107 +0,0 @@
-//! Timezone + NTP, over `timedatectl`. `systemd-timesyncd` is enabled by
-//! default (see `post-install.sh`), so NTP sync is on out of the box — this
-//! panel is mostly for picking a timezone and confirming sync is healthy.
-
-use gtk4::prelude::*;
-use gtk4::{Box as GBox, DropDown, Expression, Label, StringList, Switch};
-use std::process::Command;
-
-use crate::ui::widgets as w;
-
-fn show_property(prop: &str) -> String {
- Command::new("timedatectl")
- .args(["show", &format!("--property={prop}")])
- .output()
- .ok()
- .and_then(|o| {
- String::from_utf8_lossy(&o.stdout)
- .trim()
- .strip_prefix(&format!("{prop}="))
- .map(str::to_string)
- })
- .unwrap_or_default()
-}
-
-fn list_timezones() -> Vec {
- Command::new("timedatectl")
- .arg("list-timezones")
- .output()
- .ok()
- .map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect())
- .unwrap_or_default()
-}
-
-fn current_time_label() -> String {
- Command::new("date")
- .arg("+%A, %d %B %Y %H:%M")
- .output()
- .ok()
- .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
- .unwrap_or_default()
-}
-
-pub fn build() -> GBox {
- let (outer, content) = w::view_scaffold("Date & Time");
-
- content.append(&w::info_row("Current time", ¤t_time_label()));
-
- content.append(&w::section("Timezone"));
- let zones = list_timezones();
- let current_tz = show_property("Timezone");
- if zones.is_empty() {
- content.append(&w::hint("Couldn't list timezones (is timedatectl available?)."));
- } else {
- let labels: Vec<&str> = zones.iter().map(String::as_str).collect();
- let model = StringList::new(&labels);
- let dd = DropDown::new(Some(model), Expression::NONE);
- let sel = zones.iter().position(|z| *z == current_tz).unwrap_or(0);
- dd.set_selected(sel as u32);
-
- let status = Label::new(None);
- status.add_css_class("dim-label");
-
- {
- let zones = zones.clone();
- let status = status.clone();
- dd.connect_selected_notify(move |dd| {
- let Some(tz) = zones.get(dd.selected() as usize) else { return };
- status.set_text("Applying…");
- let log_buf = gtk4::TextBuffer::new(None);
- let status2 = status.clone();
- w::stream_command_then(
- &["pkexec", "timedatectl", "set-timezone", tz],
- log_buf.clone(),
- move || {
- let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
- status2.set_text(if text.trim().is_empty() {
- "Applied"
- } else {
- "Error — check the timezone name"
- });
- },
- );
- });
- }
-
- content.append(&w::row("Timezone", &dd));
- content.append(&status);
- }
-
- content.append(&w::section("Network time"));
- let ntp_sw = Switch::new();
- ntp_sw.set_active(show_property("NTP") == "yes");
- ntp_sw.connect_active_notify(|s| {
- let val = if s.is_active() { "true" } else { "false" };
- let _ = Command::new("pkexec").args(["timedatectl", "set-ntp", val]).spawn();
- });
- content.append(&w::row("Synchronize automatically", &ntp_sw));
-
- let synced = show_property("NTPSynchronized") == "yes";
- content.append(&w::hint(if synced {
- "Synchronized."
- } else {
- "Not synchronized yet (needs network)."
- }));
-
- outer
-}
diff --git a/src/ui/views/firewall.rs b/src/ui/views/firewall.rs
deleted file mode 100644
index 92d6041..0000000
--- a/src/ui/views/firewall.rs
+++ /dev/null
@@ -1,322 +0,0 @@
-//! ufw firewall rules — BOS enables ufw by default (deny incoming, allow
-//! outgoing, mDNS allowed for printer discovery; see post-install.sh) but
-//! previously offered no graphical way to add/remove a rule for something
-//! like a local dev server or a LAN game, only a terminal.
-//!
-//! `ufw status` itself requires root — confirmed against the installed
-//! ufw script, which exits with "ERROR: You need to be root to run this
-//! script" for a plain status check, not just for changes. Every other
-//! panel's read-only state is free to query eagerly in `build()`, but doing
-//! that here would mean a polkit password prompt on every single
-//! bos-settings launch (every view is constructed immediately at startup —
-//! see window.rs). So this panel starts blank and loads state only when the
-//! user clicks Refresh, deferring the one unavoidable prompt to an explicit
-//! action instead of forcing it on app open.
-
-use gtk4::prelude::*;
-use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch, TextView};
-use std::cell::Cell;
-use std::rc::Rc;
-
-use crate::ui::widgets as w;
-
-#[derive(Clone)]
-struct Rule {
- number: String,
- text: String,
-}
-
-struct Status {
- active: bool,
- rules: Vec,
-}
-
-/// One `pkexec ufw status numbered` call, parsed for both the active/inactive
-/// line and the numbered rules — a single privileged read instead of two.
-/// `Err` carries stderr (or a description of the exec failure) so callers can
-/// show *why* it failed instead of leaving the page silently on "Status not
-/// loaded" forever — previously this returned `Option`, discarding
-/// the reason entirely, so a failed pkexec call (wrong password, no polkit
-/// agent running in the session, ufw missing, ...) looked identical to
-/// simply never having clicked Refresh.
-fn fetch_status() -> Result {
- let output = std::process::Command::new("pkexec")
- .args(["ufw", "status", "numbered"])
- .output()
- .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(Rule { number, text: rest.trim().to_string() })
- })
- .collect();
- Ok(Status { active, rules })
-}
-
-fn render_rules(list: &ListBox, rules: &[Rule], programmatic: &Rc>, log_buf: >k4::TextBuffer, log_view: &TextView) {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
- if rules.is_empty() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- row.set_child(Some(&w::empty_state(
- "security-high-symbolic",
- "No rules",
- "Only the default policy (deny incoming, allow outgoing) applies.",
- )));
- list.append(&row);
- return;
- }
- for rule in rules {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- let hbox = GBox::new(Orientation::Horizontal, 16);
- hbox.add_css_class("card");
- hbox.set_margin_top(3);
- hbox.set_margin_bottom(3);
-
- let text_lbl = Label::new(Some(&rule.text));
- text_lbl.set_hexpand(true);
- text_lbl.set_xalign(0.0);
- text_lbl.set_wrap(true);
-
- let remove_btn = Button::with_label("Remove");
- remove_btn.add_css_class("destructive-action");
- {
- let list = list.clone();
- let number = rule.number.clone();
- let programmatic = programmatic.clone();
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- remove_btn.connect_clicked(move |_| {
- log_buf.set_text("");
- log_view.set_visible(true);
- let list2 = list.clone();
- let programmatic2 = programmatic.clone();
- let log_buf2 = log_buf.clone();
- let log_view2 = log_view.clone();
- w::stream_command_then(
- &["pkexec", "ufw", "--force", "delete", &number],
- log_buf.clone(),
- move || refresh(&list2, None, &programmatic2, &log_buf2, &log_view2),
- );
- });
- }
-
- hbox.append(&text_lbl);
- hbox.append(&remove_btn);
- row.set_child(Some(&hbox));
- list.append(&row);
- }
-}
-
-/// Shows `message` as the list's only row, in place of the rule list —
-/// used both for the initial "not loaded yet" state and for a failed
-/// Refresh, so a failure looks like a state, not a no-op.
-fn render_message(list: &ListBox, icon: &str, title: &str, detail: &str) {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
- let row = ListBoxRow::new();
- row.set_selectable(false);
- row.set_child(Some(&w::empty_state(icon, title, detail)));
- list.append(&row);
-}
-
-/// Re-fetch status on a background thread and update the list (+ switch, if
-/// given) on completion. `enabled_sw` is `None` when called from a row
-/// action (delete/add) where the enabled state can't have changed.
-///
-/// `programmatic` guards against `set_active` below re-triggering the
-/// switch's own `connect_active_notify` handler (which calls `ufw enable`/
-/// `disable`) — without it, the very first Refresh after ufw turns out to
-/// already be active would immediately fire an unwanted `ufw enable` the
-/// moment `set_active(true)` flips a switch that just became sensitive.
-fn refresh(list: &ListBox, enabled_sw: Option<&Switch>, programmatic: &Rc>, log_buf: >k4::TextBuffer, log_view: &TextView) {
- let (tx, rx) = async_channel::bounded::>(1);
- std::thread::spawn(move || {
- let _ = tx.send_blocking(fetch_status());
- });
- let list = list.clone();
- let enabled_sw = enabled_sw.cloned();
- let programmatic = programmatic.clone();
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- glib::spawn_future_local(async move {
- match rx.recv().await {
- Ok(Ok(status)) => {
- render_rules(&list, &status.rules, &programmatic, &log_buf, &log_view);
- if let Some(sw) = &enabled_sw {
- programmatic.set(true);
- sw.set_sensitive(true);
- sw.set_active(status.active);
- programmatic.set(false);
- }
- }
- Ok(Err(reason)) => {
- render_message(
- &list,
- "dialog-warning-symbolic",
- "Couldn't read firewall status",
- &reason,
- );
- }
- Err(_) => {
- render_message(
- &list,
- "dialog-warning-symbolic",
- "Couldn't read firewall status",
- "the background check never reported back",
- );
- }
- }
- });
-}
-
-pub fn build() -> GBox {
- let (outer, content) = w::view_scaffold("Firewall");
- content.append(&w::hint(
- "Reading and changing firewall state needs your password (polkit) \
- — ufw requires root even just to check status. Click Refresh to \
- load the current state.",
- ));
-
- let programmatic = Rc::new(Cell::new(false));
-
- let enabled_sw = Switch::new();
- enabled_sw.set_sensitive(false);
- content.append(&w::row("Firewall enabled", &enabled_sw));
- content.append(&w::hint(
- "Default policy: deny incoming, allow outgoing. mDNS (5353/udp) is \
- allowed by default so printer/network discovery keeps working.",
- ));
-
- let list = ListBox::new();
- list.set_selection_mode(gtk4::SelectionMode::None);
- render_message(
- &list,
- "security-high-symbolic",
- "Status not loaded",
- "Click Refresh below to check the firewall's current state.",
- );
- let scroll = ScrolledWindow::new();
- scroll.set_vexpand(true);
- scroll.set_min_content_height(260);
- scroll.set_child(Some(&list));
- content.append(&scroll);
-
- // Shared log view for every pkexec/ufw call on this page (refresh,
- // enable/disable, add, remove) — previously each action created its own
- // throwaway `TextBuffer::new(None)` that was never attached to any
- // visible widget, so stderr/stdout from a failing command (including
- // pkexec itself failing) went nowhere the user could see.
- let log_buf = gtk4::TextBuffer::new(None);
- let log_view = TextView::with_buffer(&log_buf);
- log_view.set_editable(false);
- log_view.set_monospace(true);
- log_view.set_height_request(140);
- log_view.set_margin_top(8);
- log_view.set_visible(false);
-
- let refresh_btn = Button::with_label("Refresh status");
- {
- let list = list.clone();
- let enabled_sw = enabled_sw.clone();
- let programmatic = programmatic.clone();
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- refresh_btn.connect_clicked(move |_| refresh(&list, Some(&enabled_sw), &programmatic, &log_buf, &log_view));
- }
- content.append(&refresh_btn);
-
- {
- let list = list.clone();
- let programmatic = programmatic.clone();
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- enabled_sw.connect_active_notify(move |s| {
- // Skip both the pre-refresh insensitive state and any
- // programmatic set_active() from refresh() itself — only a
- // real user click should call out to ufw enable/disable.
- if !s.is_sensitive() || programmatic.get() {
- return;
- }
- let verb = if s.is_active() { "enable" } else { "disable" };
- log_buf.set_text("");
- log_view.set_visible(true);
- let list2 = list.clone();
- let programmatic2 = programmatic.clone();
- let log_buf2 = log_buf.clone();
- let log_view2 = log_view.clone();
- w::stream_command_then(&["pkexec", "ufw", "--force", verb], log_buf.clone(), move || {
- refresh(&list2, None, &programmatic2, &log_buf2, &log_view2);
- });
- });
- }
-
- content.append(&w::section("Add rule"));
- content.append(&w::hint(
- "e.g. \"8080/tcp\", \"22/tcp\", or a service name like \"OpenSSH\".",
- ));
- let add_row = GBox::new(Orientation::Horizontal, 8);
- let add_entry = Entry::new();
- add_entry.set_hexpand(true);
- add_entry.set_placeholder_text(Some("port/proto or service name"));
- let add_btn = Button::with_label("Allow");
- {
- let list = list.clone();
- let add_entry = add_entry.clone();
- let programmatic = programmatic.clone();
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- add_btn.connect_clicked(move |_| {
- let rule = add_entry.text().to_string();
- if rule.trim().is_empty() {
- return;
- }
- log_buf.set_text("");
- log_view.set_visible(true);
- let list2 = list.clone();
- let add_entry2 = add_entry.clone();
- let programmatic2 = programmatic.clone();
- let log_buf2 = log_buf.clone();
- let log_view2 = log_view.clone();
- w::stream_command_then(
- &["pkexec", "ufw", "allow", rule.trim()],
- log_buf.clone(),
- move || {
- add_entry2.set_text("");
- refresh(&list2, None, &programmatic2, &log_buf2, &log_view2);
- },
- );
- });
- }
- add_row.append(&add_entry);
- add_row.append(&add_btn);
- content.append(&add_row);
-
- content.append(&log_view);
-
- outer
-}
diff --git a/src/ui/views/firmware.rs b/src/ui/views/firmware.rs
deleted file mode 100644
index 255959b..0000000
--- a/src/ui/views/firmware.rs
+++ /dev/null
@@ -1,159 +0,0 @@
-//! fwupd firmware updates — thin GUI over `fwupdmgr`, following the same
-//! "stream command output, refresh the list on completion" pattern as
-//! Packages. fwupd itself (the daemon + CLI) is always installed;
-//! `fwupd-refresh.timer` already keeps the metadata current in the
-//! background, this panel is just for actually seeing/applying updates.
-
-use gtk4::prelude::*;
-use gtk4::{Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView};
-
-use crate::ui::widgets as w;
-use crate::ui::widgets::stream_command_then;
-
-#[derive(Clone)]
-struct FwDevice {
- name: String,
- version: String,
-}
-
-fn list_updatable() -> Vec {
- let Ok(output) = std::process::Command::new("fwupdmgr")
- .args(["get-devices", "--json"])
- .output()
- 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()
-}
-
-fn populate(list: &ListBox) {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
- let devices = list_updatable();
- if devices.is_empty() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- row.set_child(Some(&w::empty_state(
- "software-update-available-symbolic",
- "No updatable firmware devices found",
- "Not every device supports firmware updates through fwupd.",
- )));
- list.append(&row);
- return;
- }
- for dev in devices {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- let hbox = GBox::new(Orientation::Horizontal, 16);
- hbox.add_css_class("card");
- hbox.set_margin_top(3);
- hbox.set_margin_bottom(3);
-
- let name_lbl = Label::new(Some(&dev.name));
- name_lbl.set_hexpand(true);
- name_lbl.set_xalign(0.0);
- let ver_lbl = Label::new(Some(&dev.version));
- ver_lbl.add_css_class("dim-label");
- ver_lbl.set_xalign(1.0);
-
- hbox.append(&name_lbl);
- hbox.append(&ver_lbl);
- row.set_child(Some(&hbox));
- list.append(&row);
- }
-}
-
-pub fn build() -> GBox {
- let (outer, content) = w::view_scaffold("Firmware");
- content.append(&w::hint(
- "Firmware updates for hardware that supports them (UEFI, some \
- peripherals). fwupd-refresh.timer already keeps update metadata \
- current in the background.",
- ));
-
- let list = ListBox::new();
- list.set_selection_mode(gtk4::SelectionMode::None);
- populate(&list);
- let scroll = ScrolledWindow::new();
- scroll.set_vexpand(true);
- scroll.set_child(Some(&list));
- content.append(&scroll);
-
- let log_buf = gtk4::TextBuffer::new(None);
- let log_view = TextView::with_buffer(&log_buf);
- log_view.set_editable(false);
- log_view.set_monospace(true);
- log_view.set_height_request(140);
- log_view.set_margin_top(8);
- log_view.set_visible(false);
-
- let btn_row = GBox::new(Orientation::Horizontal, 8);
- btn_row.set_margin_top(12);
-
- let check_btn = Button::with_label("Check for updates");
- {
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- let list = list.clone();
- check_btn.connect_clicked(move |_| {
- log_buf.set_text("");
- log_view.set_visible(true);
- let list2 = list.clone();
- stream_command_then(&["fwupdmgr", "refresh"], log_buf.clone(), move || {
- populate(&list2);
- });
- });
- }
-
- let update_btn = Button::with_label("Update all");
- update_btn.add_css_class("suggested-action");
- {
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- let list = list.clone();
- update_btn.connect_clicked(move |_| {
- log_buf.set_text("");
- log_view.set_visible(true);
- let list2 = list.clone();
- stream_command_then(&["fwupdmgr", "update", "-y"], log_buf.clone(), move || {
- populate(&list2);
- });
- });
- }
-
- let refresh_btn = Button::with_label("Refresh list");
- {
- let list = list.clone();
- refresh_btn.connect_clicked(move |_| {
- populate(&list);
- });
- }
-
- btn_row.append(&check_btn);
- btn_row.append(&update_btn);
- btn_row.append(&refresh_btn);
- content.append(&btn_row);
- content.append(&log_view);
-
- outer
-}
diff --git a/src/ui/views/hyprland.rs b/src/ui/views/hyprland.rs
deleted file mode 100644
index 719ae27..0000000
--- a/src/ui/views/hyprland.rs
+++ /dev/null
@@ -1,287 +0,0 @@
-//! Display: live-connected-monitor readout (from `hyprctl monitors -j`) plus
-//! a real editor for `hypr/monitors.json` — the monitor *layout* Hyprland
-//! itself reads at login via `scripts/display/monitors.lua`. Like
-//! appearance.rs/autostart.rs, this is a plain typed struct round-tripped
-//! whole (JSON has no comments to preserve), not the TOML `Doc` pattern.
-//!
-//! `Default` here (the single wildcard rule) must stay in sync with
-//! `scripts/display/monitors.lua`'s own `DEFAULT_MONITORS` fallback.
-
-use std::cell::RefCell;
-use std::rc::Rc;
-
-use gtk4::prelude::*;
-use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation};
-use serde::{Deserialize, Serialize};
-
-use crate::ui::widgets as w;
-
-#[derive(Clone, Serialize, Deserialize)]
-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 {
- crate::config::config_dir().join("hypr/monitors.json")
-}
-
-fn load() -> 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()])
-}
-
-fn save(rules: &[MonitorRule]) -> std::io::Result<()> {
- let path = config_path();
- let file = MonitorsFile { monitors: rules.to_vec() };
- crate::config::atomic_write(&path, &serde_json::to_string_pretty(&file).unwrap_or_default())
-}
-
-fn get_live_monitors() -> Vec<(String, String)> {
- // Was a bare Command::new("hyprctl").output() with no timeout.
- 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((name.to_string(), format!("{w}x{h} @ {refresh:.0}Hz")))
- })
- .collect()
-}
-
-fn hypr_path(name: &str) -> std::path::PathBuf {
- crate::config::config_dir().join("hypr").join(name)
-}
-
-/// Open `path` in $EDITOR (nano if unset) inside a terminal window. Spawning
-/// an editor directly (no terminal) is a silent no-op for any TUI editor —
-/// there's nothing for it to attach to — so it always needs a terminal
-/// wrapper. Uses kitty, which is what BOS actually ships (not foot).
-fn open_in_terminal(path: &std::path::Path) {
- let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
- if let Ok(mut child) = std::process::Command::new("kitty").args(["-e", &editor]).arg(path).spawn() {
- std::thread::spawn(move || { let _ = child.wait(); });
- }
-}
-
-fn rebuild(list: &ListBox, model: &Rc>>) {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
- for (i, rule) in model.borrow().iter().enumerate() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- let hbox = GBox::new(Orientation::Horizontal, 8);
- hbox.set_margin_top(6);
- hbox.set_margin_bottom(6);
- hbox.set_margin_start(8);
- hbox.set_margin_end(8);
-
- let output = Entry::new();
- output.set_text(&rule.output);
- output.set_placeholder_text(Some("any (blank = all)"));
- output.set_width_chars(12);
-
- let mode = Entry::new();
- mode.set_text(&rule.mode);
- mode.set_placeholder_text(Some("preferred / 1920x1080@60"));
- mode.set_width_chars(18);
-
- let position = Entry::new();
- position.set_text(&rule.position);
- position.set_placeholder_text(Some("auto / 0x0"));
- position.set_width_chars(10);
-
- let scale = Entry::new();
- scale.set_text(&rule.scale);
- scale.set_placeholder_text(Some("auto / 1"));
- scale.set_width_chars(8);
- scale.set_hexpand(true);
-
- let remove = Button::with_label("Remove");
- remove.add_css_class("destructive-action");
-
- {
- let model = model.clone();
- output.connect_changed(move |e| {
- if let Some(r) = model.borrow_mut().get_mut(i) {
- r.output = e.text().to_string();
- }
- });
- }
- {
- let model = model.clone();
- mode.connect_changed(move |e| {
- if let Some(r) = model.borrow_mut().get_mut(i) {
- r.mode = e.text().to_string();
- }
- });
- }
- {
- let model = model.clone();
- position.connect_changed(move |e| {
- if let Some(r) = model.borrow_mut().get_mut(i) {
- r.position = e.text().to_string();
- }
- });
- }
- {
- let model = model.clone();
- scale.connect_changed(move |e| {
- if let Some(r) = model.borrow_mut().get_mut(i) {
- r.scale = e.text().to_string();
- }
- });
- }
- {
- let model = model.clone();
- let list = list.clone();
- remove.connect_clicked(move |_| {
- model.borrow_mut().remove(i);
- if model.borrow().is_empty() {
- model.borrow_mut().push(MonitorRule::default());
- }
- rebuild(&list, &model);
- });
- }
-
- hbox.append(&Label::new(Some("Output")));
- hbox.append(&output);
- hbox.append(&Label::new(Some("Mode")));
- hbox.append(&mode);
- hbox.append(&Label::new(Some("Position")));
- hbox.append(&position);
- hbox.append(&Label::new(Some("Scale")));
- hbox.append(&scale);
- hbox.append(&remove);
- row.set_child(Some(&hbox));
- list.append(&row);
- }
-}
-
-pub fn build() -> GBox {
- let (outer, content) = w::view_scaffold("Display");
-
- content.append(&w::section("Connected monitors"));
- let monitors = get_live_monitors();
- if monitors.is_empty() {
- content.append(&w::hint("No monitors detected (is Hyprland running?)"));
- } else {
- for (name, mode) in &monitors {
- content.append(&w::info_row(name, mode));
- }
- }
-
- content.append(&w::section("Layout"));
- content.append(&w::hint(
- "One row per monitor rule. Leave Output blank to match any monitor \
- (the default — works on any hardware). Applies on next login/reload.",
- ));
-
- let model = Rc::new(RefCell::new(load()));
- let list = ListBox::new();
- list.set_selection_mode(gtk4::SelectionMode::None);
- list.add_css_class("boxed-list");
- rebuild(&list, &model);
- content.append(&list);
-
- let add_btn = Button::with_label("Add monitor rule");
- add_btn.set_halign(gtk4::Align::Start);
- add_btn.set_margin_top(6);
- {
- let model = model.clone();
- let list = list.clone();
- add_btn.connect_clicked(move |_| {
- model.borrow_mut().push(MonitorRule::default());
- rebuild(&list, &model);
- });
- }
- content.append(&add_btn);
-
- let btn_row = GBox::new(Orientation::Horizontal, 12);
- btn_row.set_margin_top(16);
- let save_btn = Button::with_label("Save");
- save_btn.add_css_class("suggested-action");
- let status = 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);
-
- content.append(&w::section("Advanced"));
- let open_btn = Button::with_label("Open hyprland.lua in editor");
- open_btn.set_halign(gtk4::Align::Start);
- {
- let conf_path = hypr_path("hyprland.lua");
- open_btn.connect_clicked(move |_| open_in_terminal(&conf_path));
- }
- content.append(&open_btn);
-
- // breadhelp is the real keybind viewer now (SUPER+/) — /usr/share/bos/
- // keybinds.txt was deleted when breadhelp replaced bos-keybinds/
- // bos-welcome, so this used to open a file that no longer exists.
- let keybinds_btn = Button::with_label("View keybinds (breadhelp)");
- keybinds_btn.set_halign(gtk4::Align::Start);
- keybinds_btn.connect_clicked(move |_| {
- let _ = std::process::Command::new("breadhelp").spawn();
- });
- content.append(&keybinds_btn);
-
- outer
-}
diff --git a/src/ui/views/keybinds.rs b/src/ui/views/keybinds.rs
deleted file mode 100644
index a8ae2a1..0000000
--- a/src/ui/views/keybinds.rs
+++ /dev/null
@@ -1,754 +0,0 @@
-//! 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`.
-//!
-//! This editor was originally built only against the MultiLayout shape.
-//! Loading a real BOS (Flat) file into that model, then saving, silently
-//! dropped the `bindings` key entirely — still valid JSON, so the Lua
-//! `pcall` failsafes on the reading side never caught it. `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`.
-//!
-//! 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::cell::RefCell;
-use std::collections::BTreeMap;
-use std::path::Path;
-use std::rc::Rc;
-
-use gtk4::prelude::*;
-use gtk4::{
- AlertDialog, Box as GBox, Button, DropDown, Entry, Expression, Label, ListBox, ListBoxRow,
- Orientation, StringList,
-};
-use serde::{Deserialize, Serialize};
-use serde_json::{Map, Value};
-
-use crate::ui::widgets as w;
-
-/// 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 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)]
-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)]
-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 as compact
- /// inline JSON (see `extra_field`). 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)]
-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,
-}
-
-/// The editor's full in-memory state: which shape was loaded, plus the data
-/// itself. Kept together so a stray code path can't accidentally serialize
-/// `file` without knowing which shape it's supposed to come back out as.
-struct Model {
- kind: SchemaKind,
- file: BindsFile,
-}
-
-fn config_path() -> std::path::PathBuf {
- crate::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();
- crate::config::atomic_write(path, &text)
-}
-
-fn save(f: &BindsFile, kind: SchemaKind) -> std::io::Result<()> {
- save_to(&config_path(), f, kind)
-}
-
-fn mods_to_text(mods: &[String]) -> String {
- mods.join(", ")
-}
-
-fn text_to_mods(s: &str) -> Vec {
- s.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect()
-}
-
-/// A `Vec` accessor that always finds the right list regardless of
-/// whether it's `globals`, `common`, a named entry in `layouts`, or the flat
-/// `bindings` list — lets one row-builder work for every section instead of
-/// near-duplicates per schema.
-type SectionAccessor = Rc &mut Vec>;
-
-fn globals_accessor() -> SectionAccessor {
- Rc::new(|m| &mut m.file.globals)
-}
-fn common_accessor() -> SectionAccessor {
- Rc::new(|m| &mut m.file.common)
-}
-fn layout_accessor(name: String) -> SectionAccessor {
- Rc::new(move |m| m.file.layouts.entry(name.clone()).or_default())
-}
-fn bindings_accessor() -> SectionAccessor {
- Rc::new(|m| &mut m.file.bindings)
-}
-
-fn bind_row(
- model: &Rc>,
- accessor: &SectionAccessor,
- idx: usize,
- rerender: &Rc,
-) -> ListBoxRow {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- let hbox = GBox::new(Orientation::Horizontal, 6);
- hbox.set_margin_top(4);
- hbox.set_margin_bottom(4);
- hbox.set_margin_start(6);
- hbox.set_margin_end(6);
-
- let (mods_cur, key_cur, action_cur, extra_cur) = {
- let mut m = model.borrow_mut();
- let bind = &accessor(&mut m)[idx];
- (
- mods_to_text(bind.mods.as_deref().unwrap_or(&[])),
- bind.key.clone().unwrap_or_default(),
- bind.action.clone(),
- if bind.extra.is_empty() { String::new() } else { serde_json::to_string(&bind.extra).unwrap_or_default() },
- )
- };
-
- let mods = Entry::new();
- mods.set_text(&mods_cur);
- mods.set_placeholder_text(Some("SUPER, SHIFT"));
- mods.set_width_chars(14);
-
- let key = Entry::new();
- key.set_text(&key_cur);
- key.set_placeholder_text(Some("key"));
- key.set_width_chars(8);
-
- let action = Entry::new();
- action.set_text(&action_cur);
- action.set_placeholder_text(Some("exec / focus / move_dir / ..."));
- action.set_width_chars(12);
-
- let extra = Entry::new();
- extra.set_text(&extra_cur);
- extra.set_placeholder_text(Some(r#"{"command": "..."}"#));
- extra.set_hexpand(true);
-
- let remove = Button::with_label("Remove");
- remove.add_css_class("destructive-action");
-
- {
- let model = model.clone();
- let accessor = accessor.clone();
- mods.connect_changed(move |e| {
- let mut m = model.borrow_mut();
- if let Some(b) = accessor(&mut m).get_mut(idx) {
- // Explicitly setting this field (even to an empty string,
- // which `text_to_mods` turns into `vec![]`) always records
- // `Some(_)` — "use exactly these mods" — never falls back
- // to inferring "key omitted" from an empty result.
- b.mods = Some(text_to_mods(&e.text()));
- }
- });
- }
- {
- let model = model.clone();
- let accessor = accessor.clone();
- key.connect_changed(move |e| {
- let mut m = model.borrow_mut();
- if let Some(b) = accessor(&mut m).get_mut(idx) {
- let t = e.text().to_string();
- b.key = if t.is_empty() { None } else { Some(t) };
- }
- });
- }
- {
- let model = model.clone();
- let accessor = accessor.clone();
- action.connect_changed(move |e| {
- let mut m = model.borrow_mut();
- if let Some(b) = accessor(&mut m).get_mut(idx) {
- b.action = e.text().to_string();
- }
- });
- }
- {
- let model = model.clone();
- let accessor = accessor.clone();
- // Only commit on valid JSON — otherwise every keystroke while
- // composing e.g. `{"command":"kitty"}` would momentarily wipe the
- // bind's extra fields the instant the text isn't parseable yet.
- extra.connect_changed(move |e| {
- let text = e.text();
- let parsed: Result, _> =
- if text.trim().is_empty() { Ok(Map::new()) } else { serde_json::from_str(&text) };
- match parsed {
- Ok(map) => {
- e.remove_css_class("error");
- let mut m = model.borrow_mut();
- if let Some(b) = accessor(&mut m).get_mut(idx) {
- b.extra = map;
- }
- }
- Err(_) => e.add_css_class("error"),
- }
- });
- }
- {
- let model = model.clone();
- let accessor = accessor.clone();
- let rerender = rerender.clone();
- remove.connect_clicked(move |_| {
- accessor(&mut model.borrow_mut()).remove(idx);
- rerender();
- });
- }
-
- hbox.append(&mods);
- hbox.append(&key);
- hbox.append(&action);
- hbox.append(&extra);
- hbox.append(&remove);
- row.set_child(Some(&hbox));
- row
-}
-
-/// A section = a title, an "Add bind" button, and the section's bind rows —
-/// shared by Globals, Common, every named layout, and the flat Bindings list.
-fn section(
- title: Option<&str>,
- model: &Rc>,
- accessor: SectionAccessor,
- rerender: &Rc,
-) -> GBox {
- let wrapper = GBox::new(Orientation::Vertical, 4);
- if let Some(title) = title {
- wrapper.append(&w::section(title));
- }
-
- let list = ListBox::new();
- list.set_selection_mode(gtk4::SelectionMode::None);
- list.add_css_class("boxed-list");
- let count = accessor(&mut model.borrow_mut()).len();
- for i in 0..count {
- list.append(&bind_row(model, &accessor, i, rerender));
- }
- wrapper.append(&list);
-
- let add_btn = Button::with_label("Add bind");
- add_btn.set_halign(gtk4::Align::Start);
- add_btn.set_margin_top(4);
- {
- let model = model.clone();
- let accessor = accessor.clone();
- let rerender = rerender.clone();
- add_btn.connect_clicked(move |_| {
- accessor(&mut model.borrow_mut()).push(Bind { action: "exec".to_string(), ..Default::default() });
- rerender();
- });
- }
- wrapper.append(&add_btn);
- wrapper
-}
-
-fn rerender(content: &GBox, model: &Rc>, status: &Label) {
- while let Some(child) = content.first_child() {
- content.remove(&child);
- }
- populate(content, model, status);
-}
-
-fn populate_unknown(content: &GBox, status: &Label) {
- content.append(&w::hint(
- "binds.json's schema wasn't recognized (expected a \"bindings\" key, or one of \
- \"globals\"/\"common\"/\"layouts\"). Nothing below is editable, and Save is disabled, \
- so the file on disk isn't at risk of being silently overwritten with the wrong shape. \
- Fix or remove the file by hand, then reopen this panel.",
- ));
- status.set_text("binds.json schema not recognized — editing disabled");
-}
-
-fn populate_flat(content: &GBox, model: &Rc>, status: &Label, rerender: &Rc) {
- content.append(&w::hint(
- "Mods/Key/Action are the fields every bind needs. The last column holds action-specific \
- extras as inline JSON — e.g. {\"command\": \"kitty\"}, {\"direction\": \"left\"}, \
- {\"workspace\": \"e+1\"}, {\"label\": \"...\", \"category\": \"...\"} — leave it blank \
- for actions with none (close, exit, fullscreen, ...). This machine's binds.json uses \
- BOS's flat schema (no keyboard-layout switching), so that's all there is. Applies on \
- next login/reload.",
- ));
-
- let default_mods = Entry::new();
- default_mods.set_text(&mods_to_text(&model.borrow().file.default_mods));
- default_mods.set_hexpand(true);
- default_mods.set_width_chars(20);
- {
- let model = model.clone();
- default_mods.connect_changed(move |e| {
- model.borrow_mut().file.default_mods = text_to_mods(&e.text());
- });
- }
- content.append(&w::row("Default mods", &default_mods));
-
- content.append(§ion(Some("Bindings"), model, bindings_accessor(), rerender));
-
- let _ = status;
-}
-
-fn populate_multi_layout(content: &GBox, model: &Rc>, rerender: &Rc) {
- content.append(&w::hint(
- "Mods/Key/Action are the fields every bind needs. The last column \
- holds action-specific extras as inline JSON — e.g. \
- {\"command\": \"kitty\"}, {\"direction\": \"left\"}, \
- {\"workspace\": \"e+1\"}, {\"options\": {\"repeating\": true}} — \
- leave it blank for actions with none (close, exit, fullscreen, ...). \
- Applies on next login/reload.",
- ));
-
- let layout_names: Vec = model.borrow().file.layouts.keys().cloned().collect();
-
- let top_row = GBox::new(Orientation::Horizontal, 12);
- top_row.append(&{
- let dd = DropDown::new(
- Some(StringList::new(&layout_names.iter().map(String::as_str).collect::>())),
- Expression::NONE,
- );
- let cur = model.borrow().file.active_layout.clone();
- dd.set_selected(layout_names.iter().position(|n| *n == cur).unwrap_or(0) as u32);
- let model = model.clone();
- let layout_names = layout_names.clone();
- dd.connect_selected_notify(move |dd| {
- if let Some(name) = layout_names.get(dd.selected() as usize) {
- model.borrow_mut().file.active_layout = name.clone();
- }
- });
- w::row("Active layout", &dd)
- });
- content.append(&top_row);
-
- let default_mods = Entry::new();
- default_mods.set_text(&mods_to_text(&model.borrow().file.default_mods));
- default_mods.set_hexpand(true);
- default_mods.set_width_chars(20);
- {
- let model = model.clone();
- default_mods.connect_changed(move |e| {
- model.borrow_mut().file.default_mods = text_to_mods(&e.text());
- });
- }
- content.append(&w::row("Default mods", &default_mods));
-
- content.append(§ion(Some("Media & function keys (globals)"), model, globals_accessor(), rerender));
- content.append(§ion(Some("Common (every layout)"), model, common_accessor(), rerender));
-
- for name in &layout_names {
- let header = GBox::new(Orientation::Horizontal, 8);
- let title = Label::new(Some(&format!("Layout: {name}")));
- title.add_css_class("heading");
- title.set_hexpand(true);
- title.set_xalign(0.0);
- title.set_margin_top(12);
- header.append(&title);
- let remove_layout = Button::with_label("Remove layout");
- remove_layout.add_css_class("destructive-action");
- {
- let model = model.clone();
- let rerender = rerender.clone();
- let name = name.clone();
- remove_layout.connect_clicked(move |btn| {
- let window = btn.root().and_then(|r| r.downcast::().ok());
- let dialog = AlertDialog::builder()
- .message(format!("Remove layout \"{name}\"?"))
- .detail("Deletes every bind defined under this layout. This can't be undone here.")
- .buttons(["Cancel", "Remove"])
- .cancel_button(0)
- .default_button(0)
- .build();
- let model = model.clone();
- let rerender = rerender.clone();
- let name = name.clone();
- dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
- if result == Ok(1) {
- let mut m = model.borrow_mut();
- m.file.layouts.remove(&name);
- if m.file.active_layout == name {
- m.file.active_layout = m.file.layouts.keys().next().cloned().unwrap_or_default();
- }
- drop(m);
- rerender();
- }
- });
- });
- }
- header.append(&remove_layout);
- content.append(&header);
-
- // No section title here — the "Layout: X" header above (with its
- // own Remove button) already covers it.
- content.append(§ion(None, model, layout_accessor(name.clone()), rerender));
- }
-
- let add_layout_row = GBox::new(Orientation::Horizontal, 8);
- add_layout_row.set_margin_top(12);
- let new_layout_name = Entry::new();
- new_layout_name.set_placeholder_text(Some("new layout name"));
- let add_layout_btn = Button::with_label("Add layout");
- {
- let model = model.clone();
- let rerender = rerender.clone();
- let entry = new_layout_name.clone();
- add_layout_btn.connect_clicked(move |_| {
- let name = entry.text().trim().to_string();
- if name.is_empty() {
- return;
- }
- model.borrow_mut().file.layouts.entry(name).or_default();
- entry.set_text("");
- rerender();
- });
- }
- add_layout_row.append(&new_layout_name);
- add_layout_row.append(&add_layout_btn);
- content.append(&add_layout_row);
-}
-
-fn populate(content: &GBox, model: &Rc>, status: &Label) {
- let rerender: Rc = {
- let content = content.clone();
- let model = model.clone();
- let status = status.clone();
- Rc::new(move || rerender(&content, &model, &status))
- };
-
- 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),
- }
-}
-
-pub fn build() -> GBox {
- let (outer, content) = w::view_scaffold("Keybinds");
- let (file, kind) = load();
- let model = Rc::new(RefCell::new(Model { kind, file }));
-
- let status = Label::new(None);
- status.add_css_class("dim-label");
-
- populate(&content, &model, &status);
-
- let btn_row = GBox::new(Orientation::Horizontal, 12);
- btn_row.set_margin_top(16);
- let save_btn = Button::with_label("Save");
- save_btn.add_css_class("suggested-action");
- {
- let model = model.clone();
- let status = status.clone();
- save_btn.connect_clicked(move |_| {
- let m = model.borrow();
- match save(&m.file, m.kind) {
- 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
-}
-
-#[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 bug: the
- /// editor 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/network.rs b/src/ui/views/network.rs
deleted file mode 100644
index 97f8b21..0000000
--- a/src/ui/views/network.rs
+++ /dev/null
@@ -1,328 +0,0 @@
-//! 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 rather than
-//! reimplemented here.
-//!
-//! The scan is deliberately NOT run in `build()` — every view is constructed
-//! eagerly at app launch (see `window.rs`), and a Wi-Fi scan takes seconds;
-//! doing it here would add that latency to every bos-settings launch. It
-//! only runs when the user clicks Scan.
-
-use gtk4::prelude::*;
-use gtk4::{Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, PasswordEntry, ScrolledWindow, Switch};
-use std::cell::RefCell;
-use std::collections::HashSet;
-use std::process::Command;
-use std::rc::Rc;
-
-use crate::ui::widgets as w;
-
-#[derive(Clone)]
-struct WifiNetwork {
- ssid: String,
- signal: i32,
- secured: bool,
- active: bool,
-}
-
-fn radio_enabled() -> bool {
- Command::new("nmcli")
- .args(["radio", "wifi"])
- .output()
- .ok()
- .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "enabled")
- .unwrap_or(false)
-}
-
-fn ethernet_status() -> Option {
- let out = Command::new("nmcli").args(["-t", "-f", "DEVICE,TYPE,STATE"]).arg("dev").output().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}"))
- })
-}
-
-fn known_connection_names() -> HashSet {
- let Ok(out) = Command::new("nmcli").args(["-t", "-f", "NAME"]).arg("con").arg("show").output() else {
- return HashSet::new();
- };
- String::from_utf8_lossy(&out.stdout).lines().map(str::to_string).collect()
-}
-
-/// Scan + list Wi-Fi networks, deduplicated by SSID (keeping the strongest
-/// signal — the same AP shows once per band/BSSID otherwise).
-fn scan_wifi() -> Vec {
- let _ = Command::new("nmcli").args(["dev", "wifi", "rescan"]).output();
- std::thread::sleep(std::time::Duration::from_secs(2));
- let Ok(out) = Command::new("nmcli")
- .args(["-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi", "list"])
- .output()
- else {
- return Vec::new();
- };
- let text = String::from_utf8_lossy(&out.stdout);
- let mut by_ssid: std::collections::HashMap = std::collections::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("*"),
- };
- 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
-}
-
-pub fn build() -> GBox {
- let (outer, content) = w::view_scaffold("Network");
-
- content.append(&w::section("Wi-Fi"));
- let radio_sw = Switch::new();
- radio_sw.set_active(radio_enabled());
- radio_sw.connect_active_notify(|s| {
- let val = if s.is_active() { "on" } else { "off" };
- let _ = Command::new("nmcli").args(["radio", "wifi", val]).spawn();
- });
- content.append(&w::row("Wi-Fi radio", &radio_sw));
-
- let status = Label::new(None);
- status.add_css_class("dim-label");
- status.set_xalign(0.0);
-
- let list = ListBox::new();
- list.set_selection_mode(gtk4::SelectionMode::None);
- // Starts with an empty_state row ("Not scanned yet") rather than being
- // hidden — the "no results yet" and "scanned, found nothing" states are
- // the same idea and should look the same, not one a hint label and the
- // other an empty_state card.
- {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- row.set_child(Some(&w::empty_state(
- "network-wireless-symbolic",
- "Not scanned yet",
- "Press Scan to see nearby networks.",
- )));
- list.append(&row);
- }
- let scroll = ScrolledWindow::new();
- scroll.set_min_content_height(220);
- scroll.set_child(Some(&list));
- content.append(&scroll);
-
- // Password entry, shown only while connecting to a secured, unknown
- // network — set visible/hidden rather than a modal dialog, to keep this
- // panel's async flow in one place instead of a nested dialog callback.
- let pw_row = GBox::new(Orientation::Horizontal, 8);
- let pw_entry = PasswordEntry::new();
- pw_entry.set_show_peek_icon(true);
- pw_entry.set_hexpand(true);
- pw_entry.set_placeholder_text(Some("Password"));
- let pw_connect_btn = Button::with_label("Connect");
- pw_row.append(&pw_entry);
- pw_row.append(&pw_connect_btn);
- pw_row.set_visible(false);
- content.append(&pw_row);
- content.append(&status);
-
- let pending_ssid: Rc>> = Rc::new(RefCell::new(None));
-
- let connect_open_or_known = {
- let status = status.clone();
- move |ssid: String| {
- status.set_text(&format!("Connecting to {ssid}…"));
- let log_buf = gtk4::TextBuffer::new(None);
- let status2 = status.clone();
- let ssid2 = ssid.clone();
- let known = known_connection_names();
- let args: Vec = if known.contains(&ssid) {
- vec!["nmcli".into(), "con".into(), "up".into(), ssid.clone()]
- } else {
- vec!["nmcli".into(), "dev".into(), "wifi".into(), "connect".into(), ssid.clone()]
- };
- let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
- w::stream_command_then(&args_ref, log_buf.clone(), move || {
- let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
- if text.to_lowercase().contains("error") {
- status2.set_text(&format!("Failed to connect to {ssid2}: {}", text.trim()));
- } else {
- status2.set_text(&format!("Connected to {ssid2}"));
- }
- });
- }
- };
-
- {
- let pw_row = pw_row.clone();
- let pw_entry = pw_entry.clone();
- let pending_ssid = pending_ssid.clone();
- let status = status.clone();
- pw_connect_btn.connect_clicked(move |_| {
- let Some(ssid) = pending_ssid.borrow_mut().take() else { return };
- let password = pw_entry.text().to_string();
- pw_row.set_visible(false);
- pw_entry.set_text("");
- status.set_text(&format!("Connecting to {ssid}…"));
- let log_buf = gtk4::TextBuffer::new(None);
- let status2 = status.clone();
- let ssid2 = ssid.clone();
- w::stream_command_then(
- &["nmcli", "dev", "wifi", "connect", &ssid, "password", &password],
- log_buf.clone(),
- move || {
- let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
- if text.to_lowercase().contains("error") {
- status2.set_text(&format!("Failed to connect to {ssid2}: wrong password?"));
- } else {
- status2.set_text(&format!("Connected to {ssid2}"));
- }
- },
- );
- });
- }
-
- let populate_list = {
- let list = list.clone();
- let pw_row = pw_row.clone();
- let pending_ssid = pending_ssid.clone();
- let connect_open_or_known = connect_open_or_known.clone();
- move |networks: Vec| {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
- if networks.is_empty() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- row.set_child(Some(&w::empty_state(
- "network-wireless-offline-symbolic",
- "No networks found",
- "Try Scan again, or check Wi-Fi radio is on.",
- )));
- list.append(&row);
- return;
- }
- let known = known_connection_names();
- for net in networks {
- let row = ListBoxRow::new();
- let hbox = GBox::new(Orientation::Horizontal, 12);
- hbox.set_margin_top(4);
- hbox.set_margin_bottom(4);
- hbox.set_margin_start(8);
- hbox.set_margin_end(8);
-
- let name = if net.active {
- format!("{} (connected)", net.ssid)
- } else {
- net.ssid.clone()
- };
- let name_lbl = Label::new(Some(&name));
- name_lbl.set_hexpand(true);
- name_lbl.set_xalign(0.0);
- if net.active {
- name_lbl.add_css_class("heading");
- }
-
- let signal_icon_name = match net.signal.clamp(0, 100) {
- 0..=24 => "network-wireless-signal-weak-symbolic",
- 25..=49 => "network-wireless-signal-ok-symbolic",
- 50..=74 => "network-wireless-signal-good-symbolic",
- _ => "network-wireless-signal-excellent-symbolic",
- };
- let signal_icon = gtk4::Image::from_icon_name(signal_icon_name);
- signal_icon.add_css_class("dim-label");
-
- let meta_lbl = Label::new(Some(&format!("{}%", net.signal.clamp(0, 100))));
- meta_lbl.add_css_class("dim-label");
-
- hbox.append(&name_lbl);
- if net.secured {
- let lock_icon = gtk4::Image::from_icon_name("channel-secure-symbolic");
- lock_icon.add_css_class("dim-label");
- hbox.append(&lock_icon);
- }
- hbox.append(&signal_icon);
- hbox.append(&meta_lbl);
-
- if !net.active {
- let connect_btn = Button::with_label("Connect");
- let ssid = net.ssid.clone();
- let secured = net.secured;
- let already_known = known.contains(&net.ssid);
- let pw_row = pw_row.clone();
- let pending_ssid = pending_ssid.clone();
- let connect_open_or_known = connect_open_or_known.clone();
- connect_btn.connect_clicked(move |_| {
- if secured && !already_known {
- *pending_ssid.borrow_mut() = Some(ssid.clone());
- pw_row.set_visible(true);
- } else {
- pw_row.set_visible(false);
- connect_open_or_known(ssid.clone());
- }
- });
- hbox.append(&connect_btn);
- }
-
- row.set_child(Some(&hbox));
- list.append(&row);
- }
- }
- };
-
- let scan_btn = Button::with_label("Scan");
- scan_btn.set_halign(gtk4::Align::Start);
- {
- let status = status.clone();
- let populate_list = populate_list.clone();
- scan_btn.connect_clicked(move |btn| {
- btn.set_sensitive(false);
- status.set_text("Scanning…");
- let (tx, rx) = async_channel::bounded::>(1);
- std::thread::spawn(move || {
- let nets = scan_wifi();
- let _ = tx.send_blocking(nets);
- });
- let btn = btn.clone();
- let status = status.clone();
- let populate_list = populate_list.clone();
- glib::spawn_future_local(async move {
- if let Ok(nets) = rx.recv().await {
- let count = nets.len();
- populate_list(nets);
- status.set_text(&format!("Found {count} network(s)"));
- }
- btn.set_sensitive(true);
- });
- });
- }
- content.append(&scan_btn);
-
- if let Some(eth) = ethernet_status() {
- content.append(&w::section("Ethernet"));
- content.append(&w::info_row("Status", ð));
- }
-
- content.append(&w::section("Advanced"));
- content.append(&w::hint("VPN, 802.1x, and static IP configuration aren't covered here."));
- let adv_btn = Button::with_label("Open connection editor");
- adv_btn.set_halign(gtk4::Align::Start);
- adv_btn.connect_clicked(|_| {
- let _ = Command::new("nm-connection-editor").spawn();
- });
- content.append(&adv_btn);
-
- outer
-}
diff --git a/src/ui/views/packages.rs b/src/ui/views/packages.rs
deleted file mode 100644
index 9768953..0000000
--- a/src/ui/views/packages.rs
+++ /dev/null
@@ -1,205 +0,0 @@
-use gtk4::prelude::*;
-use gtk4::{
- Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView,
-};
-use std::collections::HashMap;
-
-use crate::ui::widgets as w;
-use crate::ui::widgets::{stream_command, stream_command_then};
-
-fn read_installed() -> HashMap {
- 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 HashMap::new();
- };
- let Ok(mut parsed) = serde_json::from_str::(&text) else {
- return HashMap::new();
- };
- // installed.json is {"packages": {name: {version, binaries, services}}},
- // not a flat map of package name to metadata — without unwrapping this,
- // every install shows a single bogus row named "packages".
- let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else {
- return HashMap::new();
- };
- let Ok(packages) = serde_json::from_value::>(packages) else {
- return HashMap::new();
- };
-
- packages
- .into_iter()
- .filter_map(|(name, val)| {
- let version = val
- .get("version")
- .and_then(|v| v.as_str())
- .unwrap_or("unknown")
- .to_string();
- Some((name, version))
- })
- .collect()
-}
-
-fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer, log_view: &TextView) {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
-
- let packages = read_installed();
- if packages.is_empty() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- row.set_child(Some(&w::empty_state(
- "package-x-generic-symbolic",
- "No bakery packages found",
- "~/.local/state/bakery/installed.json is missing or empty.",
- )));
- list.append(&row);
- return;
- }
-
- let mut names: Vec<_> = packages.iter().collect();
- names.sort_by_key(|(k, _)| k.as_str());
-
- for (name, version) in names {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- let hbox = GBox::new(Orientation::Horizontal, 16);
- hbox.add_css_class("card");
- hbox.set_margin_top(3);
- hbox.set_margin_bottom(3);
-
- let name_lbl = Label::new(Some(name));
- name_lbl.set_hexpand(true);
- name_lbl.set_xalign(0.0);
-
- let ver_lbl = Label::new(Some(version));
- ver_lbl.set_xalign(1.0);
-
- let pkg_name = name.clone();
- let update_btn = Button::with_label("Update");
- {
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- let list = list.clone();
- update_btn.connect_clicked(move |_| {
- log_buf.set_text("");
- log_view.set_visible(true);
- let list2 = list.clone();
- let log_buf2 = log_buf.clone();
- let log_view2 = log_view.clone();
- // Route through stream_command (like the other buttons) so
- // output is visible and the row refreshes with the new
- // version once the update actually finishes — previously
- // this was fire-and-forget with the Err case silently
- // swallowed, so a missing `bakery` binary made the button
- // look broken with zero feedback either way.
- stream_command_then(&["bakery", "update", &pkg_name], log_buf.clone(), move || {
- populate_packages(&list2, &log_buf2, &log_view2);
- });
- });
- }
-
- hbox.append(&name_lbl);
- hbox.append(&ver_lbl);
- hbox.append(&update_btn);
- row.set_child(Some(&hbox));
- list.append(&row);
- }
-}
-
-pub fn build() -> GBox {
- let (outer, content) = w::view_scaffold("Packages");
- content.append(&w::hint(
- "Bread ecosystem packages installed via bakery, and system packages via pacman below.",
- ));
-
- let list = ListBox::new();
- list.set_selection_mode(gtk4::SelectionMode::None);
-
- let log_buf = gtk4::TextBuffer::new(None);
-
- // Hidden until a command actually produces output — an always-visible
- // empty log box below a short package list was the single biggest
- // "dead space" offender in the app.
- let log_view = TextView::with_buffer(&log_buf);
- log_view.set_editable(false);
- log_view.set_monospace(true);
- log_view.set_height_request(140);
- log_view.set_margin_top(8);
- log_view.set_visible(false);
-
- populate_packages(&list, &log_buf, &log_view);
-
- let scroll = ScrolledWindow::new();
- scroll.set_vexpand(true);
- scroll.set_child(Some(&list));
- content.append(&scroll);
-
- let btn_row = GBox::new(Orientation::Horizontal, 8);
- btn_row.set_margin_top(12);
-
- // Labeled "List installed", not "Check for updates" — bakery list is a
- // listing of installed packages, it doesn't check for available updates.
- let check_btn = Button::with_label("List installed");
- let update_all_btn = Button::with_label("Update all");
-
- {
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- check_btn.connect_clicked(move |_| {
- log_buf.set_text("");
- log_view.set_visible(true);
- stream_command(&["bakery", "list"], log_buf.clone());
- });
- }
-
- {
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- update_all_btn.connect_clicked(move |_| {
- log_buf.set_text("");
- log_view.set_visible(true);
- stream_command(&["bakery", "update", "--all"], log_buf.clone());
- });
- }
-
- btn_row.append(&check_btn);
- btn_row.append(&update_all_btn);
- content.append(&btn_row);
-
- // ---------------------------------------------------------------------
- // System packages (pacman) — the other update channel. bakery only
- // covers the userspace bread apps; base system/kernel/bos-settings/AUR
- // republished packages come from pacman + the [breadway] repo, and
- // bos-update (the CLI) already updates both — this panel previously
- // only exposed the bakery half, so a user relying on it alone would
- // never get base-system updates through the GUI.
- // ---------------------------------------------------------------------
- content.append(&w::section("System packages (pacman)"));
- content.append(&w::hint(
- "Base system, kernel, bos-settings, and republished AUR packages — \
- the other half of what `bos-update` covers. Needs your password \
- (polkit) since pacman requires root.",
- ));
-
- let pacman_btn_row = GBox::new(Orientation::Horizontal, 8);
- pacman_btn_row.set_margin_top(8);
- let pacman_update_btn = Button::with_label("Update system (pacman -Syu)");
- {
- let log_buf = log_buf.clone();
- let log_view = log_view.clone();
- pacman_update_btn.connect_clicked(move |_| {
- log_buf.set_text("");
- log_view.set_visible(true);
- stream_command(&["pkexec", "pacman", "-Syu", "--noconfirm"], log_buf.clone());
- });
- }
- pacman_btn_row.append(&pacman_update_btn);
- content.append(&pacman_btn_row);
-
- content.append(&log_view);
-
- outer
-}
diff --git a/src/ui/views/power.rs b/src/ui/views/power.rs
deleted file mode 100644
index f5810dc..0000000
--- a/src/ui/views/power.rs
+++ /dev/null
@@ -1,193 +0,0 @@
-//! Battery/power status (upower), brightness (brightnessctl), and TLP's
-//! current profile. Deliberately no AC/Battery/Performance *switcher* —
-//! TLP's whole model on BOS is automatic profile selection by power source
-//! (see packages.x86_64: power-profiles-daemon is intentionally not
-//! installed, it conflicts with tlp), so this panel is read-only for TLP
-//! and only exposes controls for things that are actually user choices:
-//! brightness, and charge thresholds where the hardware supports them.
-
-use gtk4::prelude::*;
-use gtk4::{Box as GBox, Scale};
-use std::process::Command;
-
-use crate::ui::widgets as w;
-
-fn upower_device(kind: &str) -> Option {
- let out = Command::new("upower").arg("-e").output().ok()?;
- String::from_utf8_lossy(&out.stdout)
- .lines()
- .find(|l| l.to_lowercase().contains(kind))
- .map(str::to_string)
-}
-
-fn upower_field(device: &str, field: &str) -> Option {
- let out = Command::new("upower").args(["-i", device]).output().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())
-}
-
-fn battery_summary() -> Vec<(String, String)> {
- let Some(bat) = upower_device("bat") else {
- return vec![("Battery".to_string(), "No battery detected".to_string())];
- };
- let mut rows = Vec::new();
- if let Some(state) = upower_field(&bat, "state") {
- rows.push(("Status".to_string(), state));
- }
- if let Some(pct) = upower_field(&bat, "percentage") {
- rows.push(("Charge".to_string(), pct));
- }
- if let Some(t) = upower_field(&bat, "time to empty").or_else(|| upower_field(&bat, "time to full")) {
- rows.push(("Time remaining".to_string(), t));
- }
- let full: Option = upower_field(&bat, "energy-full").and_then(|v| {
- v.split_whitespace().next()?.parse().ok()
- });
- let design: Option = upower_field(&bat, "energy-full-design").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
-}
-
-fn power_source() -> String {
- match upower_device("ac").and_then(|ac| upower_field(&ac, "online")) {
- Some(v) if v == "yes" => "AC power".to_string(),
- Some(_) => "Battery".to_string(),
- None => "Unknown".to_string(),
- }
-}
-
-fn tlp_profile() -> Option {
- let out = Command::new("tlp-stat").arg("-s").output().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())
-}
-
-fn brightness_device() -> Option {
- let out = Command::new("brightnessctl").output().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)
-}
-
-fn brightness_pct() -> Option {
- let out = Command::new("brightnessctl").output().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 (ideapad_laptop/thinkpad_acpi on some models;
-/// nothing on this dev laptop's Yoga Slim 7 — this is genuinely
-/// hardware-dependent, not something every install will have).
-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)
-}
-
-pub fn build() -> GBox {
- let (outer, c) = w::view_scaffold("Power");
-
- c.append(&w::section("Battery"));
- for (label, value) in battery_summary() {
- c.append(&w::info_row(&label, &value));
- }
- c.append(&w::info_row("Power source", &power_source()));
-
- c.append(&w::section("Brightness"));
- if let Some(pct) = brightness_pct() {
- let scale = Scale::with_range(gtk4::Orientation::Horizontal, 1.0, 100.0, 1.0);
- scale.set_value(pct as f64);
- scale.set_hexpand(true);
- scale.set_draw_value(true);
- if let Some(device) = brightness_device() {
- scale.connect_value_changed(move |s| {
- let pct = format!("{}%", s.value() as i64);
- let _ = Command::new("brightnessctl")
- .args(["--device", &device, "set", &pct])
- .spawn();
- });
- }
- c.append(&w::row("Screen brightness", &scale));
- } else {
- c.append(&w::hint("No controllable backlight found."));
- }
-
- if let Some((start_path, end_path)) = charge_threshold_paths() {
- c.append(&w::section("Charge limits"));
- c.append(&w::hint(
- "Some laptops let you cap charging below 100% to slow battery \
- wear on a machine that's mostly plugged in. Not every model \
- supports this — shown here because yours reported it does.",
- ));
- let start_adj = gtk4::Adjustment::new(read_threshold(&start_path) as f64, 0.0, 100.0, 1.0, 5.0, 0.0);
- let start_spin = gtk4::SpinButton::new(Some(&start_adj), 1.0, 0);
- {
- let path = start_path.clone();
- start_spin.connect_value_changed(move |s| {
- let val = (s.value() as i64).to_string();
- let _ = Command::new("pkexec")
- .args(["tee", &path.display().to_string()])
- .arg(&val)
- .output();
- });
- }
- c.append(&w::row("Start charging below (%)", &start_spin));
-
- let end_adj = gtk4::Adjustment::new(read_threshold(&end_path) as f64, 1.0, 100.0, 1.0, 5.0, 0.0);
- let end_spin = gtk4::SpinButton::new(Some(&end_adj), 1.0, 0);
- {
- let path = end_path.clone();
- end_spin.connect_value_changed(move |s| {
- let val = (s.value() as i64).to_string();
- let _ = Command::new("pkexec")
- .args(["tee", &path.display().to_string()])
- .arg(&val)
- .output();
- });
- }
- c.append(&w::row("Stop charging at (%)", &end_spin));
- }
-
- c.append(&w::section("TLP"));
- c.append(&w::hint(
- "TLP automatically applies a power-saving profile on battery and a \
- performance profile on AC — there's no manual profile switch here \
- by design (power-profiles-daemon isn't installed; it conflicts \
- with tlp).",
- ));
- c.append(&w::info_row("Current profile", tlp_profile().as_deref().unwrap_or("unknown")));
-
- outer
-}
diff --git a/src/ui/views/snapshots.rs b/src/ui/views/snapshots.rs
deleted file mode 100644
index 435f7ee..0000000
--- a/src/ui/views/snapshots.rs
+++ /dev/null
@@ -1,281 +0,0 @@
-use gtk4::prelude::*;
-use gtk4::{
- AlertDialog, Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow,
-};
-use std::process::Command;
-
-use crate::ui::widgets as w;
-
-#[derive(Clone)]
-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), so the caller
-/// can tell a real failure from a legitimately empty list instead of
-/// collapsing both into the same generic "not configured yet" message.
-fn list_snapshots() -> Result, String> {
- // NOTE: the real flag is --columns, not --output-cols (which snapper
- // rejects outright with "Unknown option") — confirmed against snapper
- // 0.13's own --help. With the wrong flag this always failed and the
- // panel silently showed "No snapshots found" on every install.
- let output = Command::new("snapper")
- .args(["list", "--columns", "number,date,description"])
- .output()
- .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 — filter it out.
- if number == "0" {
- return None;
- }
- Some(SnapshotRow {
- number,
- date: cols.next()?.trim().to_string(),
- description: cols.next()?.trim().to_string(),
- })
- })
- .collect())
-}
-
-/// Returns whether the list ended up empty, so callers can disable the
-/// selection-dependent buttons instead of leaving them clickable no-ops.
-fn populate_list(list: &ListBox) -> bool {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
- let snapshots = match list_snapshots() {
- Ok(snapshots) => snapshots,
- Err(stderr) => {
- let lower = stderr.to_lowercase();
- let (title, detail) = if lower.contains("no permission") {
- (
- "No permission to read snapshots",
- "This user isn't allowed to run snapper. Check ALLOW_USERS in \
- /etc/snapper/configs/root — it should list your username.",
- )
- } else if lower.contains("unknown config") || lower.contains("no such file") {
- (
- "Snapper isn't configured",
- "No snapper config exists for root yet, so nothing is being \
- snapshotted. This should be set up automatically at install.",
- )
- } else {
- ("Couldn't read snapshots", stderr.as_str())
- };
- let row = ListBoxRow::new();
- row.set_selectable(false);
- row.set_child(Some(&w::empty_state("dialog-warning-symbolic", title, detail)));
- list.append(&row);
- return true;
- }
- };
- if snapshots.is_empty() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- row.set_child(Some(&w::empty_state(
- "document-open-recent-symbolic",
- "No snapshots yet",
- "Snapshots are created automatically on every pacman transaction.",
- )));
- list.append(&row);
- return true;
- }
- for snap in &snapshots {
- let row = ListBoxRow::new();
- row.set_widget_name(&snap.number);
-
- let hbox = GBox::new(Orientation::Horizontal, 16);
- hbox.set_margin_top(6);
- hbox.set_margin_bottom(6);
- hbox.set_margin_start(8);
- hbox.set_margin_end(8);
-
- let num_lbl = Label::new(Some(&snap.number));
- num_lbl.set_width_chars(4);
- num_lbl.set_xalign(0.0);
-
- let date_lbl = Label::new(Some(&snap.date));
- date_lbl.set_width_chars(22);
- date_lbl.set_xalign(0.0);
-
- let desc_lbl = Label::new(Some(&snap.description));
- desc_lbl.set_hexpand(true);
- desc_lbl.set_xalign(0.0);
-
- hbox.append(&num_lbl);
- hbox.append(&date_lbl);
- hbox.append(&desc_lbl);
- row.set_child(Some(&hbox));
- list.append(&row);
- }
- false
-}
-
-pub fn build() -> GBox {
- let (outer, content) = w::view_scaffold("Snapshots");
- content.append(&w::hint(
- "System snapshots created by snap-pac on each pacman transaction. \
- Boot into one from the GRUB menu to recover; delete old ones here.",
- ));
-
- let list = ListBox::new();
- list.set_selection_mode(gtk4::SelectionMode::Single);
- let empty = populate_list(&list);
-
- let scroll = ScrolledWindow::new();
- scroll.set_vexpand(true);
- scroll.set_child(Some(&list));
- content.append(&scroll);
-
- let btn_row = GBox::new(Orientation::Horizontal, 8);
- btn_row.set_margin_top(12);
-
- let refresh_btn = Button::with_label("Refresh");
- let rollback_btn = Button::with_label("Boot into selected...");
- let delete_btn = Button::with_label("Delete selected");
- delete_btn.add_css_class("destructive-action");
- rollback_btn.set_sensitive(!empty);
- delete_btn.set_sensitive(!empty);
-
- {
- let list = list.clone();
- let rollback_btn = rollback_btn.clone();
- let delete_btn = delete_btn.clone();
- refresh_btn.connect_clicked(move |_| {
- let empty = populate_list(&list);
- rollback_btn.set_sensitive(!empty);
- delete_btn.set_sensitive(!empty);
- });
- }
-
- {
- let list = list.clone();
- rollback_btn.connect_clicked(move |btn| {
- let Some(row) = list.selected_row() else { return };
- let number = row.widget_name().to_string();
- if number.is_empty() { return }
-
- let window = btn
- .root()
- .and_then(|r| r.downcast::().ok());
-
- // BOS boots with root pinned to a named subvolume (grub emits
- // rootflags=subvol=@), so `snapper rollback`'s usual mechanism —
- // switching the btrfs *default* subvolume — has no effect here;
- // grub never consults it. The real, working way to get back to a
- // snapshot on this layout is grub-btrfs (already installed +
- // running via grub-btrfsd.service): it generates a GRUB submenu
- // entry per snapshot, bootable directly. So this button doesn't
- // touch the filesystem at all — it just points you at that menu.
- let dialog = AlertDialog::builder()
- .message(&format!("Boot into snapshot #{number}?"))
- .detail("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, or \
- later if you'd rather keep working — the menu entry \
- will still be there.")
- .buttons(["Later", "Reboot now"])
- .cancel_button(0)
- .default_button(0)
- .build();
-
- dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
- if result == Ok(1) {
- let _ = Command::new("systemctl").args(["reboot"]).spawn();
- }
- });
- });
- }
-
- {
- let list = list.clone();
- let rollback_btn = rollback_btn.clone();
- delete_btn.connect_clicked(move |btn| {
- let Some(row) = list.selected_row() else { return };
- let number = row.widget_name().to_string();
- if number.is_empty() { return }
-
- let rollback_btn = rollback_btn.clone();
- let delete_btn = btn.clone();
- let window = btn
- .root()
- .and_then(|r| r.downcast::().ok());
-
- let dialog = AlertDialog::builder()
- .message(&format!("Delete snapshot #{number}?"))
- .detail("This cannot be undone.")
- .buttons(["Cancel", "Delete"])
- .cancel_button(0)
- .default_button(0)
- .build();
-
- let window2 = window.clone();
- let list2 = list.clone();
- let rollback_btn2 = rollback_btn.clone();
- let delete_btn2 = delete_btn.clone();
- dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
- if result != Ok(1) { return }
-
- // snapper's DBus path authorizes via ALLOW_USERS, not pkexec —
- // this only works because post-install.sh seeds that config
- // key, but if it's ever missing this fails silently unless we
- // check the exit status. GTK widgets aren't Send, so hand the
- // outcome back over a channel rather than touching them from
- // the thread (same pattern as the rollback flow used to).
- let (tx, rx) = async_channel::bounded::(1);
- std::thread::spawn(move || {
- let ok = Command::new("snapper")
- .args(["delete", &number])
- .status()
- .map(|s| s.success())
- .unwrap_or(false);
- let _ = tx.send_blocking(ok);
- });
-
- let list = list2.clone();
- let window = window2.clone();
- let rollback_btn = rollback_btn2.clone();
- let delete_btn = delete_btn2.clone();
- glib::spawn_future_local(async move {
- let ok = rx.recv().await.unwrap_or(false);
- if ok {
- let empty = populate_list(&list);
- rollback_btn.set_sensitive(!empty);
- delete_btn.set_sensitive(!empty);
- } else {
- let err = AlertDialog::builder()
- .message("Delete failed")
- .detail("snapper delete exited with an error — the \
- snapshot wasn't removed.")
- .buttons(["OK"])
- .build();
- err.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, |_| {});
- }
- });
- });
- });
- }
-
- btn_row.append(&refresh_btn);
- btn_row.append(&rollback_btn);
- btn_row.append(&delete_btn);
- outer.append(&btn_row);
-
- outer
-}
diff --git a/src/ui/views/sound.rs b/src/ui/views/sound.rs
deleted file mode 100644
index 14dddf1..0000000
--- a/src/ui/views/sound.rs
+++ /dev/null
@@ -1,167 +0,0 @@
-//! 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 of `wpctl`
-//! because it can enumerate devices with human-readable descriptions and
-//! switch the default in one command; `wpctl` cannot easily do either.
-
-use gtk4::prelude::*;
-use gtk4::{Box as GBox, Button, DropDown, Expression, Orientation, Scale, StringList, Switch};
-use serde::Deserialize;
-use std::process::Command;
-
-use crate::ui::widgets as w;
-
-#[derive(Deserialize, Clone)]
-struct Device {
- name: String,
- description: String,
- mute: bool,
- volume: std::collections::HashMap,
-}
-
-#[derive(Deserialize, Clone)]
-struct VolumeChannel {
- value_percent: String,
-}
-
-impl Device {
- fn percent(&self) -> f64 {
- self.volume
- .values()
- .next()
- .and_then(|v| v.value_percent.trim_end_matches('%').trim().parse::().ok())
- .unwrap_or(0.0)
- }
-}
-
-fn list_devices(kind: &str) -> Vec {
- let Ok(output) = Command::new("pactl").args(["-f", "json", "list", kind]).output() else {
- return Vec::new();
- };
- serde_json::from_slice(&output.stdout).unwrap_or_default()
-}
-
-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()
- .ok()
- .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
- .filter(|s| !s.is_empty())
-}
-
-/// Build one section (Output or Input): device dropdown + volume slider + mute
-/// switch. `kind` is "sinks" or "sources"; `set_default_flag` is the pactl
-/// verb ("set-default-sink"/"set-default-source"); `vol_flag`/`mute_flag`
-/// likewise.
-fn build_section(
- title: &str,
- kind: &'static str,
- set_default_flag: &'static str,
- vol_flag: &'static str,
- mute_flag: &'static str,
-) -> GBox {
- let section = GBox::new(Orientation::Vertical, 4);
- section.append(&w::section(title));
-
- let devices = list_devices(kind);
- if devices.is_empty() {
- section.append(&w::hint("No devices found."));
- return section;
- }
-
- let current_name = default_device_name(kind);
- let selected_idx = current_name
- .as_ref()
- .and_then(|n| devices.iter().position(|d| &d.name == n))
- .unwrap_or(0);
- let current = devices[selected_idx].clone();
-
- let labels: Vec<&str> = devices.iter().map(|d| d.description.as_str()).collect();
- let model = StringList::new(&labels);
- let dd = DropDown::new(Some(model), Expression::NONE);
- dd.set_selected(selected_idx as u32);
- section.append(&w::row("Device", &dd));
-
- let scale = Scale::with_range(Orientation::Horizontal, 0.0, 150.0, 1.0);
- scale.set_value(current.percent());
- scale.set_size_request(180, -1);
- scale.set_draw_value(true);
- scale.set_value_pos(gtk4::PositionType::Right);
- section.append(&w::row("Volume", &scale));
-
- let mute_sw = Switch::new();
- mute_sw.set_active(current.mute);
- section.append(&w::row("Mute", &mute_sw));
-
- // Device switch: fire-and-forget pactl call, then resync the slider/mute
- // switch to whatever the newly-default device's actual state is.
- {
- let devices = devices.clone();
- let scale = scale.clone();
- let mute_sw = mute_sw.clone();
- dd.connect_selected_notify(move |dd| {
- let Some(d) = devices.get(dd.selected() as usize) else { return };
- let _ = Command::new("pactl").args([set_default_flag, &d.name]).spawn();
- scale.set_value(d.percent());
- mute_sw.set_active(d.mute);
- });
- }
-
- // Volume: target whichever device is currently selected in the dropdown,
- // not necessarily the system default at the time this closure was built.
- {
- let devices = devices.clone();
- let dd = dd.clone();
- scale.connect_value_changed(move |s| {
- let Some(d) = devices.get(dd.selected() as usize) else { return };
- let pct = format!("{}%", s.value() as i64);
- let _ = Command::new("pactl").args([vol_flag, &d.name, &pct]).spawn();
- });
- }
-
- {
- let devices = devices.clone();
- let dd = dd.clone();
- mute_sw.connect_active_notify(move |s| {
- let Some(d) = devices.get(dd.selected() as usize) else { return };
- let val = if s.is_active() { "1" } else { "0" };
- let _ = Command::new("pactl").args([mute_flag, &d.name, val]).spawn();
- });
- }
-
- section
-}
-
-pub fn build() -> GBox {
- let (outer, content) = w::view_scaffold("Sound");
-
- content.append(&build_section(
- "Output",
- "sinks",
- "set-default-sink",
- "set-sink-volume",
- "set-sink-mute",
- ));
- content.append(&build_section(
- "Input",
- "sources",
- "set-default-source",
- "set-source-volume",
- "set-source-mute",
- ));
-
- content.append(&w::section("Advanced"));
- content.append(&w::hint(
- "Per-app volume, port selection, and profile switching aren't covered here.",
- ));
- let mixer_btn = Button::with_label("Open advanced mixer (pavucontrol)");
- mixer_btn.set_halign(gtk4::Align::Start);
- mixer_btn.connect_clicked(|_| {
- let _ = Command::new("pavucontrol").spawn();
- });
- content.append(&mixer_btn);
-
- outer
-}
diff --git a/src/ui/views/users.rs b/src/ui/views/users.rs
deleted file mode 100644
index ed45796..0000000
--- a/src/ui/views/users.rs
+++ /dev/null
@@ -1,293 +0,0 @@
-//! User account management — add/remove users, change passwords. Everything
-//! here needs root (useradd/userdel/chpasswd), so every action goes through
-//! `pkexec` on a background thread (GTK widgets aren't Send), matching the
-//! async_channel + glib::spawn_future_local handoff used elsewhere for
-//! destructive/root actions (see snapshots.rs).
-
-use gtk4::prelude::*;
-use gtk4::{AlertDialog, Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
-use std::io::Write;
-use std::process::{Command, Stdio};
-
-use crate::ui::widgets as w;
-
-#[derive(Clone)]
-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 (not
- // nologin/false — 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()
-}
-
-fn current_user() -> String {
- std::env::var("USER").unwrap_or_default()
-}
-
-/// Run a root command that needs a line of input on stdin (chpasswd's own
-/// "user:password" format) on a background thread, reporting success back
-/// via a channel. `pkexec` inherits the spawning process's stdin only when
-/// explicitly piped, so this pipes it through rather than relying on that.
-fn run_with_stdin(args: &[&str], input: String, on_done: impl FnOnce(bool) + 'static) {
- let args: Vec = args.iter().map(|s| s.to_string()).collect();
- let (tx, rx) = async_channel::bounded::(1);
- std::thread::spawn(move || {
- let ok = (|| -> std::io::Result {
- let mut child = Command::new(&args[0])
- .args(&args[1..])
- .stdin(Stdio::piped())
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .spawn()?;
- if let Some(mut stdin) = child.stdin.take() {
- stdin.write_all(input.as_bytes())?;
- }
- Ok(child.wait()?.success())
- })()
- .unwrap_or(false);
- let _ = tx.send_blocking(ok);
- });
- glib::spawn_future_local(async move {
- let ok = rx.recv().await.unwrap_or(false);
- on_done(ok);
- });
-}
-
-fn populate(list: &ListBox) {
- while let Some(child) = list.first_child() {
- list.remove(&child);
- }
- let me = current_user();
- for acc in list_accounts() {
- let row = ListBoxRow::new();
- row.set_selectable(false);
- let card = GBox::new(Orientation::Vertical, 6);
- card.add_css_class("card");
- card.set_margin_top(3);
- card.set_margin_bottom(3);
-
- let top = GBox::new(Orientation::Horizontal, 12);
- let label_text = if acc.full_name.is_empty() {
- acc.username.clone()
- } else {
- format!("{} ({})", acc.username, acc.full_name)
- };
- let name_lbl = Label::new(Some(&label_text));
- name_lbl.set_hexpand(true);
- name_lbl.set_xalign(0.0);
-
- let change_pw_btn = Button::with_label("Change password");
- let remove_btn = Button::with_label("Remove");
- remove_btn.add_css_class("destructive-action");
- // Don't let the panel remove the account it's currently running as
- // — that's a self-lockout, not a normal account-management action.
- remove_btn.set_sensitive(acc.username != me);
-
- top.append(&name_lbl);
- top.append(&change_pw_btn);
- top.append(&remove_btn);
- card.append(&top);
-
- // Inline password row, hidden until "Change password" is clicked —
- // same pattern as Network's secured-connection password prompt.
- let pw_row = GBox::new(Orientation::Horizontal, 8);
- let pw_entry = Entry::new();
- pw_entry.set_visibility(false);
- pw_entry.set_hexpand(true);
- pw_entry.set_placeholder_text(Some("New password"));
- let pw_apply_btn = Button::with_label("Apply");
- pw_row.append(&pw_entry);
- pw_row.append(&pw_apply_btn);
- pw_row.set_visible(false);
- card.append(&pw_row);
-
- let status = Label::new(None);
- status.add_css_class("dim-label");
- status.set_xalign(0.0);
- card.append(&status);
-
- {
- let pw_row = pw_row.clone();
- change_pw_btn.connect_clicked(move |_| {
- pw_row.set_visible(!pw_row.is_visible());
- });
- }
- {
- let username = acc.username.clone();
- let pw_entry = pw_entry.clone();
- let pw_row = pw_row.clone();
- let status = status.clone();
- pw_apply_btn.connect_clicked(move |_| {
- let password = pw_entry.text().to_string();
- if password.is_empty() {
- return;
- }
- let input = format!("{username}:{password}\n");
- let status2 = status.clone();
- let pw_entry2 = pw_entry.clone();
- let pw_row2 = pw_row.clone();
- status.set_text("Applying…");
- run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
- if ok {
- status2.set_text("Password changed");
- pw_entry2.set_text("");
- pw_row2.set_visible(false);
- } else {
- status2.set_text("Failed to change password");
- }
- });
- });
- }
- {
- let list = list.clone();
- let username = acc.username.clone();
- remove_btn.connect_clicked(move |btn| {
- let window = btn.root().and_then(|r| r.downcast::().ok());
- let dialog = AlertDialog::builder()
- .message(format!("Remove user {username}?"))
- .detail("Deletes the account and its home directory. This cannot be undone.")
- .buttons(["Cancel", "Remove"])
- .cancel_button(0)
- .default_button(0)
- .build();
- let list2 = list.clone();
- let username2 = username.clone();
- dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
- if result != Ok(1) {
- return;
- }
- let log_buf = gtk4::TextBuffer::new(None);
- let list3 = list2.clone();
- w::stream_command_then(
- &["pkexec", "userdel", "-r", &username2],
- log_buf,
- move || populate(&list3),
- );
- });
- });
- }
-
- row.set_child(Some(&card));
- list.append(&row);
- }
-}
-
-pub fn build() -> GBox {
- let (outer, content) = w::view_scaffold("Users");
- content.append(&w::hint(
- "Real login accounts on this machine (system/service accounts \
- aren't shown). Your own account can't be removed from here.",
- ));
-
- let list = ListBox::new();
- list.set_selection_mode(gtk4::SelectionMode::None);
- populate(&list);
- let scroll = ScrolledWindow::new();
- scroll.set_vexpand(true);
- scroll.set_min_content_height(260);
- scroll.set_child(Some(&list));
- content.append(&scroll);
-
- content.append(&w::section("Add user"));
- let username_entry = Entry::new();
- username_entry.set_placeholder_text(Some("username"));
- content.append(&w::row("Username", &username_entry));
-
- let fullname_entry = Entry::new();
- fullname_entry.set_placeholder_text(Some("Full name (optional)"));
- content.append(&w::row("Full name", &fullname_entry));
-
- let password_entry = Entry::new();
- password_entry.set_visibility(false);
- password_entry.set_placeholder_text(Some("password"));
- content.append(&w::row("Password", &password_entry));
-
- let add_status = Label::new(None);
- add_status.add_css_class("dim-label");
- add_status.set_xalign(0.0);
-
- let add_btn = Button::with_label("Add user");
- add_btn.add_css_class("suggested-action");
- add_btn.set_halign(gtk4::Align::Start);
- add_btn.set_margin_top(8);
- {
- let list = list.clone();
- let username_entry = username_entry.clone();
- let fullname_entry = fullname_entry.clone();
- let password_entry = password_entry.clone();
- let add_status = add_status.clone();
- add_btn.connect_clicked(move |_| {
- let username = username_entry.text().to_string();
- let full_name = fullname_entry.text().to_string();
- let password = password_entry.text().to_string();
- if username.trim().is_empty() || password.is_empty() {
- add_status.set_text("Username and password are required.");
- return;
- }
- add_status.set_text("Adding…");
-
- let mut useradd_args = vec!["pkexec", "useradd", "-m", "-s", "/bin/bash"];
- if !full_name.trim().is_empty() {
- useradd_args.push("-c");
- useradd_args.push(full_name.trim());
- }
- useradd_args.push(username.trim());
-
- let list2 = list.clone();
- let username2 = username.trim().to_string();
- let password2 = password.clone();
- let add_status2 = add_status.clone();
- let username_entry2 = username_entry.clone();
- let fullname_entry2 = fullname_entry.clone();
- let password_entry2 = password_entry.clone();
- let log_buf = gtk4::TextBuffer::new(None);
- w::stream_command_then(&useradd_args, log_buf, move || {
- let input = format!("{username2}:{password2}\n");
- let list3 = list2.clone();
- let add_status3 = add_status2.clone();
- let username_entry3 = username_entry2.clone();
- let fullname_entry3 = fullname_entry2.clone();
- let password_entry3 = password_entry2.clone();
- run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
- if ok {
- add_status3.set_text("User added.");
- username_entry3.set_text("");
- fullname_entry3.set_text("");
- password_entry3.set_text("");
- populate(&list3);
- } else {
- add_status3.set_text("User created, but setting the password failed.");
- populate(&list3);
- }
- });
- });
- });
- }
- content.append(&add_btn);
- content.append(&add_status);
-
- outer
-}
diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs
deleted file mode 100644
index 62e0609..0000000
--- a/src/ui/widgets.rs
+++ /dev/null
@@ -1,515 +0,0 @@
-//! Reusable settings rows bound to a shared `toml_edit` document.
-//!
-//! Every row reads its current value from the document on build and writes the
-//! single key it owns back into the document on change. A view collects rows,
-//! then a [`save_button`] persists the whole document to disk in one shot — so
-//! unmodelled keys and comments are always preserved (see `crate::config`).
-
-use std::cell::RefCell;
-use std::path::PathBuf;
-use std::rc::Rc;
-
-use gtk4::prelude::*;
-use gtk4::{
- Adjustment, AlertDialog, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation,
- SpinButton, StringList, Switch,
-};
-use toml_edit::DocumentMut;
-
-use crate::config;
-
-/// Shared, mutable config document handed to every row in a view.
-pub type Doc = Rc>;
-
-/// A fixed key path into the document, e.g. `&["adapters", "power", "enabled"]`.
-type Path = &'static [&'static str];
-
-fn field_label(text: &str) -> Label {
- let lbl = Label::new(Some(text));
- lbl.set_hexpand(true);
- lbl.set_xalign(0.0);
- lbl
-}
-
-/// A label + control row, the same layout every `*_row` helper below uses.
-/// Styled as its own small card (background + padding + rounded corners) so
-/// a page of settings reads as a list of distinct rows, not a flat column of
-/// labels with the control floating far away at the window's edge — which is
-/// what plain edge-aligned rows look like once [`view_scaffold`] stops
-/// letting the content column stretch to the full window width.
-/// Exposed so panels that front live system state (not a `Doc`) — About,
-/// Sound, Date & Time, Network — can still lay out rows consistently with
-/// the TOML-editor panels.
-pub fn row(label: &str, control: &impl IsA) -> GBox {
- let row = GBox::new(Orientation::Horizontal, 16);
- row.add_css_class("card");
- row.set_margin_top(3);
- row.set_margin_bottom(3);
- row.append(&field_label(label));
- control.set_halign(gtk4::Align::End);
- control.set_valign(gtk4::Align::Center);
- row.append(control);
- row
-}
-
-/// A label + read-only value label, for panels that only display state
-/// (About's CPU/memory/disk readouts, etc).
-pub fn info_row(label: &str, value: &str) -> GBox {
- let value_lbl = Label::new(Some(value));
- value_lbl.add_css_class("dim-label");
- value_lbl.set_selectable(true);
- row(label, &value_lbl)
-}
-
-/// A bold section heading with spacing above it.
-pub fn section(text: &str) -> Label {
- let lbl = Label::new(Some(text));
- lbl.add_css_class("heading");
- lbl.set_xalign(0.0);
- lbl.set_margin_top(12);
- lbl.set_margin_bottom(2);
- lbl
-}
-
-/// Small dimmed helper text under a section or row.
-pub fn hint(text: &str) -> Label {
- let lbl = Label::new(Some(text));
- lbl.add_css_class("dim-label");
- lbl.set_xalign(0.0);
- lbl.set_wrap(true);
- // set_wrap alone doesn't cap a label's *natural* width — a long enough
- // hint reports a natural width of its whole unwrapped string, which
- // `view_scaffold`'s content column (sized to its widest child, floored
- // not capped at CONTENT_MAX_WIDTH) then inherits, blowing the entire
- // panel full-width. This is what makes breadsearch/breadclip render
- // edge-to-edge while every other panel sits in a stable ~760px column.
- lbl.set_max_width_chars(64);
- lbl.set_margin_bottom(4);
- lbl
-}
-
-/// Widest a settings column is allowed to grow. Without a cap, `content`
-/// inherits hexpand from its rows' hexpand-ing labels and the ScrolledWindow
-/// stretches it to the full window width — on a maximized/ultrawide window
-/// that leaves the control on every row stranded ~1500px from its label.
-///
-/// `set_size_request` below sets this as a *minimum*, not just a cap (GTK4
-/// has no real max-width clamp without libadwaita's `AdwClamp`) — so this
-/// number also becomes a floor under the window's minimum width. 760 pushed
-/// the window's minimum past the logical screen width at Hyprland scale 2.0
-/// (e.g. 1920px physical -> 960 logical), clipping the app. 560 keeps rows
-/// readable while surviving scale 2.0 on common panel widths.
-const CONTENT_MAX_WIDTH: i32 = 560;
-
-/// A centered placeholder for a panel's empty state (no snapshots yet, no
-/// scan results yet, etc) — a dim icon + title + hint, instead of a single
-/// small sentence lost in an otherwise-empty scroll area.
-pub fn empty_state(icon_name: &str, title: &str, hint_text: &str) -> GBox {
- let wrapper = GBox::new(Orientation::Vertical, 6);
- wrapper.set_valign(gtk4::Align::Center);
- wrapper.set_halign(gtk4::Align::Center);
- wrapper.set_vexpand(true);
- wrapper.set_margin_top(32);
- wrapper.set_margin_bottom(32);
-
- let icon = gtk4::Image::from_icon_name(icon_name);
- icon.set_pixel_size(48);
- icon.add_css_class("dim-label");
- wrapper.append(&icon);
-
- let title_lbl = Label::new(Some(title));
- title_lbl.add_css_class("heading");
- wrapper.append(&title_lbl);
-
- let hint_lbl = Label::new(Some(hint_text));
- hint_lbl.add_css_class("dim-label");
- hint_lbl.set_justify(gtk4::Justification::Center);
- hint_lbl.set_wrap(true);
- hint_lbl.set_max_width_chars(48);
- wrapper.append(&hint_lbl);
-
- wrapper
-}
-
-/// Standard view scaffold: an outer vertical box with a title and a scrollable
-/// content area. Append setting rows to the returned `content`, then append a
-/// [`save_button`] to `outer`. Returns `(outer, content)`.
-pub fn view_scaffold(title: &str) -> (GBox, GBox) {
- let outer = GBox::new(Orientation::Vertical, 8);
- outer.add_css_class("view-content");
-
- let title_lbl = Label::new(Some(title));
- title_lbl.add_css_class("title");
- title_lbl.set_xalign(0.0);
- // Same width+center treatment as `content` below, so the title's left
- // edge lines up with the column it's heading instead of sitting flush
- // against the panel edge while the column centers ~440px to its right.
- title_lbl.set_halign(gtk4::Align::Center);
- title_lbl.set_size_request(CONTENT_MAX_WIDTH, -1);
- outer.append(&title_lbl);
-
- let content = GBox::new(Orientation::Vertical, 4);
- // Explicit hexpand(false) overrides the auto-computed value GTK would
- // otherwise derive from the hexpand-ing labels inside every row, and
- // Center distributes the dead space evenly either side on a wide window
- // instead of stranding it all on the right of a left-hugging column.
- content.set_hexpand(false);
- content.set_halign(gtk4::Align::Center);
- content.set_size_request(CONTENT_MAX_WIDTH, -1);
-
- let scroll = gtk4::ScrolledWindow::new();
- scroll.set_vexpand(true);
- scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
- scroll.set_child(Some(&content));
- outer.append(&scroll);
-
- (outer, content)
-}
-
-pub fn switch_row(label: &str, doc: &Doc, path: Path, default: bool) -> GBox {
- let cur = config::get_bool(&doc.borrow(), path).unwrap_or(default);
- let sw = Switch::new();
- sw.set_active(cur);
- let doc = doc.clone();
- sw.connect_active_notify(move |s| {
- config::set_bool(&mut doc.borrow_mut(), path, s.is_active());
- });
- row(label, &sw)
-}
-
-pub fn entry_row(label: &str, doc: &Doc, path: Path, placeholder: &str, default: &str) -> GBox {
- let cur = config::get_str(&doc.borrow(), path).unwrap_or_else(|| default.to_string());
- let entry = Entry::new();
- entry.set_text(&cur);
- entry.set_hexpand(true);
- entry.set_width_chars(28);
- if !placeholder.is_empty() {
- entry.set_placeholder_text(Some(placeholder));
- }
- let doc = doc.clone();
- entry.connect_changed(move |e| {
- config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str());
- });
- row(label, &entry)
-}
-
-/// `PasswordEntry` (core GTK4 since 4.0, not a libadwaita widget) has a
-/// built-in reveal/unhide eye icon via `set_show_peek_icon` — the correct
-/// idiomatic replacement for a masked `Entry`, which had no way to unhide.
-pub fn password_row(label: &str, doc: &Doc, path: Path) -> GBox {
- let cur = config::get_str(&doc.borrow(), path).unwrap_or_default();
- let entry = gtk4::PasswordEntry::new();
- entry.set_text(&cur);
- entry.set_show_peek_icon(true);
- entry.set_hexpand(true);
- entry.set_width_chars(28);
- let doc = doc.clone();
- entry.connect_changed(move |e| {
- config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str());
- });
- row(label, &entry)
-}
-
-/// A dropdown that stores the selected option string at `path`.
-pub fn dropdown_row(label: &str, doc: &Doc, path: Path, options: &[&str], default: &str) -> GBox {
- let cur = config::get_str(&doc.borrow(), path).unwrap_or_else(|| default.to_string());
- let model = StringList::new(options);
- let dd = DropDown::new(Some(model), Expression::NONE);
- let sel = options.iter().position(|o| *o == cur).unwrap_or(0) as u32;
- dd.set_selected(sel);
- let owned: Vec = options.iter().map(|s| s.to_string()).collect();
- let doc = doc.clone();
- dd.connect_selected_notify(move |dd| {
- if let Some(opt) = owned.get(dd.selected() as usize) {
- config::set_str(&mut doc.borrow_mut(), path, opt);
- }
- });
- row(label, &dd)
-}
-
-/// An integer spin button storing its value at `path`.
-pub fn spin_row(
- label: &str,
- doc: &Doc,
- path: Path,
- min: f64,
- max: f64,
- step: f64,
- default: i64,
-) -> GBox {
- let cur = config::get_i64(&doc.borrow(), path).unwrap_or(default);
- let adj = Adjustment::new(cur as f64, min, max, step, step, 0.0);
- let spin = SpinButton::new(Some(&adj), step, 0);
- let doc = doc.clone();
- spin.connect_value_changed(move |s| {
- config::set_i64(&mut doc.borrow_mut(), path, s.value() as i64);
- });
- row(label, &spin)
-}
-
-/// A fractional spin button (e.g. 0.0–1.0 confidence) storing a float.
-pub fn spin_f64_row(
- label: &str,
- doc: &Doc,
- path: Path,
- min: f64,
- max: f64,
- step: f64,
- digits: u32,
- default: f64,
-) -> GBox {
- let cur = config::get_f64(&doc.borrow(), path).unwrap_or(default);
- let adj = Adjustment::new(cur, min, max, step, step, 0.0);
- let spin = SpinButton::new(Some(&adj), step, digits);
- let doc = doc.clone();
- spin.connect_value_changed(move |s| {
- config::set_f64(&mut doc.borrow_mut(), path, s.value());
- });
- row(label, &spin)
-}
-
-/// A comma-separated list editor storing an array of strings at `path`.
-pub fn csv_row(label: &str, doc: &Doc, path: Path, placeholder: &str) -> GBox {
- let cur = config::get_str_list(&doc.borrow(), path).join(", ");
- let entry = Entry::new();
- entry.set_text(&cur);
- entry.set_hexpand(true);
- entry.set_width_chars(28);
- if !placeholder.is_empty() {
- entry.set_placeholder_text(Some(placeholder));
- }
- let doc = doc.clone();
- entry.connect_changed(move |e| {
- let items: Vec = e
- .text()
- .split(',')
- .map(|s| s.trim().to_string())
- .filter(|s| !s.is_empty())
- .collect();
- config::set_str_list(&mut doc.borrow_mut(), path, &items);
- });
- row(label, &entry)
-}
-
-/// Run `args` as a subprocess, streaming stdout+stderr into `log_buf` line by
-/// line, then call `on_done` once the process exits. GTK widgets aren't
-/// `Send`, so the child runs on its own thread and results come back over an
-/// `async_channel` to a `glib::spawn_future_local` task that touches the
-/// widget. Shared by every panel that shells out to a CLI tool and wants live
-/// output (Packages, Network, Firewall, Firmware, ...).
-pub fn stream_command_then(
- args: &[&str],
- log_buf: gtk4::TextBuffer,
- on_done: impl FnOnce() + 'static,
-) {
- let (sender, receiver) = async_channel::bounded::(256);
- let args: Vec = args.iter().map(|s| s.to_string()).collect();
-
- std::thread::spawn(move || {
- let mut child = match std::process::Command::new(&args[0])
- .args(&args[1..])
- .stdout(std::process::Stdio::piped())
- .stderr(std::process::Stdio::piped())
- .spawn()
- {
- Ok(c) => c,
- Err(e) => {
- let _ = sender.send_blocking(format!("Error: {e}"));
- return;
- }
- };
-
- // Both are Some because we spawned with Stdio::piped() above.
- let stdout = child.stdout.take().expect("stdout piped");
- let stderr = child.stderr.take().expect("stderr piped");
-
- let tx2 = sender.clone();
- let stderr_thread = std::thread::spawn(move || {
- for line in std::io::BufRead::lines(std::io::BufReader::new(stderr)).flatten() {
- let _ = tx2.send_blocking(line);
- }
- });
-
- for line in std::io::BufRead::lines(std::io::BufReader::new(stdout)).flatten() {
- let _ = sender.send_blocking(line);
- }
- let _ = child.wait();
- let _ = stderr_thread.join();
- });
-
- glib::spawn_future_local(async move {
- while let Ok(line) = receiver.recv().await {
- let mut end = log_buf.end_iter();
- log_buf.insert(&mut end, &format!("{line}\n"));
- }
- on_done();
- });
-}
-
-/// [`stream_command_then`] with no completion callback.
-pub fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) {
- stream_command_then(args, log_buf, || {});
-}
-
-fn systemctl_active(unit: &str) -> bool {
- std::process::Command::new("systemctl")
- .args(["--user", "is-active", "--quiet", unit])
- .status()
- .map(|s| s.success())
- .unwrap_or(false)
-}
-
-fn systemctl_enabled(unit: &str) -> bool {
- std::process::Command::new("systemctl")
- .args(["--user", "is-enabled", "--quiet", unit])
- .status()
- .map(|s| s.success())
- .unwrap_or(false)
-}
-
-/// Live systemd `--user` unit status plus Start/Stop/Restart/Logs controls —
-/// every bread-ecosystem panel whose app is actually a daemon (not just a
-/// config file) gets this, instead of only ever being able to edit the TOML
-/// and hope the running process picks it up.
-///
-/// `critical` guards Stop behind a confirm dialog — for a unit like `breadd`
-/// (the whole desktop's event backbone), a bare click currently stops it as
-/// casually as an optional sync helper; everything else can stay one-click.
-///
-/// `has_config` adds a one-line note that Restart (not Save) is what applies
-/// config changes below — previously only breadsearch's panel said this,
-/// inconsistently, in its own hint text; bread/breadbox/breadcrumbs edit a
-/// running daemon's TOML and never mentioned the running process won't
-/// notice until restarted.
-pub fn service_control(unit: &'static str, critical: bool, has_config: bool) -> GBox {
- let wrapper = GBox::new(Orientation::Vertical, 4);
- wrapper.append(§ion("Service"));
-
- let status_lbl = Label::new(None);
- status_lbl.add_css_class("dim-label");
- status_lbl.set_selectable(true);
- wrapper.append(&row(unit, &status_lbl));
-
- let enabled_lbl = Label::new(None);
- enabled_lbl.add_css_class("dim-label");
- wrapper.append(&row("Starts at login", &enabled_lbl));
-
- let btn_row = GBox::new(Orientation::Horizontal, 8);
- btn_row.set_margin_top(4);
- let toggle_btn = Button::new();
- let restart_btn = Button::with_label("Restart");
- let logs_btn = Button::with_label("View logs");
-
- // Rc rather than a plain closure: three different button
- // handlers all need to re-run this after their action completes, and
- // plain closures aren't Clone.
- let refresh: Rc = {
- let status_lbl = status_lbl.clone();
- let enabled_lbl = enabled_lbl.clone();
- let toggle_btn = toggle_btn.clone();
- Rc::new(move || {
- let active = systemctl_active(unit);
- status_lbl.set_text(if active { "Running" } else { "Stopped" });
- toggle_btn.set_label(if active { "Stop" } else { "Start" });
- enabled_lbl.set_text(if systemctl_enabled(unit) { "Yes" } else { "No" });
- })
- };
- refresh();
-
- let do_toggle: Rc = {
- let refresh = refresh.clone();
- Rc::new(move || {
- let verb = if systemctl_active(unit) { "stop" } else { "start" };
- let log_buf = gtk4::TextBuffer::new(None);
- let refresh = refresh.clone();
- stream_command_then(&["systemctl", "--user", verb, unit], log_buf, move || {
- refresh();
- });
- })
- };
- {
- let do_toggle = do_toggle.clone();
- toggle_btn.connect_clicked(move |btn| {
- if critical && systemctl_active(unit) {
- let window = btn.root().and_then(|r| r.downcast::().ok());
- let dialog = AlertDialog::builder()
- .message(format!("Stop {unit}?"))
- .detail(
- "This is a core part of the desktop's event handling — \
- stopping it may affect other bread apps until it's \
- restarted.",
- )
- .buttons(["Cancel", "Stop"])
- .cancel_button(0)
- .default_button(0)
- .build();
- let do_toggle = do_toggle.clone();
- dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
- if result == Ok(1) {
- do_toggle();
- }
- });
- } else {
- do_toggle();
- }
- });
- }
- {
- let refresh = refresh.clone();
- restart_btn.connect_clicked(move |_| {
- let log_buf = gtk4::TextBuffer::new(None);
- let refresh = refresh.clone();
- stream_command_then(&["systemctl", "--user", "restart", unit], log_buf, move || {
- refresh();
- });
- });
- }
- logs_btn.connect_clicked(move |_| {
- let _ = std::process::Command::new("kitty")
- .args(["-e", "journalctl", "--user", "-u", unit, "-f"])
- .spawn();
- });
-
- btn_row.append(&toggle_btn);
- btn_row.append(&restart_btn);
- btn_row.append(&logs_btn);
- wrapper.append(&btn_row);
-
- if has_config {
- wrapper.append(&hint(
- "Save (below) only writes the config file — click Restart above \
- for the running service to pick up the change.",
- ));
- }
-
- wrapper
-}
-
-/// A Save button + transient status label that persists the document to `path`.
-pub fn save_button(doc: &Doc, path: PathBuf) -> GBox {
- let btn_row = GBox::new(Orientation::Horizontal, 12);
- btn_row.set_margin_top(16);
-
- let save_btn = Button::with_label("Save");
- save_btn.add_css_class("suggested-action");
- let status = Label::new(None);
- status.add_css_class("dim-label");
-
- let doc = doc.clone();
- let status_c = status.clone();
- save_btn.connect_clicked(move |_| match config::save_doc(&path, &doc.borrow()) {
- Ok(()) => {
- status_c.set_text("Saved");
- let lbl = status_c.clone();
- glib::timeout_add_seconds_local(3, move || {
- lbl.set_text("");
- glib::ControlFlow::Break
- });
- }
- Err(e) => status_c.set_text(&format!("Error: {e}")),
- });
-
- btn_row.append(&save_btn);
- btn_row.append(&status);
- btn_row
-}
diff --git a/src/ui/window.rs b/src/ui/window.rs
deleted file mode 100644
index b485078..0000000
--- a/src/ui/window.rs
+++ /dev/null
@@ -1,81 +0,0 @@
-use gtk4::prelude::*;
-use gtk4::{Application, ApplicationWindow, Orientation, Paned, Stack};
-
-use super::sidebar;
-use super::views;
-
-// About is a settings app's most GNOME-Settings-like landing page —
-// identifies the machine, no config editor thrown at you first. Defined
-// once here and threaded through to both the `Stack` and the sidebar's
-// initial selection, so the two can't independently drift out of sync.
-const DEFAULT_PAGE: &str = "about";
-
-/// `requested_page` comes from `--page ` (e.g. breadhelp's "Learn more"
-/// deep links) and overrides `DEFAULT_PAGE` when it names a real page.
-pub fn build_ui(app: &Application, requested_page: Option) {
- let window = ApplicationWindow::builder()
- .application(app)
- .title("BOS Settings")
- .default_width(960)
- .default_height(640)
- .build();
-
- crate::theme::load(&WidgetExt::display(&window));
-
- let hpaned = Paned::new(Orientation::Horizontal);
- hpaned.set_position(210);
- hpaned.set_shrink_start_child(false);
- hpaned.set_resize_start_child(false);
-
- let initial_page = requested_page.as_deref().unwrap_or(DEFAULT_PAGE);
- let (sidebar_box, list) = sidebar::build(initial_page);
-
- let stack = Stack::new();
- stack.set_hexpand(true);
- stack.set_vexpand(true);
-
- stack.add_named(&views::about::build(), Some("about"));
- stack.add_named(&views::network::build(), Some("network"));
- stack.add_named(&views::bluetooth::build(), Some("bluetooth"));
- stack.add_named(&views::sound::build(), Some("sound"));
- stack.add_named(&views::datetime::build(), Some("datetime"));
- stack.add_named(&views::power::build(), Some("power"));
- stack.add_named(&views::firewall::build(), Some("firewall"));
- stack.add_named(&views::users::build(), Some("users"));
- stack.add_named(&views::snapshots::build(), Some("snapshots"));
- stack.add_named(&views::packages::build(), Some("packages"));
- stack.add_named(&views::firmware::build(), Some("firmware"));
- stack.add_named(&views::aur::build(), Some("aur"));
- stack.add_named(&views::bread::build(), Some("bread"));
- stack.add_named(&views::breadbar::build(), Some("breadbar"));
- stack.add_named(&views::breadbox::build(), Some("breadbox"));
- stack.add_named(&views::breadclip::build(), Some("breadclip"));
- stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs"));
- stack.add_named(&views::breadpad::build(), Some("breadpad"));
- stack.add_named(&views::breadpaper::build(), Some("breadpaper"));
- stack.add_named(&views::breadsearch::build(), Some("breadsearch"));
- stack.add_named(&views::hyprland::build(), Some("hyprland"));
- stack.add_named(&views::keybinds::build(), Some("keybinds"));
- stack.add_named(&views::appearance::build(), Some("appearance"));
- stack.add_named(&views::autostart::build(), Some("autostart"));
-
- stack.set_visible_child_name(initial_page);
-
- {
- let stack = stack.clone();
- list.connect_row_selected(move |_, row| {
- if let Some(row) = row {
- let name = row.widget_name();
- if !name.is_empty() {
- stack.set_visible_child_name(&name);
- }
- }
- });
- }
-
- hpaned.set_start_child(Some(&sidebar_box));
- hpaned.set_end_child(Some(&stack));
-
- window.set_child(Some(&hpaned));
- window.present();
-}
| |