diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7409b04 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build-and-test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + rust: [stable] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@v1 + with: + toolchain: ${{ matrix.rust }} + - name: Cargo cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + - name: Build + run: cargo build --workspace --verbose + - name: Run tests + run: cargo test --workspace --verbose + - name: Build release + run: cargo build --workspace --release + - name: Package artifacts + run: | + mkdir -p dist + tar -czf dist/bread-${{ matrix.os }}.tgz target/release/breadd target/release/bread-cli + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: bread-${{ matrix.os }} + path: dist/*.tgz diff --git a/.gitignore b/.gitignore index 7b178b6..acf737f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,39 @@ +# Rust build artifacts target/ + +# Editor and IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS artifacts +.DS_Store +Thumbs.db +desktop.ini + +# Environment and secrets +.env +.env.* +*.env +*.pem +*.key +*.p12 +secrets/ + +# Log files +*.log +logs/ + +# Runtime files +*.sock +*.pid + +# Internal project docs and spec files kept out of public history Overview.md DAEMON.md -.github/ \ No newline at end of file +LUA_RUNTIME.md +CLAUDE_SPEC.md +.claude +CLAUDE.md diff --git a/Cargo.lock b/Cargo.lock index a36c9da..52ab50b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,16 +3,10 @@ version = 4 [[package]] -name = "ahash" -version = "0.8.12" +name = "adler2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" @@ -23,6 +17,15 @@ dependencies = [ "memchr", ] +[[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 = "anstream" version = "1.0.0" @@ -262,9 +265,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "base64" -version = "0.22.1" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] name = "bitflags" @@ -302,36 +305,60 @@ dependencies = [ [[package]] name = "bread-cli" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "bread-shared", + "bread-sync", + "chrono", "clap", + "dirs", + "flate2", + "libc", + "notify", + "reqwest", "serde", "serde_json", + "tar", + "tempfile", "tokio", + "toml", ] [[package]] name = "bread-shared" -version = "0.1.0" +version = "1.0.0" dependencies = [ "serde", "serde_json", ] +[[package]] +name = "bread-sync" +version = "1.0.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "git2", + "glob", + "libc", + "serde", + "serde_json", + "tempfile", + "toml", +] + [[package]] name = "breadd" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "async-trait", "bread-shared", + "bread-sync", "futures-util", - "hex", "libc", - "metrics 0.23.1", - "metrics-exporter-prometheus", "mlua", "netlink-packet-core", "netlink-packet-route", @@ -382,6 +409,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -391,6 +420,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -446,6 +489,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -472,10 +525,19 @@ dependencies = [ ] [[package]] -name = "crossbeam-epoch" -version = "0.9.18" +name = "crc32fast" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] @@ -517,12 +579,53 @@ dependencies = [ "crypto-common", ] +[[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 = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -630,12 +733,32 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "filetime" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" +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" @@ -663,6 +786,24 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[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" version = "0.3.32" @@ -800,6 +941,18 @@ dependencies = [ "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.2" @@ -808,22 +961,43 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] [[package]] -name = "h2" -version = "0.4.14" +name = "git2" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" +dependencies = [ + "bitflags 2.11.1", + "libc", + "libgit2-sys", + "log", + "openssl-probe 0.1.6", + "openssl-sys", + "url", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ - "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", + "futures-util", "http", "indexmap", "slab", @@ -832,15 +1006,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -882,34 +1047,23 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "http" -version = "1.4.0" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ "bytes", + "fnv", "itoa", ] [[package]] name = "http-body" -version = "1.0.1" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", "pin-project-lite", ] @@ -927,14 +1081,14 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" dependencies = [ - "atomic-waker", "bytes", "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -942,45 +1096,130 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "smallvec", + "socket2 0.4.10", "tokio", + "tower-service", + "tracing", "want", ] [[package]] name = "hyper-tls" -version = "0.6.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "http-body-util", "hyper", - "hyper-util", "native-tls", "tokio", "tokio-native-tls", - "tower-service", ] [[package]] -name = "hyper-util" -version = "0.1.20" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2 0.6.3", - "tokio", - "tower-service", - "tracing", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[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 = "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]] @@ -989,6 +1228,27 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[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" @@ -1001,6 +1261,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "instant" version = "0.1.13" @@ -1039,6 +1319,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.98" @@ -1051,6 +1341,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "285efcf12ef41bec907b3000d5ffaeb54191d4d9d83c0d6157e6cbc2db255e64" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1069,6 +1379,43 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libgit2-sys" +version = "0.16.2+1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + [[package]] name = "libudev-sys" version = "0.1.4" @@ -1079,6 +1426,18 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -1097,6 +1456,12 @@ 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" @@ -1174,59 +1539,31 @@ dependencies = [ ] [[package]] -name = "metrics" -version = "0.22.4" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d05972e8cbac2671e85aa9d04d9160d193f8bebd1a5c1a2f4542c62e65d1d0" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ - "ahash", - "portable-atomic", + "adler2", + "simd-adler32", ] [[package]] -name = "metrics" -version = "0.23.1" +name = "mio" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3045b4193fbdc5b5681f32f11070da9be3609f189a79f3390706d42587f46bb5" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ - "ahash", - "portable-atomic", -] - -[[package]] -name = "metrics-exporter-prometheus" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d58e362dc7206e9456ddbcdbd53c71ba441020e62104703075a69151e38d85f" -dependencies = [ - "base64", - "http-body-util", - "hyper", - "hyper-tls", - "hyper-util", - "indexmap", - "ipnet", - "metrics 0.22.4", - "metrics-util", - "quanta", - "thiserror", - "tokio", - "tracing", -] - -[[package]] -name = "metrics-util" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b07a5eb561b8cbc16be2d216faf7757f9baf3bfb94dbb0fae3df8387a5bb47f" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", - "hashbrown 0.14.5", - "metrics 0.22.4", - "num_cpus", - "quanta", - "sketches-ddsketch", + "libc", + "log", + "wasi", + "windows-sys 0.48.0", ] [[package]] @@ -1279,7 +1616,7 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.2.1", "openssl-sys", "schannel", "security-framework", @@ -1377,6 +1714,25 @@ dependencies = [ "memoffset 0.7.1", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1395,16 +1751,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi 0.5.2", - "libc", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1442,6 +1788,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -1460,6 +1812,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "2.10.1" @@ -1514,6 +1872,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[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" @@ -1568,10 +1932,13 @@ dependencies = [ ] [[package]] -name = "portable-atomic" -version = "1.13.1" +name = "potential_utf" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] [[package]] name = "ppv-lite86" @@ -1611,21 +1978,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quanta" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" -dependencies = [ - "crossbeam-utils", - "libc", - "once_cell", - "raw-cpuid", - "wasi", - "web-sys", - "winapi", -] - [[package]] name = "quote" version = "1.0.45" @@ -1635,6 +1987,12 @@ 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" @@ -1671,15 +2029,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "raw-cpuid" -version = "11.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" -dependencies = [ - "bitflags 2.11.1", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -1689,6 +2038,17 @@ dependencies = [ "bitflags 2.11.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", +] + [[package]] name = "regex" version = "1.12.3" @@ -1718,6 +2078,46 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "rtnetlink" version = "0.9.1" @@ -1779,12 +2179,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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 = "schannel" version = "0.1.29" @@ -1807,7 +2231,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -1902,6 +2326,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1939,10 +2375,10 @@ dependencies = [ ] [[package]] -name = "sketches-ddsketch" -version = "0.2.2" +name = "simd-adler32" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "slab" @@ -1976,6 +2412,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2010,6 +2452,55 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[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.117", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2052,6 +2543,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -2060,7 +2561,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -2276,6 +2777,24 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[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 = "utf8parse" version = "0.2.2" @@ -2306,6 +2825,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" +[[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" @@ -2352,6 +2881,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.121" @@ -2456,18 +2995,80 @@ 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 = "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", + "windows-result", + "windows-strings", +] + +[[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.117", +] + +[[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.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -2634,6 +3235,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winsafe" version = "0.0.19" @@ -2734,6 +3345,22 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xdg-home" version = "1.3.0" @@ -2744,6 +3371,29 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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 2.0.117", + "synstructure", +] + [[package]] name = "zbus" version = "3.15.2" @@ -2831,6 +3481,60 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +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.117", + "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.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index ab4e899..8216be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,8 @@ members = [ "bread-shared", "breadd", - "bread-cli" + "bread-cli", + "bread-sync", ] resolver = "2" @@ -13,3 +14,8 @@ tokio = { version = "1.40", features = ["full"] } anyhow = "1.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +git2 = "0.18" +dirs = "5.0" +chrono = { version = "0.4", features = ["serde"] } +tempfile = "3" +glob = "0.3" diff --git a/Documentation.md b/Documentation.md new file mode 100644 index 0000000..36c6d73 --- /dev/null +++ b/Documentation.md @@ -0,0 +1,927 @@ +# Bread Documentation + +## Contents + +- [Overview](#overview) +- [Getting started](#getting-started) +- [Your first module](#your-first-module) +- [Run, reload, and watch](#run-reload-and-watch) +- [Modules: install and manage](#modules-install-and-manage) +- [Sync: snapshot and restore](#sync-snapshot-and-restore) +- [Debugging tips](#debugging-tips) +- [Dictionary: Lua API](#dictionary-lua-api) + - [Bluetooth](#bluetooth) +- [Dictionary: Built-in modules](#dictionary-built-in-modules) +- [Dictionary: Event reference](#dictionary-event-reference) +- [Dictionary: Runtime state schema](#dictionary-runtime-state-schema) +- [Dictionary: IPC protocol](#dictionary-ipc-protocol) + +## Overview + +Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation. + +- **Daemon** (`breadd`) — long-running Rust process; source of truth for runtime state +- **Lua runtime** — dedicated thread inside the daemon; automation logic lives here +- **CLI** (`bread`) — talks to the daemon over a Unix socket + +Adapters currently supported: Hyprland compositor IPC, Linux udev/netlink, UPower/sysfs power, rtnetlink/sysfs network, and BlueZ Bluetooth. + +If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details. + +## Getting started + +### 1) Create a minimal config + +- Daemon config: `~/.config/bread/breadd.toml` (all values optional) +- Lua entry point: `~/.config/bread/init.lua` +- Lua modules: `~/.config/bread/modules/` + +### 2) Minimal `init.lua` + +```lua +bread.on("bread.system.startup", function(event) + bread.profile.activate("default") + bread.log("bread started on " .. bread.machine.name()) +end) +``` + +### 3) Start the daemon + +```bash +systemctl --user start breadd + +# Or directly: +breadd +``` + +### 4) Check that it's running + +```bash +bread ping +bread doctor +``` + +## Your first module + +Create a file at `~/.config/bread/modules/hello.lua`. It is discovered and loaded automatically after `init.lua`. + +```lua +local M = bread.module({ name = "hello", version = "0.1.0" }) + +function M.on_load() + bread.log("hello from bread on " .. bread.machine.name()) + + bread.on("bread.device.*", function(event) + bread.log("device event: " .. event.event) + end) +end + +return M +``` + +Key rules: + +- Every module must call `bread.module` exactly once at the top level. +- Register subscriptions inside `M.on_load` so they are cleaned up properly on hot reload. +- Use `bread.log` early to verify handlers are firing. + +## Run, reload, and watch + +```bash +# Hot-reload the Lua runtime after editing config +bread reload + +# Watch for file changes and reload automatically +bread reload --watch +``` + +If any module fails to load, `bread reload` prints the error with a full Lua stack trace. The daemon stays running — fix the file and reload again. + +## Modules: install and manage + +Modules are Lua packages installed to `~/.config/bread/modules/`. The CLI manages the install lifecycle. + +```bash +# Install from GitHub (downloads and extracts the default branch tarball) +bread modules install github:someuser/bread-wifi + +# Install from a local directory +bread modules install ~/src/my-module + +# Install a specific ref +bread modules install github:someuser/bread-wifi@v1.2.0 + +# List installed modules and their daemon status +bread modules list + +# Show full manifest for one module +bread modules info bread-wifi + +# Re-install all GitHub-sourced modules (pick up upstream changes) +bread modules update + +# Remove a module +bread modules remove bread-wifi +bread modules remove bread-wifi --yes # skip confirmation +``` + +Each installed module has a `bread.module.toml` manifest: + +```toml +name = "wifi" +version = "1.0.0" +description = "WiFi management for Bread" +author = "someuser" +source = "github:someuser/bread-wifi" +installed_at = "2026-01-01T00:00:00Z" +``` + +## Sync: snapshot and restore + +Bread sync snapshots your config, dotfiles, and installed packages into a local Git repository. Use `export`/`import` to move state between machines — no git remote required. + +```bash +# First-time setup (remote optional) +bread sync init +bread sync init --remote git@github.com:you/bread-config.git + +# Commit local snapshot +bread sync push +bread sync push --message "before reinstall" + +# Apply snapshot to this machine +bread sync pull + +# Also reinstall packages from snapshot +bread sync pull --install-packages + +# See what has changed +bread sync status +bread sync diff + +# List known machines +bread sync machines +``` + +### Portable export/import + +`export` creates a self-contained snapshot directory or `.tar.gz` — no git auth needed. + +```bash +# Create a portable snapshot (defaults to ./bread-export--.tar.gz) +bread sync export + +# Export to a specific path +bread sync export --output ~/backups/bread.tar.gz +bread sync export --output /mnt/usb/bread-snapshot/ # directory + +# Apply a snapshot on another machine +bread sync import bread-export-hermes-2026-05-16.tar.gz +bread sync import /mnt/usb/bread-snapshot/ + +# Also install packages from the snapshot +bread sync import bread-export.tar.gz --install-packages + +# Skip cloning git repos back to their original locations +bread sync import bread-export.tar.gz --no-clone-repos + +# Skip confirmation prompt +bread sync import bread-export.tar.gz --yes +``` + +Each export snapshot includes: + +| Directory | Contents | +|-----------|----------| +| `bread/` | `~/.config/bread/` (your Bread config) | +| `configs/` | Common app configs (hypr, nvim, kitty, waybar, fish, etc.) | +| `dotfiles/` | Individual files: `.gitconfig`, `.zshrc`, `.zprofile`, `.zshenv`, SSH config, etc. | +| `local-bin/` | `~/.local/bin/` scripts (non-symlink, <512 KB) | +| `local-fonts/` | `~/.local/share/fonts/` | +| `systemd/` | `~/.config/systemd/user/` units | +| `system/` | System files: udev rules, modprobe, sysctl, NetworkManager, bluetooth (root-only paths skipped unless run with sudo) | +| `packages/` | Package lists (pacman.txt, pip.txt, cargo.txt, npm.txt) | +| `machines/` | Per-machine profile with tags and last-sync time | +| `manifest.toml` | Path map for exact restoration on import | +| `restore.sh` | Shell script for manual restore (system files shown as sudo commands) | + +**Git repository tracking:** at export time, all git repositories with remotes found under `~`, `~/Projects`, `~/Documents`, and `~/.config` are auto-committed and pushed. Their remote URLs and branches are recorded in the manifest. On import, `--no-clone-repos` suppresses cloning them back. + +Configure sync in `~/.config/bread/sync.toml`: + +```toml +[remote] +url = "git@github.com:you/bread-config.git" +branch = "main" + +[machine] +name = "hermes" +tags = ["laptop", "battery"] + +[packages] +enabled = true +managers = ["pacman", "pip", "cargo"] + +[delegates] +include = ["~/.config/nvim", "~/.config/waybar"] +exclude = ["**/.git", "**/*.cache"] +``` + +## Debugging tips + +- Run `bread events` to see live normalized events. +- Run `bread state` to see full runtime state as JSON. +- Run `bread doctor` to check adapter and module health. +- Log event payloads with `bread.log(tostring(event.data))`. +- Use `RUST_LOG=debug breadd` for verbose daemon output. + +--- + +## Dictionary: Lua API + +Every API is exposed through the `bread` global table. + +### Module declaration + +Every module must call `bread.module` exactly once at the top level. + +```lua +local M = bread.module({ + name = "my.module", + version = "0.1.0", + after = { "bread.devices" }, -- optional: load after this module +}) + +return M +``` + +If a module does not call `bread.module`, it fails to load and is marked as a load error. + +### Events + +#### `bread.on(pattern, fn) -> id` +Subscribe to matching events. Returns a numeric subscription ID. + +```lua +local id = bread.on("bread.device.*", function(event) + -- event.event → the full event name string + -- event.data → table of event-specific fields + -- event.source → adapter that produced it ("Udev", "Hyprland", etc.) + bread.log(event.event) +end) +``` + +#### `bread.once(pattern, fn) -> id` +Subscribe once. The handler is removed after the first match. + +#### `bread.filter(pattern, fn, opts) -> id` +Subscribe with a predicate. `opts` must contain a `filter` function: + +```lua +bread.filter("bread.device.*", function(event) + bread.exec("xset r rate 200 40") +end, { + filter = function(event) + return event.data and event.data.class == "keyboard" + end, +}) +``` + +#### `bread.off(id)` +Unsubscribe an event handler or state watch by ID. + +#### `bread.emit(event, data)` +Emit a custom event into the system pipeline. Useful for cross-module communication. + +#### `bread.wait(pattern, opts) -> event | nil` +Coroutine-only helper that suspends until a matching event arrives. + +```lua +bread.spawn(function() + local event = bread.wait("bread.device.dock.connected", { timeout = 5000 }) + if event then + bread.log("dock arrived") + end +end) +``` + +#### `bread.spawn(fn)` +Spawn a coroutine and surface errors if it fails. Required for using `bread.wait`. + +### State + +#### `bread.state.get(path)` +Read a state subtree by dotted path. + +```lua +local monitors = bread.state.get("monitors") +local online = bread.state.get("network.online") +``` + +#### Typed shorthands + +```lua +bread.state.monitors() +bread.state.active_workspace() +bread.state.active_window() +bread.state.devices() +bread.state.power() +bread.state.network() +bread.state.profile() +``` + +#### `bread.state.watch(path, fn) -> id` +Watch a state path for changes. The callback receives `(new_value, old_value)`. + +```lua +bread.state.watch("power.ac_connected", function(new_val, old_val) + if new_val then + bread.notify("AC connected") + end +end) +``` + +### Profiles + +#### `bread.profile.activate(name)` +Activate a named profile. Emits `bread.profile.activated` over IPC. + +### Execution + +#### `bread.exec(cmd)` +Run a shell command. Fire-and-forget (async, does not block Lua). + +### Notifications + +#### `bread.notify(message, opts)` +Send a desktop notification via `notify-send`. + +Options: + +| Key | Type | Default | +|-----|------|---------| +| `title` | string | `"bread"` | +| `urgency` | string | from config | +| `timeout` | ms | from config | +| `icon` | string | none | + +Calling `bread.notify` emits `bread.notify.sent` with `{ title, message, urgency }`. + +### Timers + +#### `bread.after(delay_ms, fn) -> id` +Run once after a delay. + +#### `bread.every(interval_ms, fn) -> id` +Run on a repeating interval. + +#### `bread.cancel(id)` +Cancel a timer created by `after` or `every`. Timers are also cancelled automatically on reload. + +### Utilities + +#### `bread.debounce(delay_ms, fn) -> wrapped_fn` +Returns a wrapper that fires only after `delay_ms` of quiet time. + +```lua +local fn = bread.debounce(200, function(event) + reconfigure_monitors() +end) +bread.on("bread.monitor.**", fn) +``` + +#### `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)` +Logging helpers. Accept any Lua value (coerced via `tostring`). + +### Machine and filesystem + +#### `bread.machine.name() -> string` +Returns the machine name from `sync.toml`. Falls back to the system hostname if sync is not initialized. + +#### `bread.machine.tags() -> string[]` +Returns the tags array from `sync.toml`, or `{}` if sync is not initialized. + +#### `bread.machine.has_tag(tag) -> bool` +Returns true if the machine has the given tag. + +#### `bread.fs.write(path, content)` +Write a file. Creates parent directories as needed. `~` is expanded. + +#### `bread.fs.read(path) -> string | nil` +Read a file. Returns `nil` if the file does not exist. `~` is expanded. + +#### `bread.fs.exists(path) -> bool` +Returns true if the path exists. `~` is expanded. + +#### `bread.fs.expand(path) -> string` +Expand `~` to the home directory. + +### Hyprland + +The `bread.hyprland` namespace provides compositor bindings. + +```lua +-- Dispatch a Hyprland command +bread.hyprland.dispatch("workspace", "2") +bread.hyprland.dispatch("exec", "kitty") + +-- Set a keyword +bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1") + +-- Query compositor state (returns deserialized Lua tables) +local win = bread.hyprland.active_window() +local monitors = bread.hyprland.monitors() +local workspaces = bread.hyprland.workspaces() +local clients = bread.hyprland.clients() + +-- Subscribe to raw Hyprland events (bypasses normalization) +bread.hyprland.on_raw("activewindow", function(raw) + -- raw payload includes: kind, raw (original string), data +end) +``` + +### Bluetooth + +The `bread.bluetooth` namespace provides control over the local Bluetooth adapter and its paired devices via BlueZ D-Bus. All functions degrade gracefully when BlueZ is unavailable — control functions log a warning and return `nil`, query functions return `nil`. + +#### `bread.bluetooth.power(enabled)` +Power the Bluetooth adapter on (`true`) or off (`false`). Fire-and-forget. + +#### `bread.bluetooth.powered() -> bool | nil` +Returns the current power state of the adapter, or `nil` if unavailable. + +```lua +if bread.bluetooth.powered() then + bread.log("Bluetooth is on") +end +``` + +#### `bread.bluetooth.connect(address)` +Connect to a paired device by MAC address. Fire-and-forget — the result is delivered as a `bread.device.connected` event when the connection succeeds. + +```lua +bread.bluetooth.connect("AA:BB:CC:DD:EE:FF") +``` + +#### `bread.bluetooth.disconnect(address)` +Disconnect from a device by MAC address. Fire-and-forget — delivered as `bread.device.disconnected`. + +#### `bread.bluetooth.scan(enabled)` +Start (`true`) or stop (`false`) device discovery. + +#### `bread.bluetooth.devices() -> table | nil` +Returns all devices known to BlueZ as an array of tables. Returns `nil` if BlueZ is unavailable. + +```lua +local devs = bread.bluetooth.devices() +if devs then + for _, dev in ipairs(devs) do + bread.log(dev.name .. " " .. dev.address + .. (dev.connected and " [connected]" or "")) + end +end +``` + +Each device table: + +| Field | Type | Description | +|-------|------|-------------| +| `address` | string | Bluetooth MAC address, e.g. `"AA:BB:CC:DD:EE:FF"` | +| `name` | string | Device name from BlueZ (Alias or Name property) | +| `connected` | bool | Whether the device is currently connected | +| `paired` | bool | Whether the device is paired | + +#### Example: auto-connect headphones on AC power + +```lua +local M = bread.module({ name = "headphones", version = "1.0.0" }) +local HEADPHONES = "AA:BB:CC:DD:EE:FF" + +function M.on_load() + bread.state.watch("power.ac_connected", function(ac) + if ac then + bread.bluetooth.power(true) + bread.bluetooth.connect(HEADPHONES) + end + end) +end + +return M +``` + +#### Example: turn off Bluetooth on battery + +```lua +bread.state.watch("power.ac_connected", function(ac) + bread.bluetooth.power(ac) +end) +``` + +### Module lifecycle hooks + +All hooks are optional. + +```lua +function M.on_load() + -- Called after the module loads. Register subscriptions here. +end + +function M.on_reload() + -- Called after a hot reload completes across all modules. +end + +function M.on_unload() + -- Called before the Lua instance is dropped. +end + +function M.on_error(err) + -- Called when a subscription handler in this module throws. + -- Return true to keep the subscription alive, false to cancel it. + return true +end +``` + +### Module storage + +Survives hot reload; does not survive daemon restart. + +```lua +M.store.set("last_profile", "docked") +local value = M.store.get("last_profile") +``` + +Storage is scoped per module and is not shared across modules. + +--- + +## Dictionary: Built-in modules + +Built-ins are loaded before user modules. Disable them via `[modules].disable` in the daemon config. + +### `bread.monitors` + +High-level declarative monitor event handlers. + +```lua +local monitors = require("bread.monitors") + +monitors.layout("dock", function() + bread.exec("~/.config/bread/scripts/layout-dock.sh") +end) + +monitors.on({ + when = "connected", + monitors = { "HDMI-A-1" }, + run = monitors.apply("dock"), +}) +``` + +| Function | Description | +|----------|-------------| +| `M.on(opts)` | Register a monitor workflow. `opts`: `when`, `monitors` (optional list), `run` (function or shell string) | +| `M.layout(name, fn)` | Register a named layout function | +| `M.apply(name) -> fn` | Returns a function that calls the named layout | + +`when` is one of `connected`, `disconnected`, `changed`. + +### `bread.devices` + +Device connection rules with name-based matching. This module handles hardware hotplug events from USB devices, monitors, and other peripherals. + +Device names are defined in `~/.config/bread/devices.lua` — the daemon resolves the name before dispatching events, so modules can match on stable user-defined names rather than raw hardware identifiers. + +```lua +local devices = require("bread.devices") + +devices.on({ + when = "connected", + device = "keyboard", + run = function(event) + bread.exec("xset r rate 200 40") + end, +}) + +devices.on({ + when = "connected", + device = "dock", + run = "~/.config/bread/scripts/dock-connected.sh" +}) + +devices.on({ + when = "disconnected", + name = "CalDigit", -- pattern-matched against event.data.name + run = function(event) + bread.log("Dock disconnected: " .. event.data.name) + end, +}) +``` + +#### Functions + +| Function | Description | +|----------|-------------| +| `M.on(opts)` | Register a device rule. See options below. | + +#### Device rule options + +```lua +devices.on({ + when = "connected", -- required: "connected" or "disconnected" + device = "keyboard", -- optional: device name from devices.lua + name = "Keychron", -- optional: substring matched against device name + run = function(event) ... end -- required: function or shell string +}) +``` + +- `when` (required): One of `connected` or `disconnected`. +- `device` (optional): Device name as defined in `devices.lua`. If specified, the rule only fires for devices with that name. +- `name` (optional): Pattern that must be found in `event.data.name` (case-insensitive substring). Can be combined with `device` (both must match). +- `run` (required): Function or shell string to run when the rule matches. + +The callback receives the full device event: +```lua +{ + event = "bread.device.dock.connected", + data = { + id = "/sys/...", + device = "dock", -- name resolved from devices.lua + name = "CalDigit TS4", -- raw device name from udev + subsystem = "usb", + vendor_id = "0x35f5", + product_id = "0x0104", + raw = { ... } -- full udev properties + } +} +``` + +#### Example: Keyboard configuration on connect + +```lua +devices.on({ + when = "connected", + device = "keyboard", + run = function(event) + bread.log("Keyboard connected: " .. event.data.name) + bread.exec("xset r rate 200 40") + end, +}) +``` + +#### Example: Dock-specific setup + +```lua +-- devices.lua defines: { device = "dock", vendor_id = "35f5" } + +devices.on({ + when = "connected", + device = "dock", + run = function(event) + bread.log("Dock connected") + bread.exec("~/.config/bread/scripts/dock-connected.sh") + end, +}) + +devices.on({ + when = "disconnected", + device = "dock", + run = function(event) + bread.log("Dock disconnected") + bread.exec("~/.config/bread/scripts/dock-disconnected.sh") + end, +}) +``` + +### `bread.workspaces` + +Workspace-to-monitor assignment and app pinning. + +```lua +local workspaces = require("bread.workspaces") + +workspaces.assign("1", "HDMI-A-1") +workspaces.pin({ app = "Firefox", workspace = "2" }) +``` + +| Function | Description | +|----------|-------------| +| `M.assign(workspace, monitor)` | Assign a workspace to a monitor | +| `M.pin(opts)` | Pin an app class to a workspace. `opts`: `app`, `workspace` | +| `M.apply_assignments()` | Apply all registered assignments via Hyprland dispatch | + +### `bread.binds` + +Runtime keybind management via Hyprland. + +```lua +local binds = require("bread.binds") + +binds.add({ + mods = { "SUPER" }, + key = "Return", + dispatch = "exec", + args = "kitty", +}) +``` + +| Function | Description | +|----------|-------------| +| `M.add(opts)` | Add a keybind. `opts`: `mods`, `key`, `dispatch`, `args` | +| `M.remove(key)` | Remove a keybind by key | +| `M.replace(key, opts)` | Remove and re-add a keybind | + +--- + +## Dictionary: Event reference + +Events are delivered as a `BreadEvent`: + +```json +{ + "event": "bread.device.dock.connected", + "timestamp": 1710000000000, + "source": "Udev", + "data": {} +} +``` + +### Pattern matching + +| Pattern | Matches | +|---------|---------| +| `bread.device.dock.connected` | Exact match only | +| `bread.device.*` | One segment wildcard (does not cross `.`) | +| `bread.device.**` | Any depth under `bread.device` | +| `bread.monitor.?` | Single character within one segment | + +### Normalized events + +#### System + +| Event | Data | +|-------|------| +| `bread.system.startup` | `{}` | + +#### Devices (udev / Bluetooth) + +| Event | Data | +|-------|------| +| `bread.device.connected` | `{ id, device, name, vendor, vendor_id, product_id, subsystem, raw }` | +| `bread.device.disconnected` | same | +| `bread.device..connected` | `{ id, device }` | +| `bread.device..disconnected` | `{ id, device }` | + +`device` is the name resolved from `~/.config/bread/devices.lua`. Devices that match no rule use `"unknown"`. The generic `bread.device.connected` event carries the full payload including `raw` udev properties; the named companion event carries only `id` and `device`. + +Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bread.device.disconnected`. They can be distinguished by `event.data.subsystem`: + +| `subsystem` | Source | Unique identifier field | +|-------------|--------|------------------------| +| `"usb"`, `"input"`, etc. | udev | `vendor_id` + `product_id` | +| `"bluetooth"` | BlueZ | `address` (MAC address) | + +#### Bluetooth (BlueZ) + +| Event | Data | +|-------|------| +| `bread.device.connected` | `{ id, device, name, address, subsystem: "bluetooth", raw }` | +| `bread.device.disconnected` | same | +| `bread.bluetooth.device.paired` | `{ id, name, address, subsystem: "bluetooth", raw }` | +| `bread.bluetooth.device.unpaired` | `{ id, address, subsystem: "bluetooth", raw }` | + +`bread.bluetooth.device.paired` fires when BlueZ first learns about a device (new pairing or adapter restart). It does not mean the device is connected. `bread.device.connected` fires when the device profile actually connects. + +`name` may be `"unknown"` on `bread.device.connected` events emitted from `PropertiesChanged` signals, since BlueZ only includes changed properties. It is always populated on `bread.bluetooth.device.paired` and on events from the initial enumeration at startup. + +#### Hyprland + +| Event | Data | +|-------|------| +| `bread.workspace.changed` | raw payload | +| `bread.workspace.created` | `{ workspace }` | +| `bread.workspace.destroyed` | `{ workspace }` | +| `bread.monitor.connected` | raw payload | +| `bread.monitor.disconnected` | raw payload | +| `bread.window.focus.changed` | raw payload | +| `bread.window.focused` | `{ address }` | +| `bread.window.opened` | `{ address, workspace, class, title }` | +| `bread.window.closed` | `{ address }` | +| `bread.window.moved` | `{ address, workspace }` | +| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds) | + +#### Power + +| Event | Data | +|-------|------| +| `bread.power.ac.connected` | `{ ac_connected, battery_percent }` | +| `bread.power.ac.disconnected` | `{ ac_connected, battery_percent }` | +| `bread.power.battery.low` | `{ battery_percent }` | +| `bread.power.battery.very_low` | `{ battery_percent }` | +| `bread.power.battery.critical` | `{ battery_percent }` | +| `bread.power.battery.full` | `{ battery_percent }` | +| `bread.power.changed` | `{ ac_connected, battery_percent }` | + +#### Network + +| Event | Data | +|-------|------| +| `bread.network.connected` | `{ online, interfaces }` | +| `bread.network.disconnected` | `{ online, interfaces }` | + +#### System events + +| Event | Data | +|-------|------| +| `bread.profile.activated` | `{ name }` | +| `bread.notify.sent` | `{ title, message, urgency }` | +| `bread.state.changed.` | emitted by state watches | + +--- + +## Dictionary: Runtime state schema + +`bread state` and `bread.state.get("")` return the full `RuntimeState`: + +```json +{ + "monitors": [ + { "name": "HDMI-A-1", "connected": true, "resolution": null, "position": null } + ], + "workspaces": [ + { "id": "1", "monitor": "HDMI-A-1" } + ], + "active_workspace": "1", + "active_window": "0x...", + "devices": { + "connected": [ + { + "id": "/sys/...", + "name": "CalDigit TS4", + "device": "dock", + "subsystem": "usb", + "vendor_id": "0x35f5", + "product_id": "0x0104" + } + ] + }, + "network": { + "interfaces": { "eth0": { "up": true } }, + "online": true + }, + "power": { + "ac_connected": true, + "battery_percent": 87, + "battery_low": false + }, + "profile": { + "active": "default", + "history": [], + "profiles": {} + }, + "modules": [ + { + "name": "bread.monitors", + "status": "loaded", + "last_error": null, + "builtin": true, + "store": {} + } + ] +} +``` + +`status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`. + +--- + +## Dictionary: IPC protocol + +The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON. + +Request: + +```json +{ "id": "1", "method": "state.get", "params": { "key": "monitors" } } +``` + +Response: + +```json +{ "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] } +``` + +Available methods: + +| Method | Params | Description | +|--------|--------|-------------| +| `ping` | — | Connectivity check | +| `health` | — | Version, uptime, PID, adapter status | +| `state.get` | `key` (dotted path) | Read a value from `RuntimeState` | +| `state.dump` | — | Return the full `RuntimeState` as JSON | +| `modules.list` | — | List all loaded modules and their status | +| `modules.reload` | — | Hot-reload the Lua runtime | +| `profile.list` | — | List defined profiles | +| `profile.activate` | `name` | Switch active profile | +| `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line | +| `events.replay` | `since_ms` | Replay buffered events from the last N ms | +| `emit` | `event`, `data` | Inject a synthetic event into the pipeline | +| `sync.status` | — | Return sync init state: `{ initialized, machine?, remote? }` | diff --git a/Examples.md b/Examples.md new file mode 100644 index 0000000..77b9eb1 --- /dev/null +++ b/Examples.md @@ -0,0 +1,187 @@ +# Bread Examples + +These examples show how to translate existing Hyprland automation into Bread's event-driven Lua runtime. + +Each snippet is designed to be drop-in friendly for a `~/.config/bread/modules/*.lua` file. Start with a new module file and `require` it from `~/.config/bread/init.lua`. + +## Example 1: Porting keyboard_and_display_watcher.sh (system script) + +Source inspiration: `~/.config/hypr/scripts/system/keyboard_and_display_watcher.sh`. + +This example covers two parts that port cleanly to Bread: + +- Start/stop the Redox layout viewer when the keyboard appears +- Start/stop a display sync service when an external monitor appears + +```lua +-- ~/.config/bread/modules/redox_and_display.lua +local M = bread.module({ name = "redox_and_display", version = "1.0.0" }) + +local PREVIEW_CMD = "/home/breadway/redox-layout-viewer/target/release/redox-layout-viewer" +local APP_NAME = "redox-layout-vi" + +local function start_viewer() + bread.exec("pgrep -f '" .. APP_NAME .. "' >/dev/null || " .. PREVIEW_CMD .. " >/dev/null 2>&1 &") +end + +local function stop_viewer() + bread.exec("pkill -f '" .. APP_NAME .. "' >/dev/null 2>&1 || true") +end + +local function is_redox(event) + -- Inspect event.data.raw once to find stable identifiers in your environment. + -- Typical udev fields include id_vendor, id_model, id_vendor_id, id_model_id, and name. + local raw = event.data and event.data.raw or {} + local name = tostring(raw.name or "") + local vendor = tostring(raw.id_vendor or "") + local model = tostring(raw.id_model or "") + + return name:lower():find("redox", 1, true) + or vendor:lower():find("redox", 1, true) + or model:lower():find("redox", 1, true) +end + +local external_monitors = 0 + +local function update_display_service() + if external_monitors > 0 then + bread.exec("systemctl --user start hypr-display-sync.service") + else + bread.exec("systemctl --user stop hypr-display-sync.service") + end +end + +function M.on_load() + bread.on("bread.device.keyboard.connected", function(event) + if is_redox(event) then + start_viewer() + end + end) + + bread.on("bread.device.keyboard.disconnected", function(event) + if is_redox(event) then + stop_viewer() + end + end) + + bread.on("bread.monitor.connected", function(event) + local name = event.data and (event.data.name or event.data.raw) or "" + -- ignore internal panel (eDP-1) and count only externals + if not tostring(name):match("eDP%-1") then + external_monitors = external_monitors + 1 + update_display_service() + end + end) + + bread.on("bread.monitor.disconnected", function(event) + local name = event.data and (event.data.name or event.data.raw) or "" + if not tostring(name):match("eDP%-1") then + external_monitors = math.max(0, external_monitors - 1) + update_display_service() + end + end) +end + +return M +``` + +Notes: + +- Use `bread.log(event.data.raw)` once to see your exact udev fields for matching. +- This drops polling and relies on udev/Hyprland events. + +## Example 2: Porting autostart.lua + +Source inspiration: `~/.config/hypr/scripts/system/autostart.lua`. + +```lua +-- ~/.config/bread/modules/autostart.lua +local M = bread.module({ name = "autostart", version = "1.0.0" }) + +local home = os.getenv("HOME") or "/home/breadway" +local startup_commands = { + "wal -R", + home .. "/colorshell/build/colorshell", + "awww-daemon", + "awww restore", + home .. "/.config/hypr/scripts/system/keyboard_and_display_watcher.sh", + home .. "/.config/hypr/watch_hypr_scripts.sh", + "systemctl --user daemon-reload", + "systemctl --user start hypr-display-sync.service", + "systemctl --user start hyprpolkitagent", + "dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP", + "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1", + "flatpak run dev.deedles.Trayscale", + "wificonf init", + "pkill -f hyprpaper", +} + +function M.on_load() + bread.once("bread.system.startup", function() + for _, cmd in ipairs(startup_commands) do + bread.exec(cmd) + end + end) +end + +return M +``` + +## Example 3: Porting display/monitors.lua + +Source inspiration: `~/.config/hypr/scripts/display/monitors.lua`. + +This uses Bread events and Hyprland keywords to update monitor layout when external displays change. + +```lua +-- ~/.config/bread/modules/monitors.lua +local M = bread.module({ name = "monitors", version = "1.0.0" }) + +local function apply_internal_mode(has_external) + local mode = has_external and "1920x1080@60" or "1920x1200@60" + bread.hyprland.keyword("monitor", "eDP-1, " .. mode .. ", 0x0, 1") +end + +local function apply_external() + bread.hyprland.keyword("monitor", "DP-3, 1920x1080@60, auto, 1, mirror, eDP-1") +end + +local externals = 0 +local function update() + apply_internal_mode(externals > 0) + if externals > 0 then + apply_external() + end +end + +function M.on_load() + bread.on("bread.monitor.connected", function(event) + local name = tostring((event.data and (event.data.name or event.data.raw)) or "") + if not name:match("eDP%-1") then + externals = externals + 1 + update() + end + end) + + bread.on("bread.monitor.disconnected", function(event) + local name = tostring((event.data and (event.data.name or event.data.raw)) or "") + if not name:match("eDP%-1") then + externals = math.max(0, externals - 1) + update() + end + end) + + bread.once("bread.system.startup", function() + update() + end) +end + +return M +``` + +## Tips for porting your own scripts + +- Start by logging the event payload: `bread.log(event.data.raw)` +- Replace polling loops with event subscriptions +- Use `bread.exec` for shell commands and systemd operations +- Use `bread.state.watch` for data that already lives in the runtime state diff --git a/LUA_RUNTIME.md b/LUA_RUNTIME.md deleted file mode 100644 index f2bb6a4..0000000 --- a/LUA_RUNTIME.md +++ /dev/null @@ -1,527 +0,0 @@ -# bread — Lua Runtime Architecture -### The Bread Scripting and Automation Layer - ---- - -## Overview - -The Lua runtime is the automation half of Bread. Where `breadd` maintains truth about the desktop, the Lua layer decides what to do about it. - -Modules written in Lua subscribe to events, read state, execute shell commands, and activate profiles. The entire scripting surface is exposed through a single `bread.*` global API — stable, versioned, and designed to be hostile to accidents. - -The runtime lives on a dedicated OS thread inside `breadd`. It is reachable from the async side only through a bounded message channel. Lua never touches sockets, sysfs, or compositor IPC directly. Everything flows through the daemon. - ---- - -## Phase 1 — Runtime Core - -These capabilities exist in the codebase today. Phase 1 is the foundation the Lua runtime is built on. - -### Daemon Stability - -`breadd` is a single long-running Rust process. It survives compositor restarts, module load errors, and Lua runtime panics. The daemon never terminates because a Lua file has a syntax error. - -The Lua runtime thread is spawned once at startup: - -```rust -std::thread::Builder::new() - .name("breadd-lua".to_string()) - .spawn(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("failed to create lua runtime thread"); - - rt.block_on(async move { - let mut engine = LuaEngine::new(config, state_handle, emit_tx)?; - engine.reload_internal()?; - - while let Some(msg) = rx.recv().await { - match msg { /* ... */ } - } - }); - })?; -``` - -If the initial module load fails, the daemon enters degraded mode: no Lua handlers are active, but the daemon itself remains alive. IPC stays responsive and `bread reload` can be used to recover after the user fixes their config. - -### Event Ingestion - -Every signal that enters `breadd` from an external system flows through a strict pipeline before it reaches Lua: - -``` -External System (Hyprland / udev / power / network) - │ - ▼ - Adapter — raw ingestion, owns the connection - │ RawEvent - ▼ - Normalizer — semantic interpretation - │ BreadEvent - ▼ - State Engine — state update + fan-out - │ - ├──► RuntimeState (updated atomically) - └──► Subscription Dispatcher - │ BreadEvent (per subscriber) - ▼ - Lua Runtime - (module handlers) -``` - -Raw events never reach Lua directly. Lua never observes a `RawEvent` — it only ever sees a normalized `BreadEvent` with a stable namespace string like `bread.device.dock.connected`. - -### Subscriptions - -Modules subscribe to events by pattern. The subscription table maps pattern strings to `(SubscriptionId, is_once)` pairs. The state engine evaluates each incoming `BreadEvent` against the table and dispatches to every matching subscriber. - -```rust -pub struct SubscriptionId(pub u64); -``` - -The Lua side registers subscriptions via `bread.on` and `bread.once`. Each call allocates a monotonically increasing `SubscriptionId`, stores the callback in the Lua registry, and registers the pattern with the state engine: - -```lua -bread.on("bread.device.dock.*", function(event) - bread.exec("~/.config/bread/scripts/dock.sh") -end) - -bread.once("bread.system.startup", function(event) - bread.profile.activate("default") -end) -``` - -`bread.once` subscriptions are automatically cancelled after first delivery. The handler is removed from both the Lua registry and the subscription table. - -### IPC - -`breadd` exposes a Unix domain socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The protocol is newline-delimited JSON. All IPC requests that affect the Lua runtime route through the `LuaMessage` channel — IPC never touches the Lua thread directly. - -Relevant IPC methods: - -| Method | Description | -|--------|-------------| -| `modules.list` | List loaded modules and their status | -| `modules.reload` | Trigger a hot reload of the Lua layer | -| `emit` | Inject a synthetic `BreadEvent` into the pipeline | -| `state.get` | Read a value from `RuntimeState` by key path | -| `state.dump` | Return the full `RuntimeState` as JSON | - -The `emit` method is particularly useful for testing: it allows injecting arbitrary `BreadEvent`s without needing the real hardware event that would normally produce them. - -### Hot Reload - -Hot reload is a first-class feature. The daemon persists; the Lua layer restarts. No process restart required. - -Reload sequence: - -``` -bread reload (CLI) - │ - ▼ -IPC: modules.reload - │ - ▼ -StateEngine: pause event dispatch to Lua - │ - ▼ -LuaRuntime: receive Reload message - │ - ├── cancel all active subscriptions - ├── clear handler registry - ├── drop Lua instance (all state cleared) - ├── create fresh Lua instance - ├── re-register bread.* API - ├── re-evaluate init.lua and all modules - └── re-register subscriptions with SubscriptionTable - │ - ▼ -StateEngine: resume event dispatch - │ - ▼ -IPC: reload complete response -``` - -If any module fails to load during reload, the reload aborts and the daemon enters degraded mode. There is no rollback — the previous Lua state was dropped before the reload began. This is intentional for V1. A syntax error in a module produces a clear error message from `bread reload`, and the daemon stays alive. - -### State Registry - -The daemon maintains a live `RuntimeState` behind an `Arc>`. It is the authoritative record of what is true about the desktop right now. - -```rust -pub struct RuntimeState { - pub monitors: Vec, - pub workspaces: Vec, - pub active_workspace: Option, - pub active_window: Option, - pub devices: DeviceTopology, - pub network: NetworkState, - pub power: PowerState, - pub profile: ProfileState, - pub modules: Vec, -} -``` - -Lua accesses this via `bread.state.get(path)`. The call takes a brief read lock, serializes the requested subtree to JSON, and converts it to a Lua value. Lua never holds the lock — the lock is dropped before control returns to the Lua callback: - -```lua -local monitors = bread.state.get("monitors") -local power = bread.state.get("power") -local active = bread.state.get("active_workspace") -``` - -Dotted paths are supported for nested access: - -```lua -local online = bread.state.get("network.online") -``` - -State is read-only from Lua. Lua cannot write to `RuntimeState` directly — it can only influence state indirectly by activating a profile or emitting an event that the state engine processes. - ---- - -## Phase 2 — Lua Runtime - -Phase 2 covers what is not yet built: the features required to make the Lua layer a complete, ergonomic automation platform. - -### Module Loader - -Currently, `breadd` loads modules by scanning `~/.config/bread/modules/` and executing every `.lua` file in sorted order. There is no concept of module identity, exports, or dependency declarations. - -Phase 2 introduces a proper module system: - -``` -~/.config/bread/ -├── init.lua ← entry point; declares module list -└── modules/ - ├── dock.lua - ├── display.lua - ├── power.lua - └── lib/ - └── utils.lua ← shared library, loaded on require -``` - -**`bread.module` declaration** — each module declares itself at the top of the file: - -```lua -local M = bread.module({ - name = "dock", - version = "1.0.0", - after = { "display" }, -- load after display.lua -}) -``` - -The runtime resolves the dependency graph and loads modules in topological order. Circular dependencies are detected at load time and reported as a load error on the offending module. - -**`require` support** — modules in `lib/` are loadable via `require`: - -```lua -local utils = require("bread.lib.utils") -``` - -The module loader intercepts `require` calls that begin with `bread.` and resolves them relative to `~/.config/bread/`. Standard Lua `require` semantics apply for everything else. - -**Module status tracking** — each module's load state is reflected in `RuntimeState.modules` and visible via `bread doctor` and `modules.list`: - -```rust -pub enum ModuleLoadState { - Loaded, - LoadError, - NotFound, -} -``` - -Phase 2 extends this with `Degraded` (loaded but encountered a runtime error since last reload) and `Disabled` (explicitly disabled in config). - -### Lifecycle Hooks - -Currently, modules have no way to run code at load time or cleanup code at unload time. Phase 2 adds four lifecycle hooks. - -```lua -function M.on_load() - -- called once when the module is first loaded - -- register subscriptions, initialize module state -end - -function M.on_reload() - -- called after a hot reload completes - -- re-apply any external side effects the module manages -end - -function M.on_unload() - -- called before the Lua instance is dropped - -- cancel external resources, write state if needed -end - -function M.on_error(err) - -- called when a subscription handler in this module throws - -- return true to keep the subscription, false to cancel it -end -``` - -The runtime calls hooks in a defined order: - -- **Load**: `on_load` is called after the module file executes successfully, in dependency order. -- **Reload**: `on_unload` is called in reverse dependency order. After the new Lua instance is ready, `on_load` runs on every module. `on_reload` runs after all `on_load` calls complete. -- **Error**: `on_error` is called on the Lua thread immediately after a handler throws. If the module does not define `on_error`, the default behavior is to log the error and keep the subscription alive. - -All hooks are optional. A module with no lifecycle hooks continues to work exactly as it does today. - -### Event APIs - -Phase 2 expands the event surface available to Lua modules. - -**Pattern syntax** — the current subscription API matches event names against patterns using glob-style `*` wildcards. Phase 2 adds `**` for recursive matching and `?` for single-character wildcards: - -```lua -bread.on("bread.device.*", handler) -- matches bread.device.dock.connected -bread.on("bread.device.**", handler) -- matches any depth under bread.device -bread.on("bread.monitor.?", handler) -- single-segment wildcard -``` - -**`bread.off`** — cancel a subscription by the ID returned from `bread.on`: - -```lua -local id = bread.on("bread.power.*", handler) --- later: -bread.off(id) -``` - -**`bread.wait`** — yield until a matching event arrives, with an optional timeout: - -```lua -local event = bread.wait("bread.device.dock.connected", { timeout = 5000 }) -if event then - -- dock arrived within 5 seconds -end -``` - -`bread.wait` is syntactic sugar over a `bread.once` subscription combined with a coroutine yield. It can only be used inside a coroutine context; calling it from a top-level module body is a load error. - -**`bread.filter`** — attach a predicate to a subscription. The handler is only called when the predicate returns true: - -```lua -bread.on("bread.device.*", handler, { - filter = function(event) - return event.data.class == "dock" - end -}) -``` - -**Timers** — schedule callbacks without relying on an external timer process: - -```lua -local id = bread.after(500, function() - -- called once, 500ms from now -end) - -local id = bread.every(30000, function() - -- called every 30 seconds -end) - -bread.cancel(id) -- cancel either kind -``` - -Timers are cancelled automatically on reload. A module does not need to track its own timer IDs for cleanup. - -### State Access - -Phase 2 extends `bread.state` from a read-only snapshot query into a richer interface. - -**Typed helpers** — convenience wrappers for the most common state subtrees: - -```lua -bread.state.monitors() -- Vec -bread.state.active_workspace() -- string | nil -bread.state.active_window() -- string | nil -bread.state.devices() -- Vec -bread.state.power() -- PowerState -bread.state.network() -- NetworkState -bread.state.profile() -- ProfileState -``` - -These are thin wrappers over `bread.state.get` — they add no locking overhead. - -**Reactive state** — watch a state path for changes and receive a callback when it changes: - -```lua -bread.state.watch("power.ac_connected", function(new_val, old_val) - if new_val then - bread.exec("notify-send 'AC connected'") - end -end) -``` - -State watches are implemented as synthetic subscriptions: the state engine compares the watched path before and after each `RuntimeState` update and synthesizes a `bread.state.changed.` event when a difference is detected. From the Lua runtime's perspective, watches are ordinary subscriptions. - -**Module-scoped storage** — a key-value store persisted across reloads (but not across daemon restarts): - -```lua -M.store.set("last_profile", "docked") -local p = M.store.get("last_profile") -- "docked" -``` - -Storage is scoped per module. A module cannot read another module's store. The store is backed by a `HashMap` in the `RuntimeState.modules` entry for that module, so it survives hot reload. - -### Hyprland Bindings - -Phase 2 exposes a `bread.hyprland` namespace for direct interaction with the Hyprland compositor. This is the only place in the Lua API that is compositor-specific; all other APIs are compositor-agnostic. - -The bindings communicate over Hyprland's IPC request socket (`$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock`), not the event socket. Calls are dispatched to a Tokio task on the async side and awaited transparently from Lua via coroutine suspension. - -**Dispatch** - -```lua -bread.hyprland.dispatch("workspace", "2") -bread.hyprland.dispatch("movetoworkspace", "2,address:0x...") -bread.hyprland.dispatch("exec", "kitty") -``` - -**Keyword** - -```lua -local result = bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1") -``` - -**Active window** - -```lua -local win = bread.hyprland.active_window() --- { address, title, class, workspace, monitor, ... } -``` - -**Monitor and workspace queries** - -```lua -local monitors = bread.hyprland.monitors() -local workspaces = bread.hyprland.workspaces() -local clients = bread.hyprland.clients() -``` - -All calls return deserialized Lua tables matching Hyprland's JSON response shape. Errors from the compositor (malformed dispatch, unknown keyword) are surfaced as Lua errors catchable with `pcall`. - -**Hyprland-specific events** — the existing `bread.monitor.*` and `bread.workspace.*` event namespaces already cover the most common Hyprland signals. The Phase 2 bindings add lower-level passthrough for events that do not yet have a normalized `BreadEvent` representation: - -```lua -bread.hyprland.on_raw("activewindow", function(raw) - -- raw is the unparsed string from Hyprland's event socket -end) -``` - -Raw subscriptions bypass normalization. They are intended for power users and for features not yet covered by the normalized event namespace. Once a raw event pattern is common enough, it graduates to a stable `BreadEvent` and the raw subscription is deprecated. - ---- - -## Lua ↔ Rust Boundary - -All calls across the boundary go through `mlua`'s safe API. Rust functions registered as Lua globals return `mlua::Result` and handle their own error mapping. Panics inside registered Rust functions are caught by mlua and converted to Lua errors — they do not unwind into the Lua thread and they do not crash the daemon. - -The `LuaMessage` enum is the only channel between the async Tokio runtime and the Lua thread: - -```rust -pub enum LuaMessage { - Event { - subscription_id: SubscriptionId, - event: BreadEvent, - }, - SubscriptionCancelled { - id: SubscriptionId, - }, - Reload { - reply: oneshot::Sender>, - }, - Shutdown, -} -``` - -Lua is not `Send`. The `LuaEngine` and the `Lua` instance live exclusively on the dedicated Lua OS thread. The async side communicates only by sending `LuaMessage` values through the channel — it never holds a reference to anything inside the Lua VM. - ---- - -## Error Isolation - -### Handler errors - -Lua errors during event handler execution are caught with `pcall` at the Rust boundary: - -```rust -fn handle_event(&self, id: SubscriptionId, event: BreadEvent) -> Result<()> { - let callback: Function = self.lua.registry_value(reg)?; - let event_value = self.lua.to_value(&event)?; - if let Err(err) = callback.call::<_, ()>(event_value) { - error!(subscription = id.0, error = %err, "lua callback failed"); - } - Ok(()) -} -``` - -The error is logged with the subscription ID and full Lua stack trace. The handler remains registered and will fire again on the next matching event. A persistently failing handler is the module's responsibility to cancel via `bread.off`. - -Phase 2's `on_error` hook gives modules a structured way to respond to handler failures rather than relying solely on the daemon log. - -### Module load errors - -Errors during module load are fatal to that module but not to the daemon or to other modules. The failed module is marked `LoadError` in `RuntimeState.modules`. Remaining modules continue loading in dependency order; only modules that declared `after` the failed module are also skipped (their dependency is broken). - -### Degraded mode - -If the initial load or a hot reload fails such that no Lua instance is running, the daemon enters degraded mode: - -- No Lua handlers are active. -- IPC remains fully operational. -- `bread reload` can be retried after the user fixes their config. -- `bread doctor` reports the load error with the full stack trace. - -The daemon never requires a full restart to recover from a Lua error. - ---- - -## `bread.*` API Surface Summary - -### Phase 1 (implemented) - -| Function | Description | -|----------|-------------| -| `bread.on(pattern, fn)` | Subscribe to a pattern; returns subscription ID | -| `bread.once(pattern, fn)` | Subscribe once; auto-cancelled after first delivery | -| `bread.emit(event, payload)` | Inject a synthetic `BreadEvent` | -| `bread.exec(cmd)` | Fire-and-forget shell command | -| `bread.state.get(path)` | Read a value from `RuntimeState` by dotted path | -| `bread.profile.activate(name)` | Activate a named profile | - -### Phase 2 (planned) - -| Function | Description | -|----------|-------------| -| `bread.off(id)` | Cancel a subscription by ID | -| `bread.wait(pattern, opts)` | Yield until a matching event arrives | -| `bread.filter(pattern, fn, opts)` | Subscribe with a predicate guard | -| `bread.after(ms, fn)` | One-shot timer | -| `bread.every(ms, fn)` | Repeating timer | -| `bread.cancel(id)` | Cancel a timer | -| `bread.state.watch(path, fn)` | React to state changes at a path | -| `bread.state.monitors()` | Typed shorthand for `bread.state.get("monitors")` | -| `bread.state.power()` | Typed shorthand for `bread.state.get("power")` | -| `bread.state.network()` | Typed shorthand for `bread.state.get("network")` | -| `bread.hyprland.dispatch(cmd, args)` | Send a Hyprland dispatch | -| `bread.hyprland.keyword(key, val)` | Set a Hyprland keyword | -| `bread.hyprland.active_window()` | Query the active window | -| `bread.hyprland.monitors()` | Query all monitors | -| `bread.hyprland.workspaces()` | Query all workspaces | -| `bread.hyprland.clients()` | Query all open clients | -| `bread.hyprland.on_raw(event, fn)` | Subscribe to a raw Hyprland event string | -| `bread.module(decl)` | Declare a module with name, version, and dependencies | -| `M.store.get(key)` | Read from module-scoped persistent storage | -| `M.store.set(key, val)` | Write to module-scoped persistent storage | - ---- - -## Summary - -The Lua runtime is where Bread becomes useful. The daemon provides a reliable, normalized view of the desktop; the Lua layer acts on it. - -Phase 1 delivers the mechanical minimum: a stable thread, a working `bread.*` API, event subscriptions, state access, hot reload, and IPC. That foundation is in the codebase today. - -Phase 2 builds the ergonomics: module identity, lifecycle hooks, reactive state, timers, richer event APIs, and Hyprland control bindings. Each Phase 2 feature is additive — nothing in Phase 1 needs to change to support it. - -The boundary between Rust and Lua is intentionally narrow. The daemon knows nothing about what modules do. Modules know nothing about how events arrive. The `bread.*` API is the entire contract between them. diff --git a/README.md b/README.md index 5ed2a35..18ad5ba 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Bread is a modular desktop automation runtime built around a single idea: your d Instead of scattering behavior across shell scripts, compositor configs, udev rules, and ad-hoc daemons, Bread centralizes runtime awareness into a coherent layer that can observe, interpret, and react to system state dynamically. -> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is under active development. +> **Status:** Early development. The daemon (`breadd`) is stable. The Lua automation API is active and feature-complete for daily use. --- @@ -22,14 +22,19 @@ Bread runs a long-lived daemon (`breadd`) that: Your automation lives in Lua. You subscribe to events, read state, and call APIs: ```lua -bread.on("bread.device.dock.connected", function() +local M = bread.module({ name = "dock", version = "1.0.0" }) + +bread.on("bread.device.dock.connected", function(event) bread.profile.activate("desk") bread.exec("waybar --config ~/.config/waybar/desk.jsonc") + bread.notify("Dock connected", { urgency = "low" }) end) -bread.on("bread.device.dock.disconnected", function() +bread.on("bread.device.dock.disconnected", function(event) bread.profile.activate("default") end) + +return M ``` --- @@ -40,12 +45,13 @@ end) breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision bread-cli/ CLI frontend — talks to breadd over a Unix socket bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource +bread-sync/ Sync engine — snapshot and restore system state via a Git remote packaging/ Arch PKGBUILD and systemd user service ``` The daemon is structured in four layers: -- **Adapters** — interface with Hyprland IPC, udev, power state, and network interfaces +- **Adapters** — interface with Hyprland IPC, udev, power state, network interfaces, and Bluetooth (BlueZ) - **Normalizer** — transforms raw adapter signals into semantic Bread events - **State engine** — maintains runtime state and dispatches events to subscribers - **Lua runtime** — loads your modules, registers handlers, executes automation @@ -62,6 +68,7 @@ The daemon is structured in four layers: Optional but preferred: - UPower (for battery events via D-Bus rather than sysfs polling) - rtnetlink (for network events; falls back to sysfs polling without it) +- BlueZ (for Bluetooth device events and control) --- @@ -70,18 +77,22 @@ Optional but preferred: ### From source ```bash -git clone https://github.com/Breadway/bread +git clone https://github.com/Breadway/bread.git cd bread -cargo build --release ``` -Binaries will be at `target/release/breadd` and `target/release/bread`. - -Install them: +Run the install script — it builds, symlinks `breadd` and `bread` into `~/.local/bin` (override with `BIN_DIR=…`), installs the systemd user service, and starts the daemon: ```bash -sudo install -Dm755 target/release/breadd /usr/local/bin/breadd -sudo install -Dm755 target/release/bread /usr/local/bin/bread +bash scripts/install.sh +``` + +Or step by step (system-wide install): + +```bash +cargo build --release +sudo install -Dm755 target/release/breadd /usr/bin/breadd +sudo install -Dm755 target/release/bread /usr/bin/bread ``` ### Arch Linux (PKGBUILD) @@ -128,19 +139,28 @@ poll_interval_secs = 30 [adapters.network] enabled = true +[adapters.bluetooth] +enabled = true + [events] dedup_window_ms = 100 + +[notifications] +default_timeout_ms = 5000 +default_urgency = "normal" +notify_send_path = "notify-send" + +[modules] +builtin = true # load built-in modules (monitors, devices, workspaces, binds) +disable = [] # list of built-in module names to disable ``` -Your automation lives in `~/.config/bread/init.lua`: +Your automation lives in `~/.config/bread/init.lua`. Modules placed in `~/.config/bread/modules/` are auto-loaded after `init.lua`: ```lua -- ~/.config/bread/init.lua -require("modules.devices") -require("modules.workspaces") - -bread.on("bread.system.startup", function() +bread.on("bread.system.startup", function(event) bread.profile.activate("default") end) ``` @@ -152,16 +172,160 @@ end) All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. ```bash -bread reload # Hot-reload all Lua modules -bread state # Dump full runtime state as JSON -bread events # Stream live normalized events -bread events --filter bread.device.* # Stream filtered events -bread modules # List loaded modules and status -bread profile-list # List defined profiles -bread profile-activate # Activate a named profile -bread emit --data '{}' # Manually fire an event (for testing) +# Daemon bread ping # Check daemon connectivity bread health # Daemon version, uptime, PID +bread doctor # Diagnose daemon and module health + +# Lua runtime +bread reload # Hot-reload all Lua modules +bread reload --watch # Watch config dir and reload on changes + +# State and events +bread state # Dump full runtime state as JSON +bread events # Stream live normalized events +bread events bread.device.* # Stream filtered events +bread events --since 60 # Replay events from the last 60 seconds +bread emit # Manually fire an event (for testing) + +# Profiles +bread profile-list # List defined profiles +bread profile-activate # Activate a named profile + +# Modules +bread modules list # List installed modules and daemon status +bread modules install github:user/repo # Install from GitHub +bread modules install /local/path # Install from a local directory +bread modules remove # Remove an installed module +bread modules update [name] # Re-install one or all GitHub-sourced modules +bread modules info # Show full manifest and daemon status + +# Sync +bread sync init # Initialize sync for this machine (remote optional) +bread sync push # Commit local snapshot +bread sync push --message "note" # Commit with a custom message +bread sync pull # Apply local snapshot to this machine +bread sync pull --install-packages # Also install packages from snapshot +bread sync status # Show what has changed since last push +bread sync diff # Show file-level diff vs last commit +bread sync machines # List known machines from sync repo +bread sync export # Create a portable .tar.gz snapshot (no git auth) +bread sync export --output path # Export to a specific file or directory +bread sync import # Apply a portable snapshot (.tar.gz or directory) +bread sync import --install-packages # Also install packages +bread sync import --no-clone-repos # Skip cloning git repos +``` + +--- + +## Module system + +Modules are Lua files (or directories) installed to `~/.config/bread/modules/`. Each module must declare itself with `bread.module()` and have a `bread.module.toml` manifest. + +### Installing modules + +```bash +# From GitHub (downloads latest release tarball) +bread modules install github:someuser/bread-wifi + +# From a local path +bread modules install ~/src/my-module + +# From a specific ref +bread modules install github:someuser/bread-wifi@v1.2.0 +``` + +### Writing a module + +A module directory looks like: + +``` +~/.config/bread/modules/ +└── wifi/ + ├── bread.module.toml ← required manifest + └── init.lua ← entry point +``` + +`bread.module.toml`: +```toml +name = "wifi" +version = "1.0.0" +description = "WiFi management for Bread" +author = "someuser" +source = "github:someuser/bread-wifi" +installed_at = "2026-01-01T00:00:00Z" +``` + +`init.lua`: +```lua +local M = bread.module({ name = "wifi", version = "1.0.0" }) + +bread.on("bread.network.connected", function(event) + bread.log("Network up: " .. (event.data.interface or "unknown")) +end) + +return M +``` + +--- + +## Sync system + +Bread sync snapshots your entire setup — Bread config, dotfiles, fonts, systemd units, package lists, and git repos — into a local Git repository. Use `export`/`import` to move state between machines without needing a git remote. + +```bash +# First-time setup (remote is optional) +bread sync init +bread sync init --remote git@github.com:you/bread-config.git + +# Commit a local snapshot +bread sync push + +# Create a portable .tar.gz (no git auth required) +bread sync export + +# On another machine: apply the snapshot +bread sync import bread-export-hermes-2026-05-16.tar.gz + +# Also install packages on import +bread sync import bread-export.tar.gz --install-packages +``` + +Configure what gets synced in `~/.config/bread/sync.toml`: + +```toml +[remote] +url = "git@github.com:you/bread-config.git" # optional +branch = "main" + +[machine] +name = "hermes" +tags = ["laptop", "battery"] + +[packages] +enabled = true +managers = ["pacman", "pip", "cargo"] + +[delegates] +include = ["~/.config/nvim", "~/.config/waybar"] +exclude = ["**/.git", "**/*.cache"] +``` + +A portable export snapshot contains: + +``` +bread-export-hermes-2026-05-16/ +├── bread/ ← ~/.config/bread/ +├── configs/ ← hypr, nvim, kitty, waybar, fish, dunst, btop, … +├── dotfiles/ ← .gitconfig, .zshrc, .zprofile, .zshenv, ssh config, … +├── local-bin/ ← ~/.local/bin/ scripts +├── local-fonts/ ← ~/.local/share/fonts/ +├── systemd/ ← ~/.config/systemd/user/ units +├── system/ ← udev rules, modprobe, sysctl (sudo required for some) +├── packages/ ← pacman.txt, pip.txt, cargo.txt, npm.txt +├── machines/ ← per-machine profiles +├── manifest.toml ← path map for exact restore +└── restore.sh ← shell script for manual restore ``` --- @@ -175,9 +339,8 @@ Events follow the namespace convention `bread...`. | `bread.system.startup` | Daemon fully initialized | | `bread.device.connected` | Any device attached | | `bread.device.disconnected` | Any device removed | -| `bread.device.dock.connected` | Dock attached | -| `bread.device.dock.disconnected` | Dock removed | -| `bread.device.keyboard.connected` | Keyboard attached | +| `bread.device..connected` | Named device attached (name from `devices.lua`) | +| `bread.device..disconnected` | Named device removed | | `bread.monitor.connected` | Display connected | | `bread.monitor.disconnected` | Display disconnected | | `bread.workspace.changed` | Active workspace changed | @@ -192,36 +355,91 @@ Events follow the namespace convention `bread...`. | `bread.power.battery.full` | Battery at 100% | | `bread.network.connected` | Network interface came online | | `bread.network.disconnected` | Network interface went offline | +| `bread.bluetooth.device.paired` | Bluetooth device paired / discovered | +| `bread.bluetooth.device.unpaired` | Bluetooth device removed from BlueZ | | `bread.profile.activated` | Profile switched | +| `bread.notify.sent` | Desktop notification dispatched | --- ## Lua API +### Modules + +Every module file must declare itself. The declaration is used for dependency ordering and status tracking. + +```lua +local M = bread.module({ + name = "my-module", + version = "1.0.0", + after = { "bread.devices" }, -- load after this module +}) + +-- ... module body ... + +return M +``` + ### Events ```lua --- Subscribe to an event -bread.on("bread.monitor.connected", function(event) - print(event.data.name) +-- Subscribe to events; returns a subscription ID +local id = bread.on("bread.monitor.connected", function(event) + -- event.event → "bread.monitor.connected" + -- event.data → table of event-specific fields + -- event.source → adapter that produced it + bread.log(event.event) end) --- Subscribe once, then auto-unsubscribe +-- Unsubscribe by ID +bread.off(id) + +-- Subscribe once, auto-unsubscribe after first delivery bread.once("bread.system.startup", function(event) - -- runs exactly once + bread.profile.activate("default") end) +-- Subscribe with a filter predicate. The predicate goes in an opts table. +bread.filter("bread.device.connected", function(event) + bread.exec("xset r rate 200 40") +end, { + filter = function(event) + return event.data.device == "keyboard" + end, +}) + -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) ``` +Pattern matching supports `*` (single segment), `**` (any depth), and `?` (single character): +```lua +bread.on("bread.device.*", handler) -- matches bread.device.dock.connected +bread.on("bread.device.**", handler) -- matches any depth under bread.device +``` + ### State ```lua --- Read a value from runtime state by dot-separated path +-- Read from runtime state by dot-separated path local monitors = bread.state.get("monitors") -local workspace = bread.state.get("active_workspace") -local power = bread.state.get("power") +local online = bread.state.get("network.online") + +-- Typed shorthands +local monitors = bread.state.monitors() +local workspace = bread.state.active_workspace() +local window = bread.state.active_window() +local devices = bread.state.devices() +local power = bread.state.power() +local network = bread.state.network() +local profile = bread.state.profile() + +-- Watch a state path for changes +bread.state.watch("power.ac_connected", function(new_val, old_val) + if new_val then + bread.notify("AC connected") + end +end) ``` ### Profiles @@ -231,12 +449,140 @@ bread.profile.activate("desk") bread.profile.activate("default") ``` -### Execution +### Execution and notifications ```lua --- Fire-and-forget: returns immediately, process runs in background +-- Fire-and-forget shell command bread.exec("kitty") -bread.exec("notify-send 'Dock connected'") + +-- Desktop notification (uses notify-send) +bread.notify("Title", { urgency = "normal", timeout = 3000, icon = "dialog-info" }) +bread.notify("Simple message") -- title defaults to "bread" +``` + +### Timers + +```lua +-- Run once after a delay (ms) +local id = bread.after(500, function() + bread.exec("some-delayed-command") +end) + +-- Run on a repeating interval (ms) +local id = bread.every(60000, function() + bread.log("tick") +end) + +-- Cancel either kind +bread.cancel(id) + +-- Debounce a rapidly-firing handler +local fn = bread.debounce(200, function(event) + reconfigure_monitors() +end) +bread.on("bread.monitor.*", fn) +``` + +### Wait (inside coroutines) + +```lua +-- Yield until a matching event arrives +local event = bread.wait("bread.device.dock.connected", { timeout = 5000 }) +if event then + -- dock arrived within 5 seconds +end +``` + +### Machine and filesystem + +```lua +-- Machine identity (from sync.toml, falls back to hostname) +local name = bread.machine.name() +local tags = bread.machine.tags() -- array of strings +local ok = bread.machine.has_tag("laptop") + +-- Filesystem helpers (~ is expanded) +bread.fs.write("~/.config/some/file", "content") +local content = bread.fs.read("~/.config/some/file") -- nil if not found +local exists = bread.fs.exists("~/some/path") +local abs = bread.fs.expand("~/some/path") +``` + +### Logging + +```lua +bread.log("Module loaded") -- info level +bread.warn("Unexpected state") -- warn level +bread.error("Something failed") -- error level +``` + +### Hyprland bindings + +```lua +-- Dispatch a Hyprland command +bread.hyprland.dispatch("workspace", "2") +bread.hyprland.dispatch("exec", "kitty") + +-- Set a keyword +bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1") + +-- Query compositor state +local win = bread.hyprland.active_window() +local monitors = bread.hyprland.monitors() +local workspaces = bread.hyprland.workspaces() +local clients = bread.hyprland.clients() + +-- Subscribe to raw Hyprland events (bypass normalization) +bread.hyprland.on_raw("activewindow", function(raw) + -- raw is the unparsed string from Hyprland's event socket +end) +``` + +### Bluetooth + +The `bread.bluetooth` namespace provides BlueZ control. All operations degrade gracefully when Bluetooth hardware is unavailable. + +```lua +-- Power the adapter on or off +bread.bluetooth.power(true) +bread.bluetooth.power(false) + +-- Query current power state (returns true/false, or nil if unavailable) +local on = bread.bluetooth.powered() + +-- Connect/disconnect a paired device by MAC address +-- Fire-and-forget; result arrives as bread.device.connected/disconnected +bread.bluetooth.connect("AA:BB:CC:DD:EE:FF") +bread.bluetooth.disconnect("AA:BB:CC:DD:EE:FF") + +-- Start or stop device discovery +bread.bluetooth.scan(true) +bread.bluetooth.scan(false) + +-- List all devices known to BlueZ +local devs = bread.bluetooth.devices() +-- Returns nil if BlueZ is unavailable, otherwise: +-- { { address, name, connected, paired }, ... } +``` + +Example — auto-connect headphones when Bluetooth powers on: + +```lua +bread.state.watch("power.ac_connected", function(ac) + if ac then + bread.bluetooth.power(true) + bread.bluetooth.connect("AA:BB:CC:DD:EE:FF") + end +end) +``` + +### Module-scoped storage + +Survives hot reload; does not survive daemon restart. + +```lua +M.store.set("last_profile", "docked") +local p = M.store.get("last_profile") -- "docked" ``` --- @@ -255,9 +601,24 @@ Response: { "id": "1", "result": [ { "name": "HDMI-A-1", "connected": true } ] } ``` -Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, `modules.reload`, `profile.list`, `profile.activate`, `events.subscribe`, `emit`. +Available methods: -`events.subscribe` upgrades the connection to a streaming mode — the daemon pushes events line by line until the client disconnects. +| Method | Description | +|--------|-------------| +| `ping` | Connectivity check | +| `health` | Version, uptime, PID, adapter status | +| `state.get` | Read a value from `RuntimeState` by dotted key path | +| `state.dump` | Return the full `RuntimeState` as JSON | +| `modules.list` | List all loaded modules and their status | +| `modules.reload` | Hot-reload the Lua runtime | +| `profile.list` | List defined profiles | +| `profile.activate` | Switch active profile | +| `events.subscribe` | Upgrade connection to streaming mode | +| `events.replay` | Replay buffered events from the last N ms | +| `emit` | Inject a synthetic event into the pipeline | +| `sync.status` | Return sync initialization state and machine info | + +`events.subscribe` upgrades the connection to streaming mode — the daemon pushes events line by line until the client disconnects. --- @@ -265,7 +626,7 @@ Available methods: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, Bread is early-stage software. Contributions, issues, and feedback are welcome. -The daemon (`breadd`) is the most stable part of the codebase. The Lua API surface is where most active development is happening. +The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API, module system, and sync subsystem. --- diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 0550c57..1e4b667 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -1,12 +1,30 @@ [package] name = "bread-cli" -version = "0.1.0" +version = "1.0.0" edition = "2021" +[[bin]] +name = "bread" +path = "src/main.rs" + +[lib] +name = "bread_cli" +path = "src/lib.rs" + [dependencies] bread-shared = { path = "../bread-shared" } +bread-sync = { path = "../bread-sync" } serde.workspace = true serde_json.workspace = true tokio.workspace = true anyhow.workspace = true +chrono.workspace = true +dirs.workspace = true clap = { version = "4.5", features = ["derive"] } +notify = "6.1" +libc = "0.2" +toml = "0.8" +reqwest = { version = "0.11", features = ["json"] } +flate2 = "1.0" +tar = "0.4" +tempfile.workspace = true diff --git a/bread-cli/src/lib.rs b/bread-cli/src/lib.rs new file mode 100644 index 0000000..72bcce2 --- /dev/null +++ b/bread-cli/src/lib.rs @@ -0,0 +1,2 @@ +/// Module management (install, remove, list, update, info). +pub mod modules_mgmt; diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index e4af194..924c7b3 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,13 +1,27 @@ -use anyhow::Result; +mod modules_mgmt; + +use anyhow::{Context, Result}; +use bread_sync::{ + config::{bread_config_dir, SyncConfig}, + delegates, machine, packages, apply_import, stage_export, SyncRepo, +}; use clap::{Parser, Subcommand}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{json, Value}; use std::env; +use std::io::{self, Write as IoWrite}; use std::path::{Path, PathBuf}; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; +use tokio::sync::mpsc; #[derive(Parser, Debug)] -#[command(author, version, about = "Bread CLI - the reactive desktop automation fabric")] +#[command( + author, + version, + about = "Bread CLI - the reactive desktop automation fabric" +)] struct Cli { #[command(subcommand)] command: Commands, @@ -16,16 +30,43 @@ struct Cli { #[derive(Subcommand, Debug)] enum Commands { /// Hot-reload all Lua modules - Reload, + Reload { + /// Watch config directory and reload on changes + #[arg(long)] + watch: bool, + }, /// Dump current runtime state - State, + State { + /// Optional dotted path into RuntimeState + path: Option, + /// Output raw JSON + #[arg(long)] + json: bool, + }, /// Stream live normalized events Events { + /// Optional glob pattern to filter events (e.g. bread.device.*, bread.**) + pattern: Option, + /// Output raw JSON #[arg(long)] - filter: Option, + json: bool, + /// Comma-separated fields to display + #[arg(long)] + fields: Option, + /// Replay events from the last N seconds + #[arg(long)] + since: Option, + }, + /// Manage installed Lua modules + Modules { + #[command(subcommand)] + subcommand: ModulesCommand, + }, + /// Manage sync (snapshot and restore system state) + Sync { + #[command(subcommand)] + subcommand: SyncCommand, }, - /// List loaded modules and status - Modules, /// List available profiles ProfileList, /// Activate a profile @@ -40,6 +81,88 @@ enum Commands { Ping, /// Fetch daemon health details Health, + /// Diagnose daemon and module health + Doctor { + /// Output raw JSON + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand, Debug)] +enum ModulesCommand { + /// Install a module from a source + Install { + /// Source: github:user/repo[@ref] or /path/to/dir + source: String, + }, + /// Remove an installed module + Remove { + name: String, + /// Skip confirmation prompt + #[arg(long)] + yes: bool, + }, + /// List all installed modules + List, + /// Update one or all installed modules + Update { + /// Module name (omit to update all) + name: Option, + }, + /// Show full manifest details for a module + Info { name: String }, +} + +#[derive(Subcommand, Debug)] +enum SyncCommand { + /// Initialize sync for this machine + Init { + /// Git remote URL + #[arg(long)] + remote: Option, + }, + /// Snapshot and push current state + Push { + /// Custom commit message + #[arg(long)] + message: Option, + }, + /// Pull and apply latest state + Pull { + /// Also install packages from manifest + #[arg(long)] + install_packages: bool, + }, + /// Show what has changed since last push + Status, + /// Show file-level diff vs last commit (or vs remote with --remote) + Diff { + #[arg(long)] + remote: bool, + }, + /// List known machines from sync repo + Machines, + /// Create a portable export archive (no git auth required) + Export { + /// Output path: directory or .tar.gz file. Defaults to ./bread-export--.tar.gz + #[arg(long, short)] + output: Option, + }, + /// Apply a portable export archive to this machine + Import { + /// Path to a bread export directory or .tar.gz file + from: PathBuf, + /// Also install packages from the package manifests + #[arg(long)] + install_packages: bool, + /// Skip cloning git repositories to their original locations + #[arg(long)] + no_clone_repos: bool, + /// Skip confirmation prompt + #[arg(long)] + yes: bool, + }, } #[tokio::main] @@ -47,32 +170,52 @@ async fn main() -> Result<()> { let cli = Cli::parse(); let socket = daemon_socket_path(); - match &cli.command { - Commands::Reload => { - let response = send_request(&socket, "modules.reload", json!({})).await?; - print_json(&response)?; + match cli.command { + Commands::Reload { watch } => { + if watch { + watch_reload(&socket).await?; + } else { + let response = send_request(&socket, "modules.reload", json!({})).await?; + print_reload(&response); + } } - Commands::State => { - let response = send_request(&socket, "state.dump", json!({})).await?; - print_json(&response)?; + Commands::State { path, json } => { + let response = if let Some(ref path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; + if json { + print_json(&response)?; + } else { + print_state_formatted(path.as_deref(), &response); + } } - Commands::Events { filter } => { - stream_events(&socket, filter.clone()).await?; + Commands::Events { + pattern, + json, + fields, + since, + } => { + stream_events(&socket, pattern, json, fields, since).await?; } - Commands::Modules => { - let response = send_request(&socket, "modules.list", json!({})).await?; - print_json(&response)?; + Commands::Modules { subcommand } => { + handle_modules_cmd(subcommand, &socket).await?; + } + Commands::Sync { subcommand } => { + handle_sync_cmd(subcommand, &socket).await?; } Commands::ProfileList => { let response = send_request(&socket, "profile.list", json!({})).await?; print_json(&response)?; } Commands::ProfileActivate { name } => { - let response = send_request(&socket, "profile.activate", json!({ "name": name })).await?; + let response = + send_request(&socket, "profile.activate", json!({ "name": name })).await?; print_json(&response)?; } Commands::Emit { event, data } => { - let parsed = serde_json::from_str::(data).unwrap_or_else(|_| json!({})); + let parsed = serde_json::from_str::(&data).unwrap_or_else(|_| json!({})); let response = send_request( &socket, "emit", @@ -92,11 +235,796 @@ async fn main() -> Result<()> { let response = send_request(&socket, "health", json!({})).await?; print_json(&response)?; } + Commands::Doctor { json } => { + if json { + let response = send_request(&socket, "health", json!({})).await?; + print_json(&response)?; + } else { + print_doctor(&socket).await?; + } + } } Ok(()) } +// --------------------------------------------------------------------------- +// Module subcommands +// --------------------------------------------------------------------------- + +async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { + let mods_dir = modules_mgmt::modules_dir(); + + match cmd { + ModulesCommand::Install { source } => { + let manifest = install_module(&source, &mods_dir).await?; + println!("installed {} v{}", manifest.name, manifest.version); + try_daemon_reload(socket).await; + } + + ModulesCommand::Remove { name, yes } => { + let module_dir = mods_dir.join(&name); + if !module_dir.exists() { + eprintln!("bread: module '{}' is not installed", name); + std::process::exit(1); + } + if !yes { + print!("remove {}? (y/n): ", name); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + if !line.trim().eq_ignore_ascii_case("y") { + println!("aborted"); + return Ok(()); + } + } + modules_mgmt::remove_module(&name, &mods_dir)?; + println!("removed {}", name); + try_daemon_reload(socket).await; + } + + ModulesCommand::List => { + let modules = modules_mgmt::list_modules(&mods_dir)?; + // Try to get daemon module status + let daemon_statuses = match send_request(socket, "modules.list", json!({})).await { + Ok(resp) => resp + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|v| { + let name = v.get("name").and_then(Value::as_str)?.to_string(); + let status = v.get("status").and_then(Value::as_str)?.to_string(); + Some((name, status)) + }) + .collect::>(), + Err(_) => std::collections::HashMap::new(), + }; + for m in &modules { + let status = daemon_statuses + .get(&m.name) + .map(String::as_str) + .unwrap_or("unknown"); + println!( + " {:20} {:10} {:10} {}", + m.name, m.version, status, m.source + ); + } + } + + ModulesCommand::Update { name } => { + let targets: Vec<_> = if let Some(n) = name { + vec![modules_mgmt::read_module_manifest(&n, &mods_dir)?] + } else { + modules_mgmt::list_modules(&mods_dir)? + }; + + let mut updated_any = false; + for manifest in targets { + if manifest.source.starts_with("github:") { + let old_ver = manifest.version.clone(); + let new_manifest = install_module(&manifest.source, &mods_dir).await?; + if new_manifest.version == old_ver { + println!("{} already up to date", manifest.name); + } else { + println!( + "updated {} v{} → v{}", + manifest.name, old_ver, new_manifest.version + ); + updated_any = true; + } + } else { + eprintln!( + "cannot update local module '{}' — reinstall manually", + manifest.name + ); + } + } + if updated_any { + try_daemon_reload(socket).await; + } + } + + ModulesCommand::Info { name } => { + let m = modules_mgmt::read_module_manifest(&name, &mods_dir)?; + let status = match send_request(socket, "modules.list", json!({})).await { + Ok(resp) => resp + .as_array() + .and_then(|arr| { + arr.iter() + .find(|v| v.get("name").and_then(Value::as_str) == Some(&m.name)) + .and_then(|v| v.get("status").and_then(Value::as_str)) + .map(ToString::to_string) + }) + .unwrap_or_else(|| "unknown".to_string()), + Err(_) => "unknown".to_string(), + }; + println!("name: {}", m.name); + println!("version: {}", m.version); + println!("description: {}", m.description); + println!("author: {}", m.author); + println!("source: {}", m.source); + println!("installed_at: {}", m.installed_at); + println!("status: {}", status); + } + } + Ok(()) +} + +async fn install_module( + source: &str, + mods_dir: &std::path::Path, +) -> Result { + match modules_mgmt::parse_source(source)? { + modules_mgmt::InstallSource::LocalPath(path) => { + modules_mgmt::install_from_local(&path, source, mods_dir) + } + modules_mgmt::InstallSource::GitHub { + user, + repo, + git_ref, + } => install_from_github(&user, &repo, git_ref.as_deref(), source, mods_dir).await, + } +} + +async fn install_from_github( + user: &str, + repo: &str, + git_ref: Option<&str>, + source_str: &str, + mods_dir: &Path, +) -> Result { + let client = reqwest::Client::builder() + .user_agent("bread-cli/0.1") + .build()?; + + let ref_to_use = match git_ref { + Some(r) => r.to_string(), + None => { + let url = format!("https://api.github.com/repos/{user}/{repo}"); + let resp: Value = client + .get(&url) + .send() + .await + .context("failed to reach GitHub API")? + .json() + .await + .context("failed to parse GitHub API response")?; + resp.get("default_branch") + .and_then(Value::as_str) + .unwrap_or("main") + .to_string() + } + }; + + let tarball_url = format!("https://api.github.com/repos/{user}/{repo}/tarball/{ref_to_use}"); + let bytes = client + .get(&tarball_url) + .send() + .await + .context("failed to download module archive")? + .bytes() + .await + .context("failed to read module archive")?; + + let tmp = tempfile::tempdir()?; + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..])); + archive.unpack(tmp.path())?; + + // GitHub extracts to a single subdirectory (e.g. "user-repo-sha/") + let root = std::fs::read_dir(tmp.path())? + .filter_map(|e| e.ok()) + .find(|e| e.path().is_dir()) + .map(|e| e.path()) + .ok_or_else(|| anyhow::anyhow!("no directory found in extracted archive"))?; + + modules_mgmt::install_from_local(&root, source_str, mods_dir) +} + +/// Notify the daemon to reload modules. Prints a warning if the daemon is unreachable. +async fn try_daemon_reload(socket: &Path) { + match send_request(socket, "modules.reload", json!({})).await { + Ok(_) => {} + Err(_) => { + eprintln!("note: daemon not running; reload manually with 'bread reload'"); + } + } +} + +// --------------------------------------------------------------------------- +// Sync subcommands +// --------------------------------------------------------------------------- + +async fn handle_sync_cmd(cmd: SyncCommand, socket: &Path) -> Result<()> { + let cfg_dir = bread_config_dir(); + + match cmd { + SyncCommand::Init { remote } => cmd_sync_init(&cfg_dir, remote).await?, + SyncCommand::Push { message } => cmd_sync_push(&cfg_dir, message).await?, + SyncCommand::Pull { install_packages } => { + cmd_sync_pull(&cfg_dir, install_packages, socket).await? + } + SyncCommand::Status => cmd_sync_status(&cfg_dir).await?, + SyncCommand::Diff { remote } => cmd_sync_diff(&cfg_dir, remote).await?, + SyncCommand::Machines => cmd_sync_machines(&cfg_dir).await?, + SyncCommand::Export { output } => cmd_sync_export(&cfg_dir, output).await?, + SyncCommand::Import { from, install_packages, no_clone_repos, yes } => { + cmd_sync_import(&cfg_dir, from, install_packages, !no_clone_repos, yes, &socket).await? + } + } + Ok(()) +} + +async fn cmd_sync_init(cfg_dir: &Path, remote: Option) -> Result<()> { + let sync_toml = cfg_dir.join("sync.toml"); + if sync_toml.exists() { + eprintln!( + "bread: sync already initialized. Edit {} to reconfigure.", + sync_toml.display() + ); + std::process::exit(1); + } + + let remote_url = match remote { + Some(u) => u, + None => { + print!("Sync remote URL (leave empty for local-only, e.g. git@github.com:you/config): "); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + line.trim().to_string() + } + }; + + let default_hostname = machine::hostname(); + print!("Machine name [{}]: ", default_hostname); + io::stdout().flush()?; + let mut name_line = String::new(); + io::stdin().read_line(&mut name_line)?; + let machine_name = { + let t = name_line.trim(); + if t.is_empty() { + default_hostname + } else { + t.to_string() + } + }; + + print!("Machine tags (comma-separated, e.g. mobile,battery): "); + io::stdout().flush()?; + let mut tags_line = String::new(); + io::stdin().read_line(&mut tags_line)?; + let tags: Vec = tags_line + .trim() + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + .collect(); + + let config = SyncConfig { + remote: bread_sync::config::RemoteConfig { + url: remote_url.clone(), + branch: "main".to_string(), + }, + machine: bread_sync::config::MachineConfig { + name: machine_name.clone(), + tags, + }, + packages: bread_sync::config::PackagesConfig::default(), + delegates: bread_sync::config::DelegatesConfig::default(), + }; + config.save(cfg_dir)?; + + println!(); + println!("sync initialized"); + println!(" machine: {}", machine_name); + if remote_url.is_empty() { + println!(" remote: (local-only — use 'bread sync export' to create a portable snapshot)"); + } else { + println!(" remote: {}", remote_url); + if !remote_url.starts_with('/') && !remote_url.starts_with('.') { + println!(" note: remote will be created on first push"); + } + } + println!(" config: {}", cfg_dir.join("sync.toml").display()); + Ok(()) +} + +async fn cmd_sync_push(cfg_dir: &Path, message: Option) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + let repo = if repo_path.exists() { + SyncRepo::open(&repo_path)? + } else { + SyncRepo::init(&repo_path)? + }; + + // Snapshot bread/ directory + let bread_dest = repo_path.join("bread"); + delegates::sync_dir(cfg_dir, &bread_dest, &[".git".to_string()])?; + + // Snapshot delegate configs + let configs_dir = repo_path.join("configs"); + let delegate_paths = delegates::resolve_include_paths(&config.delegates.include); + for (basename, src_path) in &delegate_paths { + if src_path.exists() { + let dst = configs_dir.join(basename); + delegates::sync_dir(src_path, &dst, &config.delegates.exclude)?; + } + } + + // Snapshot packages + if config.packages.enabled { + let packages_dir = repo_path.join("packages"); + for manager in &config.packages.managers { + let dest_file = packages_dir.join(format!("{manager}.txt")); + if let Err(e) = packages::snapshot(manager, &dest_file) { + eprintln!("bread: warning: package snapshot for {manager} failed: {e}"); + } + } + } + + // Write machine profile + let machines_dir = repo_path.join("machines"); + machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()) + .write(&machines_dir)?; + + let commit_msg = message.unwrap_or_else(|| { + format!( + "sync: {} {}", + config.machine.name, + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ") + ) + }); + + if repo.commit(&commit_msg)?.is_none() { + println!("nothing to commit — already up to date"); + return Ok(()); + } + + println!("committed sync for {}", config.machine.name); + println!(" snapshot: {}", repo_path.display()); + println!(" tip: run 'bread sync export' to create a portable snapshot"); + if config.packages.enabled { + println!(" packages: {}", config.packages.managers.join(", ")); + } + Ok(()) +} + +async fn cmd_sync_pull(cfg_dir: &Path, install_packages: bool, socket: &Path) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + if !repo_path.exists() { + eprintln!("bread: no local snapshot found. Run 'bread sync push' first."); + std::process::exit(1); + } + + // Apply bread/ → ~/.config/bread/ + let bread_src = repo_path.join("bread"); + if bread_src.exists() { + delegates::sync_dir(&bread_src, cfg_dir, &[])?; + } + + // Apply configs/ entries back to their original locations + let configs_dir = repo_path.join("configs"); + if configs_dir.exists() { + let delegate_paths = delegates::resolve_include_paths(&config.delegates.include); + for (basename, dst_path) in &delegate_paths { + let src = configs_dir.join(basename); + if src.exists() { + delegates::sync_dir(&src, dst_path, &config.delegates.exclude)?; + } + } + } + + // Package installs + if config.packages.enabled { + let packages_dir = repo_path.join("packages"); + if install_packages { + run_package_installs(&packages_dir, &config.packages.managers)?; + } else { + // Check if packages differ + let has_package_files = config + .packages + .managers + .iter() + .any(|m| packages_dir.join(format!("{m}.txt")).exists()); + if has_package_files { + println!( + "note: run 'bread sync pull --install-packages' to install missing packages" + ); + } + } + } + + // Notify daemon + try_daemon_reload(socket).await; + + println!("applied sync for {}", config.machine.name); + Ok(()) +} + +async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> { + let config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + if !repo_path.exists() { + println!("bread sync status"); + println!(" not yet committed — run 'bread sync push'"); + return Ok(()); + } + + let repo = SyncRepo::open(&repo_path)?; + + let last_commit = repo + .last_commit_time() + .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "never".to_string()); + + println!("bread sync status"); + println!(" machine {}", config.machine.name); + println!(" snapshot {}", repo_path.display()); + println!(" last commit {}", last_commit); + + let local_changes = repo.local_changes()?; + println!(); + println!("uncommitted changes:"); + if local_changes.is_empty() { + println!(" none"); + } else { + for (ch, path) in &local_changes { + println!(" {} {}", ch, path); + } + } + + Ok(()) +} + +async fn cmd_sync_diff(cfg_dir: &Path, _vs_remote: bool) -> Result<()> { + let _config = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + + if !repo_path.exists() { + eprintln!("bread: sync repo not initialized. Run: bread sync push"); + std::process::exit(1); + } + + let repo = SyncRepo::open(&repo_path)?; + let diff = repo.working_diff()?; + print!("{}", diff); + Ok(()) +} + +async fn cmd_sync_machines(cfg_dir: &Path) -> Result<()> { + let _ = load_sync_config(cfg_dir)?; + let repo_path = SyncConfig::local_repo_path(); + let machines_dir = repo_path.join("machines"); + + let profiles = machine::MachineProfile::list(&machines_dir)?; + for p in &profiles { + let tags = if p.tags.is_empty() { + String::new() + } else { + format!(" tags: {}", p.tags.join(", ")) + }; + println!(" {:20} last sync: {}{}", p.name, &p.last_sync[..16], tags); + } + Ok(()) +} + +async fn cmd_sync_export(cfg_dir: &Path, output: Option) -> Result<()> { + // Load sync config if available; fall back to machine defaults. + let config = match SyncConfig::load(cfg_dir) { + Ok(c) => c, + Err(_) => { + let name = machine::hostname(); + SyncConfig { + remote: bread_sync::config::RemoteConfig { + url: String::new(), + branch: "main".to_string(), + }, + machine: bread_sync::config::MachineConfig { name, tags: vec![] }, + packages: bread_sync::config::PackagesConfig::default(), + delegates: bread_sync::config::DelegatesConfig::default(), + } + } + }; + + let date = chrono::Utc::now().format("%Y-%m-%d"); + let export_name = format!("bread-export-{}-{}", config.machine.name, date); + + // Decide: tarball or directory? + let (staging_path, make_tarball, final_path) = match &output { + Some(p) if p.extension().and_then(|e| e.to_str()) == Some("gz") => { + // User wants a .tar.gz at a specific path + let staging = std::env::temp_dir().join(&export_name); + (staging, true, p.clone()) + } + Some(p) if p.is_dir() || !p.exists() => { + // User wants a directory + let dir = if p.is_dir() { p.join(&export_name) } else { p.clone() }; + (dir.clone(), false, dir) + } + Some(p) => { + anyhow::bail!("output path {} already exists and is not a directory", p.display()); + } + None => { + // Default: .tar.gz in current directory + let tarball = std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(format!("{export_name}.tar.gz")); + let staging = std::env::temp_dir().join(&export_name); + (staging, true, tarball) + } + }; + + // Stage everything into the staging directory + let manifest = stage_export(cfg_dir, &config, &staging_path) + .context("failed to stage export")?; + + // Optionally pack into a tarball + if make_tarball { + create_tarball(&staging_path, &final_path) + .context("failed to create tarball")?; + std::fs::remove_dir_all(&staging_path).ok(); + } + + println!("exported to {}", final_path.display()); + println!(" machine: {}", manifest.machine); + if !manifest.configs.is_empty() { + println!(" configs: {}", manifest.configs.join(", ")); + } + if !manifest.path_map.is_empty() { + let file_count = manifest.path_map.iter().filter(|r| r.is_file).count(); + let dir_count = manifest.path_map.iter().filter(|r| !r.is_file).count(); + if file_count > 0 { + println!(" dotfiles: {} file(s)", file_count); + } + if dir_count > manifest.configs.len() { + println!(" dirs: {} total", dir_count); + } + } + if !manifest.packages.is_empty() { + println!(" packages: {}", manifest.packages.join(", ")); + } + if !manifest.repos.is_empty() { + println!(" repos: {} git repositories tracked", manifest.repos.len()); + } + if manifest.system { + println!(" system: udev / modprobe / sysctl (see restore.sh for sudo commands)"); + } + Ok(()) +} + +async fn cmd_sync_import( + cfg_dir: &Path, + from: PathBuf, + install_packages: bool, + clone_repos: bool, + yes: bool, + socket: &Path, +) -> Result<()> { + // Determine staging directory + let is_tarball = from.extension().and_then(|e| e.to_str()) == Some("gz"); + + let (staging, _tmp_guard) = if is_tarball { + let tmp = tempfile::tempdir().context("failed to create temp dir")?; + extract_tarball(&from, tmp.path()).context("failed to extract tarball")?; + // GitHub-style tarballs extract into a single subdirectory; unwrap if needed + let inner = find_single_subdir(tmp.path()).unwrap_or_else(|| tmp.path().to_path_buf()); + (inner, Some(tmp)) + } else if from.is_dir() { + (from.clone(), None) + } else { + anyhow::bail!("'{}' is not a directory or .tar.gz file", from.display()); + }; + + // Read manifest for summary + let manifest_path = staging.join("manifest.toml"); + if !manifest_path.exists() { + anyhow::bail!("not a bread export: manifest.toml not found in {}", staging.display()); + } + let manifest_raw = std::fs::read_to_string(&manifest_path)?; + let manifest: bread_sync::ExportManifest = toml::from_str(&manifest_raw) + .context("failed to parse manifest.toml")?; + + println!("bread import: {} (exported {})", manifest.machine, &manifest.exported_at[..16]); + println!(" configs: {}", if manifest.configs.is_empty() { "-".to_string() } else { manifest.configs.join(", ") }); + println!(" packages: {}", if manifest.packages.is_empty() { "-".to_string() } else { manifest.packages.join(", ") }); + if !manifest.repos.is_empty() { + println!(" repos: {} git repositories found", manifest.repos.len()); + if clone_repos { + println!(" (will be cloned to their original locations)"); + } else { + println!(" (skipping clone — remove --no-clone-repos to restore)"); + } + } + if manifest.system { + println!(" note: system files (udev/modprobe/sysctl) will NOT be applied automatically"); + } + + if !yes { + print!("\nApply to ~/.config and ~/.local? (y/n): "); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + if !line.trim().eq_ignore_ascii_case("y") { + println!("aborted"); + return Ok(()); + } + } + + let applied = apply_import(&staging, cfg_dir, install_packages, clone_repos) + .context("import failed")?; + + println!(); + for item in &applied { + println!(" + {item}"); + } + + if manifest.system { + println!(); + println!("system files were NOT applied automatically. To restore them:"); + println!(" {}/restore.sh", staging.display()); + } + + // Notify daemon + try_daemon_reload(socket).await; + + Ok(()) +} + +fn create_tarball(src_dir: &Path, dest: &Path) -> Result<()> { + use flate2::{write::GzEncoder, Compression}; + + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + let file = std::fs::File::create(dest) + .with_context(|| format!("failed to create {}", dest.display()))?; + let encoder = GzEncoder::new(file, Compression::default()); + let mut archive = tar::Builder::new(encoder); + + let base_name = src_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("bread-export"); + + // Walk the staging directory and append every file + append_dir_recursive(&mut archive, src_dir, src_dir, base_name)?; + + archive.finish()?; + Ok(()) +} + +fn append_dir_recursive( + archive: &mut tar::Builder>, + root: &Path, + current: &Path, + base_name: &str, +) -> Result<()> { + for entry in std::fs::read_dir(current).context("failed to read dir for tarball")? { + let entry = entry?; + let path = entry.path(); + let rel = path.strip_prefix(root).unwrap_or(&path); + let tar_path = PathBuf::from(base_name).join(rel); + + if path.is_dir() { + archive.append_dir(&tar_path, &path)?; + append_dir_recursive(archive, root, &path, base_name)?; + } else if path.is_file() { + archive.append_path_with_name(&path, &tar_path)?; + } + } + Ok(()) +} + +fn extract_tarball(src: &Path, dest: &Path) -> Result<()> { + use flate2::read::GzDecoder; + + let file = std::fs::File::open(src) + .with_context(|| format!("failed to open {}", src.display()))?; + let decoder = GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + archive.unpack(dest) + .with_context(|| format!("failed to extract {}", src.display()))?; + Ok(()) +} + +/// If a directory contains exactly one subdirectory and nothing else, return it. +fn find_single_subdir(dir: &Path) -> Option { + let entries: Vec<_> = std::fs::read_dir(dir) + .ok()? + .filter_map(|e| e.ok()) + .collect(); + if entries.len() == 1 && entries[0].path().is_dir() { + Some(entries[0].path()) + } else { + None + } +} + +fn load_sync_config(cfg_dir: &Path) -> Result { + match SyncConfig::load(cfg_dir) { + Ok(c) => Ok(c), + Err(_) => { + eprintln!("bread: sync not initialized. Run: bread sync init"); + std::process::exit(1); + } + } +} + +fn run_package_installs(packages_dir: &Path, managers: &[String]) -> Result<()> { + for manager in managers { + let file = packages_dir.join(format!("{manager}.txt")); + if !file.exists() { + continue; + } + let content = std::fs::read_to_string(&file)?; + match manager.as_str() { + "pacman" => { + let pkgs = packages::parse_pacman(&content); + if pkgs.is_empty() { + continue; + } + let mut cmd = std::process::Command::new("sudo"); + cmd.args(["pacman", "-S", "--needed"]).args(&pkgs); + let _ = cmd.status(); + } + "pip" => { + let mut cmd = std::process::Command::new("pip"); + cmd.args(["install", "--user", "-r"]).arg(&file); + let _ = cmd.status(); + } + "npm" => { + let pkgs = packages::parse_npm(&content); + for pkg in pkgs { + let _ = std::process::Command::new("npm") + .args(["install", "-g", &pkg]) + .status(); + } + } + "cargo" => { + let pkgs = packages::parse_cargo(&content); + for pkg in pkgs { + let _ = std::process::Command::new("cargo") + .args(["install", &pkg]) + .status(); + } + } + _ => {} + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helpers (shared with original commands) +// --------------------------------------------------------------------------- + fn daemon_socket_path() -> PathBuf { if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") { return Path::new(&runtime).join("bread").join("breadd.sock"); @@ -105,7 +1033,18 @@ fn daemon_socket_path() -> PathBuf { } async fn send_request(socket: &Path, method: &str, params: Value) -> Result { - let stream = UnixStream::connect(socket).await?; + let stream = UnixStream::connect(socket).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound + || e.kind() == std::io::ErrorKind::ConnectionRefused + { + anyhow::anyhow!( + "bread: daemon is not running. Start it with: systemctl --user start breadd" + ) + } else { + e.into() + } + })?; + let (read_half, mut write_half) = stream.into_split(); let request = json!({ "id": "1", @@ -128,7 +1067,31 @@ async fn send_request(socket: &Path, method: &str, params: Value) -> Result) -> Result<()> { +async fn stream_events( + socket: &Path, + filter: Option, + raw_json: bool, + fields: Option, + since: Option, +) -> Result<()> { + if let Some(seconds) = since { + let replay = send_request( + socket, + "events.replay", + json!({ "since_ms": seconds * 1000 }), + ) + .await?; + if let Some(list) = replay.as_array() { + for item in list { + if raw_json { + println!("{}", serde_json::to_string_pretty(item)?); + } else { + print_event(item, fields.as_deref()); + } + } + } + } + let stream = UnixStream::connect(socket).await?; let (read_half, mut write_half) = stream.into_split(); let request = json!({ @@ -146,7 +1109,11 @@ async fn stream_events(socket: &Path, filter: Option) -> Result<()> { let mut lines = BufReader::new(read_half).lines(); while let Some(line) = lines.next_line().await? { let value: Value = serde_json::from_str(&line)?; - println!("{}", serde_json::to_string_pretty(&value)?); + if raw_json { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + print_event(&value, fields.as_deref()); + } } Ok(()) @@ -156,3 +1123,234 @@ fn print_json(value: &Value) -> Result<()> { println!("{}", serde_json::to_string_pretty(value)?); Ok(()) } + +fn print_state_formatted(path: Option<&str>, value: &Value) { + if let Some(path) = path { + println!("{path}"); + } + print_value(value, 0); +} + +fn print_value(value: &Value, indent: usize) { + let pad = " ".repeat(indent); + match value { + Value::Object(map) => { + for (key, val) in map { + println!("{pad}{key}"); + print_value(val, indent + 2); + } + } + Value::Array(list) => { + for (idx, val) in list.iter().enumerate() { + println!("{pad}[{idx}]"); + print_value(val, indent + 2); + } + } + other => { + println!("{pad}{}", other); + } + } +} + +fn print_event(event: &Value, fields: Option<&str>) { + if let Some(fields) = fields { + let mut out = serde_json::Map::new(); + for field in fields.split(',') { + let field = field.trim(); + if field.is_empty() { + continue; + } + if let Some(val) = event.get(field) { + out.insert(field.to_string(), val.clone()); + } + } + println!("{}", Value::Object(out)); + return; + } + + let ts = event.get("timestamp").and_then(Value::as_u64).unwrap_or(0); + let event_name = event.get("event").and_then(Value::as_str).unwrap_or("?"); + let source = event.get("source").and_then(Value::as_str).unwrap_or("?"); + let time = format_timestamp(ts); + println!("{time} {event_name} source={source}"); + if let Some(data) = event.get("data") { + println!(" data: {}", data); + } +} + +fn format_timestamp(ms: u64) -> String { + let secs = ms / 1000; + let millis = ms % 1000; + + // SAFETY: localtime_r is thread-safe. We pass a valid pointer to a + // zeroed tm struct and read the result only after the call returns. + let local_secs = unsafe { + let mut tm: libc::tm = std::mem::zeroed(); + let t = secs as libc::time_t; + libc::localtime_r(&t, &mut tm); + tm.tm_hour as u64 * 3600 + tm.tm_min as u64 * 60 + tm.tm_sec as u64 + }; + + let h = (local_secs / 3600) % 24; + let m = (local_secs / 60) % 60; + let s = local_secs % 60; + format!("{:02}:{:02}:{:02}.{:03}", h, m, s, millis) +} + +fn print_reload(value: &Value) { + println!("reloading lua runtime..."); + if let Some(mods) = value.get("modules").and_then(Value::as_array) { + for module in mods { + let name = module.get("name").and_then(Value::as_str).unwrap_or("?"); + let status = module.get("status").and_then(Value::as_str).unwrap_or("?"); + let error = module.get("last_error").and_then(Value::as_str); + if let Some(error) = error { + println!(" ✗ {name} {status}"); + println!(" {error}"); + } else { + println!(" ✓ {name} {status}"); + } + } + } +} + +async fn watch_reload(socket: &Path) -> Result<()> { + let config_dir = config_directory(); + println!("watching {} for changes...", config_dir.display()); + + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| { + let _ = tx.send(res); + })?; + watcher.watch(&config_dir, RecursiveMode::Recursive)?; + + while let Some(msg) = rx.recv().await { + if msg.is_err() { + continue; + } + + tokio::time::sleep(Duration::from_millis(150)).await; + while rx.try_recv().is_ok() {} + + let response = send_request(socket, "modules.reload", json!({})).await?; + print_reload(&response); + } + + Ok(()) +} + +async fn print_doctor(socket: &Path) -> Result<()> { + let stream = match UnixStream::connect(socket).await { + Ok(stream) => stream, + Err(err) => { + if err.kind() == io::ErrorKind::NotFound { + println!("bread doctor"); + println!(" daemon ✗ not running"); + println!(" socket {} (not found)", socket.display()); + println!(); + println!(" start the daemon: systemctl --user start breadd"); + println!(" view logs: journalctl --user -u breadd -f"); + return Ok(()); + } + return Err(err.into()); + } + }; + + let response = send_request_with_stream(stream, "health", json!({})).await?; + render_doctor(&response); + Ok(()) +} + +fn render_doctor(health: &Value) { + println!("bread doctor"); + let ok = health.get("ok").and_then(Value::as_bool).unwrap_or(false); + let pid = health.get("pid").and_then(Value::as_u64).unwrap_or(0); + let version = health + .get("version") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let uptime_ms = health.get("uptime_ms").and_then(Value::as_u64).unwrap_or(0); + let socket = health.get("socket").and_then(Value::as_str).unwrap_or("?"); + println!( + " daemon {} (pid {})", + if ok { "✓ running" } else { "✗ unreachable" }, + pid + ); + println!(" version {version}"); + println!(" uptime {}s", uptime_ms / 1000); + println!(" socket {socket}"); + + if let Some(adapters) = health.get("adapters").and_then(Value::as_object) { + println!(); + println!("adapters"); + for (name, status) in adapters { + println!(" {:20} {}", name, status); + } + } + + if let Some(modules) = health.get("modules").and_then(Value::as_array) { + println!(); + println!("modules"); + for module in modules { + let name = module.get("name").and_then(Value::as_str).unwrap_or("?"); + let status = module.get("status").and_then(Value::as_str).unwrap_or("?"); + let error = module.get("last_error").and_then(Value::as_str); + println!(" {:30} {}", name, status); + if let Some(error) = error { + println!(" └ {error}"); + } + } + } + + if let Some(count) = health.get("subscriptions").and_then(Value::as_u64) { + println!(); + println!("subscriptions {count}"); + } + + if let Some(errors) = health.get("recent_errors").and_then(Value::as_array) { + if !errors.is_empty() { + println!(); + println!("recent errors ({} total)", errors.len()); + for entry in errors.iter().take(5) { + println!(" {entry}"); + } + } + } +} + +async fn send_request_with_stream( + stream: UnixStream, + method: &str, + params: Value, +) -> Result { + let (read_half, mut write_half) = stream.into_split(); + let request = json!({ + "id": "1", + "method": method, + "params": params, + }); + + write_half + .write_all(format!("{}\n", serde_json::to_string(&request)?).as_bytes()) + .await?; + + let mut lines = BufReader::new(read_half).lines(); + let Some(line) = lines.next_line().await? else { + anyhow::bail!("daemon closed connection without response"); + }; + let response: Value = serde_json::from_str(&line)?; + if let Some(error) = response.get("error").and_then(Value::as_str) { + anyhow::bail!(error.to_string()); + } + Ok(response.get("result").cloned().unwrap_or_else(|| json!({}))) +} + +fn config_directory() -> PathBuf { + if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { + return Path::new(&xdg).join("bread"); + } + if let Ok(home) = env::var("HOME") { + return Path::new(&home).join(".config/bread"); + } + PathBuf::from(".config/bread") +} diff --git a/bread-cli/src/modules_mgmt.rs b/bread-cli/src/modules_mgmt.rs new file mode 100644 index 0000000..942ad29 --- /dev/null +++ b/bread-cli/src/modules_mgmt.rs @@ -0,0 +1,181 @@ +use anyhow::{bail, Context, Result}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Contents of `bread.module.toml`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleManifest { + pub name: String, + pub version: String, + pub description: String, + pub author: String, + pub source: String, + pub installed_at: String, +} + +/// Parsed install source. +pub enum InstallSource { + GitHub { + user: String, + repo: String, + git_ref: Option, + }, + LocalPath(PathBuf), +} + +/// Parse a source string into an `InstallSource`. +pub fn parse_source(source: &str) -> Result { + if let Some(rest) = source.strip_prefix("github:") { + let (repo_part, ref_part) = rest + .split_once('@') + .map(|(r, v)| (r, Some(v.to_string()))) + .unwrap_or((rest, None)); + let (user, repo) = repo_part.split_once('/').ok_or_else(|| { + anyhow::anyhow!( + "bread: invalid github source '{}'. Expected 'github:user/repo[@ref]'", + source + ) + })?; + Ok(InstallSource::GitHub { + user: user.to_string(), + repo: repo.to_string(), + git_ref: ref_part, + }) + } else if source.starts_with('/') + || source.starts_with("./") + || source.starts_with("../") + || source.starts_with('~') + { + let expanded = bread_sync::config::expand_path(source); + Ok(InstallSource::LocalPath(expanded)) + } else { + bail!( + "bread: invalid module source '{}'. Use 'github:user/repo' or an absolute/relative path", + source + ) + } +} + +/// Install a module from a local directory into `modules_dir`. +/// `source_str` is the original source string recorded in the manifest. +pub fn install_from_local( + src: &Path, + source_str: &str, + modules_dir: &Path, +) -> Result { + let manifest_path = src.join("bread.module.toml"); + if !manifest_path.exists() { + bail!("bread: no bread.module.toml found in {}", src.display()); + } + + let raw = fs::read_to_string(&manifest_path) + .with_context(|| format!("failed to read {}", manifest_path.display()))?; + let mut manifest: ModuleManifest = + toml::from_str(&raw).context("failed to parse bread.module.toml")?; + + manifest.source = source_str.to_string(); + manifest.installed_at = Utc::now().to_rfc3339(); + + let dest = modules_dir.join(&manifest.name); + if dest.exists() { + fs::remove_dir_all(&dest) + .with_context(|| format!("failed to remove existing module at {}", dest.display()))?; + } + copy_dir(src, &dest)?; + + // Rewrite the manifest with the updated fields. + let manifest_dest = dest.join("bread.module.toml"); + let out = toml::to_string_pretty(&manifest).context("failed to serialize module manifest")?; + fs::write(&manifest_dest, out) + .with_context(|| format!("failed to write manifest to {}", manifest_dest.display()))?; + + Ok(manifest) +} + +/// Remove a module directory from `modules_dir`. +pub fn remove_module(name: &str, modules_dir: &Path) -> Result<()> { + let module_dir = modules_dir.join(name); + if !module_dir.exists() { + bail!("bread: module '{}' is not installed", name); + } + fs::remove_dir_all(&module_dir) + .with_context(|| format!("failed to remove {}", module_dir.display())) +} + +/// List all installed modules in `modules_dir`. +pub fn list_modules(modules_dir: &Path) -> Result> { + if !modules_dir.exists() { + return Ok(vec![]); + } + let mut out = Vec::new(); + for entry in fs::read_dir(modules_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + let manifest_path = path.join("bread.module.toml"); + if manifest_path.exists() { + if let Ok(m) = read_manifest_file(&manifest_path) { + out.push(m); + } + } + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +/// Read a module manifest by name. +pub fn read_module_manifest(name: &str, modules_dir: &Path) -> Result { + let manifest_path = modules_dir.join(name).join("bread.module.toml"); + if !manifest_path.exists() { + bail!("bread: module '{}' is not installed", name); + } + read_manifest_file(&manifest_path) +} + +/// Read and parse a `bread.module.toml` file. +pub fn read_manifest_file(path: &Path) -> Result { + let raw = + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&raw).context("failed to parse module manifest") +} + +/// Returns the default modules directory. +pub fn modules_dir() -> PathBuf { + if let Some(cfg) = dirs::config_dir() { + return cfg.join("bread").join("modules"); + } + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(xdg).join("bread").join("modules"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".config") + .join("bread") + .join("modules"); + } + PathBuf::from(".config/bread/modules") +} + +fn copy_dir(src: &Path, dst: &Path) -> Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + copy_dir(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path).with_context(|| { + format!( + "failed to copy {} to {}", + src_path.display(), + dst_path.display() + ) + })?; + } + } + Ok(()) +} diff --git a/bread-cli/tests/modules.rs b/bread-cli/tests/modules.rs new file mode 100644 index 0000000..74022fe --- /dev/null +++ b/bread-cli/tests/modules.rs @@ -0,0 +1,139 @@ +use bread_cli::modules_mgmt; +use std::fs; +use tempfile::TempDir; + +/// Helper: create a minimal valid module directory in `dir` with given name. +fn make_module_dir(dir: &std::path::Path, name: &str, version: &str) -> std::path::PathBuf { + let module_dir = dir.join(name); + fs::create_dir_all(&module_dir).unwrap(); + let manifest = format!( + r#"name = "{name}" +version = "{version}" +description = "Test module" +author = "test" +source = "/tmp/test" +installed_at = "" +"# + ); + fs::write(module_dir.join("bread.module.toml"), manifest).unwrap(); + fs::write(module_dir.join("init.lua"), "-- test\n").unwrap(); + module_dir +} + +#[test] +fn install_from_local_succeeds_with_manifest() { + let src_tmp = TempDir::new().unwrap(); + let modules_tmp = TempDir::new().unwrap(); + + make_module_dir(src_tmp.path(), "mymod", "1.2.3"); + let src = src_tmp.path().join("mymod"); + + let result = modules_mgmt::install_from_local(&src, "test:mymod", modules_tmp.path()); + + assert!(result.is_ok(), "install failed: {:?}", result.err()); + let manifest = result.unwrap(); + assert_eq!(manifest.name, "mymod"); + assert_eq!(manifest.version, "1.2.3"); + + // Module directory must exist in modules dir + assert!(modules_tmp.path().join("mymod").exists()); + assert!(modules_tmp + .path() + .join("mymod") + .join("bread.module.toml") + .exists()); + assert!(modules_tmp.path().join("mymod").join("init.lua").exists()); +} + +#[test] +fn install_from_local_fails_without_manifest() { + let src_tmp = TempDir::new().unwrap(); + let modules_tmp = TempDir::new().unwrap(); + + // No bread.module.toml in src + let src = src_tmp.path(); + fs::write(src.join("init.lua"), "-- no manifest\n").unwrap(); + + let result = modules_mgmt::install_from_local(src, "test:nomod", modules_tmp.path()); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("bread.module.toml"), + "expected error about bread.module.toml, got: {msg}" + ); +} + +#[test] +fn remove_deletes_module_directory() { + let modules_tmp = TempDir::new().unwrap(); + make_module_dir(modules_tmp.path(), "delme", "0.1.0"); + + // Verify it exists before removal + assert!(modules_tmp.path().join("delme").exists()); + + let result = modules_mgmt::remove_module("delme", modules_tmp.path()); + assert!(result.is_ok(), "remove failed: {:?}", result.err()); + assert!(!modules_tmp.path().join("delme").exists()); +} + +#[test] +fn remove_nonexistent_errors() { + let modules_tmp = TempDir::new().unwrap(); + let result = modules_mgmt::remove_module("ghost", modules_tmp.path()); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("ghost"), + "expected error mentioning module name, got: {msg}" + ); +} + +#[test] +fn list_reads_manifests_from_disk() { + let modules_tmp = TempDir::new().unwrap(); + make_module_dir(modules_tmp.path(), "alpha", "1.0.0"); + make_module_dir(modules_tmp.path(), "beta", "2.0.0"); + + // Add a non-module dir (no manifest) — should be ignored + fs::create_dir_all(modules_tmp.path().join("notamodule")).unwrap(); + + let modules = modules_mgmt::list_modules(modules_tmp.path()).unwrap(); + assert_eq!(modules.len(), 2); + assert_eq!(modules[0].name, "alpha"); + assert_eq!(modules[1].name, "beta"); +} + +#[test] +fn manifest_written_correctly_on_install() { + let src_tmp = TempDir::new().unwrap(); + let modules_tmp = TempDir::new().unwrap(); + + make_module_dir(src_tmp.path(), "installtest", "3.0.0"); + let src = src_tmp.path().join("installtest"); + + let manifest = + modules_mgmt::install_from_local(&src, "github:test/installtest", modules_tmp.path()) + .unwrap(); + + // All required fields must be present and non-empty + assert_eq!(manifest.name, "installtest"); + assert_eq!(manifest.version, "3.0.0"); + assert!(!manifest.description.is_empty()); + assert!(!manifest.author.is_empty()); + assert_eq!(manifest.source, "github:test/installtest"); + assert!(!manifest.installed_at.is_empty()); + + // installed_at must be valid RFC 3339 + let parsed = chrono::DateTime::parse_from_rfc3339(&manifest.installed_at); + assert!( + parsed.is_ok(), + "installed_at '{}' is not valid RFC 3339", + manifest.installed_at + ); + + // Verify the on-disk manifest also has all fields + let on_disk = modules_mgmt::read_module_manifest("installtest", modules_tmp.path()).unwrap(); + assert_eq!(on_disk.name, manifest.name); + assert_eq!(on_disk.installed_at, manifest.installed_at); + assert_eq!(on_disk.source, "github:test/installtest"); +} diff --git a/bread-shared/Cargo.toml b/bread-shared/Cargo.toml index 0e8c503..475e94c 100644 --- a/bread-shared/Cargo.toml +++ b/bread-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-shared" -version = "0.1.0" +version = "1.0.0" edition = "2021" [dependencies] diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs index 9566bc3..25bdac7 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -1,32 +1,73 @@ +//! Shared types for the Bread automation fabric. +//! +//! This crate defines the canonical event types ([`RawEvent`], [`BreadEvent`]) +//! and the [`AdapterSource`] enum that both the daemon (`breadd`) and CLI +//! (`bread-cli`) depend on. Keeping these types in a separate crate guarantees +//! that adapters, the state engine, IPC clients, and the Lua bindings all +//! agree on a single wire format. + use serde::{Deserialize, Serialize}; +/// Identifies which adapter produced an event. +/// +/// The state engine uses this to choose a normalization strategy and the +/// IPC layer surfaces it so subscribers can filter by origin. #[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)] #[serde(rename_all = "snake_case")] pub enum AdapterSource { + /// The Hyprland compositor IPC socket. Hyprland, + /// The Linux udev / netlink subsystem. Udev, + /// Power management (sysfs / UPower). Power, + /// Network state (rtnetlink / NetworkManager). Network, + /// Internal events synthesized by the daemon itself + /// (e.g. `bread.profile.activated`, `bread.state.changed.*`). System, + /// BlueZ Bluetooth stack via D-Bus. + Bluetooth, } +/// An unnormalized event as emitted by an adapter. +/// +/// Raw events carry the adapter's native payload verbatim. The +/// [`EventNormalizer`](../breadd/core/normalizer/struct.EventNormalizer.html) +/// in `breadd` transforms `RawEvent` into one or more [`BreadEvent`]s with +/// a semantic name and structured data. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RawEvent { + /// Which adapter produced this event. pub source: AdapterSource, + /// Adapter-specific event kind (e.g. `"workspace"`, `"add"`, `"battery"`). pub kind: String, + /// Adapter-specific JSON payload — not stable across versions. pub payload: serde_json::Value, + /// Unix epoch milliseconds when the event was observed. pub timestamp: u64, } +/// A normalized event ready for dispatch to Lua subscribers and IPC consumers. +/// +/// `BreadEvent` is the public, stable contract: event names use a dotted +/// namespace (e.g. `bread.device.dock.connected`) and the `data` payload +/// follows a documented shape per event family. See `Documentation.md` for +/// the full event catalogue. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BreadEvent { + /// Dotted event name, e.g. `bread.workspace.changed`. pub event: String, + /// Unix epoch milliseconds when the originating signal was observed. pub timestamp: u64, + /// The adapter that produced the underlying raw event. pub source: AdapterSource, + /// Structured event data. The shape depends on the event family. pub data: serde_json::Value, } impl BreadEvent { + /// Construct a new event with `timestamp` set to the current wall-clock. pub fn new(event: impl Into, source: AdapterSource, data: serde_json::Value) -> Self { Self { event: event.into(), @@ -37,9 +78,142 @@ impl BreadEvent { } } +/// Current Unix epoch in milliseconds. +/// +/// Falls back to `0` if the system clock is before the epoch, which keeps +/// callers infallible. Used for `BreadEvent::timestamp` and replay cutoffs. pub fn now_unix_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64 } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn adapter_source_serializes_as_snake_case() { + assert_eq!( + serde_json::to_string(&AdapterSource::Hyprland).unwrap(), + "\"hyprland\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::Udev).unwrap(), + "\"udev\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::Power).unwrap(), + "\"power\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::Network).unwrap(), + "\"network\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::System).unwrap(), + "\"system\"" + ); + assert_eq!( + serde_json::to_string(&AdapterSource::Bluetooth).unwrap(), + "\"bluetooth\"" + ); + } + + #[test] + fn adapter_source_round_trips_through_json() { + for source in [ + AdapterSource::Hyprland, + AdapterSource::Udev, + AdapterSource::Power, + AdapterSource::Network, + AdapterSource::System, + AdapterSource::Bluetooth, + ] { + let s = serde_json::to_string(&source).unwrap(); + let back: AdapterSource = serde_json::from_str(&s).unwrap(); + assert_eq!(source, back); + } + } + + #[test] + fn adapter_source_rejects_unknown_variant() { + let result: Result = serde_json::from_str("\"floppy\""); + assert!(result.is_err()); + } + + #[test] + fn bread_event_new_sets_current_timestamp() { + let before = now_unix_ms(); + let event = BreadEvent::new("bread.test", AdapterSource::System, json!({})); + let after = now_unix_ms(); + + assert!(event.timestamp >= before); + assert!(event.timestamp <= after); + assert_eq!(event.event, "bread.test"); + assert_eq!(event.source, AdapterSource::System); + } + + #[test] + fn bread_event_new_accepts_owned_and_borrowed_names() { + let owned = BreadEvent::new(String::from("bread.a"), AdapterSource::System, json!(null)); + let borrowed = BreadEvent::new("bread.b", AdapterSource::System, json!(null)); + assert_eq!(owned.event, "bread.a"); + assert_eq!(borrowed.event, "bread.b"); + } + + #[test] + fn bread_event_round_trips_through_json() { + let original = BreadEvent { + event: "bread.device.connected".to_string(), + timestamp: 1_700_000_000_000, + source: AdapterSource::Udev, + data: json!({ "id": "usb-1-1.4", "name": "Logitech" }), + }; + let raw = serde_json::to_string(&original).unwrap(); + let decoded: BreadEvent = serde_json::from_str(&raw).unwrap(); + + assert_eq!(decoded.event, original.event); + assert_eq!(decoded.timestamp, original.timestamp); + assert_eq!(decoded.source, original.source); + assert_eq!(decoded.data, original.data); + } + + #[test] + fn raw_event_round_trips_through_json() { + let original = RawEvent { + source: AdapterSource::Hyprland, + kind: "workspace".to_string(), + payload: json!({ "data": "2" }), + timestamp: 42, + }; + let raw = serde_json::to_string(&original).unwrap(); + let decoded: RawEvent = serde_json::from_str(&raw).unwrap(); + + assert_eq!(decoded.kind, original.kind); + assert_eq!(decoded.timestamp, original.timestamp); + assert_eq!(decoded.source, original.source); + assert_eq!(decoded.payload, original.payload); + } + + #[test] + fn now_unix_ms_is_monotonically_non_decreasing_across_calls() { + let a = now_unix_ms(); + let b = now_unix_ms(); + assert!(b >= a, "now_unix_ms went backwards: {a} -> {b}"); + } + + #[test] + fn adapter_source_is_hashable_and_eq() { + use std::collections::HashSet; + let mut set = HashSet::new(); + set.insert(AdapterSource::Hyprland); + set.insert(AdapterSource::Hyprland); + set.insert(AdapterSource::Udev); + set.insert(AdapterSource::Bluetooth); + assert_eq!(set.len(), 3); + assert!(set.contains(&AdapterSource::Hyprland)); + } +} diff --git a/bread-sync/Cargo.toml b/bread-sync/Cargo.toml new file mode 100644 index 0000000..15bb845 --- /dev/null +++ b/bread-sync/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bread-sync" +version = "1.0.0" +edition = "2021" + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +git2.workspace = true +dirs.workspace = true +chrono.workspace = true +glob.workspace = true +toml = "0.8" +libc = "0.2" + +[dev-dependencies] +tempfile.workspace = true diff --git a/bread-sync/README.md b/bread-sync/README.md new file mode 100644 index 0000000..7d37899 --- /dev/null +++ b/bread-sync/README.md @@ -0,0 +1,88 @@ +# bread-sync + +Sync engine for [Bread](../README.md) — snapshot and restore desktop state via a Git remote. + +## Purpose + +`bread-sync` provides the library backing `bread sync` commands. It handles: + +- **Git operations** — clone, commit, push, pull, fetch, diff via `git2` +- **Config serialization** — read/write `sync.toml` (machine name, remote URL, delegates, packages) +- **Delegate file sync** — rsync-style directory copy with glob excludes +- **Package snapshots** — capture installed packages from pacman, pip, npm, cargo +- **Machine profiles** — per-machine TOML records with hostname, tags, and last-sync timestamp + +## Public API + +### `config` + +```rust +SyncConfig::load(config_dir: &Path) -> Result +SyncConfig::save(&self, config_dir: &Path) -> Result<()> +SyncConfig::local_repo_path() -> PathBuf // ~/.local/share/bread/sync-repo/ +bread_config_dir() -> PathBuf // ~/.config/bread/ +expand_path(path: &str) -> PathBuf // expands ~/ +``` + +### `git` + +```rust +SyncRepo::init(path: &Path) -> Result +SyncRepo::open(path: &Path) -> Result +SyncRepo::clone_from(url: &str, path: &Path) -> Result +SyncRepo::open_or_clone(url: &str, path: &Path) -> Result +SyncRepo::commit(&self, message: &str) -> Result> // None = nothing to commit +SyncRepo::push(&self, remote: &str, branch: &str) -> Result<()> +SyncRepo::pull(&self, remote: &str, branch: &str) -> Result<()> // fast-forward only +SyncRepo::fetch(&self, remote: &str, branch: &str) -> Result<()> +SyncRepo::is_clean(&self) -> Result +SyncRepo::local_changes(&self) -> Result> +SyncRepo::remote_changes(&self, remote: &str, branch: &str) -> Result> +SyncRepo::working_diff(&self) -> Result +SyncRepo::remote_diff(&self, remote: &str, branch: &str) -> Result +SyncRepo::set_remote(&self, name: &str, url: &str) -> Result<()> +SyncRepo::last_commit_time(&self) -> Option> +``` + +### `delegates` + +```rust +sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()> +resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> +``` + +### `machine` + +```rust +MachineProfile::new(name: String, tags: Vec) -> MachineProfile +MachineProfile::write(&self, machines_dir: &Path) -> Result<()> +MachineProfile::read(machines_dir: &Path, name: &str) -> Result +MachineProfile::list(machines_dir: &Path) -> Result> +hostname() -> String +``` + +### `packages` + +```rust +snapshot(manager: &str, dest: &Path) -> Result // false = manager not found (non-fatal) +parse_pacman(content: &str) -> Vec +parse_pip(content: &str) -> Vec +parse_npm(content: &str) -> Vec +parse_cargo(content: &str) -> Vec +``` + +## Sync repo layout + +``` +~/.local/share/bread/sync-repo/ +├── bread/ ← snapshot of ~/.config/bread/ +├── configs/ +│ └── / ← delegate paths +├── machines/ +│ └── .toml ← per-machine profiles +└── packages/ + ├── pacman.txt + ├── pip.txt + ├── npm.txt + └── cargo.txt +``` diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs new file mode 100644 index 0000000..9760449 --- /dev/null +++ b/bread-sync/src/config.rs @@ -0,0 +1,259 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Configuration stored in `~/.config/bread/sync.toml`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncConfig { + pub remote: RemoteConfig, + pub machine: MachineConfig, + #[serde(default)] + pub packages: PackagesConfig, + #[serde(default)] + pub delegates: DelegatesConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteConfig { + pub url: String, + #[serde(default = "default_branch")] + pub branch: String, +} + +fn default_branch() -> String { + "main".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MachineConfig { + pub name: String, + #[serde(default)] + pub tags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackagesConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub managers: Vec, +} + +fn default_true() -> bool { + true +} + +impl Default for PackagesConfig { + fn default() -> Self { + Self { + enabled: true, + managers: vec![ + "pacman".to_string(), + "aur".to_string(), + "pip".to_string(), + "npm".to_string(), + "cargo".to_string(), + ], + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DelegatesConfig { + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub exclude: Vec, +} + +impl SyncConfig { + /// Load sync config from the given bread config directory. + pub fn load(config_dir: &Path) -> Result { + let path = config_dir.join("sync.toml"); + let raw = fs::read_to_string(&path) + .with_context(|| "bread: sync not initialized. Run: bread sync init".to_string())?; + toml::from_str(&raw).context("failed to parse sync.toml") + } + + /// Save sync config to the given bread config directory. + pub fn save(&self, config_dir: &Path) -> Result<()> { + let path = config_dir.join("sync.toml"); + fs::create_dir_all(config_dir)?; + let raw = toml::to_string_pretty(self).context("failed to serialize sync config")?; + fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display())) + } + + /// Returns the local sync repo path (`~/.local/share/bread/sync-repo/`). + pub fn local_repo_path() -> PathBuf { + if let Some(data_dir) = dirs::data_dir() { + return data_dir.join("bread").join("sync-repo"); + } + // Fallback using $HOME + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".local") + .join("share") + .join("bread") + .join("sync-repo"); + } + PathBuf::from(".local/share/bread/sync-repo") + } +} + +/// Returns the bread config directory (`~/.config/bread/`). +pub fn bread_config_dir() -> PathBuf { + if let Some(cfg) = dirs::config_dir() { + return cfg.join("bread"); + } + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(xdg).join("bread"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".config").join("bread"); + } + PathBuf::from(".config/bread") +} + +/// Expand `~` to the home directory in a path string. +pub fn expand_path(path: &str) -> PathBuf { + if path == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home); + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(rest); + } + } + PathBuf::from(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn sample_config() -> SyncConfig { + SyncConfig { + remote: RemoteConfig { + url: "git@github.com:user/repo.git".to_string(), + branch: "main".to_string(), + }, + machine: MachineConfig { + name: "host".to_string(), + tags: vec!["mobile".to_string()], + }, + packages: PackagesConfig::default(), + delegates: DelegatesConfig::default(), + } + } + + #[test] + fn save_and_load_round_trip() { + let tmp = TempDir::new().unwrap(); + let cfg = sample_config(); + cfg.save(tmp.path()).unwrap(); + + assert!(tmp.path().join("sync.toml").exists()); + + let loaded = SyncConfig::load(tmp.path()).unwrap(); + assert_eq!(loaded.remote.url, cfg.remote.url); + assert_eq!(loaded.remote.branch, cfg.remote.branch); + assert_eq!(loaded.machine.name, cfg.machine.name); + assert_eq!(loaded.machine.tags, cfg.machine.tags); + } + + #[test] + fn load_missing_config_returns_helpful_error() { + let tmp = TempDir::new().unwrap(); + let err = SyncConfig::load(tmp.path()).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("sync not initialized") || msg.contains("bread sync init"), + "expected init hint, got: {msg}", + ); + } + + #[test] + fn load_invalid_toml_returns_parse_error() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("sync.toml"), "this is not [valid toml").unwrap(); + let err = SyncConfig::load(tmp.path()).unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.to_lowercase().contains("parse"), "got: {msg}"); + } + + #[test] + fn packages_config_default_includes_all_managers() { + let cfg = PackagesConfig::default(); + assert!(cfg.enabled); + assert!(cfg.managers.contains(&"pacman".to_string())); + assert!(cfg.managers.contains(&"aur".to_string())); + assert!(cfg.managers.contains(&"pip".to_string())); + assert!(cfg.managers.contains(&"npm".to_string())); + assert!(cfg.managers.contains(&"cargo".to_string())); + } + + #[test] + fn remote_branch_defaults_to_main_when_omitted() { + let raw = r#" +[remote] +url = "git@example.com:r.git" + +[machine] +name = "host" +"#; + let cfg: SyncConfig = toml::from_str(raw).unwrap(); + assert_eq!(cfg.remote.branch, "main"); + } + + #[test] + fn delegates_default_is_empty() { + let cfg = DelegatesConfig::default(); + assert!(cfg.include.is_empty()); + assert!(cfg.exclude.is_empty()); + } + + #[test] + fn local_repo_path_resolves_to_data_dir() { + let path = SyncConfig::local_repo_path(); + // Must include the bread sync-repo segment at the end. + let suffix = path.iter().rev().take(2).collect::>(); + assert_eq!( + suffix, + vec![ + std::ffi::OsStr::new("sync-repo"), + std::ffi::OsStr::new("bread") + ] + ); + } + + #[test] + fn expand_path_passes_through_absolute_paths() { + assert_eq!(expand_path("/etc/bread"), PathBuf::from("/etc/bread")); + assert_eq!(expand_path("relative/path"), PathBuf::from("relative/path")); + } + + #[test] + fn expand_path_expands_tilde_alone_to_home() { + let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from)); + if let Some(home) = home { + assert_eq!(expand_path("~"), home); + } + } + + #[test] + fn expand_path_expands_tilde_prefix() { + let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from)); + if let Some(home) = home { + assert_eq!(expand_path("~/.config"), home.join(".config")); + } + } +} diff --git a/bread-sync/src/delegates.rs b/bread-sync/src/delegates.rs new file mode 100644 index 0000000..815e87b --- /dev/null +++ b/bread-sync/src/delegates.rs @@ -0,0 +1,247 @@ +use anyhow::Result; +use glob::Pattern; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::expand_path; + +/// Copy all files from `src` into `dst`, mirroring the directory tree. +/// Files present in `dst` but not in `src` are deleted (rsync-style). +/// Files matching any `exclude` glob are skipped. +pub fn sync_dir(src: &Path, dst: &Path, exclude: &[String]) -> Result<()> { + let patterns: Vec = exclude + .iter() + .filter_map(|g| Pattern::new(g).ok()) + .collect(); + + fs::create_dir_all(dst)?; + sync_dir_inner(src, dst, src, &patterns) +} + +fn sync_dir_inner(src: &Path, dst: &Path, root: &Path, patterns: &[Pattern]) -> Result<()> { + // Remove files in dst that don't exist in src. + if dst.exists() { + for entry in fs::read_dir(dst)? { + let entry = entry?; + let rel = entry + .path() + .strip_prefix(dst) + .unwrap_or(&entry.path()) + .to_path_buf(); + let src_counterpart = src.join(&rel); + if !src_counterpart.exists() { + let p = entry.path(); + if p.is_dir() { + let _ = fs::remove_dir_all(&p); + } else { + let _ = fs::remove_file(&p); + } + } + } + } + + if !src.exists() { + return Ok(()); + } + + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let rel = src_path.strip_prefix(root).unwrap_or(&src_path); + + if is_excluded(rel, root, patterns) { + continue; + } + + let dst_path = dst.join(src_path.strip_prefix(src).unwrap_or(&src_path)); + + if src_path.is_dir() { + fs::create_dir_all(&dst_path)?; + sync_dir_inner(&src_path, &dst_path, root, patterns)?; + } else { + if let Some(parent) = dst_path.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +fn is_excluded(rel: &Path, _root: &Path, patterns: &[Pattern]) -> bool { + let rel_str = rel.to_string_lossy(); + let file_name = rel + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + + for pat in patterns { + // Match against full relative path or just filename + if pat.matches(&rel_str) || pat.matches(&file_name) { + return true; + } + // For directory-name patterns (e.g. "**/.git"), also check component names + if let Some(pat_str) = pat.as_str().strip_prefix("**/") { + for component in rel.components() { + if let std::path::Component::Normal(name) = component { + if Pattern::new(pat_str) + .map(|p| p.matches(&name.to_string_lossy())) + .unwrap_or(false) + { + return true; + } + } + } + } + } + false +} + +/// Resolve delegate paths from the config (expanding `~`). +pub fn resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> { + includes + .iter() + .map(|s| { + let expanded = expand_path(s); + let basename = expanded + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| s.clone()); + (basename, expanded) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn sync_dir_copies_nested_tree() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + + fs::create_dir_all(src.path().join("a/b/c")).unwrap(); + fs::write(src.path().join("a/b/c/leaf.txt"), "hello").unwrap(); + fs::write(src.path().join("root.txt"), "root").unwrap(); + + sync_dir(src.path(), dst.path(), &[]).unwrap(); + + assert_eq!( + fs::read_to_string(dst.path().join("a/b/c/leaf.txt")).unwrap(), + "hello" + ); + assert_eq!( + fs::read_to_string(dst.path().join("root.txt")).unwrap(), + "root" + ); + } + + #[test] + fn sync_dir_overwrites_existing_files() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + fs::write(src.path().join("f"), "new").unwrap(); + fs::write(dst.path().join("f"), "old").unwrap(); + + sync_dir(src.path(), dst.path(), &[]).unwrap(); + assert_eq!(fs::read_to_string(dst.path().join("f")).unwrap(), "new"); + } + + #[test] + fn sync_dir_removes_files_no_longer_in_src() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + fs::write(dst.path().join("orphan.txt"), "to remove").unwrap(); + fs::write(src.path().join("keeper.txt"), "stay").unwrap(); + + sync_dir(src.path(), dst.path(), &[]).unwrap(); + + assert!(!dst.path().join("orphan.txt").exists()); + assert!(dst.path().join("keeper.txt").exists()); + } + + #[test] + fn sync_dir_removes_directories_no_longer_in_src() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + fs::create_dir_all(dst.path().join("ghost-dir")).unwrap(); + fs::write(dst.path().join("ghost-dir/x"), "").unwrap(); + + sync_dir(src.path(), dst.path(), &[]).unwrap(); + assert!(!dst.path().join("ghost-dir").exists()); + } + + #[test] + fn sync_dir_exclude_filters_by_basename_pattern() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + fs::write(src.path().join("keep.lua"), "lua").unwrap(); + fs::write(src.path().join("trash.cache"), "").unwrap(); + + sync_dir(src.path(), dst.path(), &["**/*.cache".to_string()]).unwrap(); + assert!(dst.path().join("keep.lua").exists()); + assert!(!dst.path().join("trash.cache").exists()); + } + + #[test] + fn sync_dir_exclude_filters_nested_directory_by_name() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + fs::create_dir_all(src.path().join(".git/objects")).unwrap(); + fs::write(src.path().join(".git/objects/abc"), "").unwrap(); + fs::write(src.path().join("init.lua"), "lua").unwrap(); + + sync_dir(src.path(), dst.path(), &["**/.git".to_string()]).unwrap(); + assert!(dst.path().join("init.lua").exists()); + assert!(!dst.path().join(".git").exists()); + } + + #[test] + fn sync_dir_creates_destination_if_missing() { + let src = TempDir::new().unwrap(); + let dst_parent = TempDir::new().unwrap(); + let dst = dst_parent.path().join("brand-new"); + fs::write(src.path().join("hi"), "hi").unwrap(); + + sync_dir(src.path(), &dst, &[]).unwrap(); + assert!(dst.join("hi").exists()); + } + + #[test] + fn sync_dir_empty_src_clears_dst() { + let src = TempDir::new().unwrap(); + let dst = TempDir::new().unwrap(); + fs::write(dst.path().join("a"), "").unwrap(); + fs::write(dst.path().join("b"), "").unwrap(); + + sync_dir(src.path(), dst.path(), &[]).unwrap(); + let remaining: Vec<_> = fs::read_dir(dst.path()).unwrap().collect(); + assert!(remaining.is_empty()); + } + + // ─── resolve_include_paths ──────────────────────────────────────────── + + #[test] + fn resolve_include_paths_uses_basename_as_key() { + let includes = vec!["/etc/foo/bar".to_string(), "/var/lib/quux".to_string()]; + let resolved = resolve_include_paths(&includes); + assert_eq!(resolved.len(), 2); + assert_eq!(resolved[0].0, "bar"); + assert_eq!(resolved[0].1, PathBuf::from("/etc/foo/bar")); + assert_eq!(resolved[1].0, "quux"); + } + + #[test] + fn resolve_include_paths_expands_tilde_in_source() { + let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from)); + if let Some(home) = home { + let resolved = resolve_include_paths(&["~/Documents".to_string()]); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].1, home.join("Documents")); + assert_eq!(resolved[0].0, "Documents"); + } + } +} diff --git a/bread-sync/src/export.rs b/bread-sync/src/export.rs new file mode 100644 index 0000000..9397f4b --- /dev/null +++ b/bread-sync/src/export.rs @@ -0,0 +1,850 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use git2::Repository; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::{expand_path, SyncConfig}; +use crate::delegates::sync_dir; +use crate::machine::{hostname, MachineProfile}; +use crate::packages; + +/// Maps a staged path back to the original absolute path on the source machine. +/// Drives the import — no hardcoded paths needed. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PathRecord { + /// Relative path within the export (e.g. "configs/hypr"). + pub staging: String, + /// Original path with `~` (e.g. "~/.config/hypr"). + pub original: String, + /// Whether this is a single file (false = directory). + #[serde(default)] + pub is_file: bool, +} + +/// A git repository found on the machine, keyed by its remote URL. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitRepoRecord { + /// Path relative to $HOME (e.g. "Projects/bread"). + pub path: String, + /// Remote URL (e.g. "https://github.com/Breadway/bread.git"). + pub remote: String, + /// Branch that was checked out at export time. + pub branch: String, +} + +/// Manifest stored in the export root as `manifest.toml`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportManifest { + pub version: u32, + pub machine: String, + pub hostname: String, + pub exported_at: String, + /// Explicit staging→original path map for all captured items. + #[serde(default)] + pub path_map: Vec, + /// High-level list of config dir names (for display). + pub configs: Vec, + /// Git repos found on the source machine. + #[serde(default)] + pub repos: Vec, + pub system: bool, + pub packages: Vec, + // Legacy fields kept for forward compat (ignored on import) + #[serde(default)] + pub bread: bool, + #[serde(default)] + pub dotfiles: Vec, + #[serde(default)] + pub local_bin: Vec, + #[serde(default)] + pub systemd_units: Vec, +} + +/// Config directories always included in the export (if they exist on disk). +static BUILTIN_CONFIGS: &[(&str, &str)] = &[ + ("hypr", "~/.config/hypr"), + ("fish", "~/.config/fish"), + ("kitty", "~/.config/kitty"), + ("nvim", "~/.config/nvim"), + ("ags", "~/.config/ags"), + ("wofi", "~/.config/wofi"), + ("waybar", "~/.config/waybar"), + ("dunst", "~/.config/dunst"), + ("mako", "~/.config/mako"), + ("hyprlock", "~/.config/hyprlock"), + ("hyprpaper", "~/.config/hyprpaper"), + ("swaylock", "~/.config/swaylock"), + ("wlogout", "~/.config/wlogout"), + ("swappy", "~/.config/swappy"), + ("btop", "~/.config/btop"), + ("waypaper", "~/.config/waypaper"), + ("wal", "~/.config/wal"), + ("gtk-3.0", "~/.config/gtk-3.0"), + ("gtk-4.0", "~/.config/gtk-4.0"), + ("keyd", "~/.config/keyd"), + ("autostart", "~/.config/autostart"), +]; + +/// Standalone dotfiles captured as individual files: (staging-name, source-path). +static BUILTIN_DOTFILES: &[(&str, &str)] = &[ + (".gitconfig", "~/.gitconfig"), + ("user-dirs.dirs", "~/.config/user-dirs.dirs"), + ("mimeapps.list", "~/.config/mimeapps.list"), + ("ssh_config", "~/.ssh/config"), + (".zshrc", "~/.zshrc"), + (".zprofile", "~/.zprofile"), + (".zshenv", "~/.zshenv"), +]; + +/// System-level directories. World-readable ones are copied directly; +/// root-only ones (networkmanager, bluetooth) require running with sudo. +static SYSTEM_PATHS: &[(&str, &str)] = &[ + ("udev", "/etc/udev/rules.d"), + ("modprobe", "/etc/modprobe.d"), + ("sysctl", "/etc/sysctl.d"), + ("networkmanager", "/etc/NetworkManager/system-connections"), + ("bluetooth", "/var/lib/bluetooth"), +]; + +/// Directories excluded from every recursive copy. +static DEFAULT_EXCLUDES: &[&str] = &[ + "**/.git", + "**/*.cache", + "**/node_modules", + "**/@girs", + "**/__pycache__", + "fish_variables?*", +]; + +/// Directories skipped when searching for git repos. +static GIT_SKIP_DIRS: &[&str] = &[ + ".local", "Nextcloud", "target", "node_modules", "__pycache__", + ".cache", "snap", "flatpak", "@girs", "Steam", +]; + +// ── stage_export ──────────────────────────────────────────────────────────── + +/// Build a self-contained snapshot directory at `staging`. +pub fn stage_export( + cfg_dir: &Path, + config: &SyncConfig, + staging: &Path, +) -> Result { + fs::create_dir_all(staging)?; + + let excludes: Vec = DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect(); + let mut path_map: Vec = Vec::new(); + let mut included_configs: Vec = Vec::new(); + + // Helper: tilde-ify an absolute path for storage in the manifest. + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root")); + let tilde = |p: &Path| -> String { + p.strip_prefix(&home) + .map(|rel| format!("~/{}", rel.display())) + .unwrap_or_else(|_| p.display().to_string()) + }; + + // 1. Bread config → bread/ + let bread_dest = staging.join("bread"); + sync_dir(cfg_dir, &bread_dest, &excludes).context("failed to snapshot bread config")?; + path_map.push(PathRecord { + staging: "bread".to_string(), + original: tilde(cfg_dir), + is_file: false, + }); + + // 2. Built-in + delegate configs → configs// + let configs_dir = staging.join("configs"); + + for (name, raw_path) in BUILTIN_CONFIGS { + let src = expand_path(raw_path); + if src.exists() { + let dst = configs_dir.join(name); + sync_dir(&src, &dst, &excludes) + .with_context(|| format!("failed to snapshot {raw_path}"))?; + path_map.push(PathRecord { + staging: format!("configs/{name}"), + original: raw_path.to_string(), + is_file: false, + }); + included_configs.push(name.to_string()); + } + } + + let delegate_paths = crate::delegates::resolve_include_paths(&config.delegates.include); + for (basename, src_path) in &delegate_paths { + if src_path.exists() && !included_configs.contains(basename) { + let dst = configs_dir.join(basename); + sync_dir(src_path, &dst, &config.delegates.exclude) + .with_context(|| format!("failed to snapshot delegate {}", src_path.display()))?; + path_map.push(PathRecord { + staging: format!("configs/{basename}"), + original: tilde(src_path), + is_file: false, + }); + included_configs.push(basename.clone()); + } + } + + // 3. Dotfiles → dotfiles/ + let dotfiles_dir = staging.join("dotfiles"); + fs::create_dir_all(&dotfiles_dir)?; + + for (dest_name, raw_path) in BUILTIN_DOTFILES { + let src = expand_path(raw_path); + if src.exists() { + fs::copy(&src, dotfiles_dir.join(dest_name)) + .with_context(|| format!("failed to copy {raw_path}"))?; + path_map.push(PathRecord { + staging: format!("dotfiles/{dest_name}"), + original: raw_path.to_string(), + is_file: true, + }); + } + } + + // 4. ~/.local/bin custom scripts → local-bin/ + // Skip symlinks (point to installed binaries) and files >512 KB (compiled artifacts). + let local_bin_src = expand_path("~/.local/bin"); + let local_bin_dst = staging.join("local-bin"); + if local_bin_src.exists() { + fs::create_dir_all(&local_bin_dst)?; + let mut any = false; + for entry in fs::read_dir(&local_bin_src).context("failed to read ~/.local/bin")? { + let entry = entry?; + let meta = entry.metadata()?; + if meta.file_type().is_symlink() || meta.len() > 512 * 1024 { + continue; + } + let path = entry.path(); + if path.is_file() { + let name = path.file_name().unwrap().to_string_lossy().to_string(); + fs::copy(&path, local_bin_dst.join(&name))?; + any = true; + } + } + if any { + path_map.push(PathRecord { + staging: "local-bin".to_string(), + original: "~/.local/bin".to_string(), + is_file: false, + }); + } + } + + // 5. ~/.local/share/fonts → local-fonts/ + let fonts_src = expand_path("~/.local/share/fonts"); + let fonts_dst = staging.join("local-fonts"); + if fonts_src.exists() { + sync_dir(&fonts_src, &fonts_dst, &excludes) + .context("failed to snapshot fonts")?; + path_map.push(PathRecord { + staging: "local-fonts".to_string(), + original: "~/.local/share/fonts".to_string(), + is_file: false, + }); + } + + // 7. ~/.config/systemd/user → systemd/ + let systemd_src = expand_path("~/.config/systemd/user"); + let systemd_dst = staging.join("systemd"); + if systemd_src.exists() { + sync_dir(&systemd_src, &systemd_dst, &excludes) + .context("failed to snapshot systemd user units")?; + path_map.push(PathRecord { + staging: "systemd".to_string(), + original: "~/.config/systemd/user".to_string(), + is_file: false, + }); + } + + // 8. System configs → system/ (read-only; restore needs sudo) + let system_dst = staging.join("system"); + let mut has_system = false; + for (name, raw_path) in SYSTEM_PATHS { + let src = PathBuf::from(raw_path); + if !src.exists() { + continue; + } + match sync_dir(&src, &system_dst.join(name), &excludes) { + Ok(_) => has_system = true, + Err(e) => { + let msg = e.to_string(); + if msg.contains("Permission denied") || msg.contains("permission denied") { + eprintln!( + "bread: warning: {raw_path} requires sudo to export (skipping — re-run with sudo to include)" + ); + } else { + eprintln!("bread: warning: failed to snapshot {raw_path}: {e}"); + } + } + } + } + + // 9. Package snapshots → packages/ + let packages_dir = staging.join("packages"); + let mut included_managers: Vec = Vec::new(); + if config.packages.enabled { + for manager in &config.packages.managers { + let dest_file = packages_dir.join(format!("{manager}.txt")); + match packages::snapshot(manager, &dest_file) { + Ok(true) => included_managers.push(manager.clone()), + Ok(false) => {} + Err(e) => eprintln!( + "bread: warning: package snapshot for {manager} failed: {e}" + ), + } + } + } + + // 10. Machine profile → machines/ + let machines_dir = staging.join("machines"); + MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()) + .write(&machines_dir)?; + + // 11. Git repositories — find all repos with a remote, commit+push each + let nc_dirs = nextcloud_sync_dirs(&home); + if !nc_dirs.is_empty() { + let labels: Vec<_> = nc_dirs.iter() + .map(|p| p.strip_prefix(&home).map(|r| format!("~/{}", r.display())).unwrap_or_else(|_| p.display().to_string())) + .collect(); + eprintln!("bread: skipping Nextcloud-tracked folders: {}", labels.join(", ")); + } + let repos = find_git_repos(&home); + commit_and_push_repos(&repos, &home); + + // 12. Manifest + let manifest = ExportManifest { + version: 2, + machine: config.machine.name.clone(), + hostname: hostname(), + exported_at: Utc::now().to_rfc3339(), + path_map, + configs: included_configs, + repos, + system: has_system, + packages: included_managers, + bread: true, + dotfiles: vec![], + local_bin: vec![], + systemd_units: vec![], + }; + fs::write( + staging.join("manifest.toml"), + toml::to_string_pretty(&manifest).context("failed to serialize manifest")?, + )?; + + // 11. restore.sh + let restore_path = staging.join("restore.sh"); + fs::write(&restore_path, generate_restore_sh(&manifest))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&restore_path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&restore_path, perms)?; + } + + Ok(manifest) +} + +// ── apply_import ──────────────────────────────────────────────────────────── + +/// Apply a staged snapshot directory to this machine. +/// Returns a list of human-readable descriptions of what was applied. +pub fn apply_import( + staging: &Path, + cfg_dir: &Path, + install_packages: bool, + clone_repos: bool, +) -> Result> { + let mut applied: Vec = Vec::new(); + + // Read manifest to get the path map + let manifest_path = staging.join("manifest.toml"); + let path_map: Vec = if manifest_path.exists() { + let raw = fs::read_to_string(&manifest_path)?; + toml::from_str::(&raw) + .map(|m| m.path_map) + .unwrap_or_default() + } else { + vec![] + }; + + if !path_map.is_empty() { + // Manifest-driven restore: use path_map for exact original locations + for record in &path_map { + let src = staging.join(&record.staging); + if !src.exists() { + continue; + } + let dst = expand_path(&record.original); + + if record.is_file { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + // Secure directory permissions for SSH + if record.staging.contains("ssh_config") { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Some(p) = dst.parent() { + if let Ok(m) = fs::metadata(p) { + let mut perms = m.permissions(); + perms.set_mode(0o700); + let _ = fs::set_permissions(p, perms); + } + } + } + } + fs::copy(&src, &dst) + .with_context(|| format!("failed to restore {}", record.original))?; + applied.push(record.original.clone()); + } else { + sync_dir(&src, &dst, &[]) + .with_context(|| format!("failed to restore {}", record.original))?; + applied.push(record.original.clone()); + + // Reload systemd if this was the systemd dir + if record.staging == "systemd" { + let _ = std::process::Command::new("systemctl") + .args(["--user", "daemon-reload"]) + .status(); + } + + // Rebuild font cache after restoring fonts + if record.staging == "local-fonts" { + let _ = std::process::Command::new("fc-cache").arg("-f").status(); + } + + // Make local-bin scripts executable + if record.staging == "local-bin" { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(entries) = fs::read_dir(&dst) { + for entry in entries.filter_map(|e| e.ok()) { + if entry.path().is_file() { + if let Ok(m) = fs::metadata(entry.path()) { + let mut perms = m.permissions(); + perms.set_mode(perms.mode() | 0o111); + let _ = fs::set_permissions(entry.path(), perms); + } + } + } + } + } + } + } + } + } else { + // Legacy fallback for v1 exports without path_map + let bread_src = staging.join("bread"); + if bread_src.exists() { + sync_dir(&bread_src, cfg_dir, &[])?; + applied.push("~/.config/bread".to_string()); + } + let configs_dir = staging.join("configs"); + if configs_dir.exists() { + let config_home = expand_path("~/.config"); + for entry in fs::read_dir(&configs_dir)?.filter_map(|e| e.ok()) { + let src = entry.path(); + if src.is_dir() { + let name = src.file_name().unwrap().to_string_lossy().to_string(); + sync_dir(&src, &config_home.join(&name), &[])?; + applied.push(format!("~/.config/{name}")); + } + } + } + } + + // Package installs + if install_packages { + let packages_dir = staging.join("packages"); + if packages_dir.exists() { + install_packages_from(&packages_dir)?; + applied.push("packages installed".to_string()); + } + } + + // Clone git repos + if clone_repos { + let manifest_path = staging.join("manifest.toml"); + if manifest_path.exists() { + let raw = fs::read_to_string(&manifest_path)?; + if let Ok(manifest) = toml::from_str::(&raw) { + let home = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default())); + for repo in &manifest.repos { + let dest = home.join(&repo.path); + if dest.exists() { + applied.push(format!("skip (exists): ~/{}", repo.path)); + continue; + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + eprint!(" cloning ~/{} ... ", repo.path); + let status = std::process::Command::new("git") + .args(["clone", "--branch", &repo.branch, &repo.remote]) + .arg(&dest) + .status(); + match status { + Ok(s) if s.success() => { + eprintln!("done"); + applied.push(format!("cloned ~/{}", repo.path)); + } + _ => { + eprintln!("failed"); + applied.push(format!("clone failed: ~/{}", repo.path)); + } + } + } + } + } + } + + Ok(applied) +} + +// ── commit_and_push_repos ─────────────────────────────────────────────────── + +fn commit_and_push_repos(repos: &[GitRepoRecord], home: &Path) { + if repos.is_empty() { + return; + } + eprintln!("bread: committing and pushing {} repo(s)...", repos.len()); + for repo in repos { + let dir = home.join(&repo.path); + let dir_str = dir.to_string_lossy(); + + // Stage all changes + let add = std::process::Command::new("git") + .args(["-C", &dir_str, "add", "-A"]) + .output(); + if add.map(|o| !o.status.success()).unwrap_or(true) { + eprintln!(" ~/{}: git add failed, skipping", repo.path); + continue; + } + + // Check if there's anything staged + let has_changes = std::process::Command::new("git") + .args(["-C", &dir_str, "diff", "--cached", "--quiet"]) + .status() + .map(|s| !s.success()) + .unwrap_or(false); + + if has_changes { + let commit = std::process::Command::new("git") + .args(["-C", &dir_str, "commit", "-m", "Commiting for bread sync"]) + .output(); + match commit { + Ok(o) if o.status.success() => {} + Ok(o) => { + eprintln!( + " ~/{}: commit failed: {}", + repo.path, + String::from_utf8_lossy(&o.stderr).trim() + ); + continue; + } + Err(e) => { + eprintln!(" ~/{}: commit failed: {}", repo.path, e); + continue; + } + } + } + + // Push + eprint!(" ~/{}: pushing... ", repo.path); + let push = std::process::Command::new("git") + .args(["-C", &dir_str, "push"]) + .output(); + match push { + Ok(o) if o.status.success() => eprintln!("ok"), + Ok(o) => eprintln!( + "failed: {}", + String::from_utf8_lossy(&o.stderr).trim() + ), + Err(e) => eprintln!("failed: {}", e), + } + } +} + +// ── find_git_repos ────────────────────────────────────────────────────────── + +/// Read ~/.config/Nextcloud/nextcloud.cfg and return all configured local sync roots. +/// Always includes ~/Nextcloud if it exists, even without a config file. +fn nextcloud_sync_dirs(home: &Path) -> Vec { + let mut dirs: Vec = Vec::new(); + + let cfg = home.join(".config/Nextcloud/nextcloud.cfg"); + if let Ok(content) = fs::read_to_string(&cfg) { + for line in content.lines() { + if let Some(raw) = line.trim().strip_prefix("localPath=") { + let p = PathBuf::from(raw); + let p = if p.is_absolute() { p } else { home.join(p) }; + if !dirs.contains(&p) { + dirs.push(p); + } + } + } + } + + // Always treat ~/Nextcloud as off-limits if it exists + let default_nc = home.join("Nextcloud"); + if default_nc.exists() && !dirs.contains(&default_nc) { + dirs.push(default_nc); + } + + dirs +} + +fn find_git_repos(home: &Path) -> Vec { + let nc_dirs = nextcloud_sync_dirs(home); + let mut repos: Vec = Vec::new(); + + // Home root at depth 1 only (e.g. ~/bread, ~/yay, ~/colorshell) + walk_repos(home, home, 0, 1, &mut repos, &nc_dirs); + + // Deeper search in common project directories + for subdir in &["Projects", "Documents", "src", "dev", "code", "repos", "builds"] { + let p = home.join(subdir); + if p.exists() { + walk_repos(&p, home, 0, 3, &mut repos, &nc_dirs); + } + } + + // .config at depth 1 (e.g. ~/.config/hypr, ~/.config/wificonf) + let config_dir = home.join(".config"); + if config_dir.exists() { + walk_repos(&config_dir, home, 0, 1, &mut repos, &nc_dirs); + } + + // Deduplicate by path, sort for determinism + repos.sort_by(|a, b| a.path.cmp(&b.path)); + repos.dedup_by(|a, b| a.path == b.path); + repos +} + +fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut Vec, nc_dirs: &[PathBuf]) { + // Skip anything inside a Nextcloud sync root + if nc_dirs.iter().any(|nc| dir.starts_with(nc)) { + return; + } + + if dir.join(".git").exists() { + if let Ok(repo) = Repository::open(dir) { + let remote_url = repo + .find_remote("origin") + .ok() + .and_then(|r| r.url().map(str::to_string)); + + if let Some(remote) = remote_url { + let branch = repo + .head() + .ok() + .and_then(|h| h.shorthand().map(str::to_string)) + .unwrap_or_else(|| "main".to_string()); + + let rel = dir + .strip_prefix(home) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| dir.to_string_lossy().to_string()); + + repos.push(GitRepoRecord { path: rel, remote, branch }); + } + } + return; // don't recurse into git repos (skip submodules) + } + + if depth >= max_depth { + return; + } + + if let Ok(entries) = fs::read_dir(dir) { + let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect(); + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if GIT_SKIP_DIRS.contains(&name.as_ref()) { + continue; + } + walk_repos(&path, home, depth + 1, max_depth, repos, nc_dirs); + } + } +} + +// ── package install ───────────────────────────────────────────────────────── + +fn install_packages_from(packages_dir: &Path) -> Result<()> { + let pacman_file = packages_dir.join("pacman.txt"); + if pacman_file.exists() { + let pkgs = packages::parse_pacman(&fs::read_to_string(&pacman_file)?); + if !pkgs.is_empty() { + eprintln!("bread: installing {} pacman packages...", pkgs.len()); + let _ = std::process::Command::new("sudo") + .args(["pacman", "-S", "--needed"]) + .args(&pkgs) + .status(); + } + } + let cargo_file = packages_dir.join("cargo.txt"); + if cargo_file.exists() { + for pkg in packages::parse_cargo(&fs::read_to_string(&cargo_file)?) { + let _ = std::process::Command::new("cargo").args(["install", &pkg]).status(); + } + } + let pip_file = packages_dir.join("pip.txt"); + if pip_file.exists() { + let _ = std::process::Command::new("pip") + .args(["install", "--user", "-r"]) + .arg(&pip_file) + .status(); + } + let npm_file = packages_dir.join("npm.txt"); + if npm_file.exists() { + for pkg in packages::parse_npm(&fs::read_to_string(&npm_file)?) { + let _ = std::process::Command::new("npm").args(["install", "-g", &pkg]).status(); + } + } + Ok(()) +} + +// ── restore.sh ─────────────────────────────────────────────────────────────── + +fn generate_restore_sh(manifest: &ExportManifest) -> String { + let ts = &manifest.exported_at[..16]; + let mut s = String::new(); + + s.push_str("#!/bin/bash\n"); + s.push_str("set -e\n"); + s.push_str("cd \"$(dirname \"$0\")\"\n"); + s.push_str("RESTORE_DIR=\"$(pwd)\"\n\n"); + s.push_str(&format!( + "echo \"Restoring bread snapshot for {} ({})\"\n\n", + manifest.machine, ts + )); + + // Config dirs and dotfiles from path_map + let dirs: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| !r.is_file).collect(); + let files: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| r.is_file).collect(); + + if !dirs.is_empty() { + s.push_str("# configs and directories\n"); + for r in &dirs { + let dst = &r.original; + let src = &r.staging; + s.push_str(&format!("if [ -e \"$RESTORE_DIR/{src}\" ]; then\n")); + s.push_str(&format!(" mkdir -p \"{dst}\"\n")); + s.push_str(&format!(" cp -r \"$RESTORE_DIR/{src}/.\" \"{dst}/\"\n")); + if r.staging == "systemd" { + s.push_str(" systemctl --user daemon-reload\n"); + } + if r.staging == "local-bin" { + s.push_str(" chmod +x \"${dst}\"/*\n"); + } + s.push_str(&format!(" echo \"[OK] {dst}\"\n")); + s.push_str("fi\n"); + } + s.push('\n'); + } + + if !files.is_empty() { + s.push_str("# dotfiles\n"); + for r in &files { + let dst = &r.original; + let src = &r.staging; + s.push_str(&format!("if [ -f \"$RESTORE_DIR/{src}\" ]; then\n")); + if r.staging.contains("ssh_config") { + s.push_str(" mkdir -p ~/.ssh && chmod 700 ~/.ssh\n"); + } + // Expand ~ in destination for shell + let dst_shell = dst.replace('~', "$HOME"); + s.push_str(&format!(" cp \"$RESTORE_DIR/{src}\" \"{dst_shell}\"\n")); + s.push_str(&format!(" echo \"[OK] {dst}\"\n")); + s.push_str("fi\n"); + } + s.push('\n'); + } + + // Packages + if !manifest.packages.is_empty() { + s.push_str("echo \"\"\n"); + s.push_str("echo \"--- Package restore commands (not run automatically) ---\"\n"); + if manifest.packages.contains(&"pacman".to_string()) { + s.push_str("echo \" pacman: awk '{print \\$1}' \\\"$RESTORE_DIR/packages/pacman.txt\\\" | sudo pacman -S --needed -\"\n"); + } + if manifest.packages.contains(&"cargo".to_string()) { + s.push_str("echo \" cargo: grep -v '^ ' \\\"$RESTORE_DIR/packages/cargo.txt\\\" | awk '{print \\$1}' | xargs -I{} cargo install {}\"\n"); + } + if manifest.packages.contains(&"pip".to_string()) { + s.push_str("echo \" pip: pip install --user -r \\\"$RESTORE_DIR/packages/pip.txt\\\"\"\n"); + } + if manifest.packages.contains(&"npm".to_string()) { + s.push_str("echo \" npm: awk -F/ '{print \\$NF}' \\\"$RESTORE_DIR/packages/npm.txt\\\" | xargs npm install -g\"\n"); + } + s.push('\n'); + } + + // System files + if manifest.system { + s.push_str("echo \"\"\n"); + s.push_str("echo \"--- System files (require sudo, not applied automatically) ---\"\n"); + s.push_str("if [ -d \"$RESTORE_DIR/system/udev\" ]; then\n"); + s.push_str(" echo \" udev: sudo cp \\\"$RESTORE_DIR/system/udev/\\\"* /etc/udev/rules.d/ && sudo udevadm control --reload-rules\"\n"); + s.push_str("fi\n"); + s.push_str("if [ -d \"$RESTORE_DIR/system/modprobe\" ]; then\n"); + s.push_str(" echo \" modprobe: sudo cp \\\"$RESTORE_DIR/system/modprobe/\\\"* /etc/modprobe.d/\"\n"); + s.push_str("fi\n"); + s.push_str("if [ -d \"$RESTORE_DIR/system/sysctl\" ]; then\n"); + s.push_str(" echo \" sysctl: sudo cp \\\"$RESTORE_DIR/system/sysctl/\\\"* /etc/sysctl.d/ && sudo sysctl --system\"\n"); + s.push_str("fi\n"); + s.push_str("if [ -d \"$RESTORE_DIR/system/networkmanager\" ]; then\n"); + s.push_str(" echo \" networkmanager: sudo cp \\\"$RESTORE_DIR/system/networkmanager/\\\"* /etc/NetworkManager/system-connections/ && sudo chmod 600 /etc/NetworkManager/system-connections/* && sudo systemctl restart NetworkManager\"\n"); + s.push_str("fi\n"); + s.push_str("if [ -d \"$RESTORE_DIR/system/bluetooth\" ]; then\n"); + s.push_str(" echo \" bluetooth: sudo cp -r \\\"$RESTORE_DIR/system/bluetooth/\\\"* /var/lib/bluetooth/ && sudo systemctl restart bluetooth\"\n"); + s.push_str("fi\n\n"); + } + + // Git repos + if !manifest.repos.is_empty() { + s.push_str("echo \"\"\n"); + s.push_str("echo \"--- Git repositories ---\"\n"); + for repo in &manifest.repos { + let dest = format!("$HOME/{}", repo.path); + let branch = &repo.branch; + let remote = &repo.remote; + // Create parent dir and clone; skip if already present + let parent = std::path::Path::new(&repo.path) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + if !parent.is_empty() { + s.push_str(&format!("mkdir -p \"$HOME/{parent}\"\n")); + } + s.push_str(&format!( + "if [ ! -d \"{dest}/.git\" ]; then\n" + )); + s.push_str(&format!( + " git clone --branch {branch} {remote} \"{dest}\" && echo \"[OK] ~/{}\"\n", + repo.path + )); + s.push_str(&format!( + "else\n echo \"[skip] ~/{} (already exists)\"\nfi\n", + repo.path + )); + } + } + + s +} diff --git a/bread-sync/src/git.rs b/bread-sync/src/git.rs new file mode 100644 index 0000000..d8f04af --- /dev/null +++ b/bread-sync/src/git.rs @@ -0,0 +1,364 @@ +use anyhow::{Context, Result}; +use git2::{ + build::CheckoutBuilder, Cred, FetchOptions, IndexAddOption, PushOptions, RemoteCallbacks, + Repository, Signature, StatusOptions, +}; +use std::path::{Path, PathBuf}; + +/// Wraps a git2 repository with sync-specific operations. +pub struct SyncRepo { + repo: Repository, + pub path: PathBuf, +} + +impl SyncRepo { + /// Open an existing repository at `path`. + pub fn open(path: &Path) -> Result { + let repo = Repository::open(path) + .with_context(|| format!("failed to open git repo at {}", path.display()))?; + Ok(Self { + repo, + path: path.to_path_buf(), + }) + } + + /// Clone `url` into `path`. + pub fn clone_from(url: &str, path: &Path) -> Result { + let fetch_opts = make_fetch_options(); + let mut builder = git2::build::RepoBuilder::new(); + builder.fetch_options(fetch_opts); + let repo = builder + .clone(url, path) + .with_context(|| format!("failed to clone {} into {}", url, path.display()))?; + Ok(Self { + repo, + path: path.to_path_buf(), + }) + } + + /// Open the repo at `path` if it exists; otherwise clone from `url`. + pub fn open_or_clone(url: &str, path: &Path) -> Result { + if path.exists() { + Self::open(path) + } else { + std::fs::create_dir_all(path)?; + Self::clone_from(url, path) + } + } + + /// Initialize a new empty repository at `path` with `main` as the initial branch. + pub fn init(path: &Path) -> Result { + std::fs::create_dir_all(path)?; + let mut opts = git2::RepositoryInitOptions::new(); + opts.initial_head("main"); + let repo = Repository::init_opts(path, &opts) + .with_context(|| format!("failed to init git repo at {}", path.display()))?; + Ok(Self { + repo, + path: path.to_path_buf(), + }) + } + + /// Stage all changes (equivalent to `git add -A`). + pub fn stage_all(&self) -> Result<()> { + let mut index = self.repo.index().context("failed to get git index")?; + index + .add_all(["*"].iter(), IndexAddOption::DEFAULT, None) + .context("failed to stage changes")?; + index.write().context("failed to write git index")?; + Ok(()) + } + + /// Create a commit. Returns `None` if there are no staged changes. + pub fn commit(&self, message: &str) -> Result> { + self.stage_all()?; + + let mut index = self.repo.index()?; + let tree_id = index.write_tree()?; + + // Check if tree matches current HEAD (nothing to commit) + if let Ok(head) = self.repo.head() { + if let Ok(head_commit) = head.peel_to_commit() { + if head_commit.tree_id() == tree_id { + return Ok(None); + } + } + } + + let tree = self.repo.find_tree(tree_id)?; + let sig = Signature::now("Bread Sync", "bread@localhost")?; + + let oid = match self.repo.head() { + Ok(head) => { + let parent = head.peel_to_commit()?; + self.repo + .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])? + } + Err(_) => { + // First commit — no parents + self.repo + .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])? + } + }; + + Ok(Some(oid)) + } + + /// Push `branch` to `remote_name`. + pub fn push(&self, remote_name: &str, branch: &str) -> Result<()> { + let mut remote = self + .repo + .find_remote(remote_name) + .with_context(|| format!("remote '{}' not found", remote_name))?; + + let refspec = format!("refs/heads/{branch}:refs/heads/{branch}"); + let mut push_opts = PushOptions::new(); + let callbacks = make_callbacks(); + push_opts.remote_callbacks(callbacks); + remote + .push(&[refspec.as_str()], Some(&mut push_opts)) + .context("git push failed")?; + Ok(()) + } + + /// Fetch `branch` from `remote_name` without merging. + pub fn fetch(&self, remote_name: &str, branch: &str) -> Result<()> { + let mut remote = self + .repo + .find_remote(remote_name) + .with_context(|| format!("remote '{}' not found", remote_name))?; + let mut fetch_opts = make_fetch_options(); + remote + .fetch(&[branch], Some(&mut fetch_opts), None) + .context("git fetch failed")?; + Ok(()) + } + + /// Fetch and fast-forward merge. Errors on non-fast-forward. + pub fn pull(&self, remote_name: &str, branch: &str) -> Result<()> { + self.fetch(remote_name, branch)?; + + let fetch_head = self + .repo + .find_reference("FETCH_HEAD") + .context("FETCH_HEAD not found after fetch")?; + let fetch_commit = self + .repo + .reference_to_annotated_commit(&fetch_head) + .context("failed to get annotated commit from FETCH_HEAD")?; + + let (analysis, _) = self + .repo + .merge_analysis(&[&fetch_commit]) + .context("merge analysis failed")?; + + if analysis.is_up_to_date() { + return Ok(()); + } + + if analysis.is_fast_forward() { + let target_id = fetch_commit.id(); + let ref_name = format!("refs/heads/{branch}"); + match self.repo.find_reference(&ref_name) { + Ok(mut r) => { + r.set_target(target_id, "fast-forward pull")?; + } + Err(_) => { + self.repo + .reference(&ref_name, target_id, true, "fast-forward pull")?; + } + } + self.repo.set_head(&ref_name)?; + self.repo + .checkout_head(Some(CheckoutBuilder::default().force())) + .context("checkout failed during pull")?; + Ok(()) + } else { + anyhow::bail!( + "bread: sync conflict — resolve manually in {}", + self.path.display() + ) + } + } + + /// Returns true if working tree has no uncommitted changes. + pub fn is_clean(&self) -> Result { + Ok(self.local_changes()?.is_empty()) + } + + /// Returns list of (status_char, path) for working-tree changes vs HEAD. + pub fn local_changes(&self) -> Result> { + let mut status_opts = StatusOptions::new(); + status_opts + .include_untracked(true) + .recurse_untracked_dirs(true); + + let statuses = self + .repo + .statuses(Some(&mut status_opts)) + .context("failed to get git status")?; + + let mut out = Vec::new(); + for entry in statuses.iter() { + let s = entry.status(); + let ch = if s.contains(git2::Status::INDEX_NEW) || s.contains(git2::Status::WT_NEW) { + 'A' + } else if s.contains(git2::Status::INDEX_DELETED) + || s.contains(git2::Status::WT_DELETED) + { + 'D' + } else { + 'M' + }; + if let Some(path) = entry.path() { + out.push((ch, path.to_string())); + } + } + Ok(out) + } + + /// Returns list of (status_char, path) for changes on remote not yet pulled. + pub fn remote_changes(&self, remote_name: &str, branch: &str) -> Result> { + // We compare HEAD to remote/branch + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let remote_oid = match self.repo.find_reference(&remote_ref) { + Ok(r) => r.peel_to_commit()?.id(), + Err(_) => return Ok(vec![]), + }; + + let head_commit = match self.repo.head() { + Ok(h) => h.peel_to_commit()?.id(), + Err(_) => return Ok(vec![]), + }; + + if head_commit == remote_oid { + return Ok(vec![]); + } + + let head_tree = self.repo.find_commit(head_commit)?.tree()?; + let remote_tree = self.repo.find_commit(remote_oid)?.tree()?; + + let diff = self + .repo + .diff_tree_to_tree(Some(&head_tree), Some(&remote_tree), None) + .context("failed to compute remote diff")?; + + let mut out = Vec::new(); + for delta in diff.deltas() { + let ch = match delta.status() { + git2::Delta::Added => 'A', + git2::Delta::Deleted => 'D', + _ => 'M', + }; + if let Some(path) = delta.new_file().path() { + out.push((ch, path.to_string_lossy().to_string())); + } + } + Ok(out) + } + + /// Return a unified diff string of working tree vs HEAD. + pub fn working_diff(&self) -> Result { + let head_tree = match self.repo.head() { + Ok(h) => Some(h.peel_to_tree()?), + Err(_) => None, + }; + + let diff = self + .repo + .diff_tree_to_workdir_with_index(head_tree.as_ref(), None) + .context("failed to compute working diff")?; + + let mut out = String::new(); + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + let prefix = match line.origin() { + '+' | '-' | ' ' => line.origin().to_string(), + _ => String::new(), + }; + out.push_str(&prefix); + if let Ok(s) = std::str::from_utf8(line.content()) { + out.push_str(s); + } + true + }) + .context("failed to format diff")?; + + Ok(out) + } + + /// Return a unified diff string between HEAD and remote branch HEAD. + pub fn remote_diff(&self, remote_name: &str, branch: &str) -> Result { + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let remote_oid = self + .repo + .find_reference(&remote_ref) + .and_then(|r| r.peel_to_commit()) + .map(|c| c.id()) + .ok(); + + let head_tree = match self.repo.head() { + Ok(h) => Some(h.peel_to_tree()?), + Err(_) => None, + }; + let remote_tree = remote_oid + .and_then(|id| self.repo.find_commit(id).ok()) + .and_then(|c| c.tree().ok()); + + let diff = self + .repo + .diff_tree_to_tree(head_tree.as_ref(), remote_tree.as_ref(), None) + .context("failed to compute remote diff")?; + + let mut out = String::new(); + diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { + let prefix = match line.origin() { + '+' | '-' | ' ' => line.origin().to_string(), + _ => String::new(), + }; + out.push_str(&prefix); + if let Ok(s) = std::str::from_utf8(line.content()) { + out.push_str(s); + } + true + }) + .context("failed to format remote diff")?; + + Ok(out) + } + + /// Set a named remote. + pub fn set_remote(&self, name: &str, url: &str) -> Result<()> { + let _ = self.repo.remote_delete(name); + self.repo + .remote(name, url) + .with_context(|| format!("failed to set remote {name}"))?; + Ok(()) + } + + /// Return the timestamp of the last commit, or None if no commits. + pub fn last_commit_time(&self) -> Option> { + let head = self.repo.head().ok()?; + let commit = head.peel_to_commit().ok()?; + let t = commit.time(); + // git2::Time uses seconds-from-epoch and offset-in-minutes + let naive = chrono::DateTime::from_timestamp(t.seconds(), 0)?; + Some(naive.with_timezone(&chrono::Local)) + } +} + +fn make_callbacks<'a>() -> RemoteCallbacks<'a> { + let mut cb = RemoteCallbacks::new(); + cb.credentials(|_url, username_from_url, allowed_types| { + if allowed_types.contains(git2::CredentialType::SSH_KEY) { + return Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")); + } + Cred::default() + }); + cb +} + +fn make_fetch_options<'a>() -> FetchOptions<'a> { + let mut opts = FetchOptions::new(); + opts.remote_callbacks(make_callbacks()); + opts +} diff --git a/bread-sync/src/lib.rs b/bread-sync/src/lib.rs new file mode 100644 index 0000000..e508750 --- /dev/null +++ b/bread-sync/src/lib.rs @@ -0,0 +1,11 @@ +/// Bread sync: snapshot and restore system state via a Git remote. +pub mod config; +pub mod delegates; +pub mod export; +pub mod git; +pub mod machine; +pub mod packages; + +pub use config::SyncConfig; +pub use export::{apply_import, stage_export, ExportManifest}; +pub use git::SyncRepo; diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs new file mode 100644 index 0000000..6044d09 --- /dev/null +++ b/bread-sync/src/machine.rs @@ -0,0 +1,167 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +/// Machine profile stored in `machines/.toml` in the sync repo. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MachineProfile { + pub name: String, + pub hostname: String, + pub tags: Vec, + pub last_sync: String, // RFC 3339 +} + +impl MachineProfile { + /// Create a new profile for this machine. + pub fn new(name: String, tags: Vec) -> Self { + Self { + hostname: hostname(), + name, + tags, + last_sync: Utc::now().to_rfc3339(), + } + } + + /// Write this profile to `/.toml`. + pub fn write(&self, machines_dir: &Path) -> Result<()> { + fs::create_dir_all(machines_dir)?; + let path = machines_dir.join(format!("{}.toml", self.name)); + let raw = toml::to_string_pretty(self).context("failed to serialize machine profile")?; + fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display())) + } + + /// Read a machine profile from `/.toml`. + pub fn read(machines_dir: &Path, name: &str) -> Result { + let path = machines_dir.join(format!("{name}.toml")); + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&raw).context("failed to parse machine profile") + } + + /// List all machine profiles in `machines_dir`. + pub fn list(machines_dir: &Path) -> Result> { + if !machines_dir.exists() { + return Ok(vec![]); + } + let mut out = Vec::new(); + for entry in fs::read_dir(machines_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("toml") { + if let Ok(raw) = fs::read_to_string(&path) { + if let Ok(profile) = toml::from_str::(&raw) { + out.push(profile); + } + } + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) + } +} + +/// Return the system hostname. +pub fn hostname() -> String { + // Try gethostname via libc, fall back to environment variable. + let mut buf = [0u8; 256]; + unsafe { + if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 { + if let Ok(s) = std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str() { + return s.to_string(); + } + } + } + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("HOST")) + .unwrap_or_else(|_| "unknown".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn write_creates_machines_dir_if_missing() { + let tmp = TempDir::new().unwrap(); + let machines = tmp.path().join("does/not/exist/yet"); + let profile = MachineProfile::new("host".to_string(), vec![]); + profile.write(&machines).unwrap(); + assert!(machines.join("host.toml").exists()); + } + + #[test] + fn write_overwrites_existing_profile() { + let tmp = TempDir::new().unwrap(); + let p1 = MachineProfile::new("host".to_string(), vec!["a".to_string()]); + p1.write(tmp.path()).unwrap(); + + let p2 = MachineProfile::new("host".to_string(), vec!["b".to_string(), "c".to_string()]); + p2.write(tmp.path()).unwrap(); + + let loaded = MachineProfile::read(tmp.path(), "host").unwrap(); + assert_eq!(loaded.tags, vec!["b", "c"]); + } + + #[test] + fn list_returns_empty_when_dir_missing() { + let tmp = TempDir::new().unwrap(); + let missing = tmp.path().join("nope"); + assert!(MachineProfile::list(&missing).unwrap().is_empty()); + } + + #[test] + fn list_returns_sorted_profiles_only_for_toml_files() { + let tmp = TempDir::new().unwrap(); + MachineProfile::new("zebra".to_string(), vec![]) + .write(tmp.path()) + .unwrap(); + MachineProfile::new("alpha".to_string(), vec![]) + .write(tmp.path()) + .unwrap(); + MachineProfile::new("middle".to_string(), vec![]) + .write(tmp.path()) + .unwrap(); + // Non-toml file should be ignored. + std::fs::write(tmp.path().join("notes.txt"), "ignored").unwrap(); + + let list = MachineProfile::list(tmp.path()).unwrap(); + let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "middle", "zebra"]); + } + + #[test] + fn list_skips_invalid_toml_files_without_failing() { + let tmp = TempDir::new().unwrap(); + MachineProfile::new("valid".to_string(), vec![]) + .write(tmp.path()) + .unwrap(); + std::fs::write(tmp.path().join("garbage.toml"), "not valid [toml").unwrap(); + + let list = MachineProfile::list(tmp.path()).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "valid"); + } + + #[test] + fn read_returns_helpful_error_when_missing() { + let tmp = TempDir::new().unwrap(); + let err = MachineProfile::read(tmp.path(), "ghost").unwrap_err(); + assert!(err.to_string().contains("failed to read")); + } + + #[test] + fn new_assigns_current_hostname_and_timestamp() { + let p = MachineProfile::new("h".to_string(), vec![]); + assert!(!p.hostname.is_empty()); + assert!(chrono::DateTime::parse_from_rfc3339(&p.last_sync).is_ok()); + } + + #[test] + fn hostname_returns_non_empty_string() { + // Whether libc or env fallback fires, the result must be non-empty. + assert!(!hostname().is_empty()); + } +} diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs new file mode 100644 index 0000000..b1548ae --- /dev/null +++ b/bread-sync/src/packages.rs @@ -0,0 +1,257 @@ +use anyhow::Result; +use std::fs; +use std::path::Path; +use std::process::Command; + +/// Snapshot a package manager's installed packages and write to `dest`. +/// Returns true if the snapshot was written, false if the package manager +/// is not installed (warns instead of failing). +pub fn snapshot(manager: &str, dest: &Path) -> Result { + let content = match manager { + "pacman" => run_pacman()?, + "aur" => run_aur()?, + "pip" => run_pip()?, + "npm" => run_npm()?, + "cargo" => run_cargo()?, + other => { + eprintln!("bread: unknown package manager '{}', skipping", other); + return Ok(false); + } + }; + + let Some(content) = content else { + eprintln!("bread: package manager '{}' not found, skipping", manager); + return Ok(false); + }; + + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::write(dest, content)?; + Ok(true) +} + +/// Parse a pacman snapshot (one "name version" per line, space-separated) and +/// return a list of package names. +pub fn parse_pacman(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.split_whitespace().next().unwrap_or(l).to_string()) + .collect() +} + +/// Parse a pip freeze snapshot and return package names. +pub fn parse_pip(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .map(|l| { + l.split("==") + .next() + .unwrap_or(l) + .split(">=") + .next() + .unwrap_or(l) + .trim() + .to_string() + }) + .collect() +} + +/// Parse npm global packages list (parseable format, one path per line). +pub fn parse_npm(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| { + // `npm list -g --parseable` outputs paths like /usr/lib/node_modules/pkg + let name = Path::new(l) + .file_name() + .map(|n| n.to_string_lossy().to_string())?; + // Skip npm itself and the root node_modules + if name == "node_modules" { + return None; + } + Some(name) + }) + .collect() +} + +/// Parse cargo install list. +/// Format: "crate v1.2.3 (some-path):\n binary\n..." +pub fn parse_cargo(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.starts_with(' ') && !l.trim().is_empty()) + .map(|l| l.split_whitespace().next().unwrap_or(l).to_string()) + .collect() +} + +fn run_aur() -> Result> { + match Command::new("pacman").arg("-Qm").output() { + Ok(out) if out.status.success() => { + Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) + } + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn run_pacman() -> Result> { + match Command::new("pacman").arg("-Qe").output() { + Ok(out) if out.status.success() => { + Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) + } + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn run_pip() -> Result> { + // Try pip3 first, then pip + for cmd in ["pip3", "pip"] { + match Command::new(cmd) + .args(["list", "--user", "--format=freeze"]) + .output() + { + Ok(out) if out.status.success() => { + return Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) + } + Ok(_) => continue, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e.into()), + } + } + Ok(None) +} + +fn run_npm() -> Result> { + match Command::new("npm") + .args(["list", "-g", "--depth=0", "--parseable"]) + .output() + { + Ok(out) if out.status.success() => { + Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) + } + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn run_cargo() -> Result> { + match Command::new("cargo").args(["install", "--list"]).output() { + Ok(out) if out.status.success() => { + Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())) + } + Ok(_) => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ─── parse_pacman ───────────────────────────────────────────────────── + + #[test] + fn pacman_parses_each_line_to_first_field() { + let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n"; + assert_eq!(parse_pacman(input), vec!["firefox", "curl", "rustup"]); + } + + #[test] + fn pacman_skips_blank_lines() { + let input = "firefox 1\n\n \ncurl 2\n"; + assert_eq!(parse_pacman(input), vec!["firefox", "curl"]); + } + + #[test] + fn pacman_handles_empty_input() { + assert!(parse_pacman("").is_empty()); + assert!(parse_pacman("\n\n\n").is_empty()); + } + + #[test] + fn pacman_handles_single_token_lines() { + // A line with no version still yields the package name. + assert_eq!(parse_pacman("firefox\n"), vec!["firefox"]); + } + + // ─── parse_pip ──────────────────────────────────────────────────────── + + #[test] + fn pip_strips_eq_and_ge_specifiers() { + let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n"; + assert_eq!(parse_pip(input), vec!["requests", "numpy", "black"]); + } + + #[test] + fn pip_skips_comments_and_blank_lines() { + let input = "# editable install\n\nflake8==1.0\n# trailing\n"; + assert_eq!(parse_pip(input), vec!["flake8"]); + } + + #[test] + fn pip_handles_package_without_specifier() { + assert_eq!(parse_pip("requests\nblack\n"), vec!["requests", "black"]); + } + + // ─── parse_npm ──────────────────────────────────────────────────────── + + #[test] + fn npm_extracts_basename_from_paths() { + let input = "/usr/lib/node_modules/npm\n/usr/lib/node_modules/typescript\n/usr/lib/node_modules/yarn\n"; + let pkgs = parse_npm(input); + assert!(pkgs.contains(&"npm".to_string())); + assert!(pkgs.contains(&"typescript".to_string())); + assert!(pkgs.contains(&"yarn".to_string())); + } + + #[test] + fn npm_skips_root_node_modules_entry() { + let input = "/usr/lib/node_modules\n/usr/lib/node_modules/typescript\n"; + assert_eq!(parse_npm(input), vec!["typescript"]); + } + + #[test] + fn npm_handles_empty_input() { + assert!(parse_npm("").is_empty()); + } + + // ─── parse_cargo ────────────────────────────────────────────────────── + + #[test] + fn cargo_extracts_crate_names_from_install_list_output() { + let input = "bottom v0.9.6:\n btm\nripgrep v14.0.3:\n rg\nbat v0.24.0:\n bat\n"; + assert_eq!(parse_cargo(input), vec!["bottom", "ripgrep", "bat"]); + } + + #[test] + fn cargo_skips_binary_lines() { + // Indented lines are binaries inside a crate. + let input = "alpha v1.0.0:\n bin1\n bin2\nbeta v2.0.0:\n bin3\n"; + assert_eq!(parse_cargo(input), vec!["alpha", "beta"]); + } + + #[test] + fn cargo_handles_empty_input() { + assert!(parse_cargo("").is_empty()); + } + + // ─── snapshot dispatch ──────────────────────────────────────────────── + + #[test] + fn snapshot_unknown_manager_returns_false_without_writing() { + let tmp = tempfile::TempDir::new().unwrap(); + let dest = tmp.path().join("out.txt"); + let wrote = snapshot("definitely-not-a-pkg-mgr", &dest).unwrap(); + assert!(!wrote); + assert!(!dest.exists()); + } +} diff --git a/bread-sync/tests/sync.rs b/bread-sync/tests/sync.rs new file mode 100644 index 0000000..0cc2dc9 --- /dev/null +++ b/bread-sync/tests/sync.rs @@ -0,0 +1,482 @@ +use bread_sync::{ + config::{DelegatesConfig, MachineConfig, PackagesConfig, RemoteConfig, SyncConfig}, + delegates, machine, packages, SyncRepo, +}; +use std::fs; +use tempfile::TempDir; + +fn make_bare_repo(path: &std::path::Path) -> git2::Repository { + let mut opts = git2::RepositoryInitOptions::new(); + opts.bare(true); + opts.initial_head("main"); + git2::Repository::init_opts(path, &opts).unwrap() +} + +// Helper to create a git commit in a non-bare repo so we have initial state +fn init_repo_with_commit(path: &std::path::Path) -> SyncRepo { + let repo = SyncRepo::init(path).unwrap(); + fs::write(path.join(".gitkeep"), "").unwrap(); + repo.stage_all().unwrap(); + repo.commit("initial commit").unwrap(); + repo +} + +#[test] +fn sync_init_creates_toml_with_required_fields() { + let tmp = TempDir::new().unwrap(); + let config = SyncConfig { + remote: RemoteConfig { + url: "git@github.com:test/sync.git".to_string(), + branch: "main".to_string(), + }, + machine: MachineConfig { + name: "testbox".to_string(), + tags: vec!["mobile".to_string()], + }, + packages: PackagesConfig::default(), + delegates: DelegatesConfig::default(), + }; + config.save(tmp.path()).unwrap(); + + let loaded = SyncConfig::load(tmp.path()).unwrap(); + assert_eq!(loaded.remote.url, "git@github.com:test/sync.git"); + assert_eq!(loaded.remote.branch, "main"); + assert_eq!(loaded.machine.name, "testbox"); + assert_eq!(loaded.machine.tags, vec!["mobile"]); +} + +#[test] +fn sync_init_errors_if_already_initialized() { + let tmp = TempDir::new().unwrap(); + let config = SyncConfig { + remote: RemoteConfig { + url: "git@github.com:test/sync.git".to_string(), + branch: "main".to_string(), + }, + machine: MachineConfig { + name: "box".to_string(), + tags: vec![], + }, + packages: PackagesConfig::default(), + delegates: DelegatesConfig::default(), + }; + config.save(tmp.path()).unwrap(); + + // Second load should succeed (init itself must check for existence externally) + // We test that load works + let result = SyncConfig::load(tmp.path()); + assert!(result.is_ok()); + // sync.toml now exists — the CLI checks this before calling save + assert!(tmp.path().join("sync.toml").exists()); +} + +#[test] +fn sync_push_creates_correct_directory_structure() { + let repo_tmp = TempDir::new().unwrap(); + let bare_tmp = TempDir::new().unwrap(); + let bread_cfg_tmp = TempDir::new().unwrap(); + + // Create initial bare remote + let _bare = make_bare_repo(bare_tmp.path()); + + // Create local bread config + fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init\n").unwrap(); + + // Init local sync repo + let repo = SyncRepo::init(repo_tmp.path()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) + .unwrap(); + + // Snapshot bread dir + let bread_dest = repo_tmp.path().join("bread"); + delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap(); + + // Write machine profile + let machines_dir = repo_tmp.path().join("machines"); + let profile = machine::MachineProfile::new("testbox".to_string(), vec![]); + profile.write(&machines_dir).unwrap(); + + // Commit and push + repo.commit("sync: testbox").unwrap(); + repo.push("origin", "main").unwrap(); + + // Verify structure in local repo + assert!(repo_tmp.path().join("bread").exists()); + assert!(repo_tmp.path().join("bread").join("init.lua").exists()); + assert!(repo_tmp + .path() + .join("machines") + .join("testbox.toml") + .exists()); +} + +#[test] +fn sync_push_snapshots_bread_config() { + let repo_tmp = TempDir::new().unwrap(); + let bare_tmp = TempDir::new().unwrap(); + let bread_cfg_tmp = TempDir::new().unwrap(); + + make_bare_repo(bare_tmp.path()); + + // Create a more complex bread config + fs::create_dir_all(bread_cfg_tmp.path().join("modules/mymod")).unwrap(); + fs::write(bread_cfg_tmp.path().join("init.lua"), "-- init").unwrap(); + fs::write( + bread_cfg_tmp.path().join("modules/mymod/init.lua"), + "-- mymod", + ) + .unwrap(); + + let repo = SyncRepo::init(repo_tmp.path()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) + .unwrap(); + + let bread_dest = repo_tmp.path().join("bread"); + delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap(); + + repo.commit("sync: testbox").unwrap(); + repo.push("origin", "main").unwrap(); + + // Verify files were copied + assert!(bread_dest.join("init.lua").exists()); + assert!(bread_dest.join("modules/mymod/init.lua").exists()); + + let content = fs::read_to_string(bread_dest.join("init.lua")).unwrap(); + assert_eq!(content, "-- init"); +} + +#[test] +fn sync_pull_copies_files_from_repo() { + let bare_tmp = TempDir::new().unwrap(); + let local_tmp = TempDir::new().unwrap(); + let apply_tmp = TempDir::new().unwrap(); + + make_bare_repo(bare_tmp.path()); + + // Create a local repo, add some files, push to bare + let repo = SyncRepo::init(local_tmp.path()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) + .unwrap(); + + let bread_dest = local_tmp.path().join("bread"); + fs::create_dir_all(&bread_dest).unwrap(); + fs::write(bread_dest.join("init.lua"), "-- from sync").unwrap(); + + repo.commit("sync: first push").unwrap(); + repo.push("origin", "main").unwrap(); + + // Now clone the bare repo and pull + let clone_tmp = TempDir::new().unwrap(); + let _cloned = + SyncRepo::clone_from(bare_tmp.path().to_str().unwrap(), clone_tmp.path()).unwrap(); + + // Apply bread/ to apply_tmp + let src = clone_tmp.path().join("bread"); + if src.exists() { + delegates::sync_dir(&src, apply_tmp.path(), &[]).unwrap(); + } + + assert!(apply_tmp.path().join("init.lua").exists()); + let content = fs::read_to_string(apply_tmp.path().join("init.lua")).unwrap(); + assert_eq!(content, "-- from sync"); +} + +#[test] +fn package_manifest_pacman_parses_output_correctly() { + let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n"; + let pkgs = packages::parse_pacman(input); + assert_eq!(pkgs, vec!["firefox", "curl", "rustup"]); +} + +#[test] +fn package_manifest_pip_parses_output_correctly() { + let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n"; + let pkgs = packages::parse_pip(input); + assert_eq!(pkgs, vec!["requests", "numpy", "black"]); +} + +#[test] +fn delegates_exclude_globs_filter_correctly() { + let src_tmp = TempDir::new().unwrap(); + let dst_tmp = TempDir::new().unwrap(); + + // Create files that should and shouldn't be copied + fs::create_dir_all(src_tmp.path().join(".git/objects")).unwrap(); + fs::write(src_tmp.path().join(".git/objects/abc"), "").unwrap(); + fs::create_dir_all(src_tmp.path().join("lua")).unwrap(); + fs::write(src_tmp.path().join("lua/init.lua"), "-- ok").unwrap(); + fs::write(src_tmp.path().join("log.cache"), "cached").unwrap(); + + let excludes = vec!["**/.git".to_string(), "**/*.cache".to_string()]; + delegates::sync_dir(src_tmp.path(), dst_tmp.path(), &excludes).unwrap(); + + assert!(dst_tmp.path().join("lua/init.lua").exists()); + assert!(!dst_tmp.path().join(".git").exists()); + assert!(!dst_tmp.path().join("log.cache").exists()); +} + +#[test] +fn machine_profile_written_with_correct_fields() { + let machines_tmp = TempDir::new().unwrap(); + let profile = machine::MachineProfile::new( + "myhost".to_string(), + vec!["mobile".to_string(), "battery".to_string()], + ); + profile.write(machines_tmp.path()).unwrap(); + + let loaded = machine::MachineProfile::read(machines_tmp.path(), "myhost").unwrap(); + assert_eq!(loaded.name, "myhost"); + assert_eq!(loaded.tags, vec!["mobile", "battery"]); + assert!(!loaded.hostname.is_empty()); + // last_sync must be valid RFC 3339 + let parsed = chrono::DateTime::parse_from_rfc3339(&loaded.last_sync); + assert!( + parsed.is_ok(), + "last_sync '{}' is not valid RFC 3339", + loaded.last_sync + ); +} + +#[test] +fn status_shows_no_changes_when_clean() { + let repo_tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(repo_tmp.path()); + let changes = repo.local_changes().unwrap(); + assert!( + changes.is_empty(), + "expected no local changes, got: {:?}", + changes + ); + assert!(repo.is_clean().unwrap()); +} + +#[test] +fn push_with_no_changes_returns_none() { + let repo_tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(repo_tmp.path()); + + // No new changes — commit should return None + let result = repo.commit("second commit").unwrap(); + assert!( + result.is_none(), + "expected None (nothing to commit), got: {:?}", + result + ); +} + +// ─── git.rs additional coverage ──────────────────────────────────────────── + +#[test] +fn init_creates_repo_with_main_branch() { + let tmp = TempDir::new().unwrap(); + let repo = SyncRepo::init(tmp.path()).unwrap(); + fs::write(tmp.path().join("x"), "").unwrap(); + repo.stage_all().unwrap(); + let oid = repo.commit("initial").unwrap(); + assert!(oid.is_some(), "first commit should succeed"); + + // Verify HEAD is on refs/heads/main. + let head_ref = std::process::Command::new("git") + .args(["-C", tmp.path().to_str().unwrap(), "symbolic-ref", "HEAD"]) + .output() + .unwrap(); + let head_name = String::from_utf8_lossy(&head_ref.stdout); + assert!( + head_name.trim() == "refs/heads/main", + "expected refs/heads/main, got {head_name}" + ); +} + +#[test] +fn open_or_clone_opens_existing_repo() { + let tmp = TempDir::new().unwrap(); + SyncRepo::init(tmp.path()).unwrap(); + + // Calling open_or_clone on an existing path must not attempt to clone. + let again = SyncRepo::open_or_clone("/nonexistent-url-that-would-fail", tmp.path()); + assert!(again.is_ok()); +} + +#[test] +fn open_or_clone_clones_into_missing_path() { + let bare = TempDir::new().unwrap(); + let bare_repo = make_bare_repo(bare.path()); + // Seed the bare repo with at least one commit so a clone is meaningful. + let local = TempDir::new().unwrap(); + let repo = SyncRepo::init(local.path()).unwrap(); + fs::write(local.path().join("seed"), "x").unwrap(); + repo.commit("seed").unwrap(); + repo.set_remote("origin", bare.path().to_str().unwrap()) + .unwrap(); + repo.push("origin", "main").unwrap(); + drop(bare_repo); + + let dest_parent = TempDir::new().unwrap(); + let dest = dest_parent.path().join("clone-target"); + let cloned = SyncRepo::open_or_clone(bare.path().to_str().unwrap(), &dest).unwrap(); + assert_eq!(cloned.path, dest); + assert!(dest.join("seed").exists()); +} + +#[test] +fn local_changes_reports_new_modified_and_deleted() { + let tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(tmp.path()); + + fs::write(tmp.path().join("added.txt"), "new").unwrap(); + fs::write(tmp.path().join(".gitkeep"), "modified").unwrap(); + + let changes = repo.local_changes().unwrap(); + assert!(!changes.is_empty()); + let kinds: Vec = changes.iter().map(|(c, _)| *c).collect(); + assert!(kinds.contains(&'A')); + assert!(kinds.contains(&'M')); +} + +#[test] +fn is_clean_after_commit() { + let tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(tmp.path()); + assert!(repo.is_clean().unwrap()); +} + +#[test] +fn working_diff_includes_modified_tracked_content() { + let tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(tmp.path()); + // Modify an already-tracked file so it appears in `git diff HEAD`. + fs::write(tmp.path().join(".gitkeep"), "tracked change\n").unwrap(); + + let diff = repo.working_diff().unwrap(); + assert!( + diff.contains("tracked change"), + "diff did not include tracked change, diff was: {diff:?}" + ); +} + +#[test] +fn working_diff_empty_when_only_untracked_files() { + let tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(tmp.path()); + fs::write(tmp.path().join("new-untracked.txt"), "hi").unwrap(); + + // working_diff uses diff_tree_to_workdir_with_index without INCLUDE_UNTRACKED, + // so untracked files don't appear — local_changes is the right tool for that. + let diff = repo.working_diff().unwrap(); + assert!( + diff.is_empty() || !diff.contains("new-untracked"), + "expected untracked file to be excluded, diff was: {diff:?}" + ); +} + +#[test] +fn set_remote_overwrites_existing_remote() { + let tmp = TempDir::new().unwrap(); + let repo = SyncRepo::init(tmp.path()).unwrap(); + repo.set_remote("origin", "https://example.com/a.git") + .unwrap(); + // A second call must not error out — it should replace the previous URL. + repo.set_remote("origin", "https://example.com/b.git") + .unwrap(); +} + +#[test] +fn last_commit_time_returns_none_for_empty_repo() { + let tmp = TempDir::new().unwrap(); + let repo = SyncRepo::init(tmp.path()).unwrap(); + assert!(repo.last_commit_time().is_none()); +} + +#[test] +fn last_commit_time_present_after_commit() { + let tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(tmp.path()); + assert!(repo.last_commit_time().is_some()); +} + +#[test] +fn push_pull_round_trip_through_bare_remote() { + let bare = TempDir::new().unwrap(); + make_bare_repo(bare.path()); + + // Push from author repo. + let author = TempDir::new().unwrap(); + let r1 = SyncRepo::init(author.path()).unwrap(); + r1.set_remote("origin", bare.path().to_str().unwrap()) + .unwrap(); + fs::write(author.path().join("note.txt"), "v1").unwrap(); + r1.commit("v1").unwrap(); + r1.push("origin", "main").unwrap(); + + // Clone into reader repo and confirm contents. + let reader_tmp = TempDir::new().unwrap(); + let r2 = SyncRepo::clone_from(bare.path().to_str().unwrap(), reader_tmp.path()).unwrap(); + assert_eq!( + fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(), + "v1" + ); + + // Author writes a second version and pushes. + fs::write(author.path().join("note.txt"), "v2").unwrap(); + r1.commit("v2").unwrap(); + r1.push("origin", "main").unwrap(); + + // Reader pulls and sees the new content. + r2.pull("origin", "main").unwrap(); + assert_eq!( + fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(), + "v2" + ); +} + +#[test] +fn pull_with_no_remote_changes_is_noop() { + let bare = TempDir::new().unwrap(); + make_bare_repo(bare.path()); + + let local = TempDir::new().unwrap(); + let repo = SyncRepo::init(local.path()).unwrap(); + repo.set_remote("origin", bare.path().to_str().unwrap()) + .unwrap(); + fs::write(local.path().join("a"), "1").unwrap(); + repo.commit("c1").unwrap(); + repo.push("origin", "main").unwrap(); + + // Calling pull immediately after push must be up-to-date and succeed. + repo.pull("origin", "main").unwrap(); + assert!(repo.is_clean().unwrap()); +} + +#[test] +fn remote_changes_returns_empty_when_remote_unknown() { + let tmp = TempDir::new().unwrap(); + let repo = init_repo_with_commit(tmp.path()); + let changes = repo.remote_changes("origin", "main").unwrap(); + assert!(changes.is_empty()); +} + +// ─── machine list ────────────────────────────────────────────────────────── + +#[test] +fn machine_list_returns_all_profiles_sorted() { + let machines_tmp = TempDir::new().unwrap(); + for name in ["delta", "alpha", "charlie", "bravo"] { + machine::MachineProfile::new(name.to_string(), vec![]) + .write(machines_tmp.path()) + .unwrap(); + } + let list = machine::MachineProfile::list(machines_tmp.path()).unwrap(); + let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "bravo", "charlie", "delta"]); +} + +// ─── packages snapshot ───────────────────────────────────────────────────── + +#[test] +fn snapshot_writes_destination_when_manager_unknown_is_skipped() { + let dest_tmp = TempDir::new().unwrap(); + let dest = dest_tmp.path().join("nested/dir/file.txt"); + let wrote = packages::snapshot("does-not-exist", &dest).unwrap(); + assert!(!wrote); + assert!(!dest.exists()); +} diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index 4b949be..03609ca 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -1,10 +1,11 @@ [package] name = "breadd" -version = "0.1.0" +version = "1.0.0" edition = "2021" [dependencies] bread-shared = { path = "../bread-shared" } +bread-sync = { path = "../bread-sync" } serde.workspace = true serde_json.workspace = true tokio.workspace = true @@ -14,10 +15,9 @@ tracing-subscriber.workspace = true mlua = { version = "0.9", features = ["lua54", "vendored", "async", "serialize"] } async-trait = "0.1" toml = "0.8" -udev = "0.9" +udev = { version = "0.9", features = ["send"] } rtnetlink = "0.9" zbus = { version = "3.13", features = ["tokio"] } -hex = "0.4" futures-util = "0.3" netlink-packet-route = "0.11" netlink-packet-core = "0.4" diff --git a/breadd/src/adapters/bluetooth.rs b/breadd/src/adapters/bluetooth.rs new file mode 100644 index 0000000..128b7cf --- /dev/null +++ b/breadd/src/adapters/bluetooth.rs @@ -0,0 +1,255 @@ +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use bread_shared::{now_unix_ms, AdapterSource, RawEvent}; +use futures_util::StreamExt; +use serde_json::json; +use std::collections::HashMap; +use tokio::sync::mpsc; +use tracing::{debug, info}; +use zbus::zvariant::{OwnedObjectPath, OwnedValue}; +use zbus::{Message, MessageStream}; + +use super::Adapter; + +#[derive(Clone, Debug)] +pub struct BluetoothAdapter; + +impl BluetoothAdapter { + pub fn new() -> Self { + Self + } + + /// Emit `bluetooth.enumerate` events for every device that is currently connected. + /// Errors are swallowed — Bluetooth hardware being absent is not a daemon startup failure. + pub async fn enumerate_existing(&self, tx: &mpsc::Sender) { + match try_enumerate(tx).await { + Ok(n) => debug!("bluetooth enumerated {n} connected device(s)"), + Err(e) => debug!("bluetooth enumeration skipped: {e}"), + } + } +} + +#[async_trait] +impl Adapter for BluetoothAdapter { + fn name(&self) -> &'static str { + "bluetooth" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + info!("bluetooth adapter starting"); + + let conn = zbus::Connection::system() + .await + .map_err(|e| anyhow!("bluetooth D-Bus unavailable: {e}"))?; + + let mut stream = MessageStream::from(&conn); + while let Some(result) = stream.next().await { + match result { + Ok(message) => { + if let Some(event) = parse_bluetooth_message(&message) { + if tx.send(event).await.is_err() { + return Ok(()); + } + } + } + Err(e) => debug!("bluetooth stream error: {e}"), + } + } + + Ok(()) + } +} + +async fn try_enumerate(tx: &mpsc::Sender) -> Result { + let conn = zbus::Connection::system().await?; + let msg = conn + .call_method( + Some("org.bluez"), + "/", + Some("org.freedesktop.DBus.ObjectManager"), + "GetManagedObjects", + &(), + ) + .await?; + + let objects: HashMap>> = + msg.body()?; + + let mut count = 0; + for (path, interfaces) in objects { + let Some(props) = interfaces.get("org.bluez.Device1") else { + continue; + }; + let props_json = serde_json::to_value(props).unwrap_or_else(|_| json!({})); + if !props_json + .get("Connected") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + continue; + } + + let name = props_json + .get("Name") + .or_else(|| props_json.get("Alias")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let address = props_json + .get("Address") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let _ = tx + .send(RawEvent { + source: AdapterSource::Bluetooth, + kind: "bluetooth.enumerate".to_string(), + payload: json!({ + "path": path.as_str(), + "address": address, + "name": name, + "properties": props_json, + }), + timestamp: now_unix_ms(), + }) + .await; + count += 1; + } + + Ok(count) +} + +fn parse_bluetooth_message(message: &Message) -> Option { + let header = message.header().ok()?; + let interface = header.interface().ok()??.as_str().to_string(); + let member = header.member().ok()??.as_str().to_string(); + let path = header + .path() + .ok() + .flatten() + .map(|p| p.as_str().to_string()) + .unwrap_or_default(); + + // Connected / disconnected — PropertiesChanged on a BlueZ device object + if interface == "org.freedesktop.DBus.Properties" && member == "PropertiesChanged" { + if !path.starts_with("/org/bluez/") { + return None; + } + let (iface, changed, _): (String, HashMap, Vec) = + message.body().ok()?; + if iface != "org.bluez.Device1" { + return None; + } + let changed_json = serde_json::to_value(&changed).ok()?; + let connected = changed_json.get("Connected").and_then(|v| v.as_bool())?; + let address = address_from_path(&path); + let kind = if connected { + "bluetooth.device.connected" + } else { + "bluetooth.device.disconnected" + }; + return Some(RawEvent { + source: AdapterSource::Bluetooth, + kind: kind.to_string(), + payload: json!({ + "path": path, + "address": address, + "properties": changed_json, + }), + timestamp: now_unix_ms(), + }); + } + + // Device paired / discovered — InterfacesAdded from BlueZ ObjectManager + if interface == "org.freedesktop.DBus.ObjectManager" && member == "InterfacesAdded" { + let (obj_path, interfaces): ( + OwnedObjectPath, + HashMap>, + ) = message.body().ok()?; + let obj_str = obj_path.as_str(); + if !obj_str.starts_with("/org/bluez/") { + return None; + } + let props = interfaces.get("org.bluez.Device1")?; + let props_json = serde_json::to_value(props).ok()?; + let name = props_json + .get("Name") + .or_else(|| props_json.get("Alias")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let address = props_json + .get("Address") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| address_from_path(obj_str)); + return Some(RawEvent { + source: AdapterSource::Bluetooth, + kind: "bluetooth.device.added".to_string(), + payload: json!({ + "path": obj_str, + "address": address, + "name": name, + "properties": props_json, + }), + timestamp: now_unix_ms(), + }); + } + + // Device unpaired — InterfacesRemoved from BlueZ ObjectManager + if interface == "org.freedesktop.DBus.ObjectManager" && member == "InterfacesRemoved" { + let (obj_path, interfaces): (OwnedObjectPath, Vec) = message.body().ok()?; + let obj_str = obj_path.as_str(); + if !obj_str.starts_with("/org/bluez/") { + return None; + } + if !interfaces.iter().any(|i| i == "org.bluez.Device1") { + return None; + } + let address = address_from_path(obj_str); + return Some(RawEvent { + source: AdapterSource::Bluetooth, + kind: "bluetooth.device.removed".to_string(), + payload: json!({ + "path": obj_str, + "address": address, + }), + timestamp: now_unix_ms(), + }); + } + + None +} + +/// `/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF` → `"AA:BB:CC:DD:EE:FF"` +fn address_from_path(path: &str) -> String { + path.rsplit('/') + .next() + .and_then(|s| s.strip_prefix("dev_")) + .map(|s| s.replace('_', ":")) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn address_from_path_parses_standard_bluez_path() { + assert_eq!( + address_from_path("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF"), + "AA:BB:CC:DD:EE:FF" + ); + } + + #[test] + fn address_from_path_returns_empty_for_adapter_path() { + assert_eq!(address_from_path("/org/bluez/hci0"), ""); + } + + #[test] + fn address_from_path_returns_empty_for_root() { + assert_eq!(address_from_path("/"), ""); + } +} diff --git a/breadd/src/adapters/hyprland.rs b/breadd/src/adapters/hyprland.rs index 2ef3731..2c4a47b 100644 --- a/breadd/src/adapters/hyprland.rs +++ b/breadd/src/adapters/hyprland.rs @@ -48,13 +48,39 @@ impl Adapter for HyprlandAdapter { } fn hyprland_event_socket() -> Result { - let instance = env::var("HYPRLAND_INSTANCE_SIGNATURE") - .map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?; let runtime = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); - Ok(PathBuf::from(runtime) - .join("hypr") - .join(instance) - .join(".socket2.sock")) + + // If the env var is set, use it directly. + if let Ok(instance) = env::var("HYPRLAND_INSTANCE_SIGNATURE") { + return Ok(PathBuf::from(runtime) + .join("hypr") + .join(instance) + .join(".socket2.sock")); + } + + // Otherwise scan $XDG_RUNTIME_DIR/hypr/ for a running instance. + // Hyprland creates a per-instance directory there containing .socket2.sock. + // This handles the case where breadd starts as a systemd user service before + // Hyprland has exported HYPRLAND_INSTANCE_SIGNATURE into the environment. + let hypr_dir = PathBuf::from(&runtime).join("hypr"); + let mut sockets: Vec = std::fs::read_dir(&hypr_dir) + .map_err(|_| anyhow!("no Hyprland instance found ({})", hypr_dir.display()))? + .flatten() + .map(|e| e.path().join(".socket2.sock")) + .filter(|p| p.exists()) + .collect(); + + match sockets.len() { + 0 => Err(anyhow!( + "no Hyprland instance found in {}", + hypr_dir.display() + )), + 1 => Ok(sockets.remove(0)), + n => { + warn!("found {n} Hyprland instances, using first"); + Ok(sockets.remove(0)) + } + } } fn parse_hyprland_line(line: &str) -> (String, String) { diff --git a/breadd/src/adapters/mod.rs b/breadd/src/adapters/mod.rs index d3a8a3f..dcd7870 100644 --- a/breadd/src/adapters/mod.rs +++ b/breadd/src/adapters/mod.rs @@ -1,18 +1,29 @@ use anyhow::Result; use async_trait::async_trait; use bread_shared::RawEvent; -use tokio::sync::{mpsc, watch}; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{mpsc, watch, RwLock}; use tracing::info; use crate::core::config::Config; use crate::core::supervisor::spawn_supervised; +pub mod bluetooth; pub mod hyprland; pub mod network; -pub mod power; -pub mod udev; pub mod network_rtnetlink; +pub mod power; pub mod power_upower; +pub mod udev; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AdapterStatus { + Connected, + Disconnected, +} #[async_trait] pub trait Adapter: Send + Sync { @@ -30,6 +41,7 @@ pub struct Manager { raw_tx: mpsc::Sender, config: Config, shutdown_rx: watch::Receiver, + status: Arc>>, } impl Manager { @@ -42,9 +54,14 @@ impl Manager { raw_tx, config, shutdown_rx, + status: Arc::new(RwLock::new(HashMap::new())), } } + pub fn status_handle(&self) -> Arc>> { + self.status.clone() + } + pub async fn start_all(&self) -> Result<()> { info!("starting adapters"); @@ -55,7 +72,7 @@ impl Manager { } if self.config.adapters.hyprland.enabled { - self.spawn_adapter(hyprland::HyprlandAdapter::default()); + self.spawn_adapter(hyprland::HyprlandAdapter); } if self.config.adapters.power.enabled { @@ -70,13 +87,19 @@ impl Manager { } } + if self.config.adapters.bluetooth.enabled { + let adapter = bluetooth::BluetoothAdapter::new(); + adapter.enumerate_existing(&self.raw_tx).await; + self.spawn_adapter(adapter); + } + if self.config.adapters.network.enabled { // Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter let rt = network_rtnetlink::RtnetlinkAdapter::new(); if let Ok(adapter) = rt { self.spawn_adapter(adapter); } else { - self.spawn_adapter(network::NetworkAdapter::default()); + self.spawn_adapter(network::NetworkAdapter); } } @@ -91,17 +114,27 @@ impl Manager { let tx = self.raw_tx.clone(); let shutdown_rx = self.shutdown_rx.clone(); let shutdown_for_task = shutdown_rx.clone(); + let status = self.status.clone(); spawn_supervised(name, shutdown_rx, move || { let adapter = adapter.clone(); let tx = tx.clone(); let mut shutdown_rx = shutdown_for_task.clone(); + let status = status.clone(); async move { adapter.on_connect().await?; + { + let mut guard = status.write().await; + guard.insert(adapter.name().to_string(), AdapterStatus::Connected); + } let result = tokio::select! { result = adapter.run(tx) => result, _ = shutdown_rx.changed() => Ok(()), }; adapter.on_disconnect().await?; + { + let mut guard = status.write().await; + guard.insert(adapter.name().to_string(), AdapterStatus::Disconnected); + } result } }); diff --git a/breadd/src/adapters/network_rtnetlink.rs b/breadd/src/adapters/network_rtnetlink.rs index aaa9f46..9e7d07e 100644 --- a/breadd/src/adapters/network_rtnetlink.rs +++ b/breadd/src/adapters/network_rtnetlink.rs @@ -70,7 +70,14 @@ impl Adapter for RtnetlinkAdapter { "netns_id": netns_id, "netns_fd": netns_fd }); - let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: kind.to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await; + let _ = tx + .send(RawEvent { + source: AdapterSource::Network, + kind: kind.to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }) + .await; } } netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewRoute(route)) => { @@ -86,17 +93,32 @@ impl Adapter for RtnetlinkAdapter { "gateway": gateway_ip, "table": route.header.table }); - let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "route.default.changed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await; + let _ = tx + .send(RawEvent { + source: AdapterSource::Network, + kind: "route.default.changed".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }) + .await; } } - netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewAddress(addr)) => { + netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewAddress( + addr, + )) => { let address = addr.nlas.iter().find_map(|nla| match nla { - netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()), - netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()), + netlink_packet_route::address::nlas::Nla::Address(bytes) => { + Some(bytes.clone()) + } + netlink_packet_route::address::nlas::Nla::Local(bytes) => { + Some(bytes.clone()) + } _ => None, }); let label = addr.nlas.iter().find_map(|nla| match nla { - netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()), + netlink_packet_route::address::nlas::Nla::Label(label) => { + Some(label.clone()) + } _ => None, }); let ip = address.as_deref().and_then(ip_from_bytes); @@ -107,16 +129,31 @@ impl Adapter for RtnetlinkAdapter { "address": ip, "label": label }); - let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.added".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await; + let _ = tx + .send(RawEvent { + source: AdapterSource::Network, + kind: "address.added".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }) + .await; } - netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::DelAddress(addr)) => { + netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::DelAddress( + addr, + )) => { let address = addr.nlas.iter().find_map(|nla| match nla { - netlink_packet_route::address::nlas::Nla::Address(bytes) => Some(bytes.clone()), - netlink_packet_route::address::nlas::Nla::Local(bytes) => Some(bytes.clone()), + netlink_packet_route::address::nlas::Nla::Address(bytes) => { + Some(bytes.clone()) + } + netlink_packet_route::address::nlas::Nla::Local(bytes) => { + Some(bytes.clone()) + } _ => None, }); let label = addr.nlas.iter().find_map(|nla| match nla { - netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()), + netlink_packet_route::address::nlas::Nla::Label(label) => { + Some(label.clone()) + } _ => None, }); let ip = address.as_deref().and_then(ip_from_bytes); @@ -127,7 +164,14 @@ impl Adapter for RtnetlinkAdapter { "address": ip, "label": label }); - let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.removed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await; + let _ = tx + .send(RawEvent { + source: AdapterSource::Network, + kind: "address.removed".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }) + .await; } _ => { debug!("unhandled netlink message"); diff --git a/breadd/src/adapters/power_upower.rs b/breadd/src/adapters/power_upower.rs index 26bcacc..a810179 100644 --- a/breadd/src/adapters/power_upower.rs +++ b/breadd/src/adapters/power_upower.rs @@ -6,8 +6,8 @@ use serde_json::json; use std::collections::HashMap; use tokio::sync::mpsc; use tracing::{debug, info}; -use zbus::{Message, MessageStream}; use zbus::zvariant::{OwnedObjectPath, OwnedValue}; +use zbus::{Message, MessageStream}; use super::Adapter; diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index ffe4d15..8142980 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -1,12 +1,9 @@ -use std::collections::HashMap; -use std::fs; -use std::path::Path; +use std::os::unix::io::AsRawFd; use anyhow::Result; use bread_shared::{now_unix_ms, AdapterSource, RawEvent}; use serde_json::json; use tokio::sync::mpsc; -use tokio::time::{sleep, Duration}; use tracing::debug; use crate::adapters::Adapter; @@ -22,10 +19,7 @@ impl UdevAdapter { } pub async fn enumerate_existing(&self, tx: &mpsc::Sender) -> Result<()> { - let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| { - scan_devices(&self.subsystems).unwrap_or_default() - }); - + let devices = enumerate_with_udev(&self.subsystems)?; for device in devices { tx.send(RawEvent { source: AdapterSource::Udev, @@ -52,100 +46,106 @@ impl Adapter for UdevAdapter { async fn run(&self, tx: mpsc::Sender) -> Result<()> { debug!("udev adapter started"); - if let Ok(()) = run_udev_monitor(self.subsystems.clone(), tx.clone()).await { - return Ok(()); - } - - // Fallback for environments where monitor sockets are unavailable. - let mut known: HashMap = scan_devices(&self.subsystems)? - .into_iter() - .map(|d| (d.id.clone(), d)) - .collect(); - - loop { - let current = scan_devices(&self.subsystems)?; - let current_map: HashMap = current - .into_iter() - .map(|d| (d.id.clone(), d)) - .collect(); - - for (id, dev) in ¤t_map { - if !known.contains_key(id) { - tx.send(raw_change_event("add", dev)).await?; - } - } - - for (id, dev) in &known { - if !current_map.contains_key(id) { - tx.send(raw_change_event("remove", dev)).await?; - } - } - - known = current_map; - sleep(Duration::from_secs(2)).await; - } + run_udev_monitor(self.subsystems.clone(), tx).await } } -#[derive(Clone, Debug)] struct ScannedDevice { id: String, name: String, subsystem: String, } +// udev::MonitorSocket uses a non-blocking socket; calling iter().next() without +// first polling the fd returns None immediately and exits the loop — which is +// why the old code silently fell back to sysfs on every start. We use poll(2) +// inside spawn_blocking so the thread truly blocks until events are available. async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) -> Result<()> { tokio::task::spawn_blocking(move || -> Result<()> { let mut builder = udev::MonitorBuilder::new()?; for subsystem in &subsystems { builder = builder.match_subsystem(subsystem)?; } - let monitor = builder.listen()?; + let socket = builder.listen()?; + let fd = socket.as_raw_fd(); - for event in monitor.iter() { - let action = event - .action() - .map(|a| a.to_string_lossy().to_string()) - .unwrap_or_else(|| "change".to_string()); - let subsystem = event - .subsystem() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - let name = event - .property_value("ID_MODEL") - .or_else(|| event.property_value("NAME")) - .map(|v| v.to_string_lossy().to_string()) - .or_else(|| event.devnode().map(|n| n.display().to_string())) - .unwrap_or_else(|| "unknown".to_string()); - let id = event - .syspath() - .to_string_lossy() - .to_string(); - - let msg = RawEvent { - source: AdapterSource::Udev, - kind: "udev.change".to_string(), - payload: json!({ - "action": action, - "id": id, - "name": name, - "subsystem": subsystem, - }), - timestamp: now_unix_ms(), + loop { + let mut pfd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, }; - if tx.blocking_send(msg).is_err() { - break; + let ret = unsafe { libc::poll(&mut pfd, 1, 1000) }; + if ret < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(err.into()); + } + if ret == 0 { + // Timeout: bail if the downstream channel has been dropped. + if tx.is_closed() { + return Ok(()); + } + continue; + } + if pfd.revents & libc::POLLIN != 0 { + while let Some(event) = socket.iter().next() { + if tx.blocking_send(build_event(&event)).is_err() { + return Ok(()); + } + } } } - - Ok(()) }) .await??; Ok(()) } +fn build_event(event: &udev::Event) -> RawEvent { + let action = event + .action() + .map(|a| a.to_string_lossy().to_string()) + .unwrap_or_else(|| "change".to_string()); + let subsystem = event + .subsystem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let name = event + .property_value("ID_MODEL") + .or_else(|| event.property_value("NAME")) + .map(|v| v.to_string_lossy().to_string()) + .or_else(|| event.devnode().map(|n| n.display().to_string())) + .unwrap_or_else(|| "unknown".to_string()); + let id = event.syspath().to_string_lossy().to_string(); + + RawEvent { + source: AdapterSource::Udev, + kind: "udev.change".to_string(), + payload: json!({ + "action": action, + "id": id, + "name": name, + "subsystem": subsystem, + "id_input_keyboard": prop_bool(event, "ID_INPUT_KEYBOARD"), + "id_input_mouse": prop_bool(event, "ID_INPUT_MOUSE"), + "id_input_joystick": prop_bool(event, "ID_INPUT_JOYSTICK"), + "id_input_touchpad": prop_bool(event, "ID_INPUT_TOUCHPAD"), + "id_input_tablet": prop_bool(event, "ID_INPUT_TABLET"), + "id_usb_class": prop_str(event, "ID_USB_CLASS"), + "id_usb_interfaces": prop_str(event, "ID_USB_INTERFACES"), + "id_vendor": prop_str(event, "ID_VENDOR"), + "id_model": prop_str(event, "ID_MODEL"), + "vendor_id": prop_str(event, "ID_VENDOR_ID"), + "product_id": prop_str(event, "ID_MODEL_ID"), + }), + timestamp: now_unix_ms(), + } +} + fn enumerate_with_udev(subsystems: &[String]) -> Result> { let mut enumerator = udev::Enumerator::new()?; for subsystem in subsystems { @@ -165,7 +165,6 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result> { .or_else(|| dev.sysname().to_str().map(ToString::to_string)) .unwrap_or_else(|| "unknown".to_string()); let id = dev.syspath().to_string_lossy().to_string(); - out.push(ScannedDevice { id, name, @@ -176,90 +175,16 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result> { Ok(out) } -fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent { - RawEvent { - source: AdapterSource::Udev, - kind: "udev.change".to_string(), - payload: json!({ - "action": action, - "id": dev.id, - "name": dev.name, - "subsystem": dev.subsystem, - }), - timestamp: now_unix_ms(), - } +fn prop_bool(event: &udev::Event, key: &str) -> bool { + event + .property_value(key) + .and_then(|v| v.to_str()) + .map(|v| v == "1") + .unwrap_or(false) } -fn scan_devices(subsystems: &[String]) -> Result> { - let mut out = Vec::new(); - - if subsystems.iter().any(|s| s == "drm") { - let drm_dir = Path::new("/sys/class/drm"); - if drm_dir.exists() { - for entry in fs::read_dir(drm_dir)? { - let entry = entry?; - let name = entry.file_name().to_string_lossy().to_string(); - if !name.contains('-') { - continue; - } - let status = fs::read_to_string(entry.path().join("status")).unwrap_or_default(); - if status.trim() == "connected" { - out.push(ScannedDevice { - id: format!("drm:{name}"), - name, - subsystem: "drm".to_string(), - }); - } - } - } - } - - if subsystems.iter().any(|s| s == "input") { - let input_dir = Path::new("/dev/input/by-id"); - if input_dir.exists() { - for entry in fs::read_dir(input_dir)? { - let entry = entry?; - let name = entry.file_name().to_string_lossy().to_string(); - out.push(ScannedDevice { - id: format!("input:{name}"), - name, - subsystem: "input".to_string(), - }); - } - } - } - - if subsystems.iter().any(|s| s == "power_supply") { - let pwr_dir = Path::new("/sys/class/power_supply"); - if pwr_dir.exists() { - for entry in fs::read_dir(pwr_dir)? { - let entry = entry?; - let name = entry.file_name().to_string_lossy().to_string(); - out.push(ScannedDevice { - id: format!("power_supply:{name}"), - name, - subsystem: "power_supply".to_string(), - }); - } - } - } - - if subsystems.iter().any(|s| s == "usb") { - let usb_dir = Path::new("/sys/bus/usb/devices"); - if usb_dir.exists() { - for entry in fs::read_dir(usb_dir)? { - let entry = entry?; - let name = entry.file_name().to_string_lossy().to_string(); - if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) { - out.push(ScannedDevice { - id: format!("usb:{name}"), - name, - subsystem: "usb".to_string(), - }); - } - } - } - } - - Ok(out) +fn prop_str(event: &udev::Event, key: &str) -> Option { + event + .property_value(key) + .map(|v| v.to_string_lossy().to_string()) } diff --git a/breadd/src/core/config.rs b/breadd/src/core/config.rs index dedd0d4..b1be12c 100644 --- a/breadd/src/core/config.rs +++ b/breadd/src/core/config.rs @@ -5,15 +5,19 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use serde::Deserialize; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] pub struct Config { #[serde(default)] pub daemon: DaemonConfig, #[serde(default)] pub lua: LuaConfig, #[serde(default)] + pub modules: ModulesConfig, + #[serde(default)] pub adapters: AdaptersConfig, #[serde(default)] + pub notifications: NotificationsConfig, + #[serde(default)] pub events: EventsConfig, } @@ -34,6 +38,14 @@ pub struct LuaConfig { } #[derive(Debug, Clone, Deserialize)] +pub struct ModulesConfig { + #[serde(default = "default_true")] + pub builtin: bool, + #[serde(default)] + pub disable: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize)] pub struct AdaptersConfig { #[serde(default)] pub hyprland: AdapterToggle, @@ -43,6 +55,8 @@ pub struct AdaptersConfig { pub power: PowerConfig, #[serde(default)] pub network: AdapterToggle, + #[serde(default)] + pub bluetooth: AdapterToggle, } #[derive(Debug, Clone, Deserialize)] @@ -73,15 +87,14 @@ pub struct EventsConfig { pub dedup_window_ms: u64, } -impl Default for Config { - fn default() -> Self { - Self { - daemon: DaemonConfig::default(), - lua: LuaConfig::default(), - adapters: AdaptersConfig::default(), - events: EventsConfig::default(), - } - } +#[derive(Debug, Clone, Deserialize)] +pub struct NotificationsConfig { + #[serde(default = "default_notify_timeout")] + pub default_timeout_ms: i64, + #[serde(default = "default_notify_urgency")] + pub default_urgency: String, + #[serde(default = "default_notify_path")] + pub notify_send_path: String, } impl Default for DaemonConfig { @@ -102,13 +115,11 @@ impl Default for LuaConfig { } } -impl Default for AdaptersConfig { +impl Default for ModulesConfig { fn default() -> Self { Self { - hyprland: AdapterToggle::default(), - udev: UdevConfig::default(), - power: PowerConfig::default(), - network: AdapterToggle::default(), + builtin: default_true(), + disable: Vec::new(), } } } @@ -147,6 +158,16 @@ impl Default for EventsConfig { } } +impl Default for NotificationsConfig { + fn default() -> Self { + Self { + default_timeout_ms: default_notify_timeout(), + default_urgency: default_notify_urgency(), + notify_send_path: default_notify_path(), + } + } +} + impl Config { pub fn load() -> Result { let path = config_path(); @@ -218,6 +239,18 @@ fn default_dedup_window() -> u64 { 100 } +fn default_notify_timeout() -> i64 { + 3000 +} + +fn default_notify_urgency() -> String { + "normal".to_string() +} + +fn default_notify_path() -> String { + "notify-send".to_string() +} + fn default_udev_subsystems() -> Vec { vec![ "usb".to_string(), @@ -226,3 +259,246 @@ fn default_udev_subsystems() -> Vec { "power_supply".to_string(), ] } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Tests that mutate process env vars must serialize against each other + // — cargo runs tests in parallel by default and HOME/XDG_RUNTIME_DIR are + // process-global. Tests that don't touch env are free to run unguarded. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + saved: Vec<(&'static str, Option)>, + _guard: std::sync::MutexGuard<'static, ()>, + } + + impl EnvGuard { + fn new(vars: &[&'static str]) -> Self { + let guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let saved = vars.iter().map(|k| (*k, std::env::var(k).ok())).collect(); + Self { + saved, + _guard: guard, + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, value) in &self.saved { + match value { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + } + } + } + + #[test] + fn default_config_uses_documented_defaults() { + let cfg = Config::default(); + assert_eq!(cfg.daemon.log_level, "info"); + assert!(cfg.daemon.socket_path.is_empty()); + assert_eq!(cfg.lua.entry_point, "~/.config/bread/init.lua"); + assert_eq!(cfg.lua.module_path, "~/.config/bread/modules"); + assert!(cfg.adapters.hyprland.enabled); + assert!(cfg.adapters.udev.enabled); + assert!(cfg.adapters.power.enabled); + assert!(cfg.adapters.network.enabled); + assert!(cfg.adapters.bluetooth.enabled); + assert_eq!(cfg.adapters.power.poll_interval_secs, 30); + assert_eq!(cfg.events.dedup_window_ms, 100); + assert_eq!(cfg.notifications.default_timeout_ms, 3000); + assert_eq!(cfg.notifications.default_urgency, "normal"); + assert_eq!(cfg.notifications.notify_send_path, "notify-send"); + assert!(cfg.modules.builtin); + assert!(cfg.modules.disable.is_empty()); + } + + #[test] + fn default_udev_subsystems_match_documented_list() { + assert_eq!( + default_udev_subsystems(), + vec!["usb", "input", "drm", "power_supply"] + ); + } + + #[test] + fn parse_empty_toml_yields_defaults() { + let cfg: Config = toml::from_str("").unwrap(); + assert_eq!(cfg.daemon.log_level, "info"); + assert!(cfg.adapters.hyprland.enabled); + } + + #[test] + fn parse_full_toml_overrides_all_values() { + let raw = r#" +[daemon] +log_level = "debug" +socket_path = "/tmp/custom.sock" + +[lua] +entry_point = "/abs/init.lua" +module_path = "/abs/mods" + +[modules] +builtin = false +disable = ["foo", "bar"] + +[adapters.hyprland] +enabled = false + +[adapters.udev] +enabled = true +subsystems = ["usb"] + +[adapters.power] +enabled = false +poll_interval_secs = 5 + +[adapters.network] +enabled = false + +[adapters.bluetooth] +enabled = false + +[events] +dedup_window_ms = 250 + +[notifications] +default_timeout_ms = 1000 +default_urgency = "critical" +notify_send_path = "/usr/local/bin/notify-send" +"#; + let cfg: Config = toml::from_str(raw).unwrap(); + assert_eq!(cfg.daemon.log_level, "debug"); + assert_eq!(cfg.daemon.socket_path, "/tmp/custom.sock"); + assert_eq!(cfg.lua.entry_point, "/abs/init.lua"); + assert_eq!(cfg.lua.module_path, "/abs/mods"); + assert!(!cfg.modules.builtin); + assert_eq!(cfg.modules.disable, vec!["foo", "bar"]); + assert!(!cfg.adapters.hyprland.enabled); + assert!(cfg.adapters.udev.enabled); + assert_eq!(cfg.adapters.udev.subsystems, vec!["usb"]); + assert!(!cfg.adapters.power.enabled); + assert_eq!(cfg.adapters.power.poll_interval_secs, 5); + assert!(!cfg.adapters.network.enabled); + assert!(!cfg.adapters.bluetooth.enabled); + assert_eq!(cfg.events.dedup_window_ms, 250); + assert_eq!(cfg.notifications.default_timeout_ms, 1000); + assert_eq!(cfg.notifications.default_urgency, "critical"); + } + + #[test] + fn parse_partial_toml_fills_missing_with_defaults() { + let raw = r#" +[daemon] +log_level = "trace" +"#; + let cfg: Config = toml::from_str(raw).unwrap(); + assert_eq!(cfg.daemon.log_level, "trace"); + // Untouched sections still get their defaults. + assert!(cfg.adapters.hyprland.enabled); + assert_eq!(cfg.events.dedup_window_ms, 100); + } + + #[test] + fn invalid_toml_returns_error() { + let result: Result = toml::from_str("[daemon\nbroken"); + assert!(result.is_err()); + } + + #[test] + fn socket_path_uses_explicit_path_verbatim() { + let mut cfg = Config::default(); + cfg.daemon.socket_path = "/run/bread.sock".to_string(); + assert_eq!(cfg.socket_path(), PathBuf::from("/run/bread.sock")); + } + + #[test] + fn socket_path_expands_tilde_when_explicit() { + let _g = EnvGuard::new(&["HOME"]); + std::env::set_var("HOME", "/synthetic/home"); + let mut cfg = Config::default(); + cfg.daemon.socket_path = "~/sockets/bread.sock".to_string(); + assert_eq!( + cfg.socket_path(), + PathBuf::from("/synthetic/home/sockets/bread.sock") + ); + } + + #[test] + fn socket_path_falls_back_to_xdg_runtime_dir() { + let _g = EnvGuard::new(&["XDG_RUNTIME_DIR"]); + std::env::set_var("XDG_RUNTIME_DIR", "/tmp/xdg"); + let cfg = Config::default(); + assert_eq!( + cfg.socket_path(), + PathBuf::from("/tmp/xdg/bread/breadd.sock") + ); + } + + #[test] + fn socket_path_uses_tmp_when_no_xdg_runtime_dir() { + let _g = EnvGuard::new(&["XDG_RUNTIME_DIR"]); + std::env::remove_var("XDG_RUNTIME_DIR"); + let cfg = Config::default(); + assert_eq!(cfg.socket_path(), PathBuf::from("/tmp/bread/breadd.sock")); + } + + #[test] + fn lua_entry_point_and_module_path_expand_tilde() { + let _g = EnvGuard::new(&["HOME"]); + std::env::set_var("HOME", "/synthetic/home"); + let cfg = Config::default(); + assert_eq!( + cfg.lua_entry_point(), + PathBuf::from("/synthetic/home/.config/bread/init.lua") + ); + assert_eq!( + cfg.lua_module_path(), + PathBuf::from("/synthetic/home/.config/bread/modules") + ); + } + + #[test] + fn lua_entry_point_returns_absolute_path_unchanged() { + let mut cfg = Config::default(); + cfg.lua.entry_point = "/etc/bread/init.lua".to_string(); + assert_eq!(cfg.lua_entry_point(), PathBuf::from("/etc/bread/init.lua")); + } + + #[test] + fn expand_home_handles_missing_home_env() { + let _g = EnvGuard::new(&["HOME"]); + std::env::remove_var("HOME"); + // Without HOME, ~/-prefixed paths fall back to the literal string. + assert_eq!(expand_home("~/foo"), PathBuf::from("~/foo")); + // Non-tilde paths are unchanged regardless. + assert_eq!(expand_home("/abs/path"), PathBuf::from("/abs/path")); + } + + #[test] + fn config_path_respects_xdg_config_home() { + let _g = EnvGuard::new(&["XDG_CONFIG_HOME", "HOME"]); + std::env::set_var("XDG_CONFIG_HOME", "/synthetic/xdg-config"); + assert_eq!( + config_path(), + PathBuf::from("/synthetic/xdg-config/bread/breadd.toml") + ); + } + + #[test] + fn config_path_falls_back_to_home_when_no_xdg() { + let _g = EnvGuard::new(&["XDG_CONFIG_HOME", "HOME"]); + std::env::remove_var("XDG_CONFIG_HOME"); + std::env::set_var("HOME", "/synthetic/home"); + assert_eq!( + config_path(), + PathBuf::from("/synthetic/home/.config/bread/breadd.toml") + ); + } +} diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index d0f7b42..963838d 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -4,14 +4,16 @@ use std::sync::RwLock; use bread_shared::{AdapterSource, BreadEvent, RawEvent}; use serde_json::{json, Value}; -use crate::core::types::DeviceClass; - /// How many multiples of `dedup_window_ms` an entry must be idle before eviction. const EVICT_MULTIPLIER: u64 = 60; pub struct EventNormalizer { dedup_window_ms: u64, recent: RwLock>, + /// Tracks the first time a physical device (keyed by verb+vendor_id+product_id) + /// fired within the current window, so subsequent child-node events from the + /// same plug-in are suppressed at the normalizer level. + seen_devices: RwLock>, } impl EventNormalizer { @@ -19,6 +21,7 @@ impl EventNormalizer { Self { dedup_window_ms, recent: RwLock::new(HashMap::new()), + seen_devices: RwLock::new(HashMap::new()), } } @@ -28,6 +31,7 @@ impl EventNormalizer { AdapterSource::Hyprland => self.normalize_hyprland(raw), AdapterSource::Power => self.normalize_power(raw), AdapterSource::Network => self.normalize_network(raw), + AdapterSource::Bluetooth => self.normalize_bluetooth(raw), AdapterSource::System => vec![BreadEvent { event: raw.kind.clone(), timestamp: raw.timestamp, @@ -41,61 +45,212 @@ impl EventNormalizer { } fn normalize_udev(&self, raw: &RawEvent) -> Vec { - let action = raw.payload.get("action").and_then(Value::as_str).unwrap_or("change"); - let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown"); - let class = classify_device(&raw.payload); - let class_str = serde_json::to_string(&class) - .unwrap_or_else(|_| "\"unknown\"".to_string()) - .replace('"', ""); + let action = raw + .payload + .get("action") + .and_then(Value::as_str) + .unwrap_or("change"); + // "bind" is the kernel attaching a driver to an interface — not a meaningful + // device state change for automation purposes. + if action == "bind" { + return vec![]; + } + + let name = raw + .payload + .get("name") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let vendor = raw + .payload + .get("id_vendor") + .and_then(Value::as_str) + .unwrap_or_default(); + let vendor_id = raw + .payload + .get("vendor_id") + .and_then(Value::as_str) + .unwrap_or_default(); + let product_id = raw + .payload + .get("product_id") + .and_then(Value::as_str) + .unwrap_or_default(); + let subsystem = raw + .payload + .get("subsystem") + .and_then(Value::as_str) + .unwrap_or_default(); + + // Drop anonymous child USB interfaces (e.g. 3-5:1.0, 3-5:1.1) that carry + // no identity information — they are USB protocol artefacts, not devices. + if name == "unknown" && vendor.is_empty() && vendor_id.is_empty() { + return vec![]; + } + + // For connected/disconnected, suppress duplicate events from child nodes of + // the same physical device (e.g. input66, mouse0, event17 all from one plug-in). + // Key by verb+vendor_id+product_id so a second distinct device of the same + // model plugged in after the window still fires correctly. let verb = match action { "add" => "connected", "remove" => "disconnected", _ => "changed", }; - let mut events = vec![BreadEvent { + if (verb == "connected" || verb == "disconnected") + && !vendor_id.is_empty() + && !product_id.is_empty() + { + let device_key = format!("{}:{}:{}", verb, vendor_id, product_id); + let now = raw.timestamp; + let already_seen = { + let seen = self.seen_devices.read().unwrap_or_else(|p| p.into_inner()); + seen.get(&device_key) + .map(|&last| now.saturating_sub(last) < self.dedup_window_ms) + .unwrap_or(false) + }; + if already_seen { + return vec![]; + } + let mut seen = self.seen_devices.write().unwrap_or_else(|p| p.into_inner()); + seen.insert(device_key, now); + // Evict stale entries + let evict_before = + now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER)); + if evict_before > 0 { + seen.retain(|_, &mut last| last >= evict_before); + } + } + + let id = raw + .payload + .get("id") + .and_then(Value::as_str) + .unwrap_or("unknown"); + + // Device name is always "unknown" here; the state engine applies user-defined + // classification rules from devices.lua before dispatching to subscribers. + vec![BreadEvent { event: format!("bread.device.{}", verb), timestamp: raw.timestamp, source: AdapterSource::Udev, data: json!({ "id": id, - "class": class, + "device": "unknown", + "name": name, + "vendor": vendor, + "vendor_id": vendor_id, + "product_id": product_id, + "subsystem": subsystem, "raw": raw.payload, }), - }]; - - events.push(BreadEvent { - event: format!("bread.device.{}.{}", class_str, verb), - timestamp: raw.timestamp, - source: AdapterSource::Udev, - data: json!({ - "id": id, - "class": class, - }), - }); - - events + }] } fn normalize_hyprland(&self, raw: &RawEvent) -> Vec { - let kind = raw.payload.get("kind").and_then(Value::as_str).unwrap_or("unknown"); - let mapped = match kind { - "workspace" | "workspacev2" => "bread.workspace.changed", - "monitoradded" => "bread.monitor.connected", - "monitorremoved" => "bread.monitor.disconnected", - "activewindow" | "activewindowv2" => "bread.window.focus.changed", - "openwindow" => "bread.window.opened", - "closewindow" => "bread.window.closed", - _ => "bread.hyprland.event", - }; + let kind = raw + .payload + .get("kind") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let data = raw + .payload + .get("data") + .and_then(Value::as_str) + .unwrap_or(""); - vec![BreadEvent { - event: mapped.to_string(), - timestamp: raw.timestamp, - source: AdapterSource::Hyprland, - data: raw.payload.clone(), - }] + match kind { + "workspace" | "workspacev2" => vec![BreadEvent { + event: "bread.workspace.changed".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: raw.payload.clone(), + }], + "createworkspace" => vec![BreadEvent { + event: "bread.workspace.created".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ "workspace": data }), + }], + "destroyworkspace" => vec![BreadEvent { + event: "bread.workspace.destroyed".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ "workspace": data }), + }], + "monitoradded" => vec![BreadEvent { + event: "bread.monitor.connected".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ "name": data }), + }], + "monitorremoved" => vec![BreadEvent { + event: "bread.monitor.disconnected".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ "name": data }), + }], + "activewindow" => vec![BreadEvent { + event: "bread.window.focus.changed".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: raw.payload.clone(), + }], + "activewindowv2" => { + let fields = split_hyprland_fields(data); + vec![BreadEvent { + event: "bread.window.focused".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ + "address": fields.first().unwrap_or(&"") + }), + }] + } + "openwindow" => { + let fields = split_hyprland_fields(data); + vec![BreadEvent { + event: "bread.window.opened".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ + "address": fields.first().unwrap_or(&""), + "workspace": fields.get(1).unwrap_or(&""), + "class": fields.get(2).unwrap_or(&""), + "title": fields.get(3).unwrap_or(&""), + }), + }] + } + "closewindow" => { + let fields = split_hyprland_fields(data); + vec![BreadEvent { + event: "bread.window.closed".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ "address": fields.first().unwrap_or(&"") }), + }] + } + "movewindow" => { + let fields = split_hyprland_fields(data); + vec![BreadEvent { + event: "bread.window.moved".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: json!({ + "address": fields.first().unwrap_or(&""), + "workspace": fields.get(1).unwrap_or(&""), + }), + }] + } + _ => vec![BreadEvent { + event: "bread.hyprland.event".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: raw.payload.clone(), + }], + } } fn normalize_power(&self, raw: &RawEvent) -> Vec { @@ -149,8 +304,89 @@ impl EventNormalizer { events } + fn normalize_bluetooth(&self, raw: &RawEvent) -> Vec { + let path = raw + .payload + .get("path") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let address = raw + .payload + .get("address") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let name = raw + .payload + .get("name") + .and_then(Value::as_str) + .or_else(|| { + raw.payload + .pointer("/properties/Name") + .or_else(|| raw.payload.pointer("/properties/Alias")) + .and_then(Value::as_str) + }) + .unwrap_or("unknown"); + + match raw.kind.as_str() { + "bluetooth.enumerate" | "bluetooth.device.connected" => vec![BreadEvent { + event: "bread.device.connected".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Bluetooth, + data: json!({ + "id": path, + "device": "unknown", + "name": name, + "address": address, + "subsystem": "bluetooth", + "raw": raw.payload, + }), + }], + "bluetooth.device.disconnected" => vec![BreadEvent { + event: "bread.device.disconnected".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Bluetooth, + data: json!({ + "id": path, + "device": "unknown", + "name": name, + "address": address, + "subsystem": "bluetooth", + "raw": raw.payload, + }), + }], + "bluetooth.device.added" => vec![BreadEvent { + event: "bread.bluetooth.device.paired".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Bluetooth, + data: json!({ + "id": path, + "name": name, + "address": address, + "subsystem": "bluetooth", + "raw": raw.payload, + }), + }], + "bluetooth.device.removed" => vec![BreadEvent { + event: "bread.bluetooth.device.unpaired".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Bluetooth, + data: json!({ + "id": path, + "address": address, + "subsystem": "bluetooth", + "raw": raw.payload, + }), + }], + _ => vec![], + } + } + fn normalize_network(&self, raw: &RawEvent) -> Vec { - let online = raw.payload.get("online").and_then(Value::as_bool).unwrap_or(false); + let online = raw + .payload + .get("online") + .and_then(Value::as_bool) + .unwrap_or(false); let name = if online { "bread.network.connected" } else { @@ -192,7 +428,8 @@ impl EventNormalizer { recent.insert(key.clone(), now); // Evict stale entries to prevent unbounded growth. - let evict_before = now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER)); + let evict_before = + now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER)); if evict_before > 0 { recent.retain(|_, &mut last| last >= evict_before); } @@ -201,36 +438,527 @@ impl EventNormalizer { } } -fn classify_device(payload: &Value) -> DeviceClass { - let name = payload - .get("name") - .and_then(Value::as_str) - .unwrap_or_default() - .to_lowercase(); - let subsystem = payload - .get("subsystem") - .and_then(Value::as_str) - .unwrap_or_default() - .to_lowercase(); - - if name.contains("dock") { - return DeviceClass::Dock; +fn split_hyprland_fields(data: &str) -> Vec<&str> { + if data.is_empty() { + return Vec::new(); + } + data.split(">>").collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn raw(source: AdapterSource, kind: &str, payload: Value, ts: u64) -> RawEvent { + RawEvent { + source, + kind: kind.to_string(), + payload, + timestamp: ts, + } + } + + // ─── Udev ───────────────────────────────────────────────────────────── + + #[test] + fn udev_add_emits_connected_with_identity_fields() { + let n = EventNormalizer::new(100); + let ev = raw( + AdapterSource::Udev, + "udev", + json!({ + "action": "add", + "name": "Logitech Mouse", + "id_vendor": "Logitech", + "vendor_id": "046d", + "product_id": "c52b", + "subsystem": "usb", + "id": "1-1.4", + }), + 1000, + ); + let out = n.normalize(&ev); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.device.connected"); + assert_eq!(out[0].data.get("vendor_id").unwrap(), "046d"); + assert_eq!(out[0].data.get("product_id").unwrap(), "c52b"); + assert_eq!(out[0].data.get("name").unwrap(), "Logitech Mouse"); + assert_eq!(out[0].data.get("subsystem").unwrap(), "usb"); + assert_eq!(out[0].data.get("device").unwrap(), "unknown"); + } + + #[test] + fn udev_remove_emits_disconnected() { + let n = EventNormalizer::new(100); + let ev = raw( + AdapterSource::Udev, + "udev", + json!({ + "action": "remove", + "name": "Logitech", + "vendor_id": "046d", + "product_id": "c52b", + "subsystem": "usb", + "id": "1-1.4", + }), + 1000, + ); + let out = n.normalize(&ev); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.device.disconnected"); + } + + #[test] + fn udev_bind_action_is_suppressed() { + let n = EventNormalizer::new(100); + let ev = raw( + AdapterSource::Udev, + "udev", + json!({ + "action": "bind", + "name": "x", + "vendor_id": "046d", + "product_id": "c52b", + }), + 1000, + ); + assert!(n.normalize(&ev).is_empty()); + } + + #[test] + fn udev_anonymous_child_interface_is_dropped() { + let n = EventNormalizer::new(100); + // No name, no vendor — pure USB protocol artefact. + let ev = raw( + AdapterSource::Udev, + "udev", + json!({ + "action": "add", + "id": "3-5:1.0", + }), + 1000, + ); + assert!(n.normalize(&ev).is_empty()); + } + + #[test] + fn udev_dedupes_child_nodes_of_same_physical_device() { + let n = EventNormalizer::new(1000); + let mk = |id: &str, ts: u64| { + raw( + AdapterSource::Udev, + "udev", + json!({ + "action": "add", + "name": "Hub Device", + "vendor_id": "1d6b", + "product_id": "0002", + "subsystem": "usb", + "id": id, + }), + ts, + ) + }; + // First child fires + assert_eq!(n.normalize(&mk("usb-1", 1000)).len(), 1); + // Sibling within window is suppressed + assert_eq!(n.normalize(&mk("usb-2", 1050)).len(), 0); + // After the dedup window, a sibling fires again + assert_eq!(n.normalize(&mk("usb-3", 3000)).len(), 1); + } + + #[test] + fn udev_disconnect_does_not_share_dedup_with_connect() { + let n = EventNormalizer::new(1000); + let connect = raw( + AdapterSource::Udev, + "udev", + json!({"action": "add", "name": "x", "vendor_id": "1", "product_id": "2", "id": "a"}), + 1000, + ); + let disconnect = raw( + AdapterSource::Udev, + "udev", + json!({"action": "remove", "name": "x", "vendor_id": "1", "product_id": "2", "id": "a"}), + 1100, + ); + assert_eq!(n.normalize(&connect).len(), 1); + // Disconnect uses a different verb in the dedup key, so it fires. + assert_eq!(n.normalize(&disconnect).len(), 1); + } + + // ─── Hyprland ───────────────────────────────────────────────────────── + + #[test] + fn hyprland_workspace_change() { + let n = EventNormalizer::new(0); + let ev = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "workspace", "data": "2"}), + 1, + ); + let out = n.normalize(&ev); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.workspace.changed"); + } + + #[test] + fn hyprland_active_window_v2_parses_address_from_fields() { + let n = EventNormalizer::new(0); + let ev = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "activewindowv2", "data": "0xdeadbeef"}), + 1, + ); + let out = n.normalize(&ev); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.window.focused"); + assert_eq!(out[0].data.get("address").unwrap(), "0xdeadbeef"); + } + + #[test] + fn hyprland_openwindow_splits_all_fields() { + let n = EventNormalizer::new(0); + let ev = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "openwindow", "data": "0xabc>>2>>firefox>>Mozilla Firefox"}), + 1, + ); + let out = n.normalize(&ev); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.window.opened"); + let d = &out[0].data; + assert_eq!(d.get("address").unwrap(), "0xabc"); + assert_eq!(d.get("workspace").unwrap(), "2"); + assert_eq!(d.get("class").unwrap(), "firefox"); + assert_eq!(d.get("title").unwrap(), "Mozilla Firefox"); + } + + #[test] + fn hyprland_unknown_kind_falls_through_to_generic_event() { + let n = EventNormalizer::new(0); + let ev = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "submap", "data": "resize"}), + 1, + ); + let out = n.normalize(&ev); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.hyprland.event"); + } + + #[test] + fn hyprland_monitor_lifecycle() { + let n = EventNormalizer::new(0); + let added = n.normalize(&raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "monitoradded", "data": "HDMI-A-1"}), + 1, + )); + let removed = n.normalize(&raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "monitorremoved", "data": "HDMI-A-1"}), + 2, + )); + assert_eq!(added[0].event, "bread.monitor.connected"); + assert_eq!(added[0].data.get("name").unwrap(), "HDMI-A-1"); + assert_eq!(removed[0].event, "bread.monitor.disconnected"); + } + + // ─── Power ───────────────────────────────────────────────────────────── + + #[test] + fn power_ac_connected_emits_named_event() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Power, + "power", + json!({"ac_connected": true}), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.power.ac.connected"); + } + + #[test] + fn power_battery_thresholds_select_correct_event() { + let n = EventNormalizer::new(0); + let cases = [ + (3, "bread.power.battery.critical"), + (5, "bread.power.battery.critical"), + (8, "bread.power.battery.very_low"), + (10, "bread.power.battery.very_low"), + (15, "bread.power.battery.low"), + (20, "bread.power.battery.low"), + (100, "bread.power.battery.full"), + ]; + for (level, expected) in cases { + let out = n.normalize(&raw( + AdapterSource::Power, + "power", + json!({"battery_percent": level}), + level * 1000, + )); + assert_eq!( + out[0].event, expected, + "level {level} should map to {expected}" + ); + } + } + + #[test] + fn power_mid_range_battery_emits_generic_changed() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Power, + "power", + json!({"battery_percent": 50}), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.power.changed"); + } + + #[test] + fn power_ac_and_battery_can_both_fire() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Power, + "power", + json!({"ac_connected": false, "battery_percent": 4}), + 1, + )); + let names: Vec<&str> = out.iter().map(|e| e.event.as_str()).collect(); + assert!(names.contains(&"bread.power.ac.disconnected")); + assert!(names.contains(&"bread.power.battery.critical")); + } + + // ─── Bluetooth ───────────────────────────────────────────────────────── + + #[test] + fn bluetooth_connected_emits_device_connected() { + let n = EventNormalizer::new(0); + let ev = raw( + AdapterSource::Bluetooth, + "bluetooth", + json!({ + "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + "address": "AA:BB:CC:DD:EE:FF", + "properties": { "Connected": true }, + }), + 1, + ); + let out = n.normalize(&raw( + AdapterSource::Bluetooth, + "bluetooth.device.connected", + ev.payload.clone(), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.device.connected"); + assert_eq!(out[0].data.get("address").unwrap(), "AA:BB:CC:DD:EE:FF"); + assert_eq!(out[0].data.get("subsystem").unwrap(), "bluetooth"); + assert_eq!(out[0].data.get("device").unwrap(), "unknown"); + } + + #[test] + fn bluetooth_disconnected_emits_device_disconnected() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Bluetooth, + "bluetooth.device.disconnected", + json!({ + "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + "address": "AA:BB:CC:DD:EE:FF", + "properties": { "Connected": false }, + }), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.device.disconnected"); + } + + #[test] + fn bluetooth_enumerate_includes_name() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Bluetooth, + "bluetooth.enumerate", + json!({ + "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + "address": "AA:BB:CC:DD:EE:FF", + "name": "WH-1000XM4", + "properties": {}, + }), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.device.connected"); + assert_eq!(out[0].data.get("name").unwrap(), "WH-1000XM4"); + } + + #[test] + fn bluetooth_paired_emits_bluetooth_specific_event() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Bluetooth, + "bluetooth.device.added", + json!({ + "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + "address": "AA:BB:CC:DD:EE:FF", + "name": "My Headphones", + "properties": {}, + }), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.bluetooth.device.paired"); + assert_eq!(out[0].data.get("name").unwrap(), "My Headphones"); + } + + #[test] + fn bluetooth_unpaired_emits_bluetooth_specific_event() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Bluetooth, + "bluetooth.device.removed", + json!({ + "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + "address": "AA:BB:CC:DD:EE:FF", + }), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.bluetooth.device.unpaired"); + assert_eq!(out[0].data.get("address").unwrap(), "AA:BB:CC:DD:EE:FF"); + } + + #[test] + fn bluetooth_name_falls_back_to_properties() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::Bluetooth, + "bluetooth.device.connected", + json!({ + "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + "address": "AA:BB:CC:DD:EE:FF", + "properties": { "Connected": true, "Name": "Fallback Name" }, + }), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].data.get("name").unwrap(), "Fallback Name"); + } + + // ─── Network ─────────────────────────────────────────────────────────── + + #[test] + fn network_online_and_offline() { + let n = EventNormalizer::new(0); + let online = n.normalize(&raw( + AdapterSource::Network, + "net", + json!({"online": true}), + 1, + )); + let offline = n.normalize(&raw( + AdapterSource::Network, + "net", + json!({"online": false}), + 2, + )); + assert_eq!(online[0].event, "bread.network.connected"); + assert_eq!(offline[0].event, "bread.network.disconnected"); + } + + // ─── System pass-through ─────────────────────────────────────────────── + + #[test] + fn system_events_pass_through_unchanged() { + let n = EventNormalizer::new(0); + let out = n.normalize(&raw( + AdapterSource::System, + "bread.custom.event", + json!({"foo": "bar"}), + 1, + )); + assert_eq!(out.len(), 1); + assert_eq!(out[0].event, "bread.custom.event"); + assert_eq!(out[0].source, AdapterSource::System); + assert_eq!(out[0].data.get("foo").unwrap(), "bar"); + } + + // ─── Dedup ───────────────────────────────────────────────────────────── + + #[test] + fn dedup_drops_duplicate_within_window() { + let n = EventNormalizer::new(500); + let ev = raw(AdapterSource::Network, "net", json!({"online": true}), 1000); + assert_eq!(n.normalize(&ev).len(), 1); + + let dup = raw(AdapterSource::Network, "net", json!({"online": true}), 1200); + assert_eq!(n.normalize(&dup).len(), 0); + } + + #[test] + fn dedup_allows_after_window_elapses() { + let n = EventNormalizer::new(500); + let first = raw(AdapterSource::Network, "net", json!({"online": true}), 1000); + assert_eq!(n.normalize(&first).len(), 1); + + let later = raw(AdapterSource::Network, "net", json!({"online": true}), 2000); + assert_eq!(n.normalize(&later).len(), 1); + } + + #[test] + fn dedup_distinguishes_different_payloads() { + let n = EventNormalizer::new(10_000); + let a = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "workspace", "data": "1"}), + 1000, + ); + let b = raw( + AdapterSource::Hyprland, + "hypr", + json!({"kind": "workspace", "data": "2"}), + 1100, + ); + assert_eq!(n.normalize(&a).len(), 1); + // Different payloads = different dedup key + assert_eq!(n.normalize(&b).len(), 1); + } + + #[test] + fn dedup_window_of_zero_allows_everything() { + let n = EventNormalizer::new(0); + for _ in 0..3 { + assert_eq!( + n.normalize(&raw( + AdapterSource::Network, + "net", + json!({"online": true}), + 1000, + )) + .len(), + 1 + ); + } + } + + // ─── Helper ──────────────────────────────────────────────────────────── + + #[test] + fn split_fields_handles_empty_and_single() { + assert!(split_hyprland_fields("").is_empty()); + assert_eq!(split_hyprland_fields("only"), vec!["only"]); + assert_eq!(split_hyprland_fields("a>>b>>c"), vec!["a", "b", "c"]); } - if subsystem == "input" && name.contains("keyboard") { - return DeviceClass::Keyboard; - } - if subsystem == "input" && name.contains("mouse") { - return DeviceClass::Mouse; - } - if subsystem == "drm" { - return DeviceClass::Display; - } - if subsystem == "sound" || name.contains("audio") { - return DeviceClass::Audio; - } - if subsystem == "block" || name.contains("storage") { - return DeviceClass::Storage; - } - - DeviceClass::Unknown } diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index d824fd0..2ed7006 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -1,13 +1,17 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use anyhow::Result; -use bread_shared::BreadEvent; -use serde_json::Value; +use bread_shared::{AdapterSource, BreadEvent}; +use serde_json::{json, Value}; use tokio::sync::{broadcast, mpsc, watch, RwLock}; use tracing::warn; use crate::core::subscriptions::{SubscriptionId, SubscriptionTable}; -use crate::core::types::{Device, DeviceClass, InterfaceState, ModuleLoadState, RuntimeState}; +use crate::core::types::{ + Device, DeviceRule, InterfaceState, MatchCondition, ModuleLoadState, RuntimeState, +}; use crate::lua::LuaMessage; #[derive(Clone)] @@ -22,19 +26,35 @@ pub enum StateCommand { pattern: String, once: bool, }, + RemoveSubscription { + id: SubscriptionId, + }, + RegisterWatch { + id: SubscriptionId, + path: String, + }, + RemoveWatch { + id: SubscriptionId, + }, ClearSubscriptions, + ClearModules, SetModuleStatus { name: String, status: ModuleLoadState, last_error: Option, + builtin: bool, }, SetProfile { name: String, }, + SetDeviceRules(Vec), } impl StateHandle { - pub fn new(state: Arc>, command_tx: mpsc::UnboundedSender) -> Self { + pub fn new( + state: Arc>, + command_tx: mpsc::UnboundedSender, + ) -> Self { Self { state, command_tx } } @@ -62,31 +82,63 @@ impl StateHandle { serde_json::to_value(&*state).unwrap_or_else(|_| serde_json::json!({})) } - pub fn register_subscription(&self, id: SubscriptionId, pattern: String, once: bool) -> Result<()> { + pub fn register_subscription( + &self, + id: SubscriptionId, + pattern: String, + once: bool, + ) -> Result<()> { self.command_tx - .send(StateCommand::RegisterSubscription { - id, - pattern, - once, - }) + .send(StateCommand::RegisterSubscription { id, pattern, once }) .map_err(|_| anyhow::anyhow!("state engine command channel closed")) } + pub fn remove_subscription(&self, id: SubscriptionId) { + let _ = self + .command_tx + .send(StateCommand::RemoveSubscription { id }); + } + + pub fn register_watch(&self, id: SubscriptionId, path: String) -> Result<()> { + self.command_tx + .send(StateCommand::RegisterWatch { id, path }) + .map_err(|_| anyhow::anyhow!("state engine command channel closed")) + } + + pub fn remove_watch(&self, id: SubscriptionId) { + let _ = self.command_tx.send(StateCommand::RemoveWatch { id }); + } + pub fn clear_subscriptions(&self) { let _ = self.command_tx.send(StateCommand::ClearSubscriptions); } - pub fn set_module_status(&self, name: String, status: ModuleLoadState, last_error: Option) { + pub fn clear_modules(&self) { + let _ = self.command_tx.send(StateCommand::ClearModules); + } + + pub fn set_module_status( + &self, + name: String, + status: ModuleLoadState, + last_error: Option, + builtin: bool, + ) { let _ = self.command_tx.send(StateCommand::SetModuleStatus { name, status, last_error, + builtin, }); } pub fn set_profile(&self, name: String) { let _ = self.command_tx.send(StateCommand::SetProfile { name }); } + + pub fn set_device_rules(&self, rules: Vec) { + let _ = self.command_tx.send(StateCommand::SetDeviceRules(rules)); + } } pub async fn run_state_engine( @@ -95,9 +147,12 @@ pub async fn run_state_engine( state: Arc>, lua_tx: mpsc::UnboundedSender, event_stream_tx: broadcast::Sender, + subscription_count: Arc, mut shutdown_rx: watch::Receiver, ) { let mut subscriptions = SubscriptionTable::default(); + let mut watches: HashMap = HashMap::new(); + let mut device_rules: Vec = Vec::new(); loop { tokio::select! { @@ -110,28 +165,92 @@ pub async fn run_state_engine( let Some(cmd) = maybe_cmd else { break; }; - handle_command(cmd, &state, &mut subscriptions).await; + if let StateCommand::SetDeviceRules(rules) = cmd { + device_rules = rules; + } else { + handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await; + } } maybe_event = event_rx.recv() => { - let Some(event) = maybe_event else { + let Some(mut event) = maybe_event else { break; }; - apply_event_to_state(&state, &event).await; + // Resolve device name from user rules and patch the event data before + // any subscriber sees it, then emit the named companion event. + let device_event = if event.event == "bread.device.connected" + || event.event == "bread.device.disconnected" + { + let is_disconnect = event.event == "bread.device.disconnected"; + let id = event.data.get("id").and_then(Value::as_str).unwrap_or("unknown").to_string(); - let _ = event_stream_tx.send(event.clone()); + // On disconnect, udev strips vendor/product identifiers from the event. + // Look up the device by id in the current state (it's still present + // because apply_event_to_state hasn't run yet) and reuse the stored name. + let device = if is_disconnect { + state.read().await + .devices.connected.iter() + .find(|d| d.id == id) + .map(|d| d.device.clone()) + .unwrap_or_else(|| resolve_device(&device_rules, &event.data)) + } else { + resolve_device(&device_rules, &event.data) + }; - let matches = subscriptions.match_event(&event.event); - for sub in &matches { - let _ = lua_tx.send(LuaMessage::Event { - subscription_id: sub.id, - event: event.clone(), - }); + if let Some(data) = event.data.as_object_mut() { + data.insert("device".to_string(), Value::String(device.clone())); + } + let verb = if is_disconnect { "disconnected" } else { "connected" }; + Some(BreadEvent::new( + format!("bread.device.{}.{}", device, verb), + AdapterSource::Udev, + json!({ "id": id, "device": device }), + )) + } else { + None + }; + + let (before_snapshot, after_snapshot) = if watches.is_empty() { + (None, None) + } else { + let mut guard = state.write().await; + let before = serde_json::to_value(&*guard).ok(); + apply_event_to_state(&mut guard, &event); + let after = serde_json::to_value(&*guard).ok(); + (before, after) + }; + + if watches.is_empty() { + let mut guard = state.write().await; + apply_event_to_state(&mut guard, &event); } - for sub in matches.into_iter().filter(|s| s.once) { - subscriptions.remove(sub.id); - let _ = lua_tx.send(LuaMessage::SubscriptionCancelled { id: sub.id }); + dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count); + + if let Some(dev_ev) = device_event { + let mut guard = state.write().await; + apply_event_to_state(&mut guard, &dev_ev); + drop(guard); + dispatch_event(&dev_ev, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count); + } + + if let (Some(before), Some(after)) = (before_snapshot, after_snapshot) { + for (_id, path) in watches.iter() { + let old_val = value_at_path(&before, path).unwrap_or(Value::Null); + let new_val = value_at_path(&after, path).unwrap_or(Value::Null); + if old_val != new_val { + let synthetic = BreadEvent::new( + format!("bread.state.changed.{path}"), + AdapterSource::System, + json!({ + "path": path, + "new": new_val, + "old": old_val, + }), + ); + dispatch_event(&synthetic, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count); + } + } } } } @@ -144,28 +263,51 @@ async fn handle_command( cmd: StateCommand, state: &Arc>, subscriptions: &mut SubscriptionTable, + watches: &mut HashMap, + subscription_count: &Arc, ) { match cmd { StateCommand::RegisterSubscription { id, pattern, once } => { subscriptions.add_with_id(id, pattern, once); + subscription_count.fetch_add(1, Ordering::Relaxed); + } + StateCommand::RemoveSubscription { id } => { + if subscriptions.remove(id) { + subscription_count.fetch_sub(1, Ordering::Relaxed); + } + } + StateCommand::RegisterWatch { id, path } => { + watches.insert(id, path); + } + StateCommand::RemoveWatch { id } => { + watches.remove(&id); } StateCommand::ClearSubscriptions => { subscriptions.clear(); + watches.clear(); + subscription_count.store(0, Ordering::Relaxed); + } + StateCommand::ClearModules => { + state.write().await.modules.clear(); } StateCommand::SetModuleStatus { name, status, last_error, + builtin, } => { let mut guard = state.write().await; if let Some(existing) = guard.modules.iter_mut().find(|m| m.name == name) { existing.status = status; existing.last_error = last_error; + existing.builtin = builtin; } else { guard.modules.push(crate::core::types::ModuleStatus { name, status, last_error, + builtin, + store: HashMap::new(), }); } } @@ -177,29 +319,75 @@ async fn handle_command( guard.profile.active = name; } } + StateCommand::SetDeviceRules(_) => { + // Handled directly in run_state_engine before this function is called. + } } } -async fn apply_event_to_state(state: &Arc>, event: &BreadEvent) { - let mut guard = state.write().await; +fn dispatch_event( + event: &BreadEvent, + subscriptions: &mut SubscriptionTable, + lua_tx: &mpsc::UnboundedSender, + event_stream_tx: &broadcast::Sender, + subscription_count: &Arc, +) { + let _ = event_stream_tx.send(event.clone()); + + let matches = subscriptions.match_event(&event.event); + for sub in &matches { + let _ = lua_tx.send(LuaMessage::Event { + subscription_id: sub.id, + event: event.clone(), + }); + } + + for sub in matches.into_iter().filter(|s| s.once) { + if subscriptions.remove(sub.id) { + subscription_count.fetch_sub(1, Ordering::Relaxed); + } + let _ = lua_tx.send(LuaMessage::SubscriptionCancelled { id: sub.id }); + } +} + +fn value_at_path(value: &Value, path: &str) -> Option { + if path.is_empty() { + return Some(value.clone()); + } + let mut current = value; + for part in path.split('.') { + current = current.get(part)?; + } + Some(current.clone()) +} + +fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { match event.event.as_str() { "bread.monitor.connected" => { if let Some(name) = event.data.get("name").and_then(Value::as_str) { - if let Some(m) = guard.monitors.iter_mut().find(|m| m.name == name) { + if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) { m.connected = true; } else { - guard.monitors.push(crate::core::types::Monitor { + state.monitors.push(crate::core::types::Monitor { name: name.to_string(), connected: true, - resolution: event.data.get("resolution").and_then(Value::as_str).map(ToString::to_string), - position: event.data.get("position").and_then(Value::as_str).map(ToString::to_string), + resolution: event + .data + .get("resolution") + .and_then(Value::as_str) + .map(ToString::to_string), + position: event + .data + .get("position") + .and_then(Value::as_str) + .map(ToString::to_string), }); } } } "bread.monitor.disconnected" => { if let Some(name) = event.data.get("name").and_then(Value::as_str) { - if let Some(m) = guard.monitors.iter_mut().find(|m| m.name == name) { + if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) { m.connected = false; } } @@ -211,31 +399,35 @@ async fn apply_event_to_state(state: &Arc>, event: &BreadEv .or_else(|| event.data.get("id")) .and_then(Value::as_str) .map(ToString::to_string); - guard.active_workspace = ws; + state.active_workspace = ws; } - "bread.window.focus.changed" => { - guard.active_window = event + "bread.window.focus.changed" | "bread.window.focused" => { + state.active_window = event .data .get("window") .or_else(|| event.data.get("class")) + .or_else(|| event.data.get("address")) .and_then(Value::as_str) .map(ToString::to_string); } "bread.device.connected" => { - apply_device_change(&mut guard, &event.data, true); + apply_device_change(state, &event.data, true); } "bread.device.disconnected" => { - apply_device_change(&mut guard, &event.data, false); + apply_device_change(state, &event.data, false); } "bread.network.connected" | "bread.network.disconnected" => { if let Some(online) = event.data.get("online").and_then(Value::as_bool) { - guard.network.online = online; + state.network.online = online; } if let Some(ifaces) = event.data.get("interfaces").and_then(Value::as_object) { - guard.network.interfaces.clear(); + state.network.interfaces.clear(); for (name, meta) in ifaces { let up = meta.get("up").and_then(Value::as_bool).unwrap_or(false); - guard.network.interfaces.insert(name.clone(), InterfaceState { up }); + state + .network + .interfaces + .insert(name.clone(), InterfaceState { up }); } } } @@ -247,19 +439,19 @@ async fn apply_event_to_state(state: &Arc>, event: &BreadEv | "bread.power.battery.critical" | "bread.power.battery.full" => { if let Some(ac) = event.data.get("ac_connected").and_then(Value::as_bool) { - guard.power.ac_connected = ac; + state.power.ac_connected = ac; } if let Some(battery) = event.data.get("battery_percent").and_then(Value::as_u64) { - guard.power.battery_percent = Some(battery.min(100) as u8); - guard.power.battery_low = battery <= 20; + state.power.battery_percent = Some(battery.min(100) as u8); + state.power.battery_low = battery <= 20; } } "bread.profile.activated" => { if let Some(name) = event.data.get("name").and_then(Value::as_str) { - if guard.profile.active != name { - let previous = guard.profile.active.clone(); - guard.profile.history.push(previous); - guard.profile.active = name.to_string(); + if state.profile.active != name { + let previous = state.profile.active.clone(); + state.profile.history.push(previous); + state.profile.active = name.to_string(); } } } @@ -267,6 +459,134 @@ async fn apply_event_to_state(state: &Arc>, event: &BreadEv } } +fn resolve_device(rules: &[DeviceRule], data: &Value) -> String { + for rule in rules { + if !rule.conditions.is_empty() && rule.conditions.iter().all(|c| condition_matches(c, data)) + { + return rule.device.clone(); + } + } + "unknown".to_string() +} + +fn condition_matches(cond: &MatchCondition, data: &Value) -> bool { + if let Some(ref expected) = cond.vendor_id { + let actual = data.get("vendor_id").and_then(Value::as_str).unwrap_or(""); + if actual.to_lowercase() != expected.to_lowercase() { + return false; + } + } + if let Some(ref expected) = cond.product_id { + let actual = data.get("product_id").and_then(Value::as_str).unwrap_or(""); + if actual.to_lowercase() != expected.to_lowercase() { + return false; + } + } + if let Some(ref expected) = cond.name { + let actual = data + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + if actual != expected.to_lowercase() { + return false; + } + } + if let Some(ref expected) = cond.vendor { + let actual = data + .get("vendor") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + if actual != expected.to_lowercase() { + return false; + } + } + if let Some(ref contains) = cond.name_contains { + let name = data + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + let vendor = data + .get("vendor") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + let combined = format!("{name} {vendor}"); + if !combined.contains(contains.to_lowercase().as_str()) { + return false; + } + } + if let Some(expected) = cond.id_input_keyboard { + if data + .get("id_input_keyboard") + .and_then(Value::as_bool) + .unwrap_or(false) + != expected + { + return false; + } + } + if let Some(expected) = cond.id_input_mouse { + if data + .get("id_input_mouse") + .and_then(Value::as_bool) + .unwrap_or(false) + != expected + { + return false; + } + } + if let Some(expected) = cond.id_input_tablet { + if data + .get("id_input_tablet") + .and_then(Value::as_bool) + .unwrap_or(false) + != expected + { + return false; + } + } + if cond.usb_hub == Some(true) { + let ifaces = data + .get("id_usb_interfaces") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + let has_hub = ifaces.contains(":0900") || ifaces.contains(":0902"); + let has_secondary = ifaces.contains(":0e") + || ifaces.contains(":0200") + || ifaces.contains(":0100") + || ifaces.contains(":0801"); + if !(has_hub && has_secondary) { + return false; + } + } + if let Some(ref expected) = cond.id_usb_class { + let actual = data + .get("id_usb_class") + .and_then(Value::as_str) + .unwrap_or(""); + if actual.to_lowercase() != expected.to_lowercase() + && actual.to_lowercase() != format!("0x{}", expected.to_lowercase()) + { + return false; + } + } + if let Some(ref expected) = cond.subsystem { + let actual = data + .get("subsystem") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + if actual != expected.to_lowercase() { + return false; + } + } + true +} + fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) { let id = data .get("id") @@ -279,10 +599,11 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) return; } - let class = data - .get("class") - .and_then(|v| serde_json::from_value::(v.clone()).ok()) - .unwrap_or(DeviceClass::Unknown); + let device = data + .get("device") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); state.devices.connected.push(Device { id, @@ -291,14 +612,426 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) .and_then(Value::as_str) .unwrap_or("unknown") .to_string(), - class, + device, subsystem: data .get("subsystem") .and_then(Value::as_str) .unwrap_or("unknown") .to_string(), + vendor_id: data + .get("vendor_id") + .and_then(Value::as_str) + .map(ToString::to_string), + product_id: data + .get("product_id") + .and_then(Value::as_str) + .map(ToString::to_string), }); } else { state.devices.connected.retain(|d| d.id != id); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn ev(name: &str, data: Value) -> BreadEvent { + BreadEvent { + event: name.to_string(), + timestamp: 0, + source: AdapterSource::System, + data, + } + } + + // ─── value_at_path ──────────────────────────────────────────────────── + + #[test] + fn value_at_path_returns_root_for_empty_path() { + let v = json!({"a": 1}); + assert_eq!(value_at_path(&v, ""), Some(json!({"a": 1}))); + } + + #[test] + fn value_at_path_navigates_nested_keys() { + let v = json!({"a": {"b": {"c": 42}}}); + assert_eq!(value_at_path(&v, "a.b.c"), Some(json!(42))); + } + + #[test] + fn value_at_path_returns_none_on_missing_key() { + let v = json!({"a": 1}); + assert!(value_at_path(&v, "missing").is_none()); + assert!(value_at_path(&v, "a.b.c").is_none()); + } + + // ─── apply_event_to_state: monitors ─────────────────────────────────── + + #[test] + fn monitor_connect_adds_new_monitor() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev( + "bread.monitor.connected", + json!({"name": "DP-1", "resolution": "1920x1080", "position": "0x0"}), + ), + ); + assert_eq!(state.monitors.len(), 1); + assert_eq!(state.monitors[0].name, "DP-1"); + assert!(state.monitors[0].connected); + assert_eq!(state.monitors[0].resolution.as_deref(), Some("1920x1080")); + assert_eq!(state.monitors[0].position.as_deref(), Some("0x0")); + } + + #[test] + fn monitor_reconnect_does_not_duplicate() { + let mut state = RuntimeState::default(); + let mk = || ev("bread.monitor.connected", json!({"name": "DP-1"})); + apply_event_to_state(&mut state, &mk()); + apply_event_to_state( + &mut state, + &ev("bread.monitor.disconnected", json!({"name": "DP-1"})), + ); + apply_event_to_state(&mut state, &mk()); + assert_eq!(state.monitors.len(), 1); + assert!(state.monitors[0].connected); + } + + #[test] + fn monitor_disconnect_keeps_record_but_flips_connected_flag() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev("bread.monitor.connected", json!({"name": "DP-1"})), + ); + apply_event_to_state( + &mut state, + &ev("bread.monitor.disconnected", json!({"name": "DP-1"})), + ); + assert_eq!(state.monitors.len(), 1); + assert!(!state.monitors[0].connected); + } + + // ─── apply_event_to_state: workspace + window ───────────────────────── + + #[test] + fn workspace_changed_updates_active_workspace() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev("bread.workspace.changed", json!({"workspace": "3"})), + ); + assert_eq!(state.active_workspace.as_deref(), Some("3")); + // Falls back to `id` when `workspace` is absent. + apply_event_to_state( + &mut state, + &ev("bread.workspace.changed", json!({"id": "5"})), + ); + assert_eq!(state.active_workspace.as_deref(), Some("5")); + } + + #[test] + fn window_focus_change_updates_active_window() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev("bread.window.focus.changed", json!({"window": "firefox"})), + ); + assert_eq!(state.active_window.as_deref(), Some("firefox")); + // Falls back to `class`, then `address`. + apply_event_to_state( + &mut state, + &ev("bread.window.focused", json!({"address": "0xdeadbeef"})), + ); + assert_eq!(state.active_window.as_deref(), Some("0xdeadbeef")); + } + + // ─── apply_device_change ────────────────────────────────────────────── + + #[test] + fn device_connect_adds_device_with_all_fields() { + let mut state = RuntimeState::default(); + apply_device_change( + &mut state, + &json!({ + "id": "1-1.4", + "name": "Logitech Mouse", + "device": "mouse", + "subsystem": "usb", + "vendor_id": "046d", + "product_id": "c52b", + }), + true, + ); + assert_eq!(state.devices.connected.len(), 1); + let d = &state.devices.connected[0]; + assert_eq!(d.id, "1-1.4"); + assert_eq!(d.name, "Logitech Mouse"); + assert_eq!(d.device, "mouse"); + assert_eq!(d.subsystem, "usb"); + assert_eq!(d.vendor_id.as_deref(), Some("046d")); + assert_eq!(d.product_id.as_deref(), Some("c52b")); + } + + #[test] + fn device_connect_is_idempotent_for_same_id() { + let mut state = RuntimeState::default(); + let data = json!({"id": "x", "device": "dock", "name": "Dock"}); + apply_device_change(&mut state, &data, true); + apply_device_change(&mut state, &data, true); + assert_eq!(state.devices.connected.len(), 1); + } + + #[test] + fn device_disconnect_removes_matching_id() { + let mut state = RuntimeState::default(); + apply_device_change(&mut state, &json!({"id": "a", "device": "x"}), true); + apply_device_change(&mut state, &json!({"id": "b", "device": "y"}), true); + assert_eq!(state.devices.connected.len(), 2); + + apply_device_change(&mut state, &json!({"id": "a"}), false); + assert_eq!(state.devices.connected.len(), 1); + assert_eq!(state.devices.connected[0].id, "b"); + } + + #[test] + fn device_disconnect_of_unknown_id_is_noop() { + let mut state = RuntimeState::default(); + apply_device_change(&mut state, &json!({"id": "a", "device": "x"}), true); + apply_device_change(&mut state, &json!({"id": "ghost"}), false); + assert_eq!(state.devices.connected.len(), 1); + } + + // ─── apply_event_to_state: power ────────────────────────────────────── + + #[test] + fn power_event_updates_ac_and_battery_low_flag() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev( + "bread.power.battery.low", + json!({"ac_connected": false, "battery_percent": 18}), + ), + ); + assert!(!state.power.ac_connected); + assert_eq!(state.power.battery_percent, Some(18)); + assert!(state.power.battery_low); + + // 25% is no longer "low" + apply_event_to_state( + &mut state, + &ev("bread.power.changed", json!({"battery_percent": 25})), + ); + assert!(!state.power.battery_low); + } + + #[test] + fn power_clamps_battery_percent_to_100() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev("bread.power.changed", json!({"battery_percent": 250u64})), + ); + assert_eq!(state.power.battery_percent, Some(100)); + } + + // ─── apply_event_to_state: network ──────────────────────────────────── + + #[test] + fn network_event_updates_online_flag_and_interfaces() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev( + "bread.network.connected", + json!({ + "online": true, + "interfaces": { + "wlan0": {"up": true}, + "eth0": {"up": false}, + } + }), + ), + ); + assert!(state.network.online); + assert_eq!(state.network.interfaces.len(), 2); + assert!(state.network.interfaces["wlan0"].up); + assert!(!state.network.interfaces["eth0"].up); + } + + // ─── apply_event_to_state: profile ──────────────────────────────────── + + #[test] + fn profile_activated_pushes_previous_to_history() { + let mut state = RuntimeState::default(); + // Initial active is "default". + apply_event_to_state( + &mut state, + &ev("bread.profile.activated", json!({"name": "battery"})), + ); + assert_eq!(state.profile.active, "battery"); + assert_eq!(state.profile.history, vec!["default"]); + + apply_event_to_state( + &mut state, + &ev("bread.profile.activated", json!({"name": "ac"})), + ); + assert_eq!(state.profile.active, "ac"); + assert_eq!(state.profile.history, vec!["default", "battery"]); + } + + #[test] + fn profile_activated_to_same_name_is_noop() { + let mut state = RuntimeState::default(); + apply_event_to_state( + &mut state, + &ev("bread.profile.activated", json!({"name": "default"})), + ); + assert_eq!(state.profile.active, "default"); + assert!(state.profile.history.is_empty()); + } + + #[test] + fn unknown_event_does_not_mutate_state() { + let mut state = RuntimeState::default(); + let before = serde_json::to_value(&state).unwrap(); + apply_event_to_state( + &mut state, + &ev("bread.unknown.event", json!({"foo": "bar"})), + ); + let after = serde_json::to_value(&state).unwrap(); + assert_eq!(before, after); + } + + // ─── condition_matches ──────────────────────────────────────────────── + + #[test] + fn condition_vendor_id_matches_case_insensitively() { + let cond = MatchCondition { + vendor_id: Some("046D".to_string()), + ..Default::default() + }; + assert!(condition_matches(&cond, &json!({"vendor_id": "046d"}))); + assert!(!condition_matches(&cond, &json!({"vendor_id": "1234"}))); + } + + #[test] + fn condition_name_contains_searches_name_and_vendor() { + let cond = MatchCondition { + name_contains: Some("logi".to_string()), + ..Default::default() + }; + assert!(condition_matches(&cond, &json!({"name": "Logitech MX"}))); + assert!(condition_matches(&cond, &json!({"vendor": "Logitech Inc"}))); + assert!(!condition_matches(&cond, &json!({"name": "Apple"}))); + } + + #[test] + fn condition_input_flags_match_booleans() { + let cond = MatchCondition { + id_input_keyboard: Some(true), + ..Default::default() + }; + assert!(condition_matches( + &cond, + &json!({"id_input_keyboard": true}) + )); + assert!(!condition_matches( + &cond, + &json!({"id_input_keyboard": false}) + )); + // Missing field defaults to false. + assert!(!condition_matches(&cond, &json!({}))); + } + + #[test] + fn condition_usb_hub_requires_hub_and_secondary_class() { + let cond = MatchCondition { + usb_hub: Some(true), + ..Default::default() + }; + assert!(condition_matches( + &cond, + &json!({"id_usb_interfaces": ":0900:0e00:"}) + )); + // Hub alone is not enough. + assert!(!condition_matches( + &cond, + &json!({"id_usb_interfaces": ":0900:"}) + )); + // Secondary alone is not enough. + assert!(!condition_matches( + &cond, + &json!({"id_usb_interfaces": ":0e00:"}) + )); + } + + #[test] + fn condition_id_usb_class_accepts_with_or_without_0x_prefix() { + let cond = MatchCondition { + id_usb_class: Some("0e".to_string()), + ..Default::default() + }; + assert!(condition_matches(&cond, &json!({"id_usb_class": "0e"}))); + assert!(condition_matches(&cond, &json!({"id_usb_class": "0x0e"}))); + assert!(!condition_matches(&cond, &json!({"id_usb_class": "ff"}))); + } + + #[test] + fn condition_empty_matches_anything() { + let cond = MatchCondition::default(); + assert!(condition_matches(&cond, &json!({}))); + assert!(condition_matches(&cond, &json!({"vendor_id": "anything"}))); + } + + // ─── resolve_device ─────────────────────────────────────────────────── + + #[test] + fn resolve_device_returns_first_matching_rule() { + let rules = vec![ + DeviceRule { + device: "mouse".to_string(), + conditions: vec![MatchCondition { + vendor_id: Some("046d".to_string()), + ..Default::default() + }], + }, + DeviceRule { + device: "dock".to_string(), + conditions: vec![MatchCondition { + vendor_id: Some("17ef".to_string()), + ..Default::default() + }], + }, + ]; + assert_eq!( + resolve_device(&rules, &json!({"vendor_id": "046d"})), + "mouse" + ); + assert_eq!( + resolve_device(&rules, &json!({"vendor_id": "17ef"})), + "dock" + ); + assert_eq!( + resolve_device(&rules, &json!({"vendor_id": "0000"})), + "unknown" + ); + } + + #[test] + fn resolve_device_skips_rules_with_no_conditions() { + let rules = vec![DeviceRule { + device: "wildcard".to_string(), + conditions: vec![], + }]; + assert_eq!(resolve_device(&rules, &json!({})), "unknown"); + } + + #[test] + fn resolve_device_with_empty_ruleset_returns_unknown() { + assert_eq!(resolve_device(&[], &json!({"vendor_id": "x"})), "unknown"); + } +} diff --git a/breadd/src/core/subscriptions.rs b/breadd/src/core/subscriptions.rs index d4e6925..9c5d6de 100644 --- a/breadd/src/core/subscriptions.rs +++ b/breadd/src/core/subscriptions.rs @@ -18,7 +18,12 @@ pub struct SubscriptionTable { } impl SubscriptionTable { - pub fn add_with_id(&mut self, id: SubscriptionId, pattern: String, once: bool) -> SubscriptionId { + pub fn add_with_id( + &mut self, + id: SubscriptionId, + pattern: String, + once: bool, + ) -> SubscriptionId { self.next_id = self.next_id.max(id.0.saturating_add(1)); let sub = Subscription { id, pattern, once }; @@ -35,7 +40,6 @@ impl SubscriptionTable { // swap_remove moves the last element into `idx`. We need to update by_id // for that element. But first, remove its stale entry (it was at the last // position before the swap); then re-insert it at the new position. - let last_idx = self.entries.len() - 1; self.entries.swap_remove(idx); if idx < self.entries.len() { @@ -68,5 +72,222 @@ fn matches_pattern(pattern: &str, event_name: &str) -> bool { return event_name.starts_with(prefix); } - pattern == event_name + if let Some(prefix) = pattern.strip_suffix(".**") { + if event_name == prefix { + return true; + } + } + + matches_glob(pattern.as_bytes(), event_name.as_bytes()) +} + +fn matches_glob(pattern: &[u8], text: &[u8]) -> bool { + if pattern.is_empty() { + return text.is_empty(); + } + + if pattern.len() >= 2 && pattern[0] == b'*' && pattern[1] == b'*' { + let mut idx = 2; + while pattern.len() >= idx + 2 && pattern[idx] == b'*' && pattern[idx + 1] == b'*' { + idx += 2; + } + let rest = &pattern[idx..]; + if rest.is_empty() { + return true; + } + for offset in 0..=text.len() { + if matches_glob(rest, &text[offset..]) { + return true; + } + } + return false; + } + + match pattern[0] { + b'*' => { + let mut offset = 0; + loop { + if matches_glob(&pattern[1..], &text[offset..]) { + return true; + } + if offset == text.len() || text[offset] == b'.' { + break; + } + offset += 1; + } + false + } + b'?' => { + if text.is_empty() || text[0] == b'.' { + return false; + } + matches_glob(&pattern[1..], &text[1..]) + } + ch => { + if text.first().copied() != Some(ch) { + return false; + } + matches_glob(&pattern[1..], &text[1..]) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_match() { + assert!(matches_pattern( + "bread.device.dock.connected", + "bread.device.dock.connected" + )); + assert!(!matches_pattern( + "bread.device.dock.connected", + "bread.device.dock.disconnected" + )); + } + + #[test] + fn single_segment_wildcard() { + assert!(matches_pattern( + "bread.device.*", + "bread.device.dock.connected" + )); + assert!(matches_pattern("bread.device.*", "bread.device.foo")); + assert!(!matches_pattern("bread.device.*", "bread.device")); + } + + #[test] + fn recursive_wildcard() { + assert!(matches_pattern( + "bread.device.**", + "bread.device.dock.connected" + )); + assert!(matches_pattern("bread.**", "bread.device.dock.connected")); + assert!(matches_pattern("bread.**", "bread")); + } + + #[test] + fn single_char_wildcard() { + assert!(matches_pattern("bread.monitor.?", "bread.monitor.1")); + assert!(!matches_pattern("bread.monitor.?", "bread.monitor.10")); + assert!(!matches_pattern("bread.monitor.?", "bread.monitor.")); + } + + #[test] + fn star_does_not_cross_dot_segments() { + // `*` matches within a segment only. + assert!(matches_pattern( + "bread.*.connected", + "bread.device.connected" + )); + assert!(!matches_pattern( + "bread.*.connected", + "bread.device.dock.connected" + )); + } + + #[test] + fn double_star_matches_zero_or_more_segments() { + assert!(matches_pattern("bread.**", "bread.a")); + assert!(matches_pattern("bread.**", "bread.a.b.c.d")); + } + + #[test] + fn empty_pattern_matches_only_empty_text() { + assert!(matches_pattern("", "")); + assert!(!matches_pattern("", "bread")); + } + + #[test] + fn empty_text_only_matches_wildcards() { + assert!(matches_pattern("**", "")); + assert!(!matches_pattern("bread.*", "")); + } + + // ─── SubscriptionTable ──────────────────────────────────────────────── + + #[test] + fn table_add_assigns_provided_id_and_finds_match() { + let mut t = SubscriptionTable::default(); + let id = t.add_with_id(SubscriptionId(7), "bread.window.*".into(), false); + assert_eq!(id, SubscriptionId(7)); + + let matches = t.match_event("bread.window.opened"); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].id, SubscriptionId(7)); + assert_eq!(matches[0].pattern, "bread.window.*"); + assert!(!matches[0].once); + } + + #[test] + fn table_match_returns_all_matching_subscriptions() { + let mut t = SubscriptionTable::default(); + t.add_with_id(SubscriptionId(1), "bread.window.opened".into(), false); + t.add_with_id(SubscriptionId(2), "bread.window.*".into(), false); + t.add_with_id(SubscriptionId(3), "bread.**".into(), true); + t.add_with_id(SubscriptionId(4), "bread.device.*".into(), false); + + let matches = t.match_event("bread.window.opened"); + let ids: Vec = matches.iter().map(|s| s.id.0).collect(); + assert!(ids.contains(&1)); + assert!(ids.contains(&2)); + assert!(ids.contains(&3)); + assert!(!ids.contains(&4)); + } + + #[test] + fn table_remove_returns_true_only_for_known_ids() { + let mut t = SubscriptionTable::default(); + t.add_with_id(SubscriptionId(1), "a".into(), false); + assert!(t.remove(SubscriptionId(1))); + // Second remove of the same id is false. + assert!(!t.remove(SubscriptionId(1))); + // Removing a never-known id is false. + assert!(!t.remove(SubscriptionId(999))); + } + + #[test] + fn table_remove_preserves_other_entries_after_swap_remove() { + let mut t = SubscriptionTable::default(); + t.add_with_id(SubscriptionId(1), "a".into(), false); + t.add_with_id(SubscriptionId(2), "b".into(), false); + t.add_with_id(SubscriptionId(3), "c".into(), false); + + // Remove the middle entry — swap_remove will move entry 3 into the slot. + assert!(t.remove(SubscriptionId(2))); + + // Subsequent removes still work, proving the by_id index was kept consistent. + assert!(t.remove(SubscriptionId(3))); + assert!(t.remove(SubscriptionId(1))); + } + + #[test] + fn table_clear_removes_all() { + let mut t = SubscriptionTable::default(); + t.add_with_id(SubscriptionId(1), "a".into(), false); + t.add_with_id(SubscriptionId(2), "b".into(), false); + t.clear(); + assert!(t.match_event("a").is_empty()); + assert!(t.match_event("b").is_empty()); + // After clear, the ids are reusable. + assert!(!t.remove(SubscriptionId(1))); + } + + #[test] + fn table_match_returns_empty_for_unmatched_event() { + let mut t = SubscriptionTable::default(); + t.add_with_id(SubscriptionId(1), "bread.device.*".into(), false); + assert!(t.match_event("bread.window.opened").is_empty()); + } + + #[test] + fn table_once_flag_is_preserved_in_match_result() { + let mut t = SubscriptionTable::default(); + t.add_with_id(SubscriptionId(1), "bread.test".into(), true); + let matches = t.match_event("bread.test"); + assert_eq!(matches.len(), 1); + assert!(matches[0].once); + } } diff --git a/breadd/src/core/supervisor.rs b/breadd/src/core/supervisor.rs index 424ba6f..d6799d5 100644 --- a/breadd/src/core/supervisor.rs +++ b/breadd/src/core/supervisor.rs @@ -8,8 +8,7 @@ pub fn spawn_supervised( name: &'static str, mut shutdown_rx: watch::Receiver, mut task_factory: F, -) -where +) where F: FnMut() -> Fut + Send + 'static, Fut: Future> + Send + 'static, { @@ -50,7 +49,11 @@ where } let wait_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6))); - warn!(adapter = name, delay_ms = wait_ms, "restarting adapter after failure"); + warn!( + adapter = name, + delay_ms = wait_ms, + "restarting adapter after failure" + ); tokio::select! { _ = sleep(Duration::from_millis(wait_ms)) => {}, _ = shutdown_rx.changed() => { diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 02886c9..ad03fa4 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -1,8 +1,9 @@ use std::collections::{BTreeMap, HashMap}; use serde::{Deserialize, Serialize}; +use serde_json::Value; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct RuntimeState { pub monitors: Vec, pub workspaces: Vec, @@ -15,22 +16,6 @@ pub struct RuntimeState { pub modules: Vec, } -impl Default for RuntimeState { - fn default() -> Self { - Self { - monitors: Vec::new(), - workspaces: Vec::new(), - active_workspace: None, - active_window: None, - devices: DeviceTopology::default(), - network: NetworkState::default(), - power: PowerState::default(), - profile: ProfileState::default(), - modules: Vec::new(), - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Monitor { pub name: String, @@ -54,21 +39,38 @@ pub struct DeviceTopology { pub struct Device { pub id: String, pub name: String, - pub class: DeviceClass, + pub device: String, pub subsystem: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub vendor_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub product_id: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum DeviceClass { - Dock, - Keyboard, - Mouse, - Tablet, - Display, - Storage, - Audio, - Unknown, +/// One set of match conditions. All provided fields must match. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MatchCondition { + pub vendor_id: Option, + pub product_id: Option, + pub name: Option, + pub vendor: Option, + pub name_contains: Option, + pub id_input_keyboard: Option, + pub id_input_mouse: Option, + pub id_input_tablet: Option, + /// True triggers the compound USB hub + secondary-interface check. + pub usb_hub: Option, + pub id_usb_class: Option, + pub subsystem: Option, +} + +/// A device rule from `devices.lua`. The device name is assigned if ANY +/// condition in `conditions` matches (OR semantics across conditions, +/// AND semantics within a condition). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceRule { + pub device: String, + pub conditions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -82,23 +84,13 @@ pub struct InterfaceState { pub up: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct PowerState { pub ac_connected: bool, pub battery_percent: Option, pub battery_low: bool, } -impl Default for PowerState { - fn default() -> Self { - Self { - ac_connected: false, - battery_percent: None, - battery_low: false, - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProfileState { pub active: String, @@ -121,6 +113,10 @@ pub struct ModuleStatus { pub name: String, pub status: ModuleLoadState, pub last_error: Option, + #[serde(default)] + pub builtin: bool, + #[serde(default)] + pub store: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -129,4 +125,6 @@ pub enum ModuleLoadState { Loaded, LoadError, NotFound, + Degraded, + Disabled, } diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index 1ac245b..e9ef497 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -1,18 +1,22 @@ +use std::collections::{HashMap, VecDeque}; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::process; +use std::sync::atomic::AtomicU64; +use std::sync::Arc; use std::time::Instant; use anyhow::{anyhow, Result}; -use bread_shared::{AdapterSource, BreadEvent}; +use bread_shared::{now_unix_ms, AdapterSource, BreadEvent}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; -use tokio::sync::{broadcast, mpsc, watch}; +use tokio::sync::{broadcast, mpsc, watch, RwLock}; use tracing::{error, info, warn}; +use crate::adapters::AdapterStatus; use crate::core::state_engine::StateHandle; use crate::lua::RuntimeHandle; @@ -23,6 +27,9 @@ pub struct Server { event_tx: broadcast::Sender, lua_runtime: RuntimeHandle, emit_tx: mpsc::UnboundedSender, + adapter_status: Arc>>, + subscription_count: Arc, + event_buffer: Arc>>, started_at: Instant, pid: u32, } @@ -45,12 +52,18 @@ struct IpcResponse { } impl Server { + // Server::new legitimately requires all 8 fields; a builder pattern here would be + // over-engineering for a single-call-site constructor. + #[allow(clippy::too_many_arguments)] pub fn new( socket_path: PathBuf, state_handle: StateHandle, event_tx: broadcast::Sender, lua_runtime: RuntimeHandle, emit_tx: mpsc::UnboundedSender, + adapter_status: Arc>>, + subscription_count: Arc, + event_buffer: Arc>>, ) -> Self { Self { socket_path, @@ -58,6 +71,9 @@ impl Server { event_tx, lua_runtime, emit_tx, + adapter_status, + subscription_count, + event_buffer, started_at: Instant::now(), pid: process::id(), } @@ -148,7 +164,10 @@ impl Server { Ok(()) } - async fn handle_request(&self, req: IpcRequest) -> std::result::Result<(String, Value), (String, String)> { + async fn handle_request( + &self, + req: IpcRequest, + ) -> std::result::Result<(String, Value), (String, String)> { let id = req.id.clone(); let result = match req.method.as_str() { "ping" => Ok(json!({ "ok": true })), @@ -166,12 +185,25 @@ impl Server { let full = self.state_handle.state_dump().await; Ok(full.get("modules").cloned().unwrap_or_else(|| json!([]))) } - "modules.reload" => self - .lua_runtime - .reload() - .await - .map(|_| json!({ "reloaded": true })) - .map_err(|e| e.to_string()), + "modules.reload" => { + let started = Instant::now(); + if let Err(err) = self.lua_runtime.reload().await { + return Err((id, err.to_string())); + } + let duration_ms = started.elapsed().as_millis(); + let modules = self + .state_handle + .state_dump() + .await + .get("modules") + .cloned() + .unwrap_or_else(|| json!([])); + Ok(json!({ + "ok": true, + "duration_ms": duration_ms, + "modules": modules, + })) + } "profile.list" => { let full = self.state_handle.state_dump().await; let profiles = full @@ -182,11 +214,7 @@ impl Server { Ok(profiles) } "profile.activate" => { - let Some(name) = req - .params - .get("name") - .and_then(Value::as_str) - else { + let Some(name) = req.params.get("name").and_then(Value::as_str) else { return Err((id, "missing profile name".to_string())); }; @@ -205,11 +233,7 @@ impl Server { Ok(json!({ "active": name })) } "emit" => { - let Some(event) = req - .params - .get("event") - .and_then(Value::as_str) - else { + let Some(event) = req.params.get("event").and_then(Value::as_str) else { return Err((id, "missing event name".to_string())); }; let data = req.params.get("data").cloned().unwrap_or_else(|| json!({})); @@ -224,13 +248,70 @@ impl Server { } "health" => { let uptime_ms = self.started_at.elapsed().as_millis(); + let state = self.state_handle.state_dump().await; + let modules = state.get("modules").cloned().unwrap_or_else(|| json!([])); + let adapters = self.adapter_status.read().await.clone(); + let subscription_count = self + .subscription_count + .load(std::sync::atomic::Ordering::Relaxed); + let recent_errors = self.lua_runtime.recent_errors(); Ok(json!({ "ok": true, "pid": self.pid, "version": env!("CARGO_PKG_VERSION"), "uptime_ms": uptime_ms, + "socket": self.socket_path.to_string_lossy(), + "adapters": adapters, + "modules": modules, + "subscriptions": subscription_count, + "recent_errors": recent_errors, })) } + "sync.status" => { + let sync_path = bread_sync::config::bread_config_dir().join("sync.toml"); + match std::fs::read_to_string(&sync_path) + .ok() + .and_then(|s| s.parse::().ok()) + { + Some(toml) => { + let machine = toml + .get("machine") + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let remote = toml + .get("remote") + .and_then(|r| r.get("url")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + Ok(json!({ + "initialized": true, + "machine": machine, + "remote": remote, + })) + } + None => Ok(json!({ "initialized": false })), + } + } + "events.replay" => { + let since_ms = req + .params + .get("since_ms") + .and_then(Value::as_u64) + .unwrap_or(0); + let cutoff = now_unix_ms().saturating_sub(since_ms); + let replay: Vec = self + .event_buffer + .lock() + .map(|buf| { + buf.iter() + .filter(|e| e.timestamp >= cutoff) + .cloned() + .collect() + }) + .unwrap_or_default(); + Ok(serde_json::to_value(replay).unwrap_or_else(|_| json!([]))) + } _ => Err("unknown method".to_string()), }; @@ -264,9 +345,134 @@ impl Server { } fn matches_filter(event_name: &str, pattern: &str) -> bool { + // Delegate to the same glob logic used by the subscription table so that + // `bread events --filter "bread.device.**"` behaves identically to + // `bread.on("bread.device.**", ...)` in Lua. if pattern.ends_with(".*") { let prefix = &pattern[..pattern.len() - 1]; return event_name.starts_with(prefix); } - event_name == pattern + + if let Some(prefix) = pattern.strip_suffix(".**") { + if event_name == prefix || event_name.starts_with(&format!("{prefix}.")) { + return true; + } + return false; + } + + matches_glob_filter(pattern.as_bytes(), event_name.as_bytes()) +} + +fn matches_glob_filter(pattern: &[u8], text: &[u8]) -> bool { + if pattern.is_empty() { + return text.is_empty(); + } + + if pattern.len() >= 2 && pattern[0] == b'*' && pattern[1] == b'*' { + let rest = &pattern[2..]; + if rest.is_empty() { + return true; + } + for offset in 0..=text.len() { + if matches_glob_filter(rest, &text[offset..]) { + return true; + } + } + return false; + } + + match pattern[0] { + b'*' => { + let mut offset = 0; + loop { + if matches_glob_filter(&pattern[1..], &text[offset..]) { + return true; + } + if offset == text.len() || text[offset] == b'.' { + break; + } + offset += 1; + } + false + } + b'?' => { + if text.is_empty() || text[0] == b'.' { + return false; + } + matches_glob_filter(&pattern[1..], &text[1..]) + } + ch => { + if text.first().copied() != Some(ch) { + return false; + } + matches_glob_filter(&pattern[1..], &text[1..]) + } + } +} + +#[cfg(test)] +mod tests { + use super::matches_filter; + + #[test] + fn filter_exact_match() { + assert!(matches_filter("bread.window.opened", "bread.window.opened")); + assert!(!matches_filter( + "bread.window.opened", + "bread.window.closed" + )); + } + + #[test] + fn filter_dot_star_matches_one_segment_only() { + assert!(matches_filter("bread.device.connected", "bread.device.*")); + assert!(matches_filter( + "bread.device.dock.connected", + "bread.device.*" + )); + assert!(!matches_filter("bread.device", "bread.device.*")); + } + + #[test] + fn filter_dot_double_star_matches_zero_or_more_segments() { + // Matches the exact prefix (zero segments after). + assert!(matches_filter("bread.device", "bread.device.**")); + // And matches deeper paths. + assert!(matches_filter( + "bread.device.dock.connected", + "bread.device.**" + )); + // But not a sibling at the same depth. + assert!(!matches_filter( + "bread.network.connected", + "bread.device.**" + )); + } + + #[test] + fn filter_question_mark_matches_single_char_not_dot() { + assert!(matches_filter("bread.x", "bread.?")); + assert!(!matches_filter("bread.xy", "bread.?")); + assert!(!matches_filter("bread.", "bread.?")); + } + + #[test] + fn filter_mid_pattern_star_does_not_cross_dots() { + // A `*` in the middle of the pattern (not the `.*` suffix shortcut) + // matches within a single segment only. + assert!(matches_filter("bread.alpha.connected", "bread.*.connected")); + assert!(!matches_filter( + "bread.alpha.beta.connected", + "bread.*.connected" + )); + } + + #[test] + fn filter_dot_star_at_end_acts_as_prefix_match() { + // `bread.*` ending the pattern is treated as a prefix match, so + // matches everything under `bread.` regardless of depth. This is + // consistent with the subscription table's pattern matcher. + assert!(matches_filter("bread.alpha", "bread.*")); + assert!(matches_filter("bread.alpha.beta", "bread.*")); + } } diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index beae215..b7a7453 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -1,20 +1,27 @@ -use std::collections::HashMap; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; use std::path::{Path, PathBuf}; +use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use anyhow::{anyhow, Result}; use bread_shared::{AdapterSource, BreadEvent}; -use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, Value}; -use tokio::sync::{mpsc, oneshot}; +use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value}; +use serde::Serialize; +use serde_json::Value as JsonValue; +use tokio::sync::{mpsc, oneshot, watch, RwLock}; use tokio::task; +use tokio::time::{interval_at, sleep, Instant}; use tracing::{error, info, warn}; -use crate::core::config::Config; +use crate::core::config::{Config, ModulesConfig, NotificationsConfig}; use crate::core::state_engine::StateHandle; use crate::core::subscriptions::SubscriptionId; -use crate::core::types::ModuleLoadState; +use crate::core::types::{DeviceRule, MatchCondition, ModuleLoadState, RuntimeState}; +use bread_shared::now_unix_ms; pub enum LuaMessage { Event { @@ -24,15 +31,26 @@ pub enum LuaMessage { SubscriptionCancelled { id: SubscriptionId, }, + TimerFired { + id: TimerId, + }, Reload { reply: oneshot::Sender>, }, Shutdown, } +#[derive(Debug, Clone, Serialize)] +pub struct ErrorEntry { + pub timestamp: u64, + pub module: Option, + pub message: String, +} + #[derive(Clone)] pub struct RuntimeHandle { tx: mpsc::UnboundedSender, + recent_errors: Arc>>, } impl RuntimeHandle { @@ -55,6 +73,13 @@ impl RuntimeHandle { pub fn shutdown(&self) { let _ = self.tx.send(LuaMessage::Shutdown); } + + pub fn recent_errors(&self) -> Vec { + self.recent_errors + .lock() + .map(|buf| buf.iter().cloned().collect()) + .unwrap_or_default() + } } pub fn spawn_runtime( @@ -63,7 +88,11 @@ pub fn spawn_runtime( emit_tx: mpsc::UnboundedSender, ) -> Result { let (tx, mut rx) = mpsc::unbounded_channel(); - let handle = RuntimeHandle { tx }; + let recent_errors = Arc::new(Mutex::new(VecDeque::with_capacity(50))); + let handle = RuntimeHandle { + tx, + recent_errors: recent_errors.clone(), + }; let thread_tx = handle.tx.clone(); std::thread::Builder::new() @@ -75,7 +104,13 @@ pub fn spawn_runtime( .expect("failed to create lua runtime thread"); rt.block_on(async move { - let mut engine = match LuaEngine::new(config, state_handle, emit_tx) { + let mut engine = match LuaEngine::new( + config, + state_handle, + emit_tx, + thread_tx.clone(), + recent_errors, + ) { Ok(engine) => engine, Err(err) => { error!(error = %err, "failed to initialize lua engine"); @@ -100,6 +135,11 @@ pub fn spawn_runtime( LuaMessage::SubscriptionCancelled { id } => { engine.remove_handler(id); } + LuaMessage::TimerFired { id } => { + if let Err(err) = engine.handle_timer(id) { + error!(error = %err, "lua timer handler failed"); + } + } LuaMessage::Reload { reply } => { let result = engine.reload_internal().map_err(|e| e.to_string()); let _ = reply.send(result); @@ -118,39 +158,126 @@ pub fn spawn_runtime( Ok(handle) } +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub(crate) struct TimerId(u64); + +struct HandlerEntry { + callback: RegistryKey, + filter: Option, + module: Option, + raw_kind: Option, + kind: HandlerKind, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum HandlerKind { + Event, + StateWatch, +} + +struct TimerEntry { + callback: RegistryKey, + repeating: bool, + cancel_tx: watch::Sender, +} + +#[derive(Clone)] +struct ModuleDecl { + name: String, + version: Option, + after: Vec, + path: PathBuf, + source: Option<&'static str>, + builtin: bool, +} + +struct ModuleInfo { + table_key: RegistryKey, +} + struct LuaEngine { lua: Lua, - handlers: Arc>>, + handlers: Arc>>, + watch_ids: Arc>>, + timers: Arc>>, next_sub_id: Arc, + next_timer_id: Arc, + current_module: Arc>>, + modules: Arc>>, + module_decls: Arc>>, + module_order: Arc>>, state_handle: StateHandle, emit_tx: mpsc::UnboundedSender, + lua_tx: mpsc::UnboundedSender, entry_point: PathBuf, module_path: PathBuf, + modules_config: ModulesConfig, + notifications_config: NotificationsConfig, + recent_errors: Arc>>, } impl LuaEngine { - fn new(config: Config, state_handle: StateHandle, emit_tx: mpsc::UnboundedSender) -> Result { + fn new( + config: Config, + state_handle: StateHandle, + emit_tx: mpsc::UnboundedSender, + lua_tx: mpsc::UnboundedSender, + recent_errors: Arc>>, + ) -> Result { Ok(Self { lua: Lua::new(), handlers: Arc::new(Mutex::new(HashMap::new())), + watch_ids: Arc::new(Mutex::new(HashSet::new())), + timers: Arc::new(Mutex::new(HashMap::new())), next_sub_id: Arc::new(AtomicU64::new(1)), + next_timer_id: Arc::new(AtomicU64::new(1)), + current_module: Arc::new(Mutex::new(None)), + modules: Arc::new(Mutex::new(HashMap::new())), + module_decls: Arc::new(Mutex::new(HashMap::new())), + module_order: Arc::new(Mutex::new(Vec::new())), state_handle, emit_tx, + lua_tx, entry_point: config.lua_entry_point(), module_path: config.lua_module_path(), + modules_config: config.modules.clone(), + notifications_config: config.notifications.clone(), + recent_errors, }) } fn reload_internal(&mut self) -> Result<()> { + self.run_on_unload(); + self.cancel_all_timers(); self.state_handle.clear_subscriptions(); + self.state_handle.clear_modules(); self.lua = Lua::new(); self.handlers .lock() .expect("lua handlers mutex poisoned") .clear(); + self.watch_ids + .lock() + .expect("lua watch ids mutex poisoned") + .clear(); + self.modules + .lock() + .expect("lua modules mutex poisoned") + .clear(); + self.module_decls + .lock() + .expect("lua module decls mutex poisoned") + .clear(); + self.module_order + .lock() + .expect("lua module order mutex poisoned") + .clear(); self.install_api()?; + self.load_device_rules()?; + self.load_profiles()?; self.load_init_and_modules()?; + self.run_on_reload(); info!("lua runtime reloaded"); Ok(()) } @@ -162,87 +289,254 @@ impl LuaEngine { let handlers = self.handlers.clone(); let next_sub_id = self.next_sub_id.clone(); let state_handle = self.state_handle.clone(); - let on_fn = self.lua.create_function(move |lua, (pattern, callback): (String, Function)| { - let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); - let key = lua.create_registry_value(callback)?; - handlers - .lock() - .map_err(|_| mlua::Error::external("handler lock poisoned"))? - .insert(id, key); - state_handle - .register_subscription(id, pattern, false) - .map_err(mlua::Error::external)?; - Ok(id.0) - })?; + let current_module = self.current_module.clone(); + let on_fn = + self.lua + .create_function(move |lua, (pattern, callback): (String, Function)| { + let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); + handlers + .lock() + .map_err(|_| LuaError::external("handler lock poisoned"))? + .insert( + id, + HandlerEntry { + callback: key, + filter: None, + module, + raw_kind: None, + kind: HandlerKind::Event, + }, + ); + state_handle + .register_subscription(id, pattern, false) + .map_err(LuaError::external)?; + Ok(id.0) + })?; bread.set("on", on_fn)?; let handlers = self.handlers.clone(); let next_sub_id = self.next_sub_id.clone(); let state_handle = self.state_handle.clone(); - let once_fn = self.lua.create_function(move |lua, (pattern, callback): (String, Function)| { - let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); - let key = lua.create_registry_value(callback)?; - handlers - .lock() - .map_err(|_| mlua::Error::external("handler lock poisoned"))? - .insert(id, key); - state_handle - .register_subscription(id, pattern, true) - .map_err(mlua::Error::external)?; - Ok(id.0) - })?; + let current_module = self.current_module.clone(); + let once_fn = + self.lua + .create_function(move |lua, (pattern, callback): (String, Function)| { + let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); + handlers + .lock() + .map_err(|_| LuaError::external("handler lock poisoned"))? + .insert( + id, + HandlerEntry { + callback: key, + filter: None, + module, + raw_kind: None, + kind: HandlerKind::Event, + }, + ); + state_handle + .register_subscription(id, pattern, true) + .map_err(LuaError::external)?; + Ok(id.0) + })?; bread.set("once", once_fn)?; - let emit_tx = self.emit_tx.clone(); - let emit_fn = self.lua.create_function(move |lua, (event_name, payload): (String, Value)| { - let data = match payload { - Value::Nil => serde_json::json!({}), - other => lua - .from_value::(other) - .unwrap_or_else(|_| serde_json::json!({})), - }; - emit_tx - .send(BreadEvent::new(event_name, AdapterSource::System, data)) - .map_err(|_| mlua::Error::external("event channel closed"))?; + let handlers = self.handlers.clone(); + let next_sub_id = self.next_sub_id.clone(); + let state_handle = self.state_handle.clone(); + let current_module = self.current_module.clone(); + let filter_fn = self + .lua + .create_function(move |lua, (pattern, callback, opts): (String, Function, Option)| { + let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let filter = if let Some(opts) = opts { + let filter_fn: Function = opts + .get("filter") + .map_err(|_| LuaError::external("missing filter function"))?; + Some(lua.create_registry_value(filter_fn)?) + } else { + return Err(LuaError::external( + "bread.filter requires an opts table with a 'filter' function: bread.filter(pattern, fn, { filter = fn })", + )); + }; + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); + handlers + .lock() + .map_err(|_| LuaError::external("handler lock poisoned"))? + .insert( + id, + HandlerEntry { + callback: key, + filter, + module, + raw_kind: None, + kind: HandlerKind::Event, + }, + ); + state_handle + .register_subscription(id, pattern, false) + .map_err(LuaError::external)?; + Ok(id.0) + })?; + bread.set("filter", filter_fn)?; + + let handlers = self.handlers.clone(); + let watch_ids = self.watch_ids.clone(); + let state_handle = self.state_handle.clone(); + let off_fn = self.lua.create_function(move |_lua, id: u64| { + let sub_id = SubscriptionId(id); + if let Ok(mut map) = handlers.lock() { + map.remove(&sub_id); + } + state_handle.remove_subscription(sub_id); + if let Ok(mut set) = watch_ids.lock() { + if set.remove(&sub_id) { + state_handle.remove_watch(sub_id); + } + } Ok(()) })?; + bread.set("off", off_fn)?; + + let emit_tx = self.emit_tx.clone(); + let emit_fn = + self.lua + .create_function(move |lua, (event_name, payload): (String, Value)| { + let data = match payload { + Value::Nil => serde_json::json!({}), + other => lua + .from_value::(other) + .unwrap_or_else(|_| serde_json::json!({})), + }; + emit_tx + .send(BreadEvent::new(event_name, AdapterSource::System, data)) + .map_err(|_| LuaError::external("event channel closed"))?; + Ok(()) + })?; bread.set("emit", emit_fn)?; let state_arc = self.state_handle.state_arc(); let state_tbl = self.lua.create_table()?; - let get_fn = self.lua.create_function(move |lua, path: String| { - let snapshot = state_arc.blocking_read(); - let mut value = serde_json::to_value(&*snapshot) - .map_err(|e| mlua::Error::external(e.to_string()))?; - if path.is_empty() { - return lua - .to_value(&value) - .map_err(|e| mlua::Error::external(e.to_string())); - } - for part in path.split('.') { - value = value - .get(part) - .cloned() - .ok_or_else(|| mlua::Error::external("state path not found"))?; - } - lua.to_value(&value) - .map_err(|e| mlua::Error::external(e.to_string())) - })?; + let get_fn = self + .lua + .create_function(move |lua, path: String| state_value_to_lua(lua, &state_arc, &path))?; state_tbl.set("get", get_fn)?; + + let state_arc = self.state_handle.state_arc(); + let monitors_fn = self + .lua + .create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "monitors"))?; + state_tbl.set("monitors", monitors_fn)?; + + let state_arc = self.state_handle.state_arc(); + let active_ws_fn = self.lua.create_function(move |lua, ()| { + state_value_to_lua(lua, &state_arc, "active_workspace") + })?; + state_tbl.set("active_workspace", active_ws_fn)?; + + let state_arc = self.state_handle.state_arc(); + let active_win_fn = self + .lua + .create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "active_window"))?; + state_tbl.set("active_window", active_win_fn)?; + + let state_arc = self.state_handle.state_arc(); + let devices_fn = self + .lua + .create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "devices"))?; + state_tbl.set("devices", devices_fn)?; + + let state_arc = self.state_handle.state_arc(); + let power_fn = self + .lua + .create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "power"))?; + state_tbl.set("power", power_fn)?; + + let state_arc = self.state_handle.state_arc(); + let network_fn = self + .lua + .create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "network"))?; + state_tbl.set("network", network_fn)?; + + let state_arc = self.state_handle.state_arc(); + let profile_state_fn = self + .lua + .create_function(move |lua, ()| state_value_to_lua(lua, &state_arc, "profile"))?; + state_tbl.set("profile", profile_state_fn)?; + + let handlers = self.handlers.clone(); + let watch_ids = self.watch_ids.clone(); + let next_sub_id = self.next_sub_id.clone(); + let state_handle = self.state_handle.clone(); + let current_module = self.current_module.clone(); + let watch_fn = + self.lua + .create_function(move |lua, (path, callback): (String, Function)| { + let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); + handlers + .lock() + .map_err(|_| LuaError::external("handler lock poisoned"))? + .insert( + id, + HandlerEntry { + callback: key, + filter: None, + module, + raw_kind: None, + kind: HandlerKind::StateWatch, + }, + ); + watch_ids + .lock() + .map_err(|_| LuaError::external("watch id lock poisoned"))? + .insert(id); + state_handle + .register_watch(id, path.clone()) + .map_err(LuaError::external)?; + state_handle + .register_subscription(id, format!("bread.state.changed.{path}"), false) + .map_err(LuaError::external)?; + Ok(id.0) + })?; + state_tbl.set("watch", watch_fn)?; + bread.set("state", state_tbl)?; let profile_tbl = self.lua.create_table()?; let state_handle = self.state_handle.clone(); + let emit_tx = self.emit_tx.clone(); let activate_fn = self.lua.create_function(move |_lua, name: String| { state_handle.set_profile(name.clone()); + let _ = emit_tx.send(BreadEvent::new( + "bread.profile.activated", + AdapterSource::System, + serde_json::json!({ "name": name }), + )); Ok(()) })?; profile_tbl.set("activate", activate_fn)?; bread.set("profile", profile_tbl)?; - // Fire-and-forget: the process is launched on a blocking thread and the - // Lua handler returns immediately. The Lua runtime is never stalled waiting - // for a slow or hanging process. Exit code is logged but not returned to Lua. let exec_fn = self.lua.create_function(move |_lua, cmd: String| { task::spawn_blocking(move || { match std::process::Command::new("sh") @@ -264,64 +558,752 @@ impl LuaEngine { })?; bread.set("exec", exec_fn)?; + let notify_path = self.notifications_config.notify_send_path.clone(); + let default_urgency = self.notifications_config.default_urgency.clone(); + let default_timeout = self.notifications_config.default_timeout_ms; + let emit_tx = self.emit_tx.clone(); + let notify_fn = + self.lua + .create_function(move |_lua, (message, opts): (String, Option
)| { + let title: String = opts + .as_ref() + .and_then(|o| o.get("title").ok()) + .unwrap_or_else(|| "bread".to_string()); + let urgency: String = opts + .as_ref() + .and_then(|o| o.get("urgency").ok()) + .unwrap_or_else(|| default_urgency.clone()); + let timeout: i64 = opts + .as_ref() + .and_then(|o| o.get("timeout").ok()) + .unwrap_or(default_timeout); + let icon: Option = opts.as_ref().and_then(|o| o.get("icon").ok()); + + let cmd_path = notify_path.clone(); + let title_clone = title.clone(); + let message_clone = message.clone(); + let urgency_clone = urgency.clone(); + task::spawn_blocking(move || { + let mut cmd = std::process::Command::new(cmd_path); + cmd.args([ + "--app-name", + "bread", + "--urgency", + &urgency_clone, + "--expire-time", + &timeout.to_string(), + ]); + if let Some(icon) = icon { + cmd.args(["--icon", &icon]); + } + let _ = cmd.args([&title_clone, &message_clone]).status(); + }); + + let _ = emit_tx.send(BreadEvent::new( + "bread.notify.sent", + AdapterSource::System, + serde_json::json!({ + "title": title, + "message": message, + "urgency": urgency, + }), + )); + + Ok(()) + })?; + bread.set("notify", notify_fn)?; + + let timers = self.timers.clone(); + let next_timer_id = self.next_timer_id.clone(); + let lua_tx = self.lua_tx.clone(); + let after_fn = + self.lua + .create_function(move |lua, (delay_ms, callback): (u64, Function)| { + let id = TimerId(next_timer_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let (cancel_tx, mut cancel_rx) = watch::channel(false); + timers + .lock() + .map_err(|_| LuaError::external("timer lock poisoned"))? + .insert( + id, + TimerEntry { + callback: key, + repeating: false, + cancel_tx, + }, + ); + let lua_tx = lua_tx.clone(); + task::spawn(async move { + tokio::select! { + _ = sleep(Duration::from_millis(delay_ms)) => { + if !*cancel_rx.borrow() { + let _ = lua_tx.send(LuaMessage::TimerFired { id }); + } + } + _ = cancel_rx.changed() => {} + } + }); + Ok(id.0) + })?; + bread.set("after", after_fn)?; + + let timers = self.timers.clone(); + let next_timer_id = self.next_timer_id.clone(); + let lua_tx = self.lua_tx.clone(); + let every_fn = + self.lua + .create_function(move |lua, (interval_ms, callback): (u64, Function)| { + let id = TimerId(next_timer_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let (cancel_tx, mut cancel_rx) = watch::channel(false); + timers + .lock() + .map_err(|_| LuaError::external("timer lock poisoned"))? + .insert( + id, + TimerEntry { + callback: key, + repeating: true, + cancel_tx, + }, + ); + let lua_tx = lua_tx.clone(); + task::spawn(async move { + let start = Instant::now() + Duration::from_millis(interval_ms); + let mut ticker = interval_at(start, Duration::from_millis(interval_ms)); + loop { + tokio::select! { + _ = ticker.tick() => { + if *cancel_rx.borrow() { + break; + } + let _ = lua_tx.send(LuaMessage::TimerFired { id }); + } + _ = cancel_rx.changed() => { + if *cancel_rx.borrow() { + break; + } + } + } + } + }); + Ok(id.0) + })?; + bread.set("every", every_fn)?; + + let timers = self.timers.clone(); + let cancel_fn = self.lua.create_function(move |_lua, id: u64| { + let timer_id = TimerId(id); + if let Ok(mut map) = timers.lock() { + if let Some(entry) = map.remove(&timer_id) { + let _ = entry.cancel_tx.send(true); + } + } + Ok(()) + })?; + bread.set("cancel", cancel_fn)?; + + let hyprland_tbl = self.lua.create_table()?; + let dispatch_fn = + self.lua + .create_function(move |_lua, (cmd, args): (String, String)| { + let resp = hyprland_request(&format!("dispatch {cmd} {args}")) + .map_err(|e| LuaError::external(e.to_string()))?; + Ok(resp) + })?; + hyprland_tbl.set("dispatch", dispatch_fn)?; + + let keyword_fn = + self.lua + .create_function(move |_lua, (key, value): (String, String)| { + let resp = hyprland_request(&format!("keyword {key} {value}")) + .map_err(|e| LuaError::external(e.to_string()))?; + Ok(resp) + })?; + hyprland_tbl.set("keyword", keyword_fn)?; + + let eval_fn = self.lua.create_function(move |_lua, expr: String| { + let resp = hyprland_request(&format!("eval {expr}")) + .map_err(|e| LuaError::external(e.to_string()))?; + Ok(resp) + })?; + hyprland_tbl.set("eval", eval_fn)?; + + let active_window_fn = self.lua.create_function(move |lua, ()| { + let resp = hyprland_request("j/activewindow") + .map_err(|e| LuaError::external(e.to_string()))?; + let json: JsonValue = + serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?; + lua.to_value(&json) + .map_err(|e| LuaError::external(e.to_string())) + })?; + hyprland_tbl.set("active_window", active_window_fn)?; + + let monitors_fn = self.lua.create_function(move |lua, ()| { + let resp = + hyprland_request("j/monitors").map_err(|e| LuaError::external(e.to_string()))?; + let json: JsonValue = + serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?; + lua.to_value(&json) + .map_err(|e| LuaError::external(e.to_string())) + })?; + hyprland_tbl.set("monitors", monitors_fn)?; + + let workspaces_fn = self.lua.create_function(move |lua, ()| { + let resp = + hyprland_request("j/workspaces").map_err(|e| LuaError::external(e.to_string()))?; + let json: JsonValue = + serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?; + lua.to_value(&json) + .map_err(|e| LuaError::external(e.to_string())) + })?; + hyprland_tbl.set("workspaces", workspaces_fn)?; + + let clients_fn = self.lua.create_function(move |lua, ()| { + let resp = + hyprland_request("j/clients").map_err(|e| LuaError::external(e.to_string()))?; + let json: JsonValue = + serde_json::from_str(&resp).map_err(|e| LuaError::external(e.to_string()))?; + lua.to_value(&json) + .map_err(|e| LuaError::external(e.to_string())) + })?; + hyprland_tbl.set("clients", clients_fn)?; + + let handlers = self.handlers.clone(); + let next_sub_id = self.next_sub_id.clone(); + let state_handle = self.state_handle.clone(); + let current_module = self.current_module.clone(); + let on_raw_fn = + self.lua + .create_function(move |lua, (event, callback): (String, Function)| { + let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + let module = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); + handlers + .lock() + .map_err(|_| LuaError::external("handler lock poisoned"))? + .insert( + id, + HandlerEntry { + callback: key, + filter: None, + module, + raw_kind: Some(event), + kind: HandlerKind::Event, + }, + ); + state_handle + .register_subscription(id, "bread.hyprland.event".to_string(), false) + .map_err(LuaError::external)?; + Ok(id.0) + })?; + hyprland_tbl.set("on_raw", on_raw_fn)?; + bread.set("hyprland", hyprland_tbl)?; + + let modules = self.modules.clone(); + let module_decls = self.module_decls.clone(); + let current_module = self.current_module.clone(); + let state_arc = self.state_handle.state_arc(); + let module_fn = self.lua.create_function(move |lua, decl: Table| { + let name: String = decl.get("name")?; + let expected = current_module + .lock() + .map_err(|_| LuaError::external("module context lock poisoned"))? + .clone(); + if expected.as_deref() != Some(&name) { + return Err(LuaError::external( + "module name does not match current load", + )); + } + + let decl = module_decls + .lock() + .map_err(|_| LuaError::external("module decls lock poisoned"))? + .get(&name) + .cloned() + .ok_or_else(|| LuaError::external("module declaration not found"))?; + + let module_tbl = lua.create_table()?; + module_tbl.set("name", decl.name.clone())?; + if let Some(version) = decl.version.clone() { + module_tbl.set("version", version)?; + } + + let store_tbl = lua.create_table()?; + let module_name = decl.name.clone(); + let state_arc_get = state_arc.clone(); + let get_fn = lua.create_function(move |lua, key: String| { + if let Some(value) = module_store_get(&state_arc_get, &module_name, &key) { + return lua + .to_value(&value) + .map_err(|e| LuaError::external(e.to_string())); + } + Ok(Value::Nil) + })?; + store_tbl.set("get", get_fn)?; + + let module_name = decl.name.clone(); + let state_arc_set = state_arc.clone(); + let set_fn = lua.create_function(move |lua, (key, value): (String, Value)| { + let json = lua + .from_value::(value) + .unwrap_or(JsonValue::Null); + module_store_set(&state_arc_set, &module_name, key, json); + Ok(()) + })?; + store_tbl.set("set", set_fn)?; + module_tbl.set("store", store_tbl)?; + + let key = lua.create_registry_value(module_tbl.clone())?; + modules + .lock() + .map_err(|_| LuaError::external("module registry lock poisoned"))? + .insert(decl.name.clone(), ModuleInfo { table_key: key }); + + // Register in package.loaded so require("bread.devices") etc. works + let package: Table = lua.globals().get("package")?; + let loaded: Table = package.get("loaded")?; + loaded.set(decl.name.clone(), module_tbl.clone())?; + + Ok(module_tbl) + })?; + bread.set("module", module_fn)?; + + // bread.machine — machine name and tags from sync.toml + let machine_tbl = self.lua.create_table()?; + + let name_fn = self + .lua + .create_function(|_lua, ()| Ok(lua_machine_name()))?; + machine_tbl.set("name", name_fn)?; + + let tags_fn = self.lua.create_function(|lua, ()| { + let tags = lua_machine_tags(); + let tbl = lua.create_table()?; + for (i, tag) in tags.iter().enumerate() { + tbl.set(i + 1, tag.clone())?; + } + Ok(tbl) + })?; + machine_tbl.set("tags", tags_fn)?; + + let has_tag_fn = self + .lua + .create_function(|_lua, tag: String| Ok(lua_machine_tags().contains(&tag)))?; + machine_tbl.set("has_tag", has_tag_fn)?; + + bread.set("machine", machine_tbl)?; + + // bread.fs — file system helpers + let fs_tbl = self.lua.create_table()?; + + let write_fn = self + .lua + .create_function(|_lua, (path, content): (String, String)| { + let expanded = lua_expand_path(&path); + if let Some(parent) = expanded.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| LuaError::external(e.to_string()))?; + } + std::fs::write(&expanded, content).map_err(|e| LuaError::external(e.to_string())) + })?; + fs_tbl.set("write", write_fn)?; + + let read_fn = self.lua.create_function(|_lua, path: String| { + let expanded = lua_expand_path(&path); + match std::fs::read_to_string(&expanded) { + Ok(s) => Ok(Some(s)), + Err(_) => Ok(None), + } + })?; + fs_tbl.set("read", read_fn)?; + + let exists_fn = self + .lua + .create_function(|_lua, path: String| Ok(lua_expand_path(&path).exists()))?; + fs_tbl.set("exists", exists_fn)?; + + let expand_fn = self.lua.create_function(|_lua, path: String| { + Ok(lua_expand_path(&path).to_string_lossy().to_string()) + })?; + fs_tbl.set("expand", expand_fn)?; + + bread.set("fs", fs_tbl)?; + + // bread.bluetooth — BlueZ control + let bluetooth_tbl = self.lua.create_table()?; + + let power_fn = self.lua.create_function(move |_lua, enabled: bool| { + bluetooth_spawn(move || async move { + if let Err(e) = bluetooth_set_powered(enabled).await { + tracing::warn!("bread.bluetooth.power failed: {e}"); + } + }); + Ok(()) + })?; + bluetooth_tbl.set("power", power_fn)?; + + let powered_fn = self.lua.create_function(move |_lua, ()| { + Ok(bluetooth_query(|| bluetooth_get_powered()).ok()) + })?; + bluetooth_tbl.set("powered", powered_fn)?; + + let connect_fn = self.lua.create_function(move |_lua, address: String| { + bluetooth_spawn(move || async move { + if let Err(e) = bluetooth_connect(address).await { + tracing::warn!("bread.bluetooth.connect failed: {e}"); + } + }); + Ok(()) + })?; + bluetooth_tbl.set("connect", connect_fn)?; + + let disconnect_fn = self.lua.create_function(move |_lua, address: String| { + bluetooth_spawn(move || async move { + if let Err(e) = bluetooth_disconnect(address).await { + tracing::warn!("bread.bluetooth.disconnect failed: {e}"); + } + }); + Ok(()) + })?; + bluetooth_tbl.set("disconnect", disconnect_fn)?; + + let scan_fn = self.lua.create_function(move |_lua, enabled: bool| { + bluetooth_spawn(move || async move { + if let Err(e) = bluetooth_set_scanning(enabled).await { + tracing::warn!("bread.bluetooth.scan failed: {e}"); + } + }); + Ok(()) + })?; + bluetooth_tbl.set("scan", scan_fn)?; + + let devices_fn = self.lua.create_function(move |lua, ()| { + let devs = match bluetooth_query(|| bluetooth_list_devices()) { + Ok(d) => d, + Err(_) => return Ok(Value::Nil), + }; + let tbl = lua.create_table()?; + for (i, dev) in devs.iter().enumerate() { + let dt = lua.create_table()?; + dt.set("address", dev.address.clone())?; + dt.set("name", dev.name.clone())?; + dt.set("connected", dev.connected)?; + dt.set("paired", dev.paired)?; + tbl.set(i + 1, dt)?; + } + Ok(Value::Table(tbl)) + })?; + bluetooth_tbl.set("devices", devices_fn)?; + + bread.set("bluetooth", bluetooth_tbl)?; + globals.set("bread", bread)?; + self.install_require_loader()?; + self.install_wait_helper()?; + self.install_log_helpers()?; + self.install_debounce()?; Ok(()) } + fn load_device_rules(&self) -> Result<()> { + let devices_path = self + .entry_point + .parent() + .map(|p| p.join("devices.lua")) + .unwrap_or_else(|| std::path::PathBuf::from("devices.lua")); + + if !devices_path.exists() { + return Ok(()); + } + + let source = fs::read_to_string(&devices_path) + .map_err(|e| anyhow!("failed to read devices.lua: {e}"))?; + + let rules_value: mlua::Value = self + .lua + .load(&source) + .set_name("devices.lua") + .eval() + .map_err(|e| anyhow!("devices.lua error: {e}"))?; + + let mlua::Value::Table(tbl) = rules_value else { + return Err(anyhow!("devices.lua must return a table of rules")); + }; + + let mut rules: Vec = Vec::new(); + for pair in tbl.sequence_values::() { + let entry = pair.map_err(|e| anyhow!("devices.lua rule error: {e}"))?; + let device: String = entry.get("device").unwrap_or_default(); + if device.is_empty() { + continue; + } + + // If the rule has a `match` key, each entry in it is a separate condition (OR logic). + // Otherwise the rule table itself is the single condition. + let conditions: Vec = + if let Ok(mlua::Value::Table(match_tbl)) = entry.get::<_, mlua::Value>("match") { + match_tbl + .sequence_values::() + .filter_map(|r| r.ok()) + .map(|t| parse_match_condition(&t)) + .collect() + } else { + vec![parse_match_condition(&entry)] + }; + + if !conditions.is_empty() { + rules.push(DeviceRule { device, conditions }); + } + } + + self.state_handle.set_device_rules(rules); + Ok(()) + } + + fn load_profiles(&self) -> Result<()> { + let profiles_path = self + .entry_point + .parent() + .map(|p| p.join("profiles.lua")) + .unwrap_or_else(|| PathBuf::from("profiles.lua")); + + if !profiles_path.exists() { + return Ok(()); + } + + let path_str = profiles_path.to_string_lossy().to_string(); + self.lua.globals().set("__profiles_path", path_str)?; + self.lua + .load( + r#" + local ok, result = pcall(loadfile, __profiles_path) + __profiles_path = nil + if ok and type(result) == "function" then + ok, result = pcall(result) + end + if ok and type(result) == "table" then + bread.on("bread.profile.activated", function(event) + local name = event.data and event.data.name + local fn = name and result[name] + if type(fn) == "function" then + fn(event) + end + end) + end + "#, + ) + .set_name("profiles.lua") + .exec() + .map_err(|e| anyhow!("profiles.lua error: {e}")) + } + fn load_init_and_modules(&self) -> Result<()> { - self.load_lua_file(&self.entry_point, "init")?; + self.load_lua_file(&self.entry_point, "init", false)?; let mut files = list_lua_files(&self.module_path)?; files.sort(); - for path in files { - let module_name = path - .file_stem() - .and_then(|v| v.to_str()) - .unwrap_or("unknown") - .to_string(); - match self.load_lua_file(&path, &module_name) { - Ok(()) => { - self.state_handle - .set_module_status(module_name, ModuleLoadState::Loaded, None); - } + + let disabled: HashSet = self.modules_config.disable.iter().cloned().collect(); + + let mut decls = Vec::new(); + if self.modules_config.builtin { + decls.extend(builtin_module_decls(&disabled)); + } + for path in files + .into_iter() + .filter(|p| !is_lib_path(&self.module_path, p)) + { + match self.scan_module_decl(&path) { + Ok(decl) => decls.push(decl), Err(err) => { + let name = module_name_from_path(&self.module_path, &path); self.state_handle.set_module_status( - module_name, + name, ModuleLoadState::LoadError, Some(err.to_string()), + false, ); } } } + let (ordered, dep_errors) = order_module_decls(decls); + + let mut decl_map = self + .module_decls + .lock() + .expect("module decls mutex poisoned"); + decl_map.clear(); + for decl in &ordered { + decl_map.insert(decl.name.clone(), decl.clone()); + } + drop(decl_map); + + for (name, err) in dep_errors { + self.state_handle + .set_module_status(name, ModuleLoadState::LoadError, Some(err), false); + } + + let mut load_order = Vec::new(); + for decl in ordered { + load_order.push(decl.name.clone()); + match self.load_module(&decl) { + Ok(()) => { + self.state_handle.set_module_status( + decl.name.clone(), + ModuleLoadState::Loaded, + None, + decl.builtin, + ); + } + Err(err) => { + self.state_handle.set_module_status( + decl.name.clone(), + ModuleLoadState::LoadError, + Some(err.to_string()), + decl.builtin, + ); + } + } + } + + *self + .module_order + .lock() + .expect("module order mutex poisoned") = load_order; + Ok(()) } - fn load_lua_file(&self, path: &Path, module_name: &str) -> Result<()> { + fn load_module(&self, decl: &ModuleDecl) -> Result<()> { + self.set_current_module(Some(decl.name.clone())); + let result = if let Some(source) = decl.source { + self.load_lua_source(source, &decl.name) + } else { + self.load_lua_file(&decl.path, &decl.name, decl.builtin) + }; + self.set_current_module(None); + result?; + + if !self.module_is_registered(&decl.name) { + return Err(anyhow!("module did not call bread.module")); + } + + self.run_on_load(&decl.name); + Ok(()) + } + + fn load_lua_file(&self, path: &Path, module_name: &str, builtin: bool) -> Result<()> { if !path.exists() { warn!(path = %path.display(), "lua file does not exist; skipping"); self.state_handle.set_module_status( module_name.to_string(), ModuleLoadState::NotFound, None, + builtin, ); return Ok(()); } let src = fs::read_to_string(path)?; - self.lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec()?; + self.lua + .load(&src) + .set_name(path.to_string_lossy().as_ref()) + .exec()?; Ok(()) } + fn load_lua_source(&self, source: &str, module_name: &str) -> Result<()> { + self.lua + .load(source) + .set_name(module_name) + .exec() + .map_err(|e| anyhow!(e.to_string())) + } + fn handle_event(&self, id: SubscriptionId, event: BreadEvent) -> Result<()> { - let handlers = self.handlers.lock().expect("lua handlers mutex poisoned"); - let Some(reg) = handlers.get(&id) else { - return Ok(()); + let (callback, filter, raw_kind, kind, module) = { + let handlers = self.handlers.lock().expect("lua handlers mutex poisoned"); + let Some(entry) = handlers.get(&id) else { + return Ok(()); + }; + let callback: Function = self.lua.registry_value(&entry.callback)?; + let filter = match entry.filter.as_ref() { + Some(key) => Some(self.lua.registry_value::(key)?), + None => None, + }; + ( + callback, + filter, + entry.raw_kind.clone(), + entry.kind, + entry.module.clone(), + ) }; - let callback: Function = self.lua.registry_value(reg)?; - let event_value = self.lua.to_value(&event)?; - if let Err(err) = callback.call::<_, ()>(event_value) { + + if let Some(kind) = raw_kind.as_deref() { + let matches = event + .data + .get("kind") + .and_then(JsonValue::as_str) + .map(|k| k == kind) + .unwrap_or(false); + if !matches { + return Ok(()); + } + } + + if let Some(filter) = filter { + let event_value = self.lua.to_value(&event)?; + let allowed = filter.call::<_, bool>(event_value).unwrap_or(false); + if !allowed { + return Ok(()); + } + } + + let result = match kind { + HandlerKind::Event => { + let event_value = self.lua.to_value(&event)?; + callback.call::<_, ()>(event_value) + } + HandlerKind::StateWatch => { + let new_val = event.data.get("new").cloned().unwrap_or(JsonValue::Null); + let old_val = event.data.get("old").cloned().unwrap_or(JsonValue::Null); + let new_lua = self.lua.to_value(&new_val)?; + let old_lua = self.lua.to_value(&old_val)?; + callback.call::<_, ()>((new_lua, old_lua)) + } + }; + + if let Err(err) = result { error!(subscription = id.0, error = %err, "lua callback failed"); + self.handle_callback_error(module.as_deref(), id, err); + } + Ok(()) + } + + fn handle_timer(&self, id: TimerId) -> Result<()> { + let (callback, repeating) = { + let timers = self.timers.lock().expect("lua timers mutex poisoned"); + let Some(entry) = timers.get(&id) else { + return Ok(()); + }; + let callback: Function = self.lua.registry_value(&entry.callback)?; + (callback, entry.repeating) + }; + if let Err(err) = callback.call::<_, ()>(()) { + error!(timer = id.0, error = %err, "lua timer callback failed"); + } + + if !repeating { + if let Ok(mut map) = self.timers.lock() { + map.remove(&id); + } } Ok(()) } @@ -331,6 +1313,932 @@ impl LuaEngine { map.remove(&id); } } + + fn run_on_load(&self, name: &str) { + if let Some(hook) = self.get_module_hook(name, "on_load") { + if let Err(err) = hook.call::<_, ()>(()) { + error!(module = %name, error = %err, "module on_load failed"); + let builtin = self.module_is_builtin(name); + self.state_handle.set_module_status( + name.to_string(), + ModuleLoadState::LoadError, + Some(err.to_string()), + builtin, + ); + } + } + } + + fn run_on_reload(&self) { + let order = self + .module_order + .lock() + .expect("module order mutex poisoned") + .clone(); + for name in order { + if let Some(hook) = self.get_module_hook(&name, "on_reload") { + if let Err(err) = hook.call::<_, ()>(()) { + error!(module = %name, error = %err, "module on_reload failed"); + let builtin = self.module_is_builtin(&name); + self.state_handle.set_module_status( + name.to_string(), + ModuleLoadState::Degraded, + Some(err.to_string()), + builtin, + ); + } + } + } + } + + fn run_on_unload(&self) { + let order = self + .module_order + .lock() + .expect("module order mutex poisoned") + .clone(); + for name in order.into_iter().rev() { + if let Some(hook) = self.get_module_hook(&name, "on_unload") { + if let Err(err) = hook.call::<_, ()>(()) { + error!(module = %name, error = %err, "module on_unload failed"); + let builtin = self.module_is_builtin(&name); + self.state_handle.set_module_status( + name.to_string(), + ModuleLoadState::Degraded, + Some(err.to_string()), + builtin, + ); + } + } + } + } + + fn handle_callback_error(&self, module: Option<&str>, id: SubscriptionId, err: LuaError) { + if let Some(module) = module { + let builtin = self.module_is_builtin(module); + if let Ok(mut buf) = self.recent_errors.lock() { + if buf.len() >= 50 { + buf.pop_front(); + } + buf.push_back(ErrorEntry { + timestamp: now_unix_ms(), + module: Some(module.to_string()), + message: err.to_string(), + }); + } + self.state_handle.set_module_status( + module.to_string(), + ModuleLoadState::Degraded, + Some(err.to_string()), + builtin, + ); + if let Some(hook) = self.get_module_hook(module, "on_error") { + match hook.call::<_, bool>(err.to_string()) { + Ok(keep) => { + if !keep { + self.remove_handler(id); + self.state_handle.remove_subscription(id); + self.state_handle.remove_watch(id); + } + } + Err(hook_err) => { + error!(module = %module, error = %hook_err, "module on_error failed"); + } + } + } + } + } + + fn get_module_hook(&self, name: &str, hook: &str) -> Option> { + let modules = self.modules.lock().ok()?; + let info = modules.get(name)?; + let table: Table = self.lua.registry_value(&info.table_key).ok()?; + match table.get::<_, Value>(hook).ok()? { + Value::Function(func) => Some(func), + _ => None, + } + } + + fn module_is_registered(&self, name: &str) -> bool { + self.modules + .lock() + .map(|map| map.contains_key(name)) + .unwrap_or(false) + } + + fn module_is_builtin(&self, name: &str) -> bool { + self.module_decls + .lock() + .ok() + .and_then(|map| map.get(name).map(|d| d.builtin)) + .unwrap_or(false) + } + + fn set_current_module(&self, name: Option) { + if let Ok(mut guard) = self.current_module.lock() { + *guard = name; + } + } + + fn cancel_all_timers(&self) { + if let Ok(mut map) = self.timers.lock() { + for (_, entry) in map.drain() { + let _ = entry.cancel_tx.send(true); + } + } + } + + fn install_log_helpers(&self) -> Result<()> { + // bread.log(msg) → tracing::info + // bread.warn(msg) → tracing::warn + // bread.error(msg) → tracing::error + // + // Each accepts any Lua value and coerces it to a string via tostring() + // so callers can do bread.log(some_table) without a crash. + self.lua + .load( + r#" + local _bread = bread + + local function stringify(v) + if type(v) == "string" then + return v + end + return tostring(v) + end + + function _bread.log(msg) + _bread.__log_info(stringify(msg)) + end + + function _bread.warn(msg) + _bread.__log_warn(stringify(msg)) + end + + function _bread.error(msg) + _bread.__log_error(stringify(msg)) + end + "#, + ) + .exec()?; + + // Register the raw Rust-backed log functions that the Lua wrappers call. + let globals = self.lua.globals(); + let bread: mlua::Table = globals.get("bread")?; + + let info_fn = self.lua.create_function(|_, msg: String| { + tracing::info!(target: "bread.lua", "{}", msg); + Ok(()) + })?; + bread.set("__log_info", info_fn)?; + + let warn_fn = self.lua.create_function(|_, msg: String| { + tracing::warn!(target: "bread.lua", "{}", msg); + Ok(()) + })?; + bread.set("__log_warn", warn_fn)?; + + let error_fn = self.lua.create_function(|_, msg: String| { + tracing::error!(target: "bread.lua", "{}", msg); + Ok(()) + })?; + bread.set("__log_error", error_fn)?; + + Ok(()) + } + + fn install_debounce(&self) -> Result<()> { + // bread.debounce(delay_ms, fn) → wrapped_fn + // + // Returns a new function. When that function is called, it resets a + // timer. The original function is only called once the timer expires + // without being reset. Useful for rapid hardware events (e.g. monitor + // topology changes that fire multiple events in quick succession). + // + // Because the Lua runtime is single-threaded, we implement this in + // pure Lua using bread.cancel / bread.after. + self.lua + .load( + r#" + function bread.debounce(delay_ms, fn) + local timer_id = nil + return function(...) + local args = { ... } + if timer_id then + bread.cancel(timer_id) + timer_id = nil + end + timer_id = bread.after(delay_ms, function() + timer_id = nil + fn(table.unpack(args)) + end) + end + end + "#, + ) + .exec()?; + Ok(()) + } + + fn scan_module_decl(&self, path: &Path) -> Result { + const MODULE_DECL_ABORT: &str = "__bread_module_decl__"; + let lua = Lua::new(); + let decl_cell: Rc>> = Rc::new(RefCell::new(None)); + let decl_cell_cloned = decl_cell.clone(); + let module_path = path.to_path_buf(); + + let module_fn = lua.create_function(move |_lua, table: Table| -> mlua::Result<()> { + let name: String = table.get("name")?; + let version: Option = table.get("version").ok(); + let after: Vec = table.get("after").unwrap_or_default(); + *decl_cell_cloned.borrow_mut() = Some(ModuleDecl { + name, + version, + after, + path: module_path.clone(), + source: None, + builtin: false, + }); + Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string())) + })?; + + // Build a minimal bread stub: bread.module() captures the decl and aborts; + // all other bread.* accesses return a no-op callable so modules that call + // bread.log() or bread.fs.exists() before bread.module() don't crash during scanning. + let bread = lua.create_table()?; + bread.set("module", module_fn)?; + lua.globals().set("bread", bread)?; + lua.load( + r#" + local _noop = function(...) end + local _noop_tbl_mt = { __index = function() return _noop end, __call = _noop } + local _noop_tbl = setmetatable({}, _noop_tbl_mt) + setmetatable(bread, { + __index = function(_, k) + if k == "module" then return rawget(bread, "module") end + return _noop_tbl + end + }) + "#, + ) + .exec()?; + + let src = fs::read_to_string(path)?; + let result = lua + .load(&src) + .set_name(path.to_string_lossy().as_ref()) + .exec(); + // bread.module() throws MODULE_DECL_ABORT to abort scanning early. + // mlua may wrap the error in CallbackError, so match on string content. + if let Err(err) = result { + if !err.to_string().contains(MODULE_DECL_ABORT) { + return Err(anyhow!(err.to_string())); + } + } + + let decl = decl_cell.borrow().clone(); + decl.ok_or_else(|| anyhow!("module missing bread.module declaration")) + } + + fn install_require_loader(&self) -> Result<()> { + let module_path = self.module_path.clone(); + let loader = self.lua.create_function(move |lua, name: String| { + if !name.starts_with("bread.") { + return Ok(Value::Nil); + } + + let rel = name.trim_start_matches("bread.").replace('.', "/"); + let path = module_path.join(format!("{rel}.lua")); + if !path.exists() { + return Ok(Value::Nil); + } + + let src = fs::read_to_string(&path).map_err(|e| LuaError::external(e.to_string()))?; + let func = lua + .load(&src) + .set_name(path.to_string_lossy().as_ref()) + .into_function() + .map_err(|e| LuaError::external(e.to_string()))?; + Ok(Value::Function(func)) + })?; + + let globals = self.lua.globals(); + let bread: Table = globals.get("bread")?; + bread.set("__require_loader", loader)?; + + self.lua + .load( + r#" + local searchers = package.searchers or package.loaders + if searchers then + table.insert(searchers, 1, function(name) + return bread.__require_loader(name) + end) + end + "#, + ) + .exec()?; + + Ok(()) + } + + fn install_wait_helper(&self) -> Result<()> { + self.lua + .load( + r#" + bread.spawn = function(fn) + local co = coroutine.create(fn) + local ok, err = coroutine.resume(co) + if not ok then + error(err) + end + end + + bread.wait = function(pattern, opts) + if type(pattern) ~= "string" then + error("bread.wait requires a pattern string") + end + opts = opts or {} + local co = coroutine.running() + if not co then + error("bread.wait must be called inside a coroutine") + end + local id + local timer + id = bread.once(pattern, function(event) + if timer then + bread.cancel(timer) + end + coroutine.resume(co, event) + end) + if opts.timeout then + timer = bread.after(opts.timeout, function() + bread.off(id) + coroutine.resume(co, nil) + end) + end + return coroutine.yield() + end + "#, + ) + .exec()?; + Ok(()) + } +} + +fn order_module_decls(decls: Vec) -> (Vec, Vec<(String, String)>) { + let mut errors = Vec::new(); + let mut map: HashMap = HashMap::new(); + for decl in decls { + if map.contains_key(&decl.name) { + errors.push((decl.name.clone(), "duplicate module name".to_string())); + continue; + } + map.insert(decl.name.clone(), decl); + } + + let mut deps: HashMap> = HashMap::new(); + let mut reverse: HashMap> = HashMap::new(); + let mut invalid: HashSet = HashSet::new(); + + for (name, decl) in map.iter() { + let mut missing = Vec::new(); + for dep in &decl.after { + if map.contains_key(dep) { + deps.entry(name.clone()).or_default().insert(dep.clone()); + reverse.entry(dep.clone()).or_default().insert(name.clone()); + } else { + missing.push(dep.clone()); + } + } + if !missing.is_empty() { + errors.push(( + name.clone(), + format!("missing dependency: {}", missing.join(", ")), + )); + invalid.insert(name.clone()); + } + } + + let mut ready: Vec = map + .keys() + .filter(|name| !deps.contains_key(*name) && !invalid.contains(*name)) + .cloned() + .collect(); + ready.sort(); + + let mut ordered = Vec::new(); + let mut deps = deps; + + while let Some(name) = ready.pop() { + if let Some(decl) = map.get(&name) { + ordered.push(decl.clone()); + } + if let Some(children) = reverse.remove(&name) { + for child in children { + if invalid.contains(&child) { + continue; + } + if let Some(entry) = deps.get_mut(&child) { + entry.remove(&name); + if entry.is_empty() { + deps.remove(&child); + ready.push(child); + ready.sort(); + } + } + } + } + } + + for (name, _) in deps { + if !invalid.contains(&name) { + errors.push((name, "circular dependency".to_string())); + } + } + + (ordered, errors) +} + +fn module_name_from_path(module_root: &Path, path: &Path) -> String { + let rel = path.strip_prefix(module_root).unwrap_or(path); + let mut name = rel.with_extension("").to_string_lossy().replace('/', "."); + if name.starts_with('.') { + name.remove(0); + } + name +} + +fn is_lib_path(module_root: &Path, path: &Path) -> bool { + let rel = path.strip_prefix(module_root).unwrap_or(path); + rel.components() + .next() + .and_then(|c| c.as_os_str().to_str()) + .map(|c| c == "lib") + .unwrap_or(false) +} + +fn state_value_to_lua<'lua>( + lua: &'lua Lua, + state_arc: &Arc>, + path: &str, +) -> mlua::Result> { + // The Lua thread runs a current_thread runtime. blocking_read and block_in_place + // both require the multi-thread runtime and panic here. try_read succeeds + // immediately in the common case; the write lock is held for microseconds. + let snapshot = loop { + if let Ok(g) = state_arc.try_read() { + break g; + } + std::hint::spin_loop(); + }; + let mut value = + serde_json::to_value(&*snapshot).map_err(|e| LuaError::external(e.to_string()))?; + if path.is_empty() { + return lua + .to_value(&value) + .map_err(|e| LuaError::external(e.to_string())); + } + for part in path.split('.') { + value = value + .get(part) + .cloned() + .ok_or_else(|| LuaError::external("state path not found"))?; + } + lua.to_value(&value) + .map_err(|e| LuaError::external(e.to_string())) +} + +fn module_store_get( + state_arc: &Arc>, + module: &str, + key: &str, +) -> Option { + let guard = loop { + if let Ok(g) = state_arc.try_read() { + break g; + } + std::hint::spin_loop(); + }; + let entry = guard.modules.iter().find(|m| m.name == module)?; + entry.store.get(key).cloned() +} + +fn module_store_set( + state_arc: &Arc>, + module: &str, + key: String, + value: JsonValue, +) { + let mut guard = loop { + if let Ok(g) = state_arc.try_write() { + break g; + } + std::hint::spin_loop(); + }; + if let Some(entry) = guard.modules.iter_mut().find(|m| m.name == module) { + entry.store.insert(key, value); + return; + } + + let mut store = HashMap::new(); + store.insert(key, value); + guard.modules.push(crate::core::types::ModuleStatus { + name: module.to_string(), + status: ModuleLoadState::Loaded, + last_error: None, + builtin: false, + store, + }); +} + +fn lua_expand_path(path: &str) -> std::path::PathBuf { + if path == "~" { + if let Some(home) = dirs_home() { + return home; + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs_home() { + return home.join(rest); + } + } + std::path::PathBuf::from(path) +} + +fn dirs_home() -> Option { + if let Ok(home) = std::env::var("HOME") { + return Some(std::path::PathBuf::from(home)); + } + None +} + +fn lua_machine_name() -> String { + if let Ok(sync_toml) = read_sync_toml() { + if let Some(name) = sync_toml + .get("machine") + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + { + return name.to_string(); + } + } + lua_hostname() +} + +fn lua_hostname() -> String { + // Try gethostname via libc + let mut buf = [0u8; 256]; + unsafe { + if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 { + if let Ok(s) = std::ffi::CStr::from_ptr(buf.as_ptr() as *const libc::c_char).to_str() { + if !s.is_empty() { + return s.to_string(); + } + } + } + } + // Fall back to /etc/hostname + if let Ok(h) = std::fs::read_to_string("/etc/hostname") { + let trimmed = h.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("HOST")) + .unwrap_or_else(|_| "unknown".to_string()) +} + +fn lua_machine_tags() -> Vec { + if let Ok(sync_toml) = read_sync_toml() { + if let Some(tags) = sync_toml + .get("machine") + .and_then(|m| m.get("tags")) + .and_then(|v| v.as_array()) + { + return tags + .iter() + .filter_map(|v| v.as_str().map(ToString::to_string)) + .collect(); + } + } + vec![] +} + +fn read_sync_toml() -> anyhow::Result { + let config_dir = std::env::var("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|_| std::env::var("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) + .unwrap_or_else(|_| std::path::PathBuf::from(".config")); + let path = config_dir.join("bread").join("sync.toml"); + let raw = std::fs::read_to_string(path)?; + Ok(raw.parse::()?) +} + +const BUILTIN_MONITORS: &str = r#" +local M = bread.module({ name = "bread.monitors", version = "1.0.0" }) + +local workflows = {} +local layouts = {} + +local function matches_when(event_name, when) + if when == "connected" then + return event_name == "bread.monitor.connected" + elseif when == "disconnected" then + return event_name == "bread.monitor.disconnected" + elseif when == "changed" then + return event_name == "bread.monitor.changed" + end + return false +end + +local function matches_monitors(list, event) + if not list or #list == 0 then + return true + end + local name = event.data and event.data.name + if not name then + return false + end + for _, monitor in ipairs(list) do + if monitor == name then + return true + end + end + return false +end + +local function run_workflow(wf, event) + if type(wf.run) == "function" then + wf.run(event) + elseif type(wf.run) == "string" then + bread.exec(wf.run) + end +end + +function M.on(opts) + table.insert(workflows, opts) +end + +function M.layout(name, fn) + layouts[name] = fn +end + +function M.apply(name) + return function() + local fn = layouts[name] + if fn then + fn() + end + end +end + +function M.on_load() + bread.on("bread.monitor.**", function(event) + for _, wf in ipairs(workflows) do + if matches_when(event.event, wf.when) and matches_monitors(wf.monitors, event) then + run_workflow(wf, event) + end + end + end) +end + +return M +"#; + +const BUILTIN_DEVICES: &str = r#" +local M = bread.module({ name = "bread.devices", version = "1.0.0" }) + +local rules = {} + +local function matches_rule(rule, event) + local when = rule.when + local data = event.data or {} + + if when == "connected" and not event.event:match("%.connected$") then + return false + elseif when == "disconnected" and not event.event:match("%.disconnected$") then + return false + end + + if rule.device and data.device ~= rule.device then + return false + end + + if rule.name and data.name and not tostring(data.name):match(rule.name) then + return false + end + + return true +end + +local function run_rule(rule, event) + if type(rule.run) == "function" then + rule.run(event) + elseif type(rule.run) == "string" then + bread.exec(rule.run) + end +end + +function M.on(opts) + table.insert(rules, opts) +end + +function M.on_load() + bread.on("bread.device.**", function(event) + for _, rule in ipairs(rules) do + if matches_rule(rule, event) then + run_rule(rule, event) + end + end + end) +end + +return M +"#; + +const BUILTIN_WORKSPACES: &str = r#" +local M = bread.module({ name = "bread.workspaces", version = "1.0.0", after = { "bread.monitors" } }) + +local assignments = {} +local rules = {} + +function M.assign(workspace, monitor) + table.insert(assignments, { workspace = workspace, monitor = monitor }) +end + +function M.pin(opts) + table.insert(rules, opts) +end + +function M.apply_assignments() + local monitors = bread.state.monitors() + local active = {} + for _, m in ipairs(monitors) do + if m.connected then + active[m.name] = true + end + end + + for _, a in ipairs(assignments) do + if active[a.monitor] then + bread.hyprland.dispatch("moveworkspacetomonitor", a.workspace .. " " .. a.monitor) + end + end +end + +function M.on_load() + bread.on("bread.monitor.**", function() + M.apply_assignments() + end) + + bread.on("bread.window.opened", function(event) + for _, rule in ipairs(rules) do + if event.data and event.data.class and event.data.class:match(rule.app) then + local address = event.data.address or "" + bread.hyprland.dispatch("movetoworkspacesilent", rule.workspace .. ",address:" .. address) + end + end + end) + + bread.once("bread.system.startup", function() + M.apply_assignments() + end) +end + +return M +"#; + +const BUILTIN_BINDS: &str = r#" +local M = bread.module({ name = "bread.binds", version = "1.0.0" }) + +local active = {} + +local function bind_string(opts) + local mods = table.concat(opts.mods or {}, " ") + local args = opts.args or "" + if mods ~= "" then + return mods .. ", " .. opts.key .. ", " .. opts.dispatch .. ", " .. args + end + return opts.key .. ", " .. opts.dispatch .. ", " .. args +end + +function M.add(opts) + local bind = bind_string(opts) + bread.hyprland.keyword("bind", bind) + active[opts.key] = opts + return opts.key +end + +function M.remove(key) + local bind = active[key] + if not bind then + return + end + bread.hyprland.keyword("unbind", bind_string(bind)) + active[key] = nil +end + +function M.replace(key, opts) + M.remove(key) + return M.add(opts) +end + +function M.on_unload() + for key, _ in pairs(active) do + M.remove(key) + end +end + +return M +"#; + +fn builtin_module_decls(disabled: &HashSet) -> Vec { + let mut out = Vec::new(); + + let entries = vec![ + ("bread.monitors", "1.0.0", Vec::new(), BUILTIN_MONITORS), + ("bread.devices", "1.0.0", Vec::new(), BUILTIN_DEVICES), + ( + "bread.workspaces", + "1.0.0", + vec!["bread.monitors".to_string()], + BUILTIN_WORKSPACES, + ), + ("bread.binds", "1.0.0", Vec::new(), BUILTIN_BINDS), + ]; + + for (name, version, after, source) in entries { + if disabled.contains(name) { + continue; + } + out.push(ModuleDecl { + name: name.to_string(), + version: Some(version.to_string()), + after, + path: PathBuf::from(format!("")), + source: Some(source), + builtin: true, + }); + } + + out +} + +fn hyprland_request_socket() -> Result { + let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + + if let Ok(instance) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") { + return Ok(PathBuf::from(runtime) + .join("hypr") + .join(instance) + .join(".socket.sock")); + } + + let hypr_dir = PathBuf::from(&runtime).join("hypr"); + let mut sockets: Vec = std::fs::read_dir(&hypr_dir) + .map_err(|_| anyhow!("no Hyprland instance found ({})", hypr_dir.display()))? + .flatten() + .map(|e| e.path().join(".socket.sock")) + .filter(|p| p.exists()) + .collect(); + + match sockets.len() { + 0 => Err(anyhow!( + "no Hyprland instance found in {}", + hypr_dir.display() + )), + 1 => Ok(sockets.remove(0)), + _ => Ok(sockets.remove(0)), + } +} + +fn hyprland_request(request: &str) -> Result { + use std::io::{Read, Write}; + use std::os::unix::net::UnixStream; + + let socket = hyprland_request_socket()?; + let mut stream = UnixStream::connect(&socket)?; + stream.write_all(request.as_bytes())?; + let mut buffer = String::new(); + stream.read_to_string(&mut buffer)?; + Ok(buffer) +} + +fn parse_match_condition(tbl: &mlua::Table) -> MatchCondition { + MatchCondition { + vendor_id: tbl.get("vendor_id").ok(), + product_id: tbl.get("product_id").ok(), + name: tbl.get("name").ok(), + vendor: tbl.get("vendor").ok(), + name_contains: tbl.get("name_contains").ok(), + id_input_keyboard: tbl.get("id_input_keyboard").ok(), + id_input_mouse: tbl.get("id_input_mouse").ok(), + id_input_tablet: tbl.get("id_input_tablet").ok(), + usb_hub: tbl.get("usb_hub").ok(), + id_usb_class: tbl.get("id_usb_class").ok(), + subsystem: tbl.get("subsystem").ok(), + } } fn list_lua_files(root: &Path) -> Result> { @@ -353,3 +2261,199 @@ fn list_lua_files(root: &Path) -> Result> { } Ok(out) } + +// ─── Bluetooth helpers ──────────────────────────────────────────────────────── + +/// Spawn a dedicated thread with its own Tokio runtime for a fire-and-forget +/// async Bluetooth operation. Needed because the Lua thread runs inside +/// `block_on` on a current-thread runtime, so nested `block_on` is forbidden. +fn bluetooth_spawn(factory: F) +where + F: FnOnce() -> Fut + Send + 'static, + Fut: std::future::Future, +{ + std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("bluetooth action thread") + .block_on(factory()); + }); +} + +/// Like `bluetooth_spawn` but waits for the result via a sync channel so Lua +/// gets a return value. +fn bluetooth_query(factory: F) -> anyhow::Result +where + F: FnOnce() -> Fut + Send + 'static, + Fut: std::future::Future>, + T: Send + 'static, +{ + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let result = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("bluetooth query thread") + .block_on(factory()); + let _ = tx.send(result); + }); + rx.recv().map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))? +} + +async fn bluetooth_find_adapter(conn: &zbus::Connection) -> anyhow::Result { + use zbus::zvariant::{OwnedObjectPath, OwnedValue}; + let msg = conn + .call_method( + Some("org.bluez"), + "/", + Some("org.freedesktop.DBus.ObjectManager"), + "GetManagedObjects", + &(), + ) + .await?; + let objects: std::collections::HashMap< + OwnedObjectPath, + std::collections::HashMap>, + > = msg.body()?; + for (path, interfaces) in &objects { + if interfaces.contains_key("org.bluez.Adapter1") { + return Ok(path.as_str().to_string()); + } + } + Err(anyhow::anyhow!("no Bluetooth adapter found")) +} + +async fn bluetooth_set_powered(enabled: bool) -> anyhow::Result<()> { + let conn = zbus::Connection::system().await?; + let adapter = bluetooth_find_adapter(&conn).await?; + conn.call_method( + Some("org.bluez"), + adapter.as_str(), + Some("org.freedesktop.DBus.Properties"), + "Set", + &( + "org.bluez.Adapter1", + "Powered", + zbus::zvariant::Value::from(enabled), + ), + ) + .await?; + Ok(()) +} + +async fn bluetooth_get_powered() -> anyhow::Result { + let conn = zbus::Connection::system().await?; + let adapter = bluetooth_find_adapter(&conn).await?; + let msg = conn + .call_method( + Some("org.bluez"), + adapter.as_str(), + Some("org.freedesktop.DBus.Properties"), + "Get", + &("org.bluez.Adapter1", "Powered"), + ) + .await?; + let (value,): (zbus::zvariant::OwnedValue,) = msg.body()?; + let json = serde_json::to_value(&value).unwrap_or(serde_json::json!(false)); + Ok(json.as_bool().unwrap_or(false)) +} + +async fn bluetooth_connect(address: String) -> anyhow::Result<()> { + let conn = zbus::Connection::system().await?; + let adapter = bluetooth_find_adapter(&conn).await?; + let dev_path = format!("{}/dev_{}", adapter, address.replace(':', "_")); + conn.call_method( + Some("org.bluez"), + dev_path.as_str(), + Some("org.bluez.Device1"), + "Connect", + &(), + ) + .await?; + Ok(()) +} + +async fn bluetooth_disconnect(address: String) -> anyhow::Result<()> { + let conn = zbus::Connection::system().await?; + let adapter = bluetooth_find_adapter(&conn).await?; + let dev_path = format!("{}/dev_{}", adapter, address.replace(':', "_")); + conn.call_method( + Some("org.bluez"), + dev_path.as_str(), + Some("org.bluez.Device1"), + "Disconnect", + &(), + ) + .await?; + Ok(()) +} + +async fn bluetooth_set_scanning(enabled: bool) -> anyhow::Result<()> { + let conn = zbus::Connection::system().await?; + let adapter = bluetooth_find_adapter(&conn).await?; + let method = if enabled { "StartDiscovery" } else { "StopDiscovery" }; + conn.call_method( + Some("org.bluez"), + adapter.as_str(), + Some("org.bluez.Adapter1"), + method, + &(), + ) + .await?; + Ok(()) +} + +struct BluetoothDevice { + address: String, + name: String, + connected: bool, + paired: bool, +} + +async fn bluetooth_list_devices() -> anyhow::Result> { + use zbus::zvariant::{OwnedObjectPath, OwnedValue}; + let conn = zbus::Connection::system().await?; + let msg = conn + .call_method( + Some("org.bluez"), + "/", + Some("org.freedesktop.DBus.ObjectManager"), + "GetManagedObjects", + &(), + ) + .await?; + let objects: std::collections::HashMap< + OwnedObjectPath, + std::collections::HashMap>, + > = msg.body()?; + + let mut devices = Vec::new(); + for (_, interfaces) in &objects { + if let Some(props) = interfaces.get("org.bluez.Device1") { + let json = serde_json::to_value(props).unwrap_or_else(|_| serde_json::json!({})); + devices.push(BluetoothDevice { + address: json + .get("Address") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + name: json + .get("Name") + .or_else(|| json.get("Alias")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + connected: json + .get("Connected") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + paired: json + .get("Paired") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + }); + } + } + Ok(devices) +} diff --git a/breadd/src/main.rs b/breadd/src/main.rs index c57fabd..809c879 100644 --- a/breadd/src/main.rs +++ b/breadd/src/main.rs @@ -3,6 +3,8 @@ mod core; mod ipc; mod lua; +use std::collections::VecDeque; +use std::sync::atomic::AtomicU64; use std::sync::Arc; use anyhow::Result; @@ -33,9 +35,11 @@ async fn main() -> Result<()> { let (event_stream_tx, _) = broadcast::channel(2048); let (shutdown_tx, shutdown_rx) = watch::channel(false); + let subscription_count = Arc::new(AtomicU64::new(0)); let state_handle = StateHandle::new(state.clone(), state_cmd_tx); - let lua_runtime = lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?; + let lua_runtime = + lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?; let lua_tx = lua_runtime.sender(); tokio::spawn(run_state_engine( @@ -44,6 +48,7 @@ async fn main() -> Result<()> { state.clone(), lua_tx, event_stream_tx.clone(), + subscription_count.clone(), shutdown_rx.clone(), )); @@ -78,6 +83,28 @@ async fn main() -> Result<()> { let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone()); adapter_manager.start_all().await?; + let adapter_status = adapter_manager.status_handle(); + + let event_buffer = Arc::new(std::sync::Mutex::new(VecDeque::with_capacity(1000))); + { + let mut rx = event_stream_tx.subscribe(); + let event_buffer = event_buffer.clone(); + tokio::spawn(async move { + loop { + let evt = match rx.recv().await { + Ok(evt) => evt, + Err(_) => break, + }; + if let Ok(mut buf) = event_buffer.lock() { + if buf.len() >= 1000 { + buf.pop_front(); + } + buf.push_back(evt); + } + } + }); + } + let _ = normalized_tx.send(BreadEvent::new( "bread.system.startup", AdapterSource::System, @@ -90,6 +117,9 @@ async fn main() -> Result<()> { event_stream_tx, lua_runtime.clone(), normalized_tx, + adapter_status, + subscription_count, + event_buffer, ); info!("breadd fully started"); @@ -115,7 +145,8 @@ async fn wait_for_shutdown() { #[cfg(unix)] { use tokio::signal::unix::{signal, SignalKind}; - let mut sigterm = signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); + let mut sigterm = + signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); tokio::select! { _ = ctrl_c => {}, _ = sigterm.recv() => {}, diff --git a/breadd/tests/ipc_integration.rs b/breadd/tests/ipc_integration.rs index 9f8e573..a12e504 100644 --- a/breadd/tests/ipc_integration.rs +++ b/breadd/tests/ipc_integration.rs @@ -31,6 +31,291 @@ async fn ping_and_state_dump_work() -> Result<()> { Ok(()) } +#[tokio::test] +async fn unknown_method_returns_error() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness.send_request("not.a.real.method", json!({})).await; + assert!(result.is_err(), "expected error for unknown method"); + let msg = result.err().unwrap().to_string(); + assert!( + msg.contains("unknown method"), + "expected 'unknown method', got: {msg}" + ); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn profile_activate_updates_state() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness + .send_request("profile.activate", json!({"name": "battery"})) + .await?; + assert_eq!( + result.get("active").and_then(Value::as_str), + Some("battery") + ); + + let dump = harness.send_request("state.dump", json!({})).await?; + assert_eq!( + dump.get("profile") + .and_then(|v| v.get("active")) + .and_then(Value::as_str), + Some("battery") + ); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn profile_activate_without_name_errors() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness.send_request("profile.activate", json!({})).await; + assert!(result.is_err()); + let msg = result.err().unwrap().to_string(); + assert!(msg.contains("missing profile name"), "got: {msg}"); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn emit_without_event_errors() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness.send_request("emit", json!({})).await; + assert!(result.is_err()); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn state_get_returns_specific_subtree() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let modules = harness + .send_request("state.get", json!({"key": "modules"})) + .await?; + assert!(modules.is_array(), "expected modules to be an array"); + + let active = harness + .send_request("state.get", json!({"key": "profile.active"})) + .await?; + assert!( + active.as_str().is_some(), + "expected profile.active to be a string, got: {active:?}" + ); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn state_get_missing_key_returns_error() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness + .send_request("state.get", json!({"key": "does.not.exist"})) + .await; + assert!(result.is_err()); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn modules_list_returns_array() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness.send_request("modules.list", json!({})).await?; + assert!(result.is_array()); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn modules_reload_succeeds() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness.send_request("modules.reload", json!({})).await?; + assert_eq!(result.get("ok").and_then(Value::as_bool), Some(true)); + assert!(result.get("duration_ms").is_some()); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn sync_status_uninitialized_when_no_config() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let result = harness.send_request("sync.status", json!({})).await?; + assert_eq!( + result.get("initialized").and_then(Value::as_bool), + Some(false) + ); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn sync_status_reports_initialized_with_config() -> Result<()> { + let harness = TestHarness::spawn_with_sync_config("myhost", "git@example.com:user/repo.git")?; + harness.wait_until_ready().await?; + + let result = harness.send_request("sync.status", json!({})).await?; + assert_eq!( + result.get("initialized").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + result.get("machine").and_then(Value::as_str), + Some("myhost") + ); + assert_eq!( + result.get("remote").and_then(Value::as_str), + Some("git@example.com:user/repo.git") + ); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn events_replay_returns_buffered_events() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + // Emit a couple of events. + harness + .send_request("emit", json!({"event": "bread.replay.a", "data": {}})) + .await?; + harness + .send_request("emit", json!({"event": "bread.replay.b", "data": {}})) + .await?; + + // Small delay so the events make it into the buffer. + sleep(Duration::from_millis(100)).await; + + let result = harness + .send_request("events.replay", json!({"since_ms": 10_000})) + .await?; + let arr = result.as_array().expect("replay result should be array"); + let names: Vec<&str> = arr + .iter() + .filter_map(|e| e.get("event").and_then(Value::as_str)) + .collect(); + assert!(names.contains(&"bread.replay.a")); + assert!(names.contains(&"bread.replay.b")); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn event_stream_filter_excludes_non_matching_events() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + let stream = UnixStream::connect(harness.socket_path()).await?; + let (read_half, mut write_half) = stream.into_split(); + let subscribe = json!({ + "id": "sub-x", + "method": "events.subscribe", + "params": { + "filter": "bread.match.*" + } + }); + write_half + .write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes()) + .await?; + + let mut reader = BufReader::new(read_half).lines(); + // Consume the ack line. + reader.next_line().await?; + + // Emit one matching and one non-matching event. + harness + .send_request("emit", json!({"event": "bread.nomatch.x", "data": {}})) + .await?; + harness + .send_request("emit", json!({"event": "bread.match.yes", "data": {}})) + .await?; + + let deadline = Instant::now() + Duration::from_secs(5); + let mut matched = false; + while Instant::now() < deadline { + let Some(line) = reader.next_line().await? else { + break; + }; + let event: Value = serde_json::from_str(&line)?; + let name = event.get("event").and_then(Value::as_str).unwrap_or(""); + assert!( + !name.starts_with("bread.nomatch"), + "filter let through non-matching event: {name}" + ); + if name == "bread.match.yes" { + matched = true; + break; + } + } + assert!(matched, "did not receive matching event through filter"); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn multiple_concurrent_clients_each_get_response() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + let socket = harness.socket_path().to_path_buf(); + + let mut handles = Vec::new(); + for i in 0..8 { + let socket = socket.clone(); + handles.push(tokio::spawn(async move { + let stream = UnixStream::connect(&socket).await?; + let (read_half, mut write_half) = stream.into_split(); + let req = json!({"id": i.to_string(), "method": "ping", "params": {}}); + write_half + .write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes()) + .await?; + let mut lines = BufReader::new(read_half).lines(); + let line = lines.next_line().await?.ok_or_else(|| anyhow!("eof"))?; + let parsed: Value = serde_json::from_str(&line)?; + assert_eq!( + parsed.get("id").and_then(Value::as_str), + Some(i.to_string().as_str()) + ); + Ok::<(), anyhow::Error>(()) + })); + } + for h in handles { + h.await??; + } + + harness.shutdown(); + Ok(()) +} + #[tokio::test] async fn events_stream_receives_emitted_events() -> Result<()> { let harness = TestHarness::spawn()?; @@ -100,6 +385,14 @@ struct TestHarness { impl TestHarness { fn spawn() -> Result { + Self::spawn_inner(None) + } + + fn spawn_with_sync_config(machine: &str, remote_url: &str) -> Result { + Self::spawn_inner(Some((machine.to_string(), remote_url.to_string()))) + } + + fn spawn_inner(sync_config: Option<(String, String)>) -> Result { let temp = tempfile::tempdir()?; let runtime_dir = temp.path().join("runtime"); let config_home = temp.path().join("config"); @@ -140,6 +433,21 @@ enabled = false "#, )?; + if let Some((machine, remote_url)) = sync_config { + let sync_toml = format!( + r#" +[remote] +url = "{remote_url}" +branch = "main" + +[machine] +name = "{machine}" +tags = [] +"# + ); + fs::write(bread_cfg.join("sync.toml"), sync_toml)?; + } + let socket_path = runtime_dir.join("bread").join("breadd.sock"); let child = Command::new(env!("CARGO_BIN_EXE_breadd")) .env("XDG_RUNTIME_DIR", &runtime_dir) diff --git a/packaging/README.md b/packaging/README.md index 3f829f8..18256de 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,5 +1,47 @@ -Packaging notes -================ +Packaging +========= -This repo targets Arch packaging. The Arch PKGBUILD skeleton lives under -`packaging/arch/`. +This directory contains distribution packaging for Bread. + +``` +packaging/ +├── arch/ +│ └── PKGBUILD ← Arch Linux package build script +└── systemd/ + └── breadd.service ← systemd user service unit +``` + +## Arch Linux + +```bash +cd packaging/arch +makepkg -si +``` + +The PKGBUILD builds both `breadd` and `bread` from source and installs them to `/usr/bin`. It also installs the systemd user service unit to `/usr/lib/systemd/user/`. + +Before publishing to the AUR, update `pkgver`, `source`, and `sha256sums` to point at a tagged release tarball. + +## systemd user service + +The service unit starts `breadd` as a user service after the graphical session is available. + +```bash +# Install and enable manually (if not using the PKGBUILD) +mkdir -p ~/.config/systemd/user +cp systemd/breadd.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now breadd + +# Check status +systemctl --user status breadd +journalctl --user -u breadd -f +``` + +The service sets `RUST_LOG=info` by default. To increase verbosity, override it in a drop-in: + +```ini +# ~/.config/systemd/user/breadd.service.d/debug.conf +[Service] +Environment=RUST_LOG=debug +``` diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 1a36db0..80214e1 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,14 +1,19 @@ -# Maintainer: Your Name +# Maintainer: Breadway -pkgname=breadd -pkgver=0.1.0 +pkgname=bread +pkgver=1.0.0 pkgrel=1 -pkgdesc="Bread daemon - event normalizer and automation runtime" +pkgdesc="A reactive automation fabric for Linux desktops" arch=('x86_64') url="https://github.com/Breadway/bread" license=('MIT') -depends=('glibc') -makedepends=('rust') +depends=('glibc' 'libgit2') +optdepends=( + 'libnotify: desktop notifications via bread.notify()' + 'upower: D-Bus battery events (sysfs polling used otherwise)' + 'git: bread sync push/pull operations' +) +makedepends=('rust' 'cargo') source=("${pkgname}-${pkgver}.tar.gz") sha256sums=('SKIP') @@ -17,9 +22,15 @@ build() { cargo build --release --locked } +check() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo test --release --locked --workspace +} + package() { cd "${srcdir}/${pkgname}-${pkgver}" install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd" - install -Dm755 target/release/bread-cli "${pkgdir}/usr/bin/bread-cli" + install -Dm755 target/release/bread "${pkgdir}/usr/bin/bread" install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" } diff --git a/packaging/arch/README.md b/packaging/arch/README.md index 1873cd6..020e26c 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -1,9 +1,29 @@ Arch packaging ============== -This is a minimal PKGBUILD skeleton. +`PKGBUILD` builds and installs both `breadd` and `bread` from source. -Steps to use: -- Update `pkgver`, `source`, `sha256sums`, and `url`. -- Set the correct license and dependencies. -- Ensure the release tarball includes `packaging/systemd/breadd.service`. +## Local build + +```bash +makepkg -si +``` + +## Before publishing to AUR + +1. Tag a release on GitHub. +2. Update `pkgver` to match the tag. +3. Update `source` to the release tarball URL. +4. Run `updpkgsums` (or manually set `sha256sums`). +5. Update `url` if the repository has moved. +6. Set `depends` accurately — at minimum: `glibc`. Add `udev` and `libgit2` if not linking statically. + +## Runtime dependencies + +| Package | Required | Notes | +|---------|----------|-------| +| `glibc` | yes | always | +| `udev` | yes | device events | +| `dbus` | optional | UPower battery events | +| `libnotify` | optional | `bread.notify()` (uses `notify-send`) | +| `git` | optional | `bread sync` push/pull | diff --git a/packaging/systemd/breadd.service b/packaging/systemd/breadd.service index 9f36697..95f0942 100644 --- a/packaging/systemd/breadd.service +++ b/packaging/systemd/breadd.service @@ -5,7 +5,7 @@ Wants=graphical-session.target [Service] Type=simple -ExecStart=%h/.cargo/bin/breadd +ExecStart=/usr/bin/breadd Restart=on-failure RestartSec=2 UMask=0077 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..7a16cd9 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN_DIR="${BIN_DIR:-$HOME/.local/bin}" +SERVICE_DIR="${HOME}/.config/systemd/user" +CONFIG_DIR="${HOME}/.config/bread" +MODULES_DIR="${CONFIG_DIR}/modules" + +# ── build ────────────────────────────────────────────────────────────────────── +echo "building bread (release)..." +cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" +echo "" + +# ── symlinks ─────────────────────────────────────────────────────────────────── +echo "symlinking binaries into $BIN_DIR..." +mkdir -p "$BIN_DIR" +ln -sf "$REPO_ROOT/target/release/breadd" "$BIN_DIR/breadd" +ln -sf "$REPO_ROOT/target/release/bread" "$BIN_DIR/bread" +echo " $BIN_DIR/breadd -> $REPO_ROOT/target/release/breadd" +echo " $BIN_DIR/bread -> $REPO_ROOT/target/release/bread" + +if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then + echo "" + echo " note: $BIN_DIR is not in PATH — add to your shell profile:" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" +fi +echo "" + +# ── config ───────────────────────────────────────────────────────────────────── +echo "setting up config..." +mkdir -p "$CONFIG_DIR" "$MODULES_DIR" + +if [[ ! -f "$CONFIG_DIR/breadd.toml" ]]; then + cat > "$CONFIG_DIR/breadd.toml" << 'EOF' +[daemon] +log_level = "info" + +[lua] +entry_point = "~/.config/bread/init.lua" +module_path = "~/.config/bread/modules" + +[adapters.hyprland] +enabled = true + +[adapters.udev] +enabled = true + +[adapters.power] +enabled = true + +[adapters.network] +enabled = true +EOF + echo " created $CONFIG_DIR/breadd.toml" +else + echo " $CONFIG_DIR/breadd.toml already exists, skipping" +fi + +if [[ ! -f "$CONFIG_DIR/init.lua" ]]; then + cat > "$CONFIG_DIR/init.lua" << 'EOF' +-- bread init.lua — loaded before modules, use for global setup +bread.log("bread started") +EOF + echo " created $CONFIG_DIR/init.lua" +else + echo " $CONFIG_DIR/init.lua already exists, skipping" +fi +echo "" + +# ── systemd user service ─────────────────────────────────────────────────────── +echo "installing systemd user service..." +mkdir -p "$SERVICE_DIR" +# Patch ExecStart to match the actual install location rather than hardcoding /usr/bin. +sed "s|ExecStart=.*|ExecStart=$BIN_DIR/breadd|" \ + "$REPO_ROOT/packaging/systemd/breadd.service" \ + > "$SERVICE_DIR/breadd.service" +echo " installed $SERVICE_DIR/breadd.service (ExecStart=$BIN_DIR/breadd)" + +systemctl --user daemon-reload + +if systemctl --user is-active --quiet breadd 2>/dev/null; then + systemctl --user restart breadd + echo " breadd restarted" +else + systemctl --user enable --now breadd + echo " breadd enabled and started" +fi +echo "" + +# ── verify ───────────────────────────────────────────────────────────────────── +# Wait up to ~5s for the daemon to come up. Polling beats a fixed sleep +# because a freshly enabled systemd unit can take a variable amount of time +# to fork, bind the socket, and become ready. +ready=0 +for _ in $(seq 1 25); do + if "$BIN_DIR/bread" ping &>/dev/null; then + ready=1 + break + fi + sleep 0.2 +done + +if [[ "$ready" -eq 1 ]]; then + "$BIN_DIR/bread" doctor +else + echo "warning: daemon did not respond to ping within 5s" + echo " check: journalctl --user -u breadd -n 20" +fi