From 81319dd5848bf229bfc335a52f0254e50d7c0753 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 24 May 2026 18:57:01 +0800 Subject: [PATCH] can't be bothered writing a commit message --- Cargo.lock | 675 +++++++++++++++++++++++++++++++- Cargo.toml | 11 +- README.md | 124 ++++++ breadbox-shared/Cargo.toml | 9 + breadbox-shared/src/lib.rs | 261 ++++++++++++ breadbox-sync/Cargo.toml | 14 + breadbox-sync/src/main.rs | 281 +++++++++++++ breadbox/Cargo.toml | 15 + breadbox/src/main.rs | 564 ++++++++++++++++++++++++++ config.example.toml | 19 + packaging/breadbox-sync.service | 15 + src/main.rs | 556 -------------------------- 12 files changed, 1971 insertions(+), 573 deletions(-) create mode 100644 README.md create mode 100644 breadbox-shared/Cargo.toml create mode 100644 breadbox-shared/src/lib.rs create mode 100644 breadbox-sync/Cargo.toml create mode 100644 breadbox-sync/src/main.rs create mode 100644 breadbox/Cargo.toml create mode 100644 breadbox/src/main.rs create mode 100644 config.example.toml create mode 100644 packaging/breadbox-sync.service delete mode 100644 src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 76771c4..0e8046d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,24 @@ # 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 = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.1" @@ -18,8 +30,27 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" name = "breadbox" version = "0.1.0" dependencies = [ + "breadbox-shared", "gtk4", "gtk4-layer-shell", + "serde_json", +] + +[[package]] +name = "breadbox-shared" +version = "0.1.0" +dependencies = [ + "serde", + "toml 0.8.23", +] + +[[package]] +name = "breadbox-sync" +version = "0.1.0" +dependencies = [ + "breadbox-shared", + "serde_json", + "ureq", ] [[package]] @@ -45,6 +76,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-expr" version = "0.20.7" @@ -55,6 +96,32 @@ dependencies = [ "target-lexicon", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -71,6 +138,31 @@ dependencies = [ "rustc_version", ] +[[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 = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -191,6 +283,17 @@ dependencies = [ "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.6" @@ -218,7 +321,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -441,6 +544,109 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[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 = "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 = "2.14.0" @@ -451,6 +657,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "khronos_api" version = "3.1.0" @@ -463,6 +675,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.29" @@ -484,6 +702,22 @@ dependencies = [ "autocfg", ] +[[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 = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "pango" version = "0.22.6" @@ -508,6 +742,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -520,13 +760,22 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -547,6 +796,20 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -556,12 +819,57 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[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" @@ -582,6 +890,28 @@ dependencies = [ "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" @@ -591,6 +921,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -603,6 +945,18 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -614,6 +968,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "system-deps" version = "7.0.8" @@ -623,7 +988,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "toml 1.1.2+spec-1.1.0", "version-compare", ] @@ -633,6 +998,28 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -641,11 +1028,20 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -657,6 +1053,20 @@ 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.11+spec-1.1.0" @@ -664,9 +1074,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -675,9 +1085,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -690,18 +1106,91 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[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", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[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 = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -711,6 +1200,79 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.3" @@ -720,8 +1282,103 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "xml-rs" version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +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", + "synstructure", +] + +[[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", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[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", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 7e0d3ae..2c3d18a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,3 @@ -[package] -name = "breadbox" -version = "0.1.0" -edition = "2021" - -[dependencies] -gtk4 = "0.11" -gtk4-layer-shell = "0.8" +[workspace] +members = ["breadbox-shared", "breadbox-sync", "breadbox"] +resolver = "2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b6ce13 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# breadbox + +A GTK4 app launcher for Hyprland / Wayland on Arch Linux. + +``` +breadbox-shared shared types (DesktopEntry, IconCache, Config) +breadbox-sync standalone icon resolution + caching binary +breadbox GTK4 layer-shell launcher +``` + +## Features + +- Layer-shell window, centered 600 px wide, keyboard-exclusive +- Reads the active Hyprland workspace and sorts apps by context priority +- Fuzzy filtering as you type; Enter launches, Escape closes +- App icons loaded from the resolved icon cache (see `breadbox-sync`) +- pywal palette auto-detected from `~/.cache/wal/colors.json`, falls back to Catppuccin Mocha +- User CSS override at `~/.config/breadbox/style.css` +- Toggle/dismiss: running a second instance kills the first + +## Build dependencies + +``` +gtk4 (pacman -S gtk4) +gtk4-layer-shell (pacman -S gtk4-layer-shell) +librsvg (pacman -S librsvg) # for SVG icon support +rust (stable) (rustup toolchain install stable) +``` + +## Build + +```bash +# debug +cargo build + +# release (recommended — put both binaries on $PATH) +cargo build --release +# binaries are at target/release/breadbox and target/release/breadbox-sync +``` + +Install to `~/.cargo/bin` (or anywhere on your PATH): + +```bash +cargo install --path breadbox +cargo install --path breadbox-sync +``` + +## Configuration + +Copy and edit the example config: + +```bash +mkdir -p ~/.config/breadbox +cp config.example.toml ~/.config/breadbox/config.toml +``` + +The `[[context]]` blocks map Hyprland workspace names to app priority lists. +Workspace name `"default"` is the catch-all fallback. + +```toml +[[context]] +name = "default" +priority = ["firefox", "code", "obsidian", "kitty"] + +[[context]] +name = "2" +priority = ["slack", "discord"] +``` + +### CSS theming + +breadbox applies pywal colors automatically when `~/.cache/wal/colors.json` is +present. To override or extend the theme: + +```bash +~/.config/breadbox/style.css +``` + +This file is loaded at the highest CSS priority level, so any rule here wins. + +## Icon sync + +`breadbox-sync` resolves icons for all installed apps and writes them to +`~/.cache/breadbox/`. Run it once before first launch: + +```bash +breadbox-sync +``` + +Icon resolution order: +1. System icon theme (`~/.local/share/icons`, `/usr/share/icons`, `/usr/share/pixmaps`) — 64 px > 48 px PNG, then SVG +2. Flathub media server — for reverse-DNS app IDs (e.g. `org.gnome.Gedit`) +3. icon.horse — downloaded and cached +4. `application-x-executable` fallback from system theme + +### Systemd service (run on login) + +```bash +cp packaging/breadbox-sync.service ~/.config/systemd/user/ +systemctl --user enable --now breadbox-sync.service +``` + +The service runs `breadbox-sync` once at login (after network is up) and logs +to journald. Re-run manually after installing new apps: + +```bash +systemctl --user start breadbox-sync.service +# or just: +breadbox-sync +``` + +## Hyprland keybind + +Add to `~/.config/hypr/hyprland.conf`: + +``` +bind = $mainMod, SPACE, exec, breadbox +``` + +Pressing the keybind again while the launcher is open dismisses it. + +## Licence + +MIT — see [LICENSE](LICENSE). diff --git a/breadbox-shared/Cargo.toml b/breadbox-shared/Cargo.toml new file mode 100644 index 0000000..1600acd --- /dev/null +++ b/breadbox-shared/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "breadbox-shared" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] +serde = { version = "1", features = ["derive"] } +toml = "0.8" diff --git a/breadbox-shared/src/lib.rs b/breadbox-shared/src/lib.rs new file mode 100644 index 0000000..8ce6f7d --- /dev/null +++ b/breadbox-shared/src/lib.rs @@ -0,0 +1,261 @@ +use std::{ + env, + fs::{self, File}, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +// ---- XDG path helpers ------------------------------------------------------- + +pub fn home_dir() -> PathBuf { + PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into())) +} + +pub fn cache_dir() -> PathBuf { + env::var("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".cache")) + .join("breadbox") +} + +pub fn config_dir() -> PathBuf { + env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".config")) + .join("breadbox") +} + +pub fn app_dirs() -> Vec { + let home = home_dir(); + let mut dirs = vec![PathBuf::from("/usr/share/applications")]; + + let xdg_data_dirs = env::var("XDG_DATA_DIRS") + .unwrap_or_else(|_| "/usr/local/share:/usr/share".into()); + for d in xdg_data_dirs.split(':') { + let p = PathBuf::from(d).join("applications"); + if p != dirs[0] { + dirs.push(p); + } + } + + dirs.push( + env::var("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home.join(".local/share")) + .join("applications"), + ); + dirs +} + +// ---- Desktop entry ---------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct DesktopEntry { + pub name: String, + pub exec: String, + pub icon_name: String, + pub icon_path: Option, // resolved by caller from manifest + pub categories: Vec, + pub wm_class: Option, + pub terminal: bool, +} + +pub fn strip_exec_codes(exec: &str) -> String { + let mut out = String::with_capacity(exec.len()); + let mut chars = exec.chars().peekable(); + while let Some(c) = chars.next() { + if c == '%' { + match chars.peek().copied() { + Some('%') => { + chars.next(); + out.push('%'); + } + Some(n) if n.is_ascii_alphabetic() => { + chars.next(); + } + _ => out.push(c), + } + } else { + out.push(c); + } + } + out +} + +/// Returns `None` for entries that should not be shown (hidden, NoDisplay, non-Application type). +pub fn parse_desktop(path: &Path) -> Option { + let file = File::open(path).ok()?; + let mut in_entry = false; + let mut name: Option = None; + let mut exec: Option = None; + let mut icon: Option = None; + let mut categories: Option = None; + let mut wm_class: Option = None; + let mut app_type: Option = None; + let mut no_display = false; + let mut hidden = false; + let mut terminal = false; + + for line in BufReader::new(file).lines() { + let Ok(raw) = line else { continue }; + let s = raw.trim(); + if s.starts_with('#') || s.is_empty() { + continue; + } + if s.starts_with('[') { + in_entry = s == "[Desktop Entry]"; + continue; + } + if !in_entry { + continue; + } + + if let Some(v) = s.strip_prefix("Name=") { + name.get_or_insert_with(|| v.to_string()); + } else if let Some(v) = s.strip_prefix("Exec=") { + exec.get_or_insert_with(|| v.to_string()); + } else if let Some(v) = s.strip_prefix("Icon=") { + icon.get_or_insert_with(|| v.to_string()); + } else if let Some(v) = s.strip_prefix("Categories=") { + categories.get_or_insert_with(|| v.to_string()); + } else if let Some(v) = s.strip_prefix("StartupWMClass=") { + wm_class.get_or_insert_with(|| v.to_string()); + } else if let Some(v) = s.strip_prefix("Type=") { + app_type.get_or_insert_with(|| v.to_string()); + } else if let Some(v) = s.strip_prefix("NoDisplay=") { + no_display = v == "true"; + } else if let Some(v) = s.strip_prefix("Hidden=") { + hidden = v == "true"; + } else if let Some(v) = s.strip_prefix("Terminal=") { + terminal = v == "true" || v == "1"; + } + } + + if no_display || hidden { + return None; + } + if app_type.as_deref() != Some("Application") { + return None; + } + + let name = name?.trim().to_string(); + let exec = strip_exec_codes(exec?.trim()).trim().to_string(); + if name.is_empty() || exec.is_empty() { + return None; + } + + let icon_name = icon.unwrap_or_default().trim().to_string(); + let cats = categories + .unwrap_or_default() + .split(';') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + Some(DesktopEntry { + name, + exec, + icon_name, + icon_path: None, + categories: cats, + wm_class: wm_class.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), + terminal, + }) +} + +/// Walk all configured application directories and return deduplicated entries. +/// Entries from later directories (user-local) override those from earlier ones. +pub fn load_all_desktop_entries() -> Vec { + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + for dir in app_dirs() { + let Ok(entries) = fs::read_dir(&dir) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("desktop") { + continue; + } + let key = entry.file_name().to_string_lossy().into_owned(); + if let Some(app) = parse_desktop(&path) { + seen.insert(key, app); + } + } + } + seen.into_values().collect() +} + +// ---- Icon cache ------------------------------------------------------------- + +pub struct IconCache { + pub dir: PathBuf, +} + +impl IconCache { + pub fn new() -> Self { + IconCache { dir: cache_dir().join("icons") } + } + + pub fn path_for(&self, icon_name: &str) -> PathBuf { + self.dir.join(format!("{}.png", icon_name)) + } + + pub fn manifest_path() -> PathBuf { + cache_dir().join("manifest.json") + } + + pub fn ensure_dir(&self) -> std::io::Result<()> { + fs::create_dir_all(&self.dir) + } +} + +impl Default for IconCache { + fn default() -> Self { + Self::new() + } +} + +// ---- Config ----------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + #[serde(default, rename = "context")] + pub contexts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Context { + pub name: String, + #[serde(default)] + pub priority: Vec, +} + +impl Config { + pub fn load() -> Self { + let path = config_dir().join("config.toml"); + let content = match fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(), + Err(e) => { + eprintln!("breadbox: could not read {}: {}", path.display(), e); + return Self::default(); + } + }; + match toml::from_str(&content) { + Ok(c) => c, + Err(e) => { + eprintln!("breadbox: parse error in {}: {}", path.display(), e); + Self::default() + } + } + } + + /// Find the context matching `workspace`, falling back to "default", then + /// returning None if neither exists. + pub fn context_for(&self, workspace: &str) -> Option<&Context> { + self.contexts + .iter() + .find(|c| c.name == workspace) + .or_else(|| self.contexts.iter().find(|c| c.name == "default")) + } +} diff --git a/breadbox-sync/Cargo.toml b/breadbox-sync/Cargo.toml new file mode 100644 index 0000000..1c68856 --- /dev/null +++ b/breadbox-sync/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "breadbox-sync" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[[bin]] +name = "breadbox-sync" +path = "src/main.rs" + +[dependencies] +breadbox-shared = { path = "../breadbox-shared" } +serde_json = "1" +ureq = "2" diff --git a/breadbox-sync/src/main.rs b/breadbox-sync/src/main.rs new file mode 100644 index 0000000..8c919bf --- /dev/null +++ b/breadbox-sync/src/main.rs @@ -0,0 +1,281 @@ +use std::{ + collections::HashMap, + env, + fs, + io::Read, + path::{Path, PathBuf}, +}; + +use breadbox_shared::{home_dir, IconCache}; + +// ---- Icon theme lookup ------------------------------------------------------ + +fn current_icon_theme() -> String { + let home = home_dir(); + for cfg in [ + home.join(".config/gtk-4.0/settings.ini"), + home.join(".config/gtk-3.0/settings.ini"), + ] { + if let Ok(content) = fs::read_to_string(&cfg) { + for line in content.lines() { + if let Some(v) = line.strip_prefix("gtk-icon-theme-name=") { + let t = v.trim().trim_matches('"'); + if !t.is_empty() { + return t.to_string(); + } + } + } + } + } + "hicolor".to_string() +} + +fn icon_search_dirs() -> Vec { + let home = home_dir(); + let xdg_data_home = env::var("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home.join(".local/share")); + + let mut dirs = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for d in [ + xdg_data_home.join("icons"), + home.join(".local/share/icons"), + PathBuf::from("/usr/share/icons"), + ] { + if seen.insert(d.clone()) { + dirs.push(d); + } + } + dirs +} + +/// Search for `name` in system icon theme directories. +/// Prefers 64px > 48px > 128px > 32px > 256px PNG, then scalable SVG. +fn find_system_icon(name: &str, theme: &str) -> Option { + let sizes = ["64x64", "48x48", "128x128", "32x32", "256x256"]; + let dirs = icon_search_dirs(); + + let themes: Vec<&str> = if theme != "hicolor" { + vec![theme, "hicolor"] + } else { + vec!["hicolor"] + }; + + for dir in &dirs { + for t in &themes { + for size in &sizes { + let p = dir.join(t).join(size).join("apps").join(format!("{}.png", name)); + if p.exists() { + return Some(p); + } + // Alternative path layout: /apps// + let p2 = dir.join(t).join("apps").join(size).join(format!("{}.png", name)); + if p2.exists() { + return Some(p2); + } + } + // SVG (scalable) + for subdir in ["scalable/apps", "apps/scalable"] { + let p = dir.join(t).join(subdir).join(format!("{}.svg", name)); + if p.exists() { + return Some(p); + } + } + } + } + + // /usr/share/pixmaps + for ext in ["png", "svg", "xpm"] { + let p = PathBuf::from("/usr/share/pixmaps").join(format!("{}.{}", name, ext)); + if p.exists() { + return Some(p); + } + } + + None +} + +// ---- Helpers ---------------------------------------------------------------- + +/// Strip file extension from an icon field value, returning the canonical name. +fn canonical_icon_name(icon: &str) -> String { + if icon.starts_with('/') { + return Path::new(icon) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(icon) + .to_string(); + } + icon.strip_suffix(".png") + .or_else(|| icon.strip_suffix(".svg")) + .or_else(|| icon.strip_suffix(".xpm")) + .unwrap_or(icon) + .to_string() +} + +/// A stem like `org.gnome.Gedit` or `com.github.App` — at least three segments, +/// all alphanumeric/hyphen/underscore. +fn looks_like_reverse_dns(stem: &str) -> bool { + let parts: Vec<&str> = stem.split('.').collect(); + parts.len() >= 3 + && parts[0].len() >= 2 + && parts.iter().all(|p| { + !p.is_empty() + && p.chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + }) +} + +/// Try to GET `url` and write the body to `dest`. Returns true on success. +fn try_download(agent: &ureq::Agent, url: &str, dest: &Path) -> bool { + let resp = match agent.get(url).call() { + Ok(r) if r.status() == 200 => r, + _ => return false, + }; + let mut bytes = Vec::new(); + if resp.into_reader().take(2_097_152).read_to_end(&mut bytes).is_err() || bytes.is_empty() { + return false; + } + // Validate the PNG signature so a 200 error page is never cached as an icon. + const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + if !bytes.starts_with(&PNG_MAGIC) { + return false; + } + fs::write(dest, &bytes).is_ok() +} + +/// Resolve an icon to a local path, downloading if necessary. +/// Returns None only if all strategies fail and no generic fallback is found. +fn resolve_icon( + icon_field: &str, + desktop_stem: &str, + theme: &str, + icon_cache: &IconCache, + agent: &ureq::Agent, +) -> Option { + // Absolute path in Icon= field + if icon_field.starts_with('/') { + let p = PathBuf::from(icon_field); + if p.exists() { + return Some(p); + } + } + + let name = canonical_icon_name(icon_field); + if name.is_empty() { + return find_system_icon("application-x-executable", theme); + } + + // 1. System icon theme + if let Some(p) = find_system_icon(&name, theme) { + return Some(p); + } + + // Already cached from a previous run? + let cached = icon_cache.path_for(&name); + if cached.exists() { + return Some(cached); + } + + // 2. Flathub (appstream icon path, not the media CDN) + if looks_like_reverse_dns(desktop_stem) { + let url = format!( + "https://dl.flathub.org/repo/appstream/x86_64/icons/128x128/{}.png", + desktop_stem + ); + let dest = icon_cache.path_for(desktop_stem); + if try_download(agent, &url, &dest) { + eprintln!(" [flathub] {}", desktop_stem); + return Some(dest); + } + } + + // 3. Generic fallback + find_system_icon("application-x-executable", theme) +} + +// ---- Main ------------------------------------------------------------------- + +fn main() { + if let Err(e) = run() { + eprintln!("breadbox-sync: {}", e); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let icon_cache = IconCache::new(); + icon_cache.ensure_dir()?; + + let theme = current_icon_theme(); + eprintln!("breadbox-sync: icon theme = {}", theme); + + let agent = ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(10)) + .build(); + + let mut manifest: HashMap = HashMap::new(); + + // Walk directories directly to get both the entry and its filename stem + // (needed for Flathub reverse-DNS resolution). + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + for dir in breadbox_shared::app_dirs() { + let Ok(read_dir) = fs::read_dir(&dir) else { continue }; + for file_entry in read_dir.flatten() { + let path = file_entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("desktop") { + continue; + } + + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + // User-local overrides system; process in dir order (system first, local last). + // Later entries for the same stem will overwrite earlier ones in the manifest. + + let app = match breadbox_shared::parse_desktop(&path) { + Some(a) => a, + None => continue, + }; + + if app.icon_name.is_empty() { + continue; + } + + // Deduplicate by the raw Icon= value, which is also the manifest key, + // so every distinct icon_name gets its own entry. + if !seen.insert(app.icon_name.clone()) { + continue; + } + + eprint!("resolving icon for {} ({}) ... ", app.name, app.icon_name); + match resolve_icon(&app.icon_name, &stem, &theme, &icon_cache, &agent) { + Some(p) => { + eprintln!("{}", p.display()); + manifest.insert(app.icon_name.clone(), p.to_string_lossy().into_owned()); + } + None => { + eprintln!("not found"); + } + } + } + } + + let manifest_path = IconCache::manifest_path(); + let json = serde_json::to_string_pretty(&manifest)?; + let tmp = manifest_path.with_extension("tmp"); + fs::write(&tmp, &json)?; + fs::rename(&tmp, &manifest_path)?; + + eprintln!( + "breadbox-sync: wrote manifest ({} entries) to {}", + manifest.len(), + manifest_path.display() + ); + Ok(()) +} diff --git a/breadbox/Cargo.toml b/breadbox/Cargo.toml new file mode 100644 index 0000000..343be40 --- /dev/null +++ b/breadbox/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "breadbox" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[[bin]] +name = "breadbox" +path = "src/main.rs" + +[dependencies] +breadbox-shared = { path = "../breadbox-shared" } +gtk4 = { version = "0.11", features = ["v4_12"] } +gtk4-layer-shell = "0.8" +serde_json = "1" diff --git a/breadbox/src/main.rs b/breadbox/src/main.rs new file mode 100644 index 0000000..8b63052 --- /dev/null +++ b/breadbox/src/main.rs @@ -0,0 +1,564 @@ +use std::{ + collections::HashMap, + env, + fs, + io::{Read, Write}, + os::unix::net::UnixStream, + path::{Path, PathBuf}, + process::{Command, Stdio}, + rc::Rc, +}; + +use breadbox_shared::{ + config_dir, home_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, +}; +use gtk4::{ + gdk::Display, + glib, + pango::EllipsizeMode, + prelude::*, + Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label, + ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, +}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; + +// ---- Hyprland IPC ----------------------------------------------------------- + +fn get_active_workspace() -> Option { + let sig = env::var("HYPRLAND_INSTANCE_SIGNATURE").ok()?; + let rt = env::var("XDG_RUNTIME_DIR").ok()?; + let socket_path = format!("{}/hypr/{}/.socket.sock", rt, sig); + + let mut stream = UnixStream::connect(&socket_path).ok()?; + stream.write_all(b"j/activeworkspace").ok()?; + stream.shutdown(std::net::Shutdown::Write).ok()?; + + let mut response = String::new(); + stream.read_to_string(&mut response).ok()?; + + let v: serde_json::Value = serde_json::from_str(&response).ok()?; + v["name"].as_str().map(|s| s.to_string()) +} + +// ---- Manifest --------------------------------------------------------------- + +fn load_manifest() -> HashMap { + let path = IconCache::manifest_path(); + let content = fs::read_to_string(&path).unwrap_or_default(); + serde_json::from_str::>(&content) + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, PathBuf::from(v))) + .collect() +} + +// ---- Entry loading and sorting ---------------------------------------------- + +fn load_sorted_entries( + manifest: &HashMap, + priority: &[String], +) -> Vec { + let mut entries = load_all_desktop_entries(); + + // Populate icon_path from manifest + for entry in &mut entries { + if let Some(path) = manifest.get(&entry.icon_name) { + if path.exists() { + entry.icon_path = Some(path.clone()); + } + } + } + + let priority_lower: Vec = priority.iter().map(|s| s.to_lowercase()).collect(); + + entries.sort_by(|a, b| { + let ai = priority_rank(a, &priority_lower); + let bi = priority_rank(b, &priority_lower); + match (ai, bi) { + (Some(i), Some(j)) => i.cmp(&j), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + } + }); + + entries +} + +fn priority_rank(entry: &DesktopEntry, priority_lower: &[String]) -> Option { + let name_l = entry.name.to_lowercase(); + let wm_l = entry.wm_class.as_deref().unwrap_or("").to_lowercase(); + priority_lower + .iter() + .position(|p| matches_term(&name_l, p) || matches_term(&wm_l, p)) +} + +/// Whole-word / exact match of `term` within `field` (both lowercase). Avoids +/// "code" matching "vscodium" while still matching "Code", "code-oss", and +/// "Visual Studio Code". +fn matches_term(field: &str, term: &str) -> bool { + if term.is_empty() || field.is_empty() { + return false; + } + if field == term { + return true; + } + let bytes = field.as_bytes(); + let tlen = term.len(); + let mut start = 0; + while let Some(pos) = field[start..].find(term) { + let i = start + pos; + let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric(); + let after = i + tlen; + let after_ok = after >= bytes.len() || !bytes[after].is_ascii_alphanumeric(); + if before_ok && after_ok { + return true; + } + start = i + 1; + if start >= field.len() { + break; + } + } + false +} + +// ---- Theming ---------------------------------------------------------------- + +#[derive(Debug)] +struct Palette { + bg: String, + surface: String, + fg: String, + accent: String, +} + +impl Palette { + fn catppuccin_mocha() -> Self { + Palette { + bg: "#1e1e2e".into(), + surface: "#181825".into(), + fg: "#cdd6f4".into(), + accent: "#89b4fa".into(), + } + } + + fn from_wal() -> Option { + let path = env::var("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".cache")) + .join("wal/colors.json"); + let content = fs::read_to_string(&path).ok()?; + let v: serde_json::Value = serde_json::from_str(&content).ok()?; + + let spec = &v["special"]; + let cols = &v["colors"]; + + let bg = spec["background"].as_str()?.to_string(); + let surface = cols["color0"].as_str().unwrap_or(&bg).to_string(); + let fg = cols["color15"].as_str().unwrap_or("#cdd6f4").to_string(); + let accent = cols["color1"].as_str().unwrap_or("#89b4fa").to_string(); + + Some(Palette { bg, surface, fg, accent }) + } +} + +fn hex_to_rgba(hex: &str, alpha: f32) -> String { + let h = hex.trim_start_matches('#'); + let r = u8::from_str_radix(h.get(0..2).unwrap_or("00"), 16).unwrap_or(0); + let g = u8::from_str_radix(h.get(2..4).unwrap_or("00"), 16).unwrap_or(0); + let b = u8::from_str_radix(h.get(4..6).unwrap_or("00"), 16).unwrap_or(0); + format!("rgba({r}, {g}, {b}, {alpha})") +} + +fn build_css(p: &Palette) -> String { + let bg_panel = hex_to_rgba(&p.bg, 0.60); + format!( + "* {{ font-family: 'JetBrainsMono Nerd Font Mono', monospace; font-size: 14px; }}\ + window {{ background-color: transparent; }}\ + .launcher-bg {{ background-color: {bg_panel}; border-radius: 8px;\ + box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\ + searchentry {{ background-color: {surface}; color: {fg}; caret-color: {accent};\ + border: none; outline: none; box-shadow: none;\ + padding: 12px 16px; border-radius: 4px 4px 0 0; }}\ + listbox {{ background-color: transparent; padding: 4px; }}\ + row {{ padding: 5px 10px; color: {fg}; background-color: transparent;\ + border-radius: 4px; }}\ + row:hover {{ background-color: {surface}; }}\ + row:selected {{ background-color: {surface}; }}\ + .app-name {{ font-size: 14px; }}\ + .app-muted {{ color: {fg}; opacity: 0.6; font-size: 12px; }}\ + image {{ margin-right: 8px; }}", + bg_panel = bg_panel, + surface = p.surface, + fg = p.fg, + accent = p.accent, + ) +} + +// ---- Icon loading ----------------------------------------------------------- + +fn make_icon(icon_name: &str, icon_path: Option<&Path>) -> gtk4::Image { + // Try loading from resolved cached path via gio::File + if let Some(path) = icon_path { + let gio_file = gtk4::gio::File::for_path(path); + if let Ok(texture) = gtk4::gdk::Texture::from_file(&gio_file) { + let img = gtk4::Image::new(); + img.set_paintable(Some(&texture)); + img.set_pixel_size(32); + return img; + } + } + // Fall back to GTK icon theme lookup by name + let name = if icon_name.is_empty() { + "application-x-executable" + } else { + icon_name + }; + let img = gtk4::Image::from_icon_name(name); + img.set_pixel_size(32); + img +} + +// ---- Launch ----------------------------------------------------------------- + +fn pick_terminal() -> String { + if let Ok(t) = env::var("TERMINAL") { + if !t.is_empty() { + return t; + } + } + let path_var = env::var("PATH").unwrap_or_default(); + for t in ["foot", "kitty", "alacritty", "wezterm", "ghostty", "xterm"] { + if path_var.split(':').any(|d| Path::new(d).join(t).exists()) { + return t.to_string(); + } + } + "xterm".to_string() +} + +fn do_launch(entry: &DesktopEntry) { + let cmd = entry.exec.trim(); + if entry.terminal { + let term = pick_terminal(); + let _ = Command::new(&term) + .args(["-e", "bash", "-c", cmd]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } else { + let _ = Command::new("bash") + .args(["-c", cmd]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } +} + +// ---- Fuzzy matching --------------------------------------------------------- + +fn fuzzy_matches(pattern: &str, text: &str) -> bool { + if pattern.is_empty() { + return true; + } + let mut chars = text.chars(); + for pc in pattern.chars() { + let pl = pc.to_lowercase().next().unwrap_or(pc); + if !chars + .by_ref() + .any(|tc| tc.to_lowercase().next().unwrap_or(tc) == pl) + { + return false; + } + } + true +} + +// ---- PID file toggle -------------------------------------------------------- + +fn pid_file() -> PathBuf { + env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")) + .join("breadbox.pid") +} + +fn is_breadbox_pid(pid: u32) -> bool { + fs::read_to_string(format!("/proc/{}/comm", pid)) + .map(|s| s.trim() == "breadbox") + .unwrap_or(false) +} + +// Returns false if an existing instance was killed (caller should exit). +fn toggle_or_continue() -> bool { + let pf = pid_file(); + if let Ok(content) = fs::read_to_string(&pf) { + if let Ok(pid) = content.trim().parse::() { + if is_breadbox_pid(pid) { + let _ = Command::new("kill").arg(pid.to_string()).status(); + return false; + } + } + } + let _ = fs::write(&pf, std::process::id().to_string()); + true +} + +fn cleanup_pid() { + let _ = fs::remove_file(pid_file()); +} + +// ---- UI --------------------------------------------------------------------- + +fn get_row_entry(row: >k4::ListBoxRow) -> Option { + unsafe { + row.data::("entry") + .map(|p| p.as_ref().clone()) + } +} + +fn run_ui(entries: Vec, css: String) { + let app = Application::builder() + .application_id("com.breadway.breadbox") + .build(); + + app.connect_activate(move |app| { + // Base CSS + let provider = CssProvider::new(); + provider.load_from_string(&css); + gtk4::style_context_add_provider_for_display( + &Display::default().expect("no display"), + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + // User CSS override + let user_css_path = config_dir().join("style.css"); + if user_css_path.exists() { + let user_provider = CssProvider::new(); + user_provider.load_from_path(&user_css_path); + gtk4::style_context_add_provider_for_display( + &Display::default().expect("no display"), + &user_provider, + gtk4::STYLE_PROVIDER_PRIORITY_USER, + ); + } + + // Full-screen transparent window; clicks outside the launcher panel close it. + let window = ApplicationWindow::builder().application(app).build(); + window.init_layer_shell(); + window.set_namespace(Some("breadbox")); + window.set_layer(Layer::Overlay); + window.set_keyboard_mode(KeyboardMode::Exclusive); + for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { + window.set_anchor(edge, true); + } + window.set_exclusive_zone(0); + + let close_all: Rc = Rc::new({ + let w = window.clone(); + move || { + cleanup_pid(); + w.close(); + } + }); + + let vbox = GBox::new(Orientation::Vertical, 0); + vbox.add_css_class("launcher-bg"); + vbox.set_halign(gtk4::Align::Center); + vbox.set_valign(gtk4::Align::Start); + vbox.set_margin_top(120); + vbox.set_size_request(600, -1); + + let search = SearchEntry::new(); + search.set_placeholder_text(Some("breadbox")); + vbox.append(&search); + + let scroll = ScrolledWindow::new(); + scroll.set_policy(PolicyType::Never, PolicyType::Automatic); + scroll.set_max_content_height(480); + scroll.set_propagate_natural_height(true); + + let list = ListBox::new(); + list.set_selection_mode(SelectionMode::Browse); + + for entry in &entries { + let row = gtk4::ListBoxRow::new(); + let hbox = GBox::new(Orientation::Horizontal, 0); + hbox.set_margin_start(6); + hbox.set_margin_end(6); + hbox.set_valign(gtk4::Align::Center); + + let icon = make_icon(&entry.icon_name, entry.icon_path.as_deref()); + hbox.append(&icon); + + let name_lbl = Label::new(Some(&entry.name)); + name_lbl.add_css_class("app-name"); + name_lbl.set_xalign(0.0); + name_lbl.set_hexpand(true); + name_lbl.set_ellipsize(EllipsizeMode::End); + hbox.append(&name_lbl); + + if let Some(ref wm) = entry.wm_class { + let wm_lbl = Label::new(Some(wm)); + wm_lbl.add_css_class("app-muted"); + wm_lbl.set_xalign(1.0); + hbox.append(&wm_lbl); + } + + row.set_child(Some(&hbox)); + unsafe { row.set_data("entry", entry.clone()) }; + list.append(&row); + } + + if let Some(first) = list.row_at_index(0) { + list.select_row(Some(&first)); + } + + scroll.set_child(Some(&list)); + vbox.append(&scroll); + window.set_child(Some(&vbox)); + + // Filter on keystroke + let list_f = list.clone(); + search.connect_changed(move |entry| { + let text = entry.text(); + let query = text.as_str(); + let mut first_vis: Option = None; + let mut i = 0i32; + while let Some(row) = list_f.row_at_index(i) { + let vis = get_row_entry(&row) + .map(|e| { + fuzzy_matches(query, &e.name) + || e.wm_class + .as_deref() + .is_some_and(|w| fuzzy_matches(query, w)) + || fuzzy_matches(query, &e.exec) + }) + .unwrap_or(false); + row.set_visible(vis); + if vis && first_vis.is_none() { + first_vis = Some(row); + } + i += 1; + } + list_f.select_row(first_vis.as_ref()); + }); + + // Keyboard handling — capture phase on window + let key_ctrl = EventControllerKey::new(); + key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); + let close_k = Rc::clone(&close_all); + let list_k = list.clone(); + key_ctrl.connect_key_pressed(move |_, key, _, _| { + use gtk4::gdk::Key; + match key { + Key::Escape => { + close_k(); + glib::Propagation::Stop + } + Key::Return | Key::KP_Enter => { + if let Some(row) = list_k.selected_row() { + if let Some(entry) = get_row_entry(&row) { + do_launch(&entry); + close_k(); + } + } + glib::Propagation::Stop + } + Key::Down => { + let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1); + let mut i = cur + 1; + loop { + match list_k.row_at_index(i) { + Some(r) if r.is_visible() => { + list_k.select_row(Some(&r)); + break; + } + Some(_) => i += 1, + None => break, + } + } + glib::Propagation::Stop + } + Key::Up => { + let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0); + let mut i = cur - 1; + loop { + if i < 0 { + break; + } + match list_k.row_at_index(i) { + Some(r) if r.is_visible() => { + list_k.select_row(Some(&r)); + break; + } + Some(_) => i -= 1, + None => break, + } + } + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, + } + }); + window.add_controller(key_ctrl); + + // Row click launches + let close_a = Rc::clone(&close_all); + list.connect_row_activated(move |_, row| { + if let Some(entry) = get_row_entry(row) { + do_launch(&entry); + close_a(); + } + }); + + // Click outside launcher panel → close + let close_outside = Rc::clone(&close_all); + let vbox_ref = vbox.clone(); + let win_ref = window.clone(); + let outside_click = gtk4::GestureClick::new(); + outside_click.connect_pressed(move |_, _, x, y| { + if let Some(b) = vbox_ref.compute_bounds(&win_ref) { + if x < b.x() as f64 + || x > (b.x() + b.width()) as f64 + || y < b.y() as f64 + || y > (b.y() + b.height()) as f64 + { + close_outside(); + } + } + }); + window.add_controller(outside_click); + + window.connect_destroy(|_| cleanup_pid()); + window.present(); + search.grab_focus(); + }); + + app.run(); +} + +// ---- Main ------------------------------------------------------------------- + +fn main() { + if !toggle_or_continue() { + return; + } + + let config = Config::load(); + let workspace = get_active_workspace().unwrap_or_default(); + let priority = config + .context_for(&workspace) + .map(|c| c.priority.clone()) + .unwrap_or_default(); + + let manifest = load_manifest(); + let entries = load_sorted_entries(&manifest, &priority); + + let palette = Palette::from_wal().unwrap_or_else(Palette::catppuccin_mocha); + let css = build_css(&palette); + + run_ui(entries, css); +} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..5ec97f7 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,19 @@ +# breadbox configuration +# Copy to ~/.config/breadbox/config.toml + +# Each [[context]] block matches a Hyprland workspace by name. +# The "default" context is used when no named context matches. +# priority lists app names (case-insensitive substring match against Name= and StartupWMClass=). +# Matched apps are sorted first, in priority order; everything else is alphabetical. + +[[context]] +name = "default" +priority = ["firefox", "code", "obsidian", "kitty"] + +# [[context]] +# name = "2" +# priority = ["slack", "discord", "telegram"] + +# [[context]] +# name = "work" +# priority = ["code", "alacritty", "postman"] diff --git a/packaging/breadbox-sync.service b/packaging/breadbox-sync.service new file mode 100644 index 0000000..ac3e6f4 --- /dev/null +++ b/packaging/breadbox-sync.service @@ -0,0 +1,15 @@ +[Unit] +Description=Breadbox icon sync +Documentation=https://github.com/breadway/breadbox +After=network.target + +[Service] +Type=oneshot +ExecStart=%h/.cargo/bin/breadbox-sync +StandardOutput=journal +StandardError=journal +# Allow up to 2 minutes for slow icon downloads +TimeoutStartSec=120 + +[Install] +WantedBy=default.target diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index a84efdc..0000000 --- a/src/main.rs +++ /dev/null @@ -1,556 +0,0 @@ -use std::{ - collections::HashMap, - env, - fs::{self, File}, - io::{BufRead, BufReader, Write}, - path::{Path, PathBuf}, - process::{Command, Stdio}, - rc::Rc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use gtk4::{ - gdk::Display, - glib, - pango::EllipsizeMode, - prelude::*, - Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label, - ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode, -}; -use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; - -const CACHE_TIMEOUT_SECS: u64 = 86400; - -const CSS: &str = " -window { - background-color: transparent; -} -.launcher-bg { - background-color: #1e1e2e; -} -searchentry { - background-color: #313244; - color: #cdd6f4; - caret-color: #cba6f7; - border: none; - outline: none; - box-shadow: none; - padding: 12px 16px; - font-size: 15px; -} -listbox { - background-color: transparent; - padding: 4px; -} -row { - padding: 6px 12px; - color: #cdd6f4; - background-color: transparent; - border-radius: 4px; -} -row:selected { - background-color: #45475a; -} -.action { - color: #6c7086; - font-size: 12px; -} -"; - -// ---- cache helpers -------------------------------------------------------- - -fn home_dir() -> PathBuf { - PathBuf::from(env::var("HOME").unwrap_or_else(|_| "/tmp".into())) -} - -fn cache_path() -> PathBuf { - env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")) - .join("breadbox.cache") -} - -fn app_dirs() -> [PathBuf; 2] { - [ - PathBuf::from("/usr/share/applications"), - home_dir().join(".local/share/applications"), - ] -} - -fn mtime(path: &Path) -> u64 { - fs::metadata(path) - .and_then(|m| m.modified()) - .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()) - .unwrap_or(0) -} - -fn cache_valid(cache: &Path) -> bool { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let cm = mtime(cache); - now.saturating_sub(cm) < CACHE_TIMEOUT_SECS - && app_dirs().iter().all(|d| !d.is_dir() || mtime(d) <= cm) -} - -fn strip_exec_codes(exec: &str) -> String { - let mut out = String::with_capacity(exec.len()); - let mut chars = exec.chars().peekable(); - while let Some(c) = chars.next() { - if c == '%' { - match chars.peek().copied() { - Some('%') => { - chars.next(); - out.push('%'); - } - Some(n) if n.is_ascii_alphabetic() => { - chars.next(); - } - _ => out.push(c), - } - } else { - out.push(c); - } - } - out -} - -struct DesktopApp { - name: String, - exec: String, - terminal: bool, -} - -fn parse_desktop(path: &Path) -> Option { - let file = File::open(path).ok()?; - let mut in_entry = false; - let (mut name, mut exec, mut app_type) = (None::, None::, None::); - let (mut no_display, mut hidden, mut terminal) = (false, false, false); - - for line in BufReader::new(file).lines() { - let Ok(raw) = line else { continue }; - let s = raw.trim(); - if s.starts_with('#') || s.is_empty() { - continue; - } - if s.starts_with('[') { - in_entry = s == "[Desktop Entry]"; - continue; - } - if !in_entry { - continue; - } - - if let Some(v) = s.strip_prefix("Name=") { - name.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("Exec=") { - exec.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("Type=") { - app_type.get_or_insert_with(|| v.to_string()); - } else if let Some(v) = s.strip_prefix("NoDisplay=") { - no_display = v == "true"; - } else if let Some(v) = s.strip_prefix("Hidden=") { - hidden = v == "true"; - } else if let Some(v) = s.strip_prefix("Terminal=") { - terminal = v == "true" || v == "1"; - } - } - - if no_display || hidden { - return None; - } - if app_type.as_deref().is_some_and(|t| t != "Application") { - return None; - } - - let name = name?.trim().to_string(); - let exec = strip_exec_codes(exec?.trim()).trim().to_string(); - if name.is_empty() || exec.is_empty() { - return None; - } - - Some(DesktopApp { name, exec, terminal }) -} - -fn build_cache(cache: &Path) { - let _ = fs::create_dir_all(cache.parent().unwrap_or(Path::new("/tmp"))); - let mut apps: HashMap = HashMap::new(); - - for dir in &app_dirs() { - let Ok(entries) = fs::read_dir(dir) else { continue }; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("desktop") { - continue; - } - if let Some(app) = parse_desktop(&path) { - apps.insert(entry.file_name().to_string_lossy().into_owned(), app); - } - } - } - - let mut lines: Vec = apps - .into_values() - .map(|a| { - let prefix = if a.terminal { "term" } else { "app" }; - format!("{}\t{}::{}", a.name, prefix, a.exec) - }) - .collect(); - lines.sort_unstable(); - - let tmp = cache.with_extension("tmp"); - if let Ok(mut f) = File::create(&tmp) { - for line in &lines { - let _ = writeln!(f, "{}", line); - } - let _ = fs::rename(&tmp, cache); - } -} - -fn load_entries(cache: &Path) -> Vec<(String, String)> { - fs::read_to_string(cache) - .unwrap_or_default() - .lines() - .filter_map(|line| { - let mut parts = line.splitn(2, '\t'); - let name = parts.next()?.to_string(); - let action = parts.next()?.to_string(); - (!name.is_empty() && !action.is_empty()).then_some((name, action)) - }) - .collect() -} - -// ---- launch --------------------------------------------------------------- - -fn pick_terminal() -> String { - if let Ok(t) = env::var("TERMINAL") { - if !t.is_empty() { - return t; - } - } - let path_var = env::var("PATH").unwrap_or_default(); - for t in ["foot", "kitty", "alacritty", "wezterm", "ghostty", "xterm"] { - if path_var.split(':').any(|d| Path::new(d).join(t).exists()) { - return t.to_string(); - } - } - "xterm".to_string() -} - -fn do_launch(action: &str) { - if let Some(cmd) = action.strip_prefix("app::") { - let _ = Command::new("bash") - .args(["-c", cmd]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - } else if let Some(cmd) = action.strip_prefix("term::") { - let term = pick_terminal(); - let _ = Command::new(&term) - .args(["-e", "bash", "-c", cmd]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - } -} - -// ---- fuzzy matching ------------------------------------------------------- - -fn fuzzy_matches(pattern: &str, text: &str) -> bool { - if pattern.is_empty() { - return true; - } - let mut chars = text.chars(); - for pc in pattern.chars() { - let pl = pc.to_lowercase().next().unwrap_or(pc); - if !chars - .by_ref() - .any(|tc| tc.to_lowercase().next().unwrap_or(tc) == pl) - { - return false; - } - } - true -} - -// ---- toggle via pid file -------------------------------------------------- - -fn pid_file() -> PathBuf { - env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp")) - .join("breadbox.pid") -} - -// Returns false if an existing instance was killed (caller should exit). -fn toggle_or_continue() -> bool { - let pf = pid_file(); - if let Ok(content) = fs::read_to_string(&pf) { - if let Ok(pid) = content.trim().parse::() { - if Path::new(&format!("/proc/{}", pid)).exists() { - let _ = Command::new("kill").arg(pid.to_string()).status(); - return false; - } - } - } - let _ = fs::write(&pf, std::process::id().to_string()); - true -} - -fn cleanup_pid() { - let _ = fs::remove_file(pid_file()); -} - -// ---- UI ------------------------------------------------------------------- - -fn get_row_data(row: >k4::ListBoxRow, key: &str) -> String { - unsafe { - row.data::(key) - .map(|p| p.as_ref().clone()) - .unwrap_or_default() - } -} - -fn run_ui(entries: Vec<(String, String)>) { - let app = Application::builder() - .application_id("com.breadway.breadbox") - .build(); - - app.connect_activate(move |app| { - let provider = CssProvider::new(); - provider.load_from_data(CSS); - gtk4::style_context_add_provider_for_display( - &Display::default().expect("no display"), - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); - - // Single full-screen window covers the entire monitor. The window - // background is transparent; only the launcher vbox is visible. - // Clicks outside the vbox are detected via coordinate check and - // close the window. KeyboardMode::Exclusive keeps focus stable so - // pointer-leave events never steal it away. - let window = ApplicationWindow::builder() - .application(app) - .build(); - window.init_layer_shell(); - window.set_layer(Layer::Overlay); - window.set_keyboard_mode(KeyboardMode::Exclusive); - for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] { - window.set_anchor(edge, true); - } - window.set_exclusive_zone(0); - - let close_all: Rc = Rc::new({ - let w = window.clone(); - move || { - cleanup_pid(); - w.close(); - } - }); - - let vbox = GBox::new(Orientation::Vertical, 0); - vbox.add_css_class("launcher-bg"); - vbox.set_halign(gtk4::Align::Center); - vbox.set_valign(gtk4::Align::Start); - vbox.set_size_request(700, -1); - - let search = SearchEntry::new(); - search.set_placeholder_text(Some("breadbox")); - vbox.append(&search); - - let scroll = ScrolledWindow::new(); - scroll.set_policy(PolicyType::Never, PolicyType::Automatic); - scroll.set_max_content_height(400); - scroll.set_propagate_natural_height(true); - - let list = ListBox::new(); - list.set_selection_mode(SelectionMode::Browse); - - for (name, action) in &entries { - let row = gtk4::ListBoxRow::new(); - let hbox = GBox::new(Orientation::Horizontal, 8); - hbox.set_margin_start(4); - hbox.set_margin_end(4); - - let name_lbl = Label::new(Some(name)); - name_lbl.set_xalign(0.0); - name_lbl.set_hexpand(true); - hbox.append(&name_lbl); - - let action_lbl = Label::new(Some(action)); - action_lbl.add_css_class("action"); - action_lbl.set_xalign(1.0); - action_lbl.set_ellipsize(EllipsizeMode::End); - action_lbl.set_max_width_chars(50); - hbox.append(&action_lbl); - - row.set_child(Some(&hbox)); - unsafe { - row.set_data("name", name.clone()); - row.set_data("action", action.clone()); - } - list.append(&row); - } - - if let Some(first) = list.row_at_index(0) { - list.select_row(Some(&first)); - } - - scroll.set_child(Some(&list)); - vbox.append(&scroll); - window.set_child(Some(&vbox)); - - // Filter rows on every keystroke - let list_f = list.clone(); - search.connect_changed(move |entry| { - let text = entry.text(); - let query = text.as_str(); - let mut first_vis: Option = None; - let mut i = 0i32; - while let Some(row) = list_f.row_at_index(i) { - let name = get_row_data(&row, "name"); - let vis = fuzzy_matches(query, &name); - row.set_visible(vis); - if vis && first_vis.is_none() { - first_vis = Some(row); - } - i += 1; - } - list_f.select_row(first_vis.as_ref()); - }); - - // Keyboard: Esc, Enter, arrows — capture phase on window so we - // intercept before SearchEntry's own handlers consume them - let key_ctrl = EventControllerKey::new(); - key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); - let close_k = Rc::clone(&close_all); - let list_k = list.clone(); - key_ctrl.connect_key_pressed(move |_, key, _, _| { - use gtk4::gdk::Key; - match key { - Key::Escape => { - close_k(); - glib::Propagation::Stop - } - Key::Return | Key::KP_Enter => { - if let Some(row) = list_k.selected_row() { - let action = get_row_data(&row, "action"); - if !action.is_empty() { - do_launch(&action); - close_k(); - } - } - glib::Propagation::Stop - } - Key::Down => { - let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1); - let mut i = cur + 1; - loop { - match list_k.row_at_index(i) { - Some(r) if r.is_visible() => { - list_k.select_row(Some(&r)); - break; - } - Some(_) => i += 1, - None => break, - } - } - glib::Propagation::Stop - } - Key::Up => { - let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0); - let mut i = cur - 1; - loop { - if i < 0 { - break; - } - match list_k.row_at_index(i) { - Some(r) if r.is_visible() => { - list_k.select_row(Some(&r)); - break; - } - Some(_) => i -= 1, - None => break, - } - } - glib::Propagation::Stop - } - _ => glib::Propagation::Proceed, - } - }); - window.add_controller(key_ctrl); - - // Row click / Enter activates launch - let close_a = Rc::clone(&close_all); - list.connect_row_activated(move |_, row| { - let action = get_row_data(row, "action"); - if !action.is_empty() { - do_launch(&action); - close_a(); - } - }); - - // Close when clicking outside the launcher box. - // connect_pressed fires before child widgets handle the click, so - // (x, y) are window-relative and always available. Clicks inside the - // vbox are within its allocation and are ignored; everything outside - // (the transparent full-screen area) dismisses the launcher. - let close_outside = Rc::clone(&close_all); - let vbox_ref = vbox.clone(); - let outside_click = gtk4::GestureClick::new(); - outside_click.connect_pressed(move |_, _, x, y| { - let a = vbox_ref.allocation(); - if x < a.x() as f64 - || x > (a.x() + a.width()) as f64 - || y < a.y() as f64 - || y > (a.y() + a.height()) as f64 - { - close_outside(); - } - }); - window.add_controller(outside_click); - - // Safety net: clean up PID if the window is destroyed by the compositor - window.connect_destroy(|_| cleanup_pid()); - - window.present(); - search.grab_focus(); - }); - - app.run(); -} - -// ---- main ----------------------------------------------------------------- - -fn main() { - let cache = cache_path(); - - if env::var("BREADBOX_REBUILD_ONLY").as_deref() == Ok("1") { - build_cache(&cache); - return; - } - - if !toggle_or_continue() { - return; - } - - if !cache.exists() { - build_cache(&cache); - } else if !cache_valid(&cache) { - if let Ok(exe) = env::current_exe() { - let _ = Command::new(exe) - .env("BREADBOX_REBUILD_ONLY", "1") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - } - } - - let entries = load_entries(&cache); - run_ui(entries); -}