From bde30928a0874a290cc761b2397c07aef4dc3052 Mon Sep 17 00:00:00 2001 From: Breadway <108389940+Breadway@users.noreply.github.com> Date: Mon, 11 May 2026 12:28:49 +0800 Subject: [PATCH 01/76] Update clone command to include .git extension --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5ed2a35..8da6518 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ 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 ``` From 6237f3d7e7f772cb1985da5aa0c019d54e6fc0a9 Mon Sep 17 00:00:00 2001 From: Breadway <108389940+Breadway@users.noreply.github.com> Date: Mon, 11 May 2026 12:28:49 +0800 Subject: [PATCH 02/76] Update clone command to include .git extension --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5ed2a35..8da6518 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ 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 ``` From 16f3765b65cadbcf9d32de2c6d6b1d4ff018b491 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 16:03:05 +0800 Subject: [PATCH 03/76] Add lua runtime --- Cargo.lock | 526 --------------- breadd/src/core/state_engine.rs | 157 ++++- breadd/src/core/subscriptions.rs | 93 ++- breadd/src/core/types.rs | 5 + breadd/src/lua/mod.rs | 1083 ++++++++++++++++++++++++++++-- 5 files changed, 1251 insertions(+), 613 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a36c9da..0ab00f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -260,12 +248,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bitflags" version = "1.3.2" @@ -330,8 +312,6 @@ dependencies = [ "futures-util", "hex", "libc", - "metrics 0.23.1", - "metrics-exporter-prometheus", "mlua", "netlink-packet-core", "netlink-packet-route", @@ -357,12 +337,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - [[package]] name = "byteorder" version = "1.5.0" @@ -446,22 +420,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -471,15 +429,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -636,33 +585,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "futures" version = "0.3.32" @@ -813,34 +741,6 @@ dependencies = [ "wasip3", ] -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "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" @@ -880,109 +780,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -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", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2 0.6.3", - "tokio", - "tower-service", - "tracing", -] - [[package]] name = "id-arena" version = "2.3.0" @@ -1021,12 +818,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1039,18 +830,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "js-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1173,62 +952,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "metrics" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d05972e8cbac2671e85aa9d04d9160d193f8bebd1a5c1a2f4542c62e65d1d0" -dependencies = [ - "ahash", - "portable-atomic", -] - -[[package]] -name = "metrics" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3045b4193fbdc5b5681f32f11070da9be3609f189a79f3390706d42587f46bb5" -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", -] - [[package]] name = "mio" version = "1.2.0" @@ -1270,23 +993,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1395,16 +1101,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" @@ -1417,49 +1113,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "ordered-float" version = "2.10.1" @@ -1567,12 +1220,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1611,21 +1258,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" @@ -1671,15 +1303,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" @@ -1779,50 +1402,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -1938,12 +1523,6 @@ dependencies = [ "libc", ] -[[package]] -name = "sketches-ddsketch" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" - [[package]] name = "slab" version = "0.4.12" @@ -2081,29 +1660,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.8.23" @@ -2156,12 +1712,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - [[package]] name = "tracing" version = "0.1.44" @@ -2223,12 +1773,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typeid" version = "1.0.3" @@ -2288,12 +1832,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -2306,15 +1844,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2339,51 +1868,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -dependencies = [ - "unicode-ident", -] - [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2418,16 +1902,6 @@ dependencies = [ "semver", ] -[[package]] -name = "web-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "which" version = "7.0.3" diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index d824fd0..aecae3c 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -1,8 +1,9 @@ +use std::collections::HashMap; 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; @@ -22,6 +23,16 @@ pub enum StateCommand { pattern: String, once: bool, }, + RemoveSubscription { + id: SubscriptionId, + }, + RegisterWatch { + id: SubscriptionId, + path: String, + }, + RemoveWatch { + id: SubscriptionId, + }, ClearSubscriptions, SetModuleStatus { name: String, @@ -72,6 +83,20 @@ impl StateHandle { .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); } @@ -98,6 +123,7 @@ pub async fn run_state_engine( mut shutdown_rx: watch::Receiver, ) { let mut subscriptions = SubscriptionTable::default(); + let mut watches: HashMap = HashMap::new(); loop { tokio::select! { @@ -110,28 +136,47 @@ pub async fn run_state_engine( let Some(cmd) = maybe_cmd else { break; }; - handle_command(cmd, &state, &mut subscriptions).await; + handle_command(cmd, &state, &mut subscriptions, &mut watches).await; } maybe_event = event_rx.recv() => { let Some(event) = maybe_event else { break; }; - apply_event_to_state(&state, &event).await; + 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) + }; - 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(), - }); + 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); + + 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); + } + } } } } @@ -144,13 +189,24 @@ async fn handle_command( cmd: StateCommand, state: &Arc>, subscriptions: &mut SubscriptionTable, + watches: &mut HashMap, ) { match cmd { StateCommand::RegisterSubscription { id, pattern, once } => { subscriptions.add_with_id(id, pattern, once); } + StateCommand::RemoveSubscription { id } => { + subscriptions.remove(id); + } + StateCommand::RegisterWatch { id, path } => { + watches.insert(id, path); + } + StateCommand::RemoveWatch { id } => { + watches.remove(&id); + } StateCommand::ClearSubscriptions => { subscriptions.clear(); + watches.clear(); } StateCommand::SetModuleStatus { name, @@ -166,6 +222,7 @@ async fn handle_command( name, status, last_error, + store: HashMap::new(), }); } } @@ -180,15 +237,47 @@ async fn handle_command( } } -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, +) { + 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) { + subscriptions.remove(sub.id); + 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), @@ -199,7 +288,7 @@ async fn apply_event_to_state(state: &Arc>, event: &BreadEv } "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,10 +300,10 @@ 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 + state.active_window = event .data .get("window") .or_else(|| event.data.get("class")) @@ -222,20 +311,20 @@ async fn apply_event_to_state(state: &Arc>, event: &BreadEv .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 +336,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(); } } } diff --git a/breadd/src/core/subscriptions.rs b/breadd/src/core/subscriptions.rs index d4e6925..a95d388 100644 --- a/breadd/src/core/subscriptions.rs +++ b/breadd/src/core/subscriptions.rs @@ -35,7 +35,7 @@ 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; + let _last_idx = self.entries.len() - 1; self.entries.swap_remove(idx); if idx < self.entries.len() { @@ -68,5 +68,94 @@ 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::matches_pattern; + + #[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.")); + } } diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 02886c9..254e075 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeMap, HashMap}; use serde::{Deserialize, Serialize}; +use serde_json::Value; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeState { @@ -121,6 +122,8 @@ pub struct ModuleStatus { pub name: String, pub status: ModuleLoadState, pub last_error: Option, + #[serde(default)] + pub store: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -129,4 +132,6 @@ pub enum ModuleLoadState { Loaded, LoadError, NotFound, + Degraded, + Disabled, } diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index beae215..a73de1a 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -1,20 +1,25 @@ -use std::collections::HashMap; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; 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_json::Value as JsonValue; +use tokio::sync::{mpsc, oneshot, watch, RwLock}; use tokio::task; +use tokio::time::{interval, sleep}; use tracing::{error, info, warn}; use crate::core::config::Config; use crate::core::state_engine::StateHandle; use crate::core::subscriptions::SubscriptionId; -use crate::core::types::ModuleLoadState; +use crate::core::types::{ModuleLoadState, RuntimeState}; pub enum LuaMessage { Event { @@ -24,6 +29,9 @@ pub enum LuaMessage { SubscriptionCancelled { id: SubscriptionId, }, + TimerFired { + id: TimerId, + }, Reload { reply: oneshot::Sender>, }, @@ -75,7 +83,7 @@ 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()) { Ok(engine) => engine, Err(err) => { error!(error = %err, "failed to initialize lua engine"); @@ -100,6 +108,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 +131,114 @@ 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, +} + +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, } 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, + ) -> 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(), }) } fn reload_internal(&mut self) -> Result<()> { + self.run_on_unload(); + self.cancel_all_timers(); self.state_handle.clear_subscriptions(); 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_init_and_modules()?; + self.run_on_reload(); info!("lua runtime reloaded"); Ok(()) } @@ -162,16 +250,30 @@ impl LuaEngine { 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_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(|_| mlua::Error::external("handler lock poisoned"))? - .insert(id, key); + .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(mlua::Error::external)?; + .map_err(LuaError::external)?; Ok(id.0) })?; bread.set("on", on_fn)?; @@ -179,20 +281,93 @@ impl LuaEngine { 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 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(|_| mlua::Error::external("handler lock poisoned"))? - .insert(id, key); + .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(mlua::Error::external)?; + .map_err(LuaError::external)?; Ok(id.0) })?; bread.set("once", once_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 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("missing filter options")); + }; + 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 { @@ -203,7 +378,7 @@ impl LuaEngine { }; emit_tx .send(BreadEvent::new(event_name, AdapterSource::System, data)) - .map_err(|_| mlua::Error::external("event channel closed"))?; + .map_err(|_| LuaError::external("event channel closed"))?; Ok(()) })?; bread.set("emit", emit_fn)?; @@ -211,24 +386,91 @@ impl LuaEngine { 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())) + 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()?; @@ -240,9 +482,6 @@ impl LuaEngine { 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,7 +503,249 @@ impl LuaEngine { })?; bread.set("exec", exec_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 mut ticker = interval(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 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_else(|_| 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 }, + ); + + Ok(module_tbl) + })?; + bread.set("module", module_fn)?; + globals.set("bread", bread)?; + self.install_require_loader()?; + self.install_wait_helper()?; Ok(()) } @@ -273,20 +754,15 @@ impl LuaEngine { 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 mut decls = Vec::new(); + 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()), ); @@ -294,6 +770,60 @@ impl LuaEngine { } } + 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)); + } + + 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); + } + Err(err) => { + self.state_handle.set_module_status( + decl.name.clone(), + ModuleLoadState::LoadError, + Some(err.to_string()), + ); + } + } + } + + *self + .module_order + .lock() + .expect("module order mutex poisoned") = load_order; + + Ok(()) + } + + fn load_module(&self, decl: &ModuleDecl) -> Result<()> { + self.set_current_module(Some(decl.name.clone())); + let result = self.load_lua_file(&decl.path, &decl.name); + 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(()) } @@ -314,14 +844,83 @@ impl LuaEngine { } 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 +930,388 @@ 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"); + self.state_handle + .set_module_status(name.to_string(), ModuleLoadState::LoadError, Some(err.to_string())); + } + } + } + + 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"); + self.state_handle.set_module_status( + name.to_string(), + ModuleLoadState::Degraded, + Some(err.to_string()), + ); + } + } + } + } + + 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"); + self.state_handle.set_module_status( + name.to_string(), + ModuleLoadState::Degraded, + Some(err.to_string()), + ); + } + } + } + } + + fn handle_callback_error(&self, module: Option<&str>, id: SubscriptionId, err: LuaError) { + if let Some(module) = module { + self.state_handle.set_module_status( + module.to_string(), + ModuleLoadState::Degraded, + Some(err.to_string()), + ); + 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 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 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(), + }); + Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string())) + })?; + + let bread = lua.create_table()?; + bread.set("module", module_fn)?; + lua.globals().set("bread", bread)?; + + let src = fs::read_to_string(path)?; + let result = lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec(); + if let Err(err) = result { + match err { + LuaError::RuntimeError(msg) if msg == MODULE_DECL_ABORT => {} + other => return Err(anyhow!(other.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.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> { + let snapshot = state_arc.blocking_read(); + 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 = state_arc.blocking_read(); + 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 = state_arc.blocking_write(); + 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, + store, + }); +} + +fn hyprland_request_socket() -> Result { + let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") + .map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?; + let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + Ok(PathBuf::from(runtime) + .join("hypr") + .join(instance) + .join(".socket.sock")) +} + +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 list_lua_files(root: &Path) -> Result> { From 0e3233009bfd595c0a6afb4e3c12112bde0c2e45 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 16:03:05 +0800 Subject: [PATCH 04/76] Add lua runtime --- Cargo.lock | 526 --------------- breadd/src/core/state_engine.rs | 157 ++++- breadd/src/core/subscriptions.rs | 93 ++- breadd/src/core/types.rs | 5 + breadd/src/lua/mod.rs | 1083 ++++++++++++++++++++++++++++-- 5 files changed, 1251 insertions(+), 613 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a36c9da..0ab00f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -260,12 +248,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bitflags" version = "1.3.2" @@ -330,8 +312,6 @@ dependencies = [ "futures-util", "hex", "libc", - "metrics 0.23.1", - "metrics-exporter-prometheus", "mlua", "netlink-packet-core", "netlink-packet-route", @@ -357,12 +337,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - [[package]] name = "byteorder" version = "1.5.0" @@ -446,22 +420,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -471,15 +429,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -636,33 +585,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "futures" version = "0.3.32" @@ -813,34 +741,6 @@ dependencies = [ "wasip3", ] -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "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" @@ -880,109 +780,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -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", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2 0.6.3", - "tokio", - "tower-service", - "tracing", -] - [[package]] name = "id-arena" version = "2.3.0" @@ -1021,12 +818,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1039,18 +830,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "js-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1173,62 +952,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "metrics" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d05972e8cbac2671e85aa9d04d9160d193f8bebd1a5c1a2f4542c62e65d1d0" -dependencies = [ - "ahash", - "portable-atomic", -] - -[[package]] -name = "metrics" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3045b4193fbdc5b5681f32f11070da9be3609f189a79f3390706d42587f46bb5" -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", -] - [[package]] name = "mio" version = "1.2.0" @@ -1270,23 +993,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1395,16 +1101,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" @@ -1417,49 +1113,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "ordered-float" version = "2.10.1" @@ -1567,12 +1220,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1611,21 +1258,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" @@ -1671,15 +1303,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" @@ -1779,50 +1402,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -1938,12 +1523,6 @@ dependencies = [ "libc", ] -[[package]] -name = "sketches-ddsketch" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" - [[package]] name = "slab" version = "0.4.12" @@ -2081,29 +1660,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.8.23" @@ -2156,12 +1712,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - [[package]] name = "tracing" version = "0.1.44" @@ -2223,12 +1773,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typeid" version = "1.0.3" @@ -2288,12 +1832,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -2306,15 +1844,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2339,51 +1868,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -dependencies = [ - "unicode-ident", -] - [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2418,16 +1902,6 @@ dependencies = [ "semver", ] -[[package]] -name = "web-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "which" version = "7.0.3" diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index d824fd0..aecae3c 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -1,8 +1,9 @@ +use std::collections::HashMap; 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; @@ -22,6 +23,16 @@ pub enum StateCommand { pattern: String, once: bool, }, + RemoveSubscription { + id: SubscriptionId, + }, + RegisterWatch { + id: SubscriptionId, + path: String, + }, + RemoveWatch { + id: SubscriptionId, + }, ClearSubscriptions, SetModuleStatus { name: String, @@ -72,6 +83,20 @@ impl StateHandle { .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); } @@ -98,6 +123,7 @@ pub async fn run_state_engine( mut shutdown_rx: watch::Receiver, ) { let mut subscriptions = SubscriptionTable::default(); + let mut watches: HashMap = HashMap::new(); loop { tokio::select! { @@ -110,28 +136,47 @@ pub async fn run_state_engine( let Some(cmd) = maybe_cmd else { break; }; - handle_command(cmd, &state, &mut subscriptions).await; + handle_command(cmd, &state, &mut subscriptions, &mut watches).await; } maybe_event = event_rx.recv() => { let Some(event) = maybe_event else { break; }; - apply_event_to_state(&state, &event).await; + 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) + }; - 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(), - }); + 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); + + 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); + } + } } } } @@ -144,13 +189,24 @@ async fn handle_command( cmd: StateCommand, state: &Arc>, subscriptions: &mut SubscriptionTable, + watches: &mut HashMap, ) { match cmd { StateCommand::RegisterSubscription { id, pattern, once } => { subscriptions.add_with_id(id, pattern, once); } + StateCommand::RemoveSubscription { id } => { + subscriptions.remove(id); + } + StateCommand::RegisterWatch { id, path } => { + watches.insert(id, path); + } + StateCommand::RemoveWatch { id } => { + watches.remove(&id); + } StateCommand::ClearSubscriptions => { subscriptions.clear(); + watches.clear(); } StateCommand::SetModuleStatus { name, @@ -166,6 +222,7 @@ async fn handle_command( name, status, last_error, + store: HashMap::new(), }); } } @@ -180,15 +237,47 @@ async fn handle_command( } } -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, +) { + 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) { + subscriptions.remove(sub.id); + 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), @@ -199,7 +288,7 @@ async fn apply_event_to_state(state: &Arc>, event: &BreadEv } "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,10 +300,10 @@ 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 + state.active_window = event .data .get("window") .or_else(|| event.data.get("class")) @@ -222,20 +311,20 @@ async fn apply_event_to_state(state: &Arc>, event: &BreadEv .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 +336,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(); } } } diff --git a/breadd/src/core/subscriptions.rs b/breadd/src/core/subscriptions.rs index d4e6925..a95d388 100644 --- a/breadd/src/core/subscriptions.rs +++ b/breadd/src/core/subscriptions.rs @@ -35,7 +35,7 @@ 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; + let _last_idx = self.entries.len() - 1; self.entries.swap_remove(idx); if idx < self.entries.len() { @@ -68,5 +68,94 @@ 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::matches_pattern; + + #[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.")); + } } diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 02886c9..254e075 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeMap, HashMap}; use serde::{Deserialize, Serialize}; +use serde_json::Value; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeState { @@ -121,6 +122,8 @@ pub struct ModuleStatus { pub name: String, pub status: ModuleLoadState, pub last_error: Option, + #[serde(default)] + pub store: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -129,4 +132,6 @@ pub enum ModuleLoadState { Loaded, LoadError, NotFound, + Degraded, + Disabled, } diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index beae215..a73de1a 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -1,20 +1,25 @@ -use std::collections::HashMap; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; 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_json::Value as JsonValue; +use tokio::sync::{mpsc, oneshot, watch, RwLock}; use tokio::task; +use tokio::time::{interval, sleep}; use tracing::{error, info, warn}; use crate::core::config::Config; use crate::core::state_engine::StateHandle; use crate::core::subscriptions::SubscriptionId; -use crate::core::types::ModuleLoadState; +use crate::core::types::{ModuleLoadState, RuntimeState}; pub enum LuaMessage { Event { @@ -24,6 +29,9 @@ pub enum LuaMessage { SubscriptionCancelled { id: SubscriptionId, }, + TimerFired { + id: TimerId, + }, Reload { reply: oneshot::Sender>, }, @@ -75,7 +83,7 @@ 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()) { Ok(engine) => engine, Err(err) => { error!(error = %err, "failed to initialize lua engine"); @@ -100,6 +108,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 +131,114 @@ 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, +} + +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, } 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, + ) -> 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(), }) } fn reload_internal(&mut self) -> Result<()> { + self.run_on_unload(); + self.cancel_all_timers(); self.state_handle.clear_subscriptions(); 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_init_and_modules()?; + self.run_on_reload(); info!("lua runtime reloaded"); Ok(()) } @@ -162,16 +250,30 @@ impl LuaEngine { 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_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(|_| mlua::Error::external("handler lock poisoned"))? - .insert(id, key); + .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(mlua::Error::external)?; + .map_err(LuaError::external)?; Ok(id.0) })?; bread.set("on", on_fn)?; @@ -179,20 +281,93 @@ impl LuaEngine { 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 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(|_| mlua::Error::external("handler lock poisoned"))? - .insert(id, key); + .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(mlua::Error::external)?; + .map_err(LuaError::external)?; Ok(id.0) })?; bread.set("once", once_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 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("missing filter options")); + }; + 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 { @@ -203,7 +378,7 @@ impl LuaEngine { }; emit_tx .send(BreadEvent::new(event_name, AdapterSource::System, data)) - .map_err(|_| mlua::Error::external("event channel closed"))?; + .map_err(|_| LuaError::external("event channel closed"))?; Ok(()) })?; bread.set("emit", emit_fn)?; @@ -211,24 +386,91 @@ impl LuaEngine { 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())) + 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()?; @@ -240,9 +482,6 @@ impl LuaEngine { 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,7 +503,249 @@ impl LuaEngine { })?; bread.set("exec", exec_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 mut ticker = interval(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 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_else(|_| 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 }, + ); + + Ok(module_tbl) + })?; + bread.set("module", module_fn)?; + globals.set("bread", bread)?; + self.install_require_loader()?; + self.install_wait_helper()?; Ok(()) } @@ -273,20 +754,15 @@ impl LuaEngine { 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 mut decls = Vec::new(); + 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()), ); @@ -294,6 +770,60 @@ impl LuaEngine { } } + 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)); + } + + 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); + } + Err(err) => { + self.state_handle.set_module_status( + decl.name.clone(), + ModuleLoadState::LoadError, + Some(err.to_string()), + ); + } + } + } + + *self + .module_order + .lock() + .expect("module order mutex poisoned") = load_order; + + Ok(()) + } + + fn load_module(&self, decl: &ModuleDecl) -> Result<()> { + self.set_current_module(Some(decl.name.clone())); + let result = self.load_lua_file(&decl.path, &decl.name); + 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(()) } @@ -314,14 +844,83 @@ impl LuaEngine { } 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 +930,388 @@ 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"); + self.state_handle + .set_module_status(name.to_string(), ModuleLoadState::LoadError, Some(err.to_string())); + } + } + } + + 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"); + self.state_handle.set_module_status( + name.to_string(), + ModuleLoadState::Degraded, + Some(err.to_string()), + ); + } + } + } + } + + 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"); + self.state_handle.set_module_status( + name.to_string(), + ModuleLoadState::Degraded, + Some(err.to_string()), + ); + } + } + } + } + + fn handle_callback_error(&self, module: Option<&str>, id: SubscriptionId, err: LuaError) { + if let Some(module) = module { + self.state_handle.set_module_status( + module.to_string(), + ModuleLoadState::Degraded, + Some(err.to_string()), + ); + 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 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 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(), + }); + Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string())) + })?; + + let bread = lua.create_table()?; + bread.set("module", module_fn)?; + lua.globals().set("bread", bread)?; + + let src = fs::read_to_string(path)?; + let result = lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec(); + if let Err(err) = result { + match err { + LuaError::RuntimeError(msg) if msg == MODULE_DECL_ABORT => {} + other => return Err(anyhow!(other.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.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> { + let snapshot = state_arc.blocking_read(); + 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 = state_arc.blocking_read(); + 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 = state_arc.blocking_write(); + 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, + store, + }); +} + +fn hyprland_request_socket() -> Result { + let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") + .map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?; + let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + Ok(PathBuf::from(runtime) + .join("hypr") + .join(instance) + .join(".socket.sock")) +} + +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 list_lua_files(root: &Path) -> Result> { From 65f81db5626ec4f751bd350ab814f3ae55988792 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 16:30:05 +0800 Subject: [PATCH 05/76] unsure --- Cargo.lock | 130 ++++++++- bread-cli/Cargo.toml | 1 + bread-cli/src/main.rs | 317 +++++++++++++++++++++- breadd/src/adapters/mod.rs | 28 +- breadd/src/core/config.rs | 55 ++++ breadd/src/core/normalizer.rs | 117 ++++++-- breadd/src/core/state_engine.rs | 53 +++- breadd/src/core/types.rs | 2 + breadd/src/ipc/mod.rs | 65 ++++- breadd/src/lua/mod.rs | 460 ++++++++++++++++++++++++++++++-- breadd/src/main.rs | 31 ++- 11 files changed, 1192 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ab00f1..d987076 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,6 +289,7 @@ dependencies = [ "anyhow", "bread-shared", "clap", + "notify", "serde", "serde_json", "tokio", @@ -429,6 +430,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -579,6 +589,16 @@ 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" @@ -591,6 +611,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[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" @@ -798,6 +827,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" @@ -830,6 +879,26 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[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" @@ -952,6 +1021,18 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.0" @@ -1083,6 +1164,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" @@ -1402,6 +1502,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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 = "scopeguard" version = "1.2.0" @@ -1639,7 +1748,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -1844,6 +1953,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 = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1930,6 +2049,15 @@ 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" diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 0550c57..6d2541e 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -10,3 +10,4 @@ serde_json.workspace = true tokio.workspace = true anyhow.workspace = true clap = { version = "4.5", features = ["derive"] } +notify = "6.1" diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index e4af194..6e1982a 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,10 +1,14 @@ use anyhow::Result; use clap::{Parser, Subcommand}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{json, Value}; use std::env; +use std::io; use std::path::{Path, PathBuf}; +use std::time::{Duration, UNIX_EPOCH}; 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")] @@ -16,13 +20,32 @@ 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 { #[arg(long)] filter: Option, + /// Output raw JSON + #[arg(long)] + json: bool, + /// Comma-separated fields to display + #[arg(long)] + fields: Option, + /// Replay events from the last N seconds + #[arg(long)] + since: Option, }, /// List loaded modules and status Modules, @@ -40,6 +63,12 @@ enum Commands { Ping, /// Fetch daemon health details Health, + /// Diagnose daemon and module health + Doctor { + /// Output raw JSON + #[arg(long)] + json: bool, + }, } #[tokio::main] @@ -48,16 +77,38 @@ async fn main() -> Result<()> { let socket = daemon_socket_path(); match &cli.command { - Commands::Reload => { - let response = send_request(&socket, "modules.reload", json!({})).await?; - print_json(&response)?; + 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 } => { + if *json { + let response = if let Some(path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; + print_json(&response)?; + } else { + let response = if let Some(path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; + print_state_formatted(path.as_deref(), &response); + } } - Commands::Events { filter } => { - stream_events(&socket, filter.clone()).await?; + Commands::Events { + filter, + json, + fields, + since, + } => { + stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?; } Commands::Modules => { let response = send_request(&socket, "modules.list", json!({})).await?; @@ -92,6 +143,14 @@ 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(()) @@ -128,7 +187,26 @@ 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 +224,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 +238,214 @@ 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; + let time = UNIX_EPOCH + Duration::from_secs(secs); + let datetime = time.duration_since(UNIX_EPOCH).unwrap_or_default(); + let seconds = datetime.as_secs() % 60; + let minutes = (datetime.as_secs() / 60) % 60; + let hours = (datetime.as_secs() / 3600) % 24; + format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, 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_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/breadd/src/adapters/mod.rs b/breadd/src/adapters/mod.rs index d3a8a3f..b33569b 100644 --- a/breadd/src/adapters/mod.rs +++ b/breadd/src/adapters/mod.rs @@ -1,8 +1,11 @@ use anyhow::Result; use async_trait::async_trait; use bread_shared::RawEvent; -use tokio::sync::{mpsc, watch}; +use tokio::sync::{mpsc, watch, RwLock}; use tracing::info; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::Arc; use crate::core::config::Config; use crate::core::supervisor::spawn_supervised; @@ -14,6 +17,13 @@ pub mod udev; pub mod network_rtnetlink; pub mod power_upower; +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AdapterStatus { + Connected, + Disconnected, +} + #[async_trait] pub trait Adapter: Send + Sync { fn name(&self) -> &'static str; @@ -30,6 +40,7 @@ pub struct Manager { raw_tx: mpsc::Sender, config: Config, shutdown_rx: watch::Receiver, + status: Arc>>, } impl Manager { @@ -42,9 +53,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"); @@ -91,17 +107,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/core/config.rs b/breadd/src/core/config.rs index dedd0d4..1c756a9 100644 --- a/breadd/src/core/config.rs +++ b/breadd/src/core/config.rs @@ -12,8 +12,12 @@ pub struct Config { #[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, } @@ -33,6 +37,14 @@ pub struct LuaConfig { pub module_path: String, } +#[derive(Debug, Clone, Deserialize)] +pub struct ModulesConfig { + #[serde(default = "default_true")] + pub builtin: bool, + #[serde(default)] + pub disable: Vec, +} + #[derive(Debug, Clone, Deserialize)] pub struct AdaptersConfig { #[serde(default)] @@ -73,12 +85,24 @@ pub struct EventsConfig { pub dedup_window_ms: u64, } +#[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 Config { fn default() -> Self { Self { daemon: DaemonConfig::default(), lua: LuaConfig::default(), + modules: ModulesConfig::default(), adapters: AdaptersConfig::default(), + notifications: NotificationsConfig::default(), events: EventsConfig::default(), } } @@ -102,6 +126,15 @@ impl Default for LuaConfig { } } +impl Default for ModulesConfig { + fn default() -> Self { + Self { + builtin: default_true(), + disable: Vec::new(), + } + } +} + impl Default for AdaptersConfig { fn default() -> Self { Self { @@ -147,6 +180,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 +261,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(), diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index d0f7b42..b424e1c 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -80,22 +80,102 @@ impl EventNormalizer { 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 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: raw.payload.clone(), + }], + "monitorremoved" => vec![BreadEvent { + event: "bread.monitor.disconnected".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: raw.payload.clone(), + }], + "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.get(0).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.get(0).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.get(0).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.get(0).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 { @@ -201,6 +281,13 @@ impl EventNormalizer { } } +fn split_hyprland_fields(data: &str) -> Vec<&str> { + if data.is_empty() { + return Vec::new(); + } + data.split(">>").collect() +} + fn classify_device(payload: &Value) -> DeviceClass { let name = payload .get("name") diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index aecae3c..caea8dc 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use anyhow::Result; use bread_shared::{AdapterSource, BreadEvent}; @@ -15,6 +16,7 @@ use crate::lua::LuaMessage; pub struct StateHandle { state: Arc>, command_tx: mpsc::UnboundedSender, + subscription_count: Arc, } pub enum StateCommand { @@ -38,6 +40,7 @@ pub enum StateCommand { name: String, status: ModuleLoadState, last_error: Option, + builtin: bool, }, SetProfile { name: String, @@ -45,8 +48,16 @@ pub enum StateCommand { } impl StateHandle { - pub fn new(state: Arc>, command_tx: mpsc::UnboundedSender) -> Self { - Self { state, command_tx } + pub fn new( + state: Arc>, + command_tx: mpsc::UnboundedSender, + subscription_count: Arc, + ) -> Self { + Self { + state, + command_tx, + subscription_count, + } } pub fn state_arc(&self) -> Arc> { @@ -101,17 +112,28 @@ impl StateHandle { let _ = self.command_tx.send(StateCommand::ClearSubscriptions); } - pub fn set_module_status(&self, name: String, status: ModuleLoadState, last_error: Option) { + 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 subscription_count(&self) -> Arc { + self.subscription_count.clone() + } } pub async fn run_state_engine( @@ -120,6 +142,7 @@ 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(); @@ -136,7 +159,7 @@ pub async fn run_state_engine( let Some(cmd) = maybe_cmd else { break; }; - handle_command(cmd, &state, &mut subscriptions, &mut watches).await; + handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await; } maybe_event = event_rx.recv() => { let Some(event) = maybe_event else { @@ -158,7 +181,7 @@ pub async fn run_state_engine( apply_event_to_state(&mut guard, &event); } - dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx); + dispatch_event(&event, &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() { @@ -174,7 +197,7 @@ pub async fn run_state_engine( "old": old_val, }), ); - dispatch_event(&synthetic, &mut subscriptions, &lua_tx, &event_stream_tx); + dispatch_event(&synthetic, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count); } } } @@ -190,13 +213,17 @@ async fn handle_command( 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 } => { - subscriptions.remove(id); + if subscriptions.remove(id) { + subscription_count.fetch_sub(1, Ordering::Relaxed); + } } StateCommand::RegisterWatch { id, path } => { watches.insert(id, path); @@ -207,21 +234,25 @@ async fn handle_command( StateCommand::ClearSubscriptions => { subscriptions.clear(); watches.clear(); + subscription_count.store(0, Ordering::Relaxed); } 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(), }); } @@ -242,6 +273,7 @@ fn dispatch_event( subscriptions: &mut SubscriptionTable, lua_tx: &mpsc::UnboundedSender, event_stream_tx: &broadcast::Sender, + subscription_count: &Arc, ) { let _ = event_stream_tx.send(event.clone()); @@ -254,7 +286,9 @@ fn dispatch_event( } for sub in matches.into_iter().filter(|s| s.once) { - subscriptions.remove(sub.id); + if subscriptions.remove(sub.id) { + subscription_count.fetch_sub(1, Ordering::Relaxed); + } let _ = lua_tx.send(LuaMessage::SubscriptionCancelled { id: sub.id }); } } @@ -302,11 +336,12 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { .map(ToString::to_string); state.active_workspace = ws; } - "bread.window.focus.changed" => { + "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); } diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 254e075..45ccfa5 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -123,6 +123,8 @@ pub struct ModuleStatus { pub status: ModuleLoadState, pub last_error: Option, #[serde(default)] + pub builtin: bool, + #[serde(default)] pub store: HashMap, } diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index 1ac245b..f4aa092 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::time::Instant; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; 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, } @@ -51,6 +58,9 @@ impl Server { 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 +68,9 @@ impl Server { event_tx, lua_runtime, emit_tx, + adapter_status, + subscription_count, + event_buffer, started_at: Instant::now(), pid: process::id(), } @@ -166,12 +179,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 @@ -224,13 +250,36 @@ 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, })) } + "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 mut replay = Vec::new(); + if let Ok(buf) = self.event_buffer.lock() { + for event in buf.iter() { + if event.timestamp >= cutoff { + replay.push(event); + } + } + } + Ok(serde_json::to_value(replay).unwrap_or_else(|_| json!([]))) + } _ => Err("unknown method".to_string()), }; diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index a73de1a..cf94a57 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -1,5 +1,5 @@ use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; use std::path::{Path, PathBuf}; use std::rc::Rc; @@ -10,16 +10,18 @@ use std::time::Duration; use anyhow::{anyhow, Result}; use bread_shared::{AdapterSource, BreadEvent}; 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, sleep}; +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, RuntimeState}; +use bread_shared::now_unix_ms; pub enum LuaMessage { Event { @@ -38,9 +40,17 @@ pub enum LuaMessage { 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 { @@ -63,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( @@ -71,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() @@ -83,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, thread_tx.clone()) { + 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"); @@ -160,6 +187,8 @@ struct ModuleDecl { version: Option, after: Vec, path: PathBuf, + source: Option<&'static str>, + builtin: bool, } struct ModuleInfo { @@ -182,6 +211,9 @@ struct LuaEngine { lua_tx: mpsc::UnboundedSender, entry_point: PathBuf, module_path: PathBuf, + modules_config: ModulesConfig, + notifications_config: NotificationsConfig, + recent_errors: Arc>>, } impl LuaEngine { @@ -190,6 +222,7 @@ impl LuaEngine { state_handle: StateHandle, emit_tx: mpsc::UnboundedSender, lua_tx: mpsc::UnboundedSender, + recent_errors: Arc>>, ) -> Result { Ok(Self { lua: Lua::new(), @@ -207,6 +240,9 @@ impl LuaEngine { 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, }) } @@ -324,7 +360,9 @@ impl LuaEngine { .map_err(|_| LuaError::external("missing filter function"))?; Some(lua.create_registry_value(filter_fn)?) } else { - return Err(LuaError::external("missing filter options")); + 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() @@ -503,6 +541,61 @@ 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(); @@ -556,7 +649,8 @@ impl LuaEngine { ); let lua_tx = lua_tx.clone(); task::spawn(async move { - let mut ticker = interval(Duration::from_millis(interval_ms)); + 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() => { @@ -750,12 +844,22 @@ impl LuaEngine { } 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(); + 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), @@ -765,6 +869,7 @@ impl LuaEngine { name, ModuleLoadState::LoadError, Some(err.to_string()), + false, ); } } @@ -784,7 +889,7 @@ impl LuaEngine { for (name, err) in dep_errors { self.state_handle - .set_module_status(name, ModuleLoadState::LoadError, Some(err)); + .set_module_status(name, ModuleLoadState::LoadError, Some(err), false); } let mut load_order = Vec::new(); @@ -792,14 +897,19 @@ impl LuaEngine { load_order.push(decl.name.clone()); match self.load_module(&decl) { Ok(()) => { - self.state_handle - .set_module_status(decl.name.clone(), ModuleLoadState::Loaded, None); + 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, ); } } @@ -815,7 +925,11 @@ impl LuaEngine { fn load_module(&self, decl: &ModuleDecl) -> Result<()> { self.set_current_module(Some(decl.name.clone())); - let result = self.load_lua_file(&decl.path, &decl.name); + let result = if let Some(source) = decl.source.as_deref() { + self.load_lua_source(source, &decl.name) + } else { + self.load_lua_file(&decl.path, &decl.name, decl.builtin) + }; self.set_current_module(None); result?; @@ -827,13 +941,14 @@ impl LuaEngine { Ok(()) } - fn load_lua_file(&self, path: &Path, module_name: &str) -> Result<()> { + 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(()); } @@ -843,6 +958,14 @@ impl LuaEngine { 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 (callback, filter, raw_kind, kind, module) = { let handlers = self.handlers.lock().expect("lua handlers mutex poisoned"); @@ -935,8 +1058,13 @@ impl LuaEngine { 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"); - self.state_handle - .set_module_status(name.to_string(), ModuleLoadState::LoadError, Some(err.to_string())); + let builtin = self.module_is_builtin(name); + self.state_handle.set_module_status( + name.to_string(), + ModuleLoadState::LoadError, + Some(err.to_string()), + builtin, + ); } } } @@ -951,10 +1079,12 @@ impl LuaEngine { 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, ); } } @@ -971,10 +1101,12 @@ impl LuaEngine { 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, ); } } @@ -983,10 +1115,22 @@ impl LuaEngine { 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()) { @@ -1022,6 +1166,14 @@ impl LuaEngine { .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; @@ -1052,6 +1204,8 @@ impl LuaEngine { version, after, path: module_path.clone(), + source: None, + builtin: false, }); Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string())) })?; @@ -1119,6 +1273,14 @@ impl LuaEngine { 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") @@ -1288,10 +1450,266 @@ fn module_store_set(state_arc: &Arc>, module: &str, key: St name: module.to_string(), status: ModuleLoadState::Loaded, last_error: None, + builtin: false, store, }); } +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 class = rule.class + local when = rule.when + local data = event.data or {} + + if when == "connected" and event.event ~= "bread.device.connected" then + if not event.event:match("%.connected$") then + return false + end + elseif when == "disconnected" and event.event ~= "bread.device.disconnected" then + if not event.event:match("%.disconnected$") then + return false + end + end + + if class and data.class ~= class 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 instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") .map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?; @@ -1307,11 +1725,13 @@ fn hyprland_request(request: &str) -> Result { 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) + tokio::task::block_in_place(|| { + 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 list_lua_files(root: &Path) -> Result> { diff --git a/breadd/src/main.rs b/breadd/src/main.rs index c57fabd..bcb4daa 100644 --- a/breadd/src/main.rs +++ b/breadd/src/main.rs @@ -3,7 +3,9 @@ mod core; mod ipc; mod lua; +use std::collections::VecDeque; use std::sync::Arc; +use std::sync::atomic::AtomicU64; use anyhow::Result; use bread_shared::{AdapterSource, BreadEvent, RawEvent}; @@ -33,7 +35,8 @@ async fn main() -> Result<()> { let (event_stream_tx, _) = broadcast::channel(2048); let (shutdown_tx, shutdown_rx) = watch::channel(false); - let state_handle = StateHandle::new(state.clone(), state_cmd_tx); + let subscription_count = Arc::new(AtomicU64::new(0)); + let state_handle = StateHandle::new(state.clone(), state_cmd_tx, subscription_count.clone()); let lua_runtime = lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?; let lua_tx = lua_runtime.sender(); @@ -44,6 +47,7 @@ async fn main() -> Result<()> { state.clone(), lua_tx, event_stream_tx.clone(), + subscription_count.clone(), shutdown_rx.clone(), )); @@ -78,6 +82,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 +116,9 @@ async fn main() -> Result<()> { event_stream_tx, lua_runtime.clone(), normalized_tx, + adapter_status, + subscription_count, + event_buffer, ); info!("breadd fully started"); From 1a00daf6a8644e8642dc8d00fd61065e1c7f536c Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 16:30:05 +0800 Subject: [PATCH 06/76] unsure --- Cargo.lock | 130 ++++++++- bread-cli/Cargo.toml | 1 + bread-cli/src/main.rs | 317 +++++++++++++++++++++- breadd/src/adapters/mod.rs | 28 +- breadd/src/core/config.rs | 55 ++++ breadd/src/core/normalizer.rs | 117 ++++++-- breadd/src/core/state_engine.rs | 53 +++- breadd/src/core/types.rs | 2 + breadd/src/ipc/mod.rs | 65 ++++- breadd/src/lua/mod.rs | 460 ++++++++++++++++++++++++++++++-- breadd/src/main.rs | 31 ++- 11 files changed, 1192 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ab00f1..d987076 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,6 +289,7 @@ dependencies = [ "anyhow", "bread-shared", "clap", + "notify", "serde", "serde_json", "tokio", @@ -429,6 +430,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -579,6 +589,16 @@ 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" @@ -591,6 +611,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[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" @@ -798,6 +827,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" @@ -830,6 +879,26 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[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" @@ -952,6 +1021,18 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.0" @@ -1083,6 +1164,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" @@ -1402,6 +1502,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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 = "scopeguard" version = "1.2.0" @@ -1639,7 +1748,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -1844,6 +1953,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 = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1930,6 +2049,15 @@ 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" diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 0550c57..6d2541e 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -10,3 +10,4 @@ serde_json.workspace = true tokio.workspace = true anyhow.workspace = true clap = { version = "4.5", features = ["derive"] } +notify = "6.1" diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index e4af194..6e1982a 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,10 +1,14 @@ use anyhow::Result; use clap::{Parser, Subcommand}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{json, Value}; use std::env; +use std::io; use std::path::{Path, PathBuf}; +use std::time::{Duration, UNIX_EPOCH}; 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")] @@ -16,13 +20,32 @@ 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 { #[arg(long)] filter: Option, + /// Output raw JSON + #[arg(long)] + json: bool, + /// Comma-separated fields to display + #[arg(long)] + fields: Option, + /// Replay events from the last N seconds + #[arg(long)] + since: Option, }, /// List loaded modules and status Modules, @@ -40,6 +63,12 @@ enum Commands { Ping, /// Fetch daemon health details Health, + /// Diagnose daemon and module health + Doctor { + /// Output raw JSON + #[arg(long)] + json: bool, + }, } #[tokio::main] @@ -48,16 +77,38 @@ async fn main() -> Result<()> { let socket = daemon_socket_path(); match &cli.command { - Commands::Reload => { - let response = send_request(&socket, "modules.reload", json!({})).await?; - print_json(&response)?; + 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 } => { + if *json { + let response = if let Some(path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; + print_json(&response)?; + } else { + let response = if let Some(path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; + print_state_formatted(path.as_deref(), &response); + } } - Commands::Events { filter } => { - stream_events(&socket, filter.clone()).await?; + Commands::Events { + filter, + json, + fields, + since, + } => { + stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?; } Commands::Modules => { let response = send_request(&socket, "modules.list", json!({})).await?; @@ -92,6 +143,14 @@ 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(()) @@ -128,7 +187,26 @@ 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 +224,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 +238,214 @@ 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; + let time = UNIX_EPOCH + Duration::from_secs(secs); + let datetime = time.duration_since(UNIX_EPOCH).unwrap_or_default(); + let seconds = datetime.as_secs() % 60; + let minutes = (datetime.as_secs() / 60) % 60; + let hours = (datetime.as_secs() / 3600) % 24; + format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, 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_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/breadd/src/adapters/mod.rs b/breadd/src/adapters/mod.rs index d3a8a3f..b33569b 100644 --- a/breadd/src/adapters/mod.rs +++ b/breadd/src/adapters/mod.rs @@ -1,8 +1,11 @@ use anyhow::Result; use async_trait::async_trait; use bread_shared::RawEvent; -use tokio::sync::{mpsc, watch}; +use tokio::sync::{mpsc, watch, RwLock}; use tracing::info; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::Arc; use crate::core::config::Config; use crate::core::supervisor::spawn_supervised; @@ -14,6 +17,13 @@ pub mod udev; pub mod network_rtnetlink; pub mod power_upower; +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AdapterStatus { + Connected, + Disconnected, +} + #[async_trait] pub trait Adapter: Send + Sync { fn name(&self) -> &'static str; @@ -30,6 +40,7 @@ pub struct Manager { raw_tx: mpsc::Sender, config: Config, shutdown_rx: watch::Receiver, + status: Arc>>, } impl Manager { @@ -42,9 +53,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"); @@ -91,17 +107,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/core/config.rs b/breadd/src/core/config.rs index dedd0d4..1c756a9 100644 --- a/breadd/src/core/config.rs +++ b/breadd/src/core/config.rs @@ -12,8 +12,12 @@ pub struct Config { #[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, } @@ -33,6 +37,14 @@ pub struct LuaConfig { pub module_path: String, } +#[derive(Debug, Clone, Deserialize)] +pub struct ModulesConfig { + #[serde(default = "default_true")] + pub builtin: bool, + #[serde(default)] + pub disable: Vec, +} + #[derive(Debug, Clone, Deserialize)] pub struct AdaptersConfig { #[serde(default)] @@ -73,12 +85,24 @@ pub struct EventsConfig { pub dedup_window_ms: u64, } +#[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 Config { fn default() -> Self { Self { daemon: DaemonConfig::default(), lua: LuaConfig::default(), + modules: ModulesConfig::default(), adapters: AdaptersConfig::default(), + notifications: NotificationsConfig::default(), events: EventsConfig::default(), } } @@ -102,6 +126,15 @@ impl Default for LuaConfig { } } +impl Default for ModulesConfig { + fn default() -> Self { + Self { + builtin: default_true(), + disable: Vec::new(), + } + } +} + impl Default for AdaptersConfig { fn default() -> Self { Self { @@ -147,6 +180,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 +261,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(), diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index d0f7b42..b424e1c 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -80,22 +80,102 @@ impl EventNormalizer { 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 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: raw.payload.clone(), + }], + "monitorremoved" => vec![BreadEvent { + event: "bread.monitor.disconnected".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: raw.payload.clone(), + }], + "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.get(0).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.get(0).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.get(0).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.get(0).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 { @@ -201,6 +281,13 @@ impl EventNormalizer { } } +fn split_hyprland_fields(data: &str) -> Vec<&str> { + if data.is_empty() { + return Vec::new(); + } + data.split(">>").collect() +} + fn classify_device(payload: &Value) -> DeviceClass { let name = payload .get("name") diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index aecae3c..caea8dc 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use anyhow::Result; use bread_shared::{AdapterSource, BreadEvent}; @@ -15,6 +16,7 @@ use crate::lua::LuaMessage; pub struct StateHandle { state: Arc>, command_tx: mpsc::UnboundedSender, + subscription_count: Arc, } pub enum StateCommand { @@ -38,6 +40,7 @@ pub enum StateCommand { name: String, status: ModuleLoadState, last_error: Option, + builtin: bool, }, SetProfile { name: String, @@ -45,8 +48,16 @@ pub enum StateCommand { } impl StateHandle { - pub fn new(state: Arc>, command_tx: mpsc::UnboundedSender) -> Self { - Self { state, command_tx } + pub fn new( + state: Arc>, + command_tx: mpsc::UnboundedSender, + subscription_count: Arc, + ) -> Self { + Self { + state, + command_tx, + subscription_count, + } } pub fn state_arc(&self) -> Arc> { @@ -101,17 +112,28 @@ impl StateHandle { let _ = self.command_tx.send(StateCommand::ClearSubscriptions); } - pub fn set_module_status(&self, name: String, status: ModuleLoadState, last_error: Option) { + 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 subscription_count(&self) -> Arc { + self.subscription_count.clone() + } } pub async fn run_state_engine( @@ -120,6 +142,7 @@ 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(); @@ -136,7 +159,7 @@ pub async fn run_state_engine( let Some(cmd) = maybe_cmd else { break; }; - handle_command(cmd, &state, &mut subscriptions, &mut watches).await; + handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await; } maybe_event = event_rx.recv() => { let Some(event) = maybe_event else { @@ -158,7 +181,7 @@ pub async fn run_state_engine( apply_event_to_state(&mut guard, &event); } - dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx); + dispatch_event(&event, &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() { @@ -174,7 +197,7 @@ pub async fn run_state_engine( "old": old_val, }), ); - dispatch_event(&synthetic, &mut subscriptions, &lua_tx, &event_stream_tx); + dispatch_event(&synthetic, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count); } } } @@ -190,13 +213,17 @@ async fn handle_command( 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 } => { - subscriptions.remove(id); + if subscriptions.remove(id) { + subscription_count.fetch_sub(1, Ordering::Relaxed); + } } StateCommand::RegisterWatch { id, path } => { watches.insert(id, path); @@ -207,21 +234,25 @@ async fn handle_command( StateCommand::ClearSubscriptions => { subscriptions.clear(); watches.clear(); + subscription_count.store(0, Ordering::Relaxed); } 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(), }); } @@ -242,6 +273,7 @@ fn dispatch_event( subscriptions: &mut SubscriptionTable, lua_tx: &mpsc::UnboundedSender, event_stream_tx: &broadcast::Sender, + subscription_count: &Arc, ) { let _ = event_stream_tx.send(event.clone()); @@ -254,7 +286,9 @@ fn dispatch_event( } for sub in matches.into_iter().filter(|s| s.once) { - subscriptions.remove(sub.id); + if subscriptions.remove(sub.id) { + subscription_count.fetch_sub(1, Ordering::Relaxed); + } let _ = lua_tx.send(LuaMessage::SubscriptionCancelled { id: sub.id }); } } @@ -302,11 +336,12 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { .map(ToString::to_string); state.active_workspace = ws; } - "bread.window.focus.changed" => { + "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); } diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 254e075..45ccfa5 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -123,6 +123,8 @@ pub struct ModuleStatus { pub status: ModuleLoadState, pub last_error: Option, #[serde(default)] + pub builtin: bool, + #[serde(default)] pub store: HashMap, } diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index 1ac245b..f4aa092 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::time::Instant; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; 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, } @@ -51,6 +58,9 @@ impl Server { 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 +68,9 @@ impl Server { event_tx, lua_runtime, emit_tx, + adapter_status, + subscription_count, + event_buffer, started_at: Instant::now(), pid: process::id(), } @@ -166,12 +179,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 @@ -224,13 +250,36 @@ 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, })) } + "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 mut replay = Vec::new(); + if let Ok(buf) = self.event_buffer.lock() { + for event in buf.iter() { + if event.timestamp >= cutoff { + replay.push(event); + } + } + } + Ok(serde_json::to_value(replay).unwrap_or_else(|_| json!([]))) + } _ => Err("unknown method".to_string()), }; diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index a73de1a..cf94a57 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -1,5 +1,5 @@ use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; use std::path::{Path, PathBuf}; use std::rc::Rc; @@ -10,16 +10,18 @@ use std::time::Duration; use anyhow::{anyhow, Result}; use bread_shared::{AdapterSource, BreadEvent}; 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, sleep}; +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, RuntimeState}; +use bread_shared::now_unix_ms; pub enum LuaMessage { Event { @@ -38,9 +40,17 @@ pub enum LuaMessage { 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 { @@ -63,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( @@ -71,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() @@ -83,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, thread_tx.clone()) { + 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"); @@ -160,6 +187,8 @@ struct ModuleDecl { version: Option, after: Vec, path: PathBuf, + source: Option<&'static str>, + builtin: bool, } struct ModuleInfo { @@ -182,6 +211,9 @@ struct LuaEngine { lua_tx: mpsc::UnboundedSender, entry_point: PathBuf, module_path: PathBuf, + modules_config: ModulesConfig, + notifications_config: NotificationsConfig, + recent_errors: Arc>>, } impl LuaEngine { @@ -190,6 +222,7 @@ impl LuaEngine { state_handle: StateHandle, emit_tx: mpsc::UnboundedSender, lua_tx: mpsc::UnboundedSender, + recent_errors: Arc>>, ) -> Result { Ok(Self { lua: Lua::new(), @@ -207,6 +240,9 @@ impl LuaEngine { 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, }) } @@ -324,7 +360,9 @@ impl LuaEngine { .map_err(|_| LuaError::external("missing filter function"))?; Some(lua.create_registry_value(filter_fn)?) } else { - return Err(LuaError::external("missing filter options")); + 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() @@ -503,6 +541,61 @@ 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(); @@ -556,7 +649,8 @@ impl LuaEngine { ); let lua_tx = lua_tx.clone(); task::spawn(async move { - let mut ticker = interval(Duration::from_millis(interval_ms)); + 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() => { @@ -750,12 +844,22 @@ impl LuaEngine { } 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(); + 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), @@ -765,6 +869,7 @@ impl LuaEngine { name, ModuleLoadState::LoadError, Some(err.to_string()), + false, ); } } @@ -784,7 +889,7 @@ impl LuaEngine { for (name, err) in dep_errors { self.state_handle - .set_module_status(name, ModuleLoadState::LoadError, Some(err)); + .set_module_status(name, ModuleLoadState::LoadError, Some(err), false); } let mut load_order = Vec::new(); @@ -792,14 +897,19 @@ impl LuaEngine { load_order.push(decl.name.clone()); match self.load_module(&decl) { Ok(()) => { - self.state_handle - .set_module_status(decl.name.clone(), ModuleLoadState::Loaded, None); + 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, ); } } @@ -815,7 +925,11 @@ impl LuaEngine { fn load_module(&self, decl: &ModuleDecl) -> Result<()> { self.set_current_module(Some(decl.name.clone())); - let result = self.load_lua_file(&decl.path, &decl.name); + let result = if let Some(source) = decl.source.as_deref() { + self.load_lua_source(source, &decl.name) + } else { + self.load_lua_file(&decl.path, &decl.name, decl.builtin) + }; self.set_current_module(None); result?; @@ -827,13 +941,14 @@ impl LuaEngine { Ok(()) } - fn load_lua_file(&self, path: &Path, module_name: &str) -> Result<()> { + 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(()); } @@ -843,6 +958,14 @@ impl LuaEngine { 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 (callback, filter, raw_kind, kind, module) = { let handlers = self.handlers.lock().expect("lua handlers mutex poisoned"); @@ -935,8 +1058,13 @@ impl LuaEngine { 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"); - self.state_handle - .set_module_status(name.to_string(), ModuleLoadState::LoadError, Some(err.to_string())); + let builtin = self.module_is_builtin(name); + self.state_handle.set_module_status( + name.to_string(), + ModuleLoadState::LoadError, + Some(err.to_string()), + builtin, + ); } } } @@ -951,10 +1079,12 @@ impl LuaEngine { 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, ); } } @@ -971,10 +1101,12 @@ impl LuaEngine { 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, ); } } @@ -983,10 +1115,22 @@ impl LuaEngine { 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()) { @@ -1022,6 +1166,14 @@ impl LuaEngine { .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; @@ -1052,6 +1204,8 @@ impl LuaEngine { version, after, path: module_path.clone(), + source: None, + builtin: false, }); Err(LuaError::RuntimeError(MODULE_DECL_ABORT.to_string())) })?; @@ -1119,6 +1273,14 @@ impl LuaEngine { 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") @@ -1288,10 +1450,266 @@ fn module_store_set(state_arc: &Arc>, module: &str, key: St name: module.to_string(), status: ModuleLoadState::Loaded, last_error: None, + builtin: false, store, }); } +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 class = rule.class + local when = rule.when + local data = event.data or {} + + if when == "connected" and event.event ~= "bread.device.connected" then + if not event.event:match("%.connected$") then + return false + end + elseif when == "disconnected" and event.event ~= "bread.device.disconnected" then + if not event.event:match("%.disconnected$") then + return false + end + end + + if class and data.class ~= class 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 instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") .map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?; @@ -1307,11 +1725,13 @@ fn hyprland_request(request: &str) -> Result { 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) + tokio::task::block_in_place(|| { + 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 list_lua_files(root: &Path) -> Result> { diff --git a/breadd/src/main.rs b/breadd/src/main.rs index c57fabd..bcb4daa 100644 --- a/breadd/src/main.rs +++ b/breadd/src/main.rs @@ -3,7 +3,9 @@ mod core; mod ipc; mod lua; +use std::collections::VecDeque; use std::sync::Arc; +use std::sync::atomic::AtomicU64; use anyhow::Result; use bread_shared::{AdapterSource, BreadEvent, RawEvent}; @@ -33,7 +35,8 @@ async fn main() -> Result<()> { let (event_stream_tx, _) = broadcast::channel(2048); let (shutdown_tx, shutdown_rx) = watch::channel(false); - let state_handle = StateHandle::new(state.clone(), state_cmd_tx); + let subscription_count = Arc::new(AtomicU64::new(0)); + let state_handle = StateHandle::new(state.clone(), state_cmd_tx, subscription_count.clone()); let lua_runtime = lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?; let lua_tx = lua_runtime.sender(); @@ -44,6 +47,7 @@ async fn main() -> Result<()> { state.clone(), lua_tx, event_stream_tx.clone(), + subscription_count.clone(), shutdown_rx.clone(), )); @@ -78,6 +82,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 +116,9 @@ async fn main() -> Result<()> { event_stream_tx, lua_runtime.clone(), normalized_tx, + adapter_status, + subscription_count, + event_buffer, ); info!("breadd fully started"); From 7c29befc0d2bdadfa51db69055cb5ea75fa88f1f Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 16:40:49 +0800 Subject: [PATCH 07/76] Enhance timestamp formatting and add reload watcher functionality --- bread-cli/Cargo.toml | 1 + bread-cli/src/main.rs | 50 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 6d2541e..5f18678 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -11,3 +11,4 @@ tokio.workspace = true anyhow.workspace = true clap = { version = "4.5", features = ["derive"] } notify = "6.1" +libc = "0.2" diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 6e1982a..9382494 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -296,12 +296,22 @@ fn print_event(event: &Value, fields: Option<&str>) { fn format_timestamp(ms: u64) -> String { let secs = ms / 1000; let millis = ms % 1000; - let time = UNIX_EPOCH + Duration::from_secs(secs); - let datetime = time.duration_since(UNIX_EPOCH).unwrap_or_default(); - let seconds = datetime.as_secs() % 60; - let minutes = (datetime.as_secs() / 60) % 60; - let hours = (datetime.as_secs() / 3600) % 24; - format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis) + + // 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) { @@ -321,6 +331,18 @@ fn print_reload(value: &Value) { } } +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)?; + + use tokio::time::{sleep, Duration}; + async fn watch_reload(socket: &Path) -> Result<()> { let config_dir = config_directory(); println!("watching {} for changes...", config_dir.display()); @@ -332,15 +354,25 @@ async fn watch_reload(socket: &Path) -> Result<()> { watcher.watch(&config_dir, RecursiveMode::Recursive)?; while let Some(msg) = rx.recv().await { - if msg.is_ok() { - let response = send_request(socket, "modules.reload", json!({})).await?; - print_reload(&response); + if msg.is_err() { + continue; } + + // Debounce: drain any follow-up events that arrive within 150ms. + // A single file save typically generates 2-3 fs events in rapid succession. + 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(()) } + Ok(()) +} + async fn print_doctor(socket: &Path) -> Result<()> { let stream = match UnixStream::connect(socket).await { Ok(stream) => stream, From 05123a5989dcb5b3538f941a8c101e1e002e0d08 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 16:40:49 +0800 Subject: [PATCH 08/76] Enhance timestamp formatting and add reload watcher functionality --- bread-cli/Cargo.toml | 1 + bread-cli/src/main.rs | 50 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 6d2541e..5f18678 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -11,3 +11,4 @@ tokio.workspace = true anyhow.workspace = true clap = { version = "4.5", features = ["derive"] } notify = "6.1" +libc = "0.2" diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 6e1982a..9382494 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -296,12 +296,22 @@ fn print_event(event: &Value, fields: Option<&str>) { fn format_timestamp(ms: u64) -> String { let secs = ms / 1000; let millis = ms % 1000; - let time = UNIX_EPOCH + Duration::from_secs(secs); - let datetime = time.duration_since(UNIX_EPOCH).unwrap_or_default(); - let seconds = datetime.as_secs() % 60; - let minutes = (datetime.as_secs() / 60) % 60; - let hours = (datetime.as_secs() / 3600) % 24; - format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis) + + // 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) { @@ -321,6 +331,18 @@ fn print_reload(value: &Value) { } } +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)?; + + use tokio::time::{sleep, Duration}; + async fn watch_reload(socket: &Path) -> Result<()> { let config_dir = config_directory(); println!("watching {} for changes...", config_dir.display()); @@ -332,15 +354,25 @@ async fn watch_reload(socket: &Path) -> Result<()> { watcher.watch(&config_dir, RecursiveMode::Recursive)?; while let Some(msg) = rx.recv().await { - if msg.is_ok() { - let response = send_request(socket, "modules.reload", json!({})).await?; - print_reload(&response); + if msg.is_err() { + continue; } + + // Debounce: drain any follow-up events that arrive within 150ms. + // A single file save typically generates 2-3 fs events in rapid succession. + 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(()) } + Ok(()) +} + async fn print_doctor(socket: &Path) -> Result<()> { let stream = match UnixStream::connect(socket).await { Ok(stream) => stream, From e339660084c279af2259b78d3d886fc1d0ce16cc Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 16:57:22 +0800 Subject: [PATCH 09/76] Refactor subscription table logic and enhance Lua logging and debounce functionality --- breadd/src/core/subscriptions.rs | 1 - breadd/src/ipc/mod.rs | 60 +++++++++++++++++++++- breadd/src/lua/mod.rs | 86 ++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/breadd/src/core/subscriptions.rs b/breadd/src/core/subscriptions.rs index a95d388..9b218ff 100644 --- a/breadd/src/core/subscriptions.rs +++ b/breadd/src/core/subscriptions.rs @@ -35,7 +35,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() { diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index f4aa092..f99ea74 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -313,9 +313,67 @@ 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..]) + } + } } diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index cf94a57..9acc814 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -840,6 +840,8 @@ impl LuaEngine { globals.set("bread", bread)?; self.install_require_loader()?; self.install_wait_helper()?; + self.install_log_helpers()?; + self.install_debounce()?; Ok(()) } @@ -1188,6 +1190,90 @@ impl LuaEngine { } } + 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(); From edb2ba338a49b4260fc2b2388c746c3ac4dc1a8c Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 16:57:22 +0800 Subject: [PATCH 10/76] Refactor subscription table logic and enhance Lua logging and debounce functionality --- breadd/src/core/subscriptions.rs | 1 - breadd/src/ipc/mod.rs | 60 +++++++++++++++++++++- breadd/src/lua/mod.rs | 86 ++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/breadd/src/core/subscriptions.rs b/breadd/src/core/subscriptions.rs index a95d388..9b218ff 100644 --- a/breadd/src/core/subscriptions.rs +++ b/breadd/src/core/subscriptions.rs @@ -35,7 +35,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() { diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index f4aa092..f99ea74 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -313,9 +313,67 @@ 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..]) + } + } } diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index cf94a57..9acc814 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -840,6 +840,8 @@ impl LuaEngine { globals.set("bread", bread)?; self.install_require_loader()?; self.install_wait_helper()?; + self.install_log_helpers()?; + self.install_debounce()?; Ok(()) } @@ -1188,6 +1190,90 @@ impl LuaEngine { } } + 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(); From f0ef411697de21ce8c23e56e1aecbce72e63f6eb Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 18:39:39 +0800 Subject: [PATCH 11/76] Enhance installation process, update service paths, and improve device classification --- .gitignore | 5 +- Cargo.lock | 1 + README.md | 84 ++++++++++++++++++++++++---- bread-cli/Cargo.toml | 4 ++ bread-cli/src/main.rs | 19 +------ breadd/src/adapters/udev.rs | 46 +++++++++++++--- breadd/src/core/normalizer.rs | 95 ++++++++++++++++++++++++++++---- breadd/src/ipc/mod.rs | 18 +++--- breadd/src/lua/mod.rs | 81 +++++++++++++++++++++++---- packaging/arch/PKGBUILD | 2 +- packaging/systemd/breadd.service | 2 +- scripts/install.sh | 36 ++++++++++++ 12 files changed, 323 insertions(+), 70 deletions(-) create mode 100755 scripts/install.sh diff --git a/.gitignore b/.gitignore index fdd4532..9472698 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ target/ Overview.md -DAEMON.md \ No newline at end of file +DAEMON.md +.claude +CLAUDE.md +.github/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d987076..313315f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,6 +289,7 @@ dependencies = [ "anyhow", "bread-shared", "clap", + "libc", "notify", "serde", "serde_json", diff --git a/README.md b/README.md index 8da6518..73512df 100644 --- a/README.md +++ b/README.md @@ -72,16 +72,20 @@ Optional but preferred: ```bash 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, installs to `/usr/bin`, sets up 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 do it step by step: + +```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) @@ -130,6 +134,15 @@ 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, etc.) +disable = [] # list of built-in module names to disable ``` Your automation lives in `~/.config/bread/init.lua`: @@ -153,15 +166,18 @@ All commands communicate with the running daemon over a Unix socket at `$XDG_RUN ```bash bread reload # Hot-reload all Lua modules +bread reload --watch # Watch config dir and reload on changes bread state # Dump full runtime state as JSON bread events # Stream live normalized events bread events --filter bread.device.* # Stream filtered events +bread events --since 60 # Replay events from the last 60 seconds 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) bread ping # Check daemon connectivity bread health # Daemon version, uptime, PID +bread doctor # Diagnose daemon and module health ``` --- @@ -201,16 +217,26 @@ Events follow the namespace convention `bread...`. ### Events ```lua --- Subscribe to an event -bread.on("bread.monitor.connected", function(event) +-- Subscribe to an event; returns a numeric ID +local id = bread.on("bread.monitor.connected", function(event) print(event.data.name) end) +-- Unsubscribe by ID +bread.off(id) + -- Subscribe once, then auto-unsubscribe bread.once("bread.system.startup", function(event) -- runs exactly once end) +-- Subscribe with a predicate filter +bread.filter("bread.device.connected", function(event) + return event.data.class == "keyboard" +end, function(event) + bread.exec("xset r rate 200 40") +end) + -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) ``` @@ -222,6 +248,12 @@ bread.emit("mymodule.something", { key = "value" }) local monitors = bread.state.get("monitors") local workspace = bread.state.get("active_workspace") local power = bread.state.get("power") +local devices = bread.state.get("devices") + +-- Watch a state key and fire on changes +bread.state.watch("active_workspace", function(new, old) + print("workspace changed from " .. tostring(old) .. " to " .. tostring(new)) +end) ``` ### Profiles @@ -231,12 +263,42 @@ bread.profile.activate("desk") bread.profile.activate("default") ``` -### Execution +### Execution and notifications ```lua -- Fire-and-forget: returns immediately, process runs in background bread.exec("kitty") -bread.exec("notify-send 'Dock connected'") + +-- Desktop notification +bread.notify("Dock connected", { urgency = "normal", timeout = 3000 }) +``` + +### Timers + +```lua +-- Run once after a delay (ms) +bread.after(500, function() + bread.exec("some-delayed-command") +end) + +-- Run on a repeating interval (ms); returns a timer ID +local id = bread.every(60000, function() + bread.log("tick") +end) +bread.cancel(id) + +-- Debounce a rapidly-firing handler +local fn = bread.debounce(200, function(event) + reconfigure_monitors() +end) +``` + +### Logging + +```lua +bread.log("Module loaded") +bread.warn("Unexpected state") +bread.error("Something failed") ``` --- @@ -255,7 +317,7 @@ 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: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, `modules.reload`, `profile.list`, `profile.activate`, `events.subscribe`, `events.replay`, `emit`. `events.subscribe` upgrades the connection to a streaming mode — the daemon pushes events line by line until the client disconnects. diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 5f18678..69a2c49 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -3,6 +3,10 @@ name = "bread-cli" version = "0.1.0" edition = "2021" +[[bin]] +name = "bread" +path = "src/main.rs" + [dependencies] bread-shared = { path = "../bread-shared" } serde.workspace = true diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 9382494..0ca91df 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -5,7 +5,7 @@ use serde_json::{json, Value}; use std::env; use std::io; use std::path::{Path, PathBuf}; -use std::time::{Duration, UNIX_EPOCH}; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tokio::sync::mpsc; @@ -331,18 +331,6 @@ fn print_reload(value: &Value) { } } -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)?; - - use tokio::time::{sleep, Duration}; - async fn watch_reload(socket: &Path) -> Result<()> { let config_dir = config_directory(); println!("watching {} for changes...", config_dir.display()); @@ -360,7 +348,7 @@ async fn watch_reload(socket: &Path) -> Result<()> { // Debounce: drain any follow-up events that arrive within 150ms. // A single file save typically generates 2-3 fs events in rapid succession. - sleep(Duration::from_millis(150)).await; + tokio::time::sleep(Duration::from_millis(150)).await; while rx.try_recv().is_ok() {} let response = send_request(socket, "modules.reload", json!({})).await?; @@ -370,9 +358,6 @@ async fn watch_reload(socket: &Path) -> Result<()> { Ok(()) } - Ok(()) -} - async fn print_doctor(socket: &Path) -> Result<()> { let stream = match UnixStream::connect(socket).await { Ok(stream) => stream, diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index ffe4d15..ade7d2f 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -52,18 +52,23 @@ 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(()); + match run_udev_monitor(self.subsystems.clone(), tx.clone()).await { + Ok(()) => return Ok(()), + Err(err) => { + tracing::warn!(error = %err, "udev netlink monitor unavailable, falling back to sysfs polling (add user to 'plugdev' group for real-time events)"); + } } - // Fallback for environments where monitor sockets are unavailable. - let mut known: HashMap = scan_devices(&self.subsystems)? + // Fallback: poll sysfs every 2 seconds for environments where the + // netlink socket is unavailable (missing plugdev membership, containers, etc). + let mut known: HashMap = scan_devices(&self.subsystems) + .unwrap_or_default() .into_iter() .map(|d| (d.id.clone(), d)) .collect(); loop { - let current = scan_devices(&self.subsystems)?; + let current = scan_devices(&self.subsystems).unwrap_or_default(); let current_map: HashMap = current .into_iter() .map(|d| (d.id.clone(), d)) @@ -71,13 +76,17 @@ impl Adapter for UdevAdapter { for (id, dev) in ¤t_map { if !known.contains_key(id) { - tx.send(raw_change_event("add", dev)).await?; + if tx.send(raw_change_event("add", dev)).await.is_err() { + return Ok(()); + } } } for (id, dev) in &known { if !current_map.contains_key(id) { - tx.send(raw_change_event("remove", dev)).await?; + if tx.send(raw_change_event("remove", dev)).await.is_err() { + return Ok(()); + } } } @@ -130,6 +139,15 @@ async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) - "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"), }), timestamp: now_unix_ms(), }; @@ -263,3 +281,17 @@ fn scan_devices(subsystems: &[String]) -> Result> { Ok(out) } + +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 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/normalizer.rs b/breadd/src/core/normalizer.rs index b424e1c..3eaef88 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -289,33 +289,104 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> { } 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; - } - if subsystem == "input" && name.contains("keyboard") { + // --- Property-based classification (reliable, hardware-agnostic) --- + + // udev sets ID_INPUT_KEYBOARD=1 for anything that presents as a keyboard HID device. + if payload.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) { return DeviceClass::Keyboard; } - if subsystem == "input" && name.contains("mouse") { + + // ID_INPUT_MOUSE=1 covers mice and trackballs. + if payload.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) { return DeviceClass::Mouse; } + + // ID_INPUT_TABLET=1 covers drawing tablets (Wacom etc). + if payload.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) { + return DeviceClass::Tablet; + } + + // USB class 0x09 = Hub. Docks expose a hub interface; they also typically + // expose video (0x0e), audio (0x01), and ethernet (CDC 0x02) interfaces. + // We check for hub + at least one of those secondary interfaces. + if let Some(ifaces) = payload.get("id_usb_interfaces").and_then(Value::as_str) { + let ifaces_lc = ifaces.to_lowercase(); + let has_hub = ifaces_lc.contains(":0900") || ifaces_lc.contains(":0902"); + let has_secondary = ifaces_lc.contains(":0e") // video + || ifaces_lc.contains(":0200") // CDC ethernet + || ifaces_lc.contains(":0100") // audio + || ifaces_lc.contains(":0801"); // mass storage + if has_hub && has_secondary { + return DeviceClass::Dock; + } + } + + // USB class 0x01 = Audio. + if let Some(cls) = payload.get("id_usb_class").and_then(Value::as_str) { + if cls == "01" || cls.to_lowercase() == "0x01" { + return DeviceClass::Audio; + } + // USB class 0x08 = Mass Storage. + if cls == "08" || cls.to_lowercase() == "0x08" { + return DeviceClass::Storage; + } + } + + // DRM subsystem = display connector. if subsystem == "drm" { return DeviceClass::Display; } - if subsystem == "sound" || name.contains("audio") { + + // Block devices = storage. + if subsystem == "block" { + return DeviceClass::Storage; + } + + // Sound subsystem = audio. + if subsystem == "sound" { return DeviceClass::Audio; } - if subsystem == "block" || name.contains("storage") { + + // --- Name-based fallback (catches user-registered patterns and obvious names) --- + // This runs last so the property-based rules above always win. + + let name = payload + .get("name") + .and_then(Value::as_str) + .or_else(|| payload.get("id_model").and_then(Value::as_str)) + .unwrap_or_default() + .to_lowercase(); + + let vendor = payload + .get("id_vendor") + .and_then(Value::as_str) + .unwrap_or_default() + .to_lowercase(); + + let combined = format!("{name} {vendor}"); + + if combined.contains("dock") || combined.contains("hub") || combined.contains("thunderbolt") { + return DeviceClass::Dock; + } + if combined.contains("keyboard") || combined.contains("kbd") { + return DeviceClass::Keyboard; + } + if combined.contains("mouse") || combined.contains("trackball") || combined.contains("trackpoint") { + return DeviceClass::Mouse; + } + if combined.contains("tablet") || combined.contains("wacom") || combined.contains("stylus") { + return DeviceClass::Tablet; + } + if combined.contains("audio") || combined.contains("headset") || combined.contains("speaker") || combined.contains("dac") { + return DeviceClass::Audio; + } + if combined.contains("storage") || combined.contains("drive") || combined.contains("flash") || combined.contains("disk") { return DeviceClass::Storage; } diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index f99ea74..fff3368 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -270,14 +270,16 @@ impl Server { "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 mut replay = Vec::new(); - if let Ok(buf) = self.event_buffer.lock() { - for event in buf.iter() { - if event.timestamp >= cutoff { - replay.push(event); - } - } - } + 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()), diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 9acc814..228ac61 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -1499,7 +1499,15 @@ fn state_value_to_lua<'lua>( state_arc: &Arc>, path: &str, ) -> mlua::Result> { - let snapshot = state_arc.blocking_read(); + // 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() { @@ -1518,13 +1526,23 @@ fn state_value_to_lua<'lua>( } fn module_store_get(state_arc: &Arc>, module: &str, key: &str) -> Option { - let guard = state_arc.blocking_read(); + 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 = state_arc.blocking_write(); + 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; @@ -1616,6 +1634,7 @@ const BUILTIN_DEVICES: &str = r#" local M = bread.module({ name = "bread.devices", version = "1.0.0" }) local rules = {} +local user_patterns = {} -- { { pattern = "...", class = "..." }, ... } local function matches_rule(rule, event) local class = rule.class @@ -1651,15 +1670,55 @@ local function run_rule(rule, event) end end +-- Reclassify an event's data.class based on user-registered name patterns. +-- Called before rule matching so that user-registered patterns take effect +-- even for devices that the daemon classified as Unknown. +local function apply_user_patterns(event) + if not event.data then return event end + local name = tostring(event.data.name or ""):lower() + local vendor = tostring(event.data.vendor or ""):lower() + local combined = name .. " " .. vendor + for _, p in ipairs(user_patterns) do + if combined:find(p.pattern, 1, true) then + -- Return a shallow copy with the class overridden so we don't + -- mutate the original event that other handlers may receive. + local patched = {} + for k, v in pairs(event) do patched[k] = v end + patched.data = {} + for k, v in pairs(event.data) do patched.data[k] = v end + patched.data.class = p.class + return patched + end + end + return event +end + function M.on(opts) table.insert(rules, opts) end +-- Register a user-defined device pattern so the daemon can correctly classify +-- hardware that the automatic classifier doesn't recognise. +-- +-- Usage: +-- local devices = require("bread.devices") +-- devices.register("CalDigit", "dock") +-- devices.register("Keychron", "keyboard") +-- devices.register("MX Master", "mouse") +-- +-- The pattern is matched case-insensitively against the device name and vendor +-- combined. The class must be one of: dock, keyboard, mouse, tablet, display, +-- storage, audio, unknown. +function M.register(pattern, class) + table.insert(user_patterns, { pattern = pattern:lower(), class = class }) +end + function M.on_load() bread.on("bread.device.**", function(event) + local patched = apply_user_patterns(event) for _, rule in ipairs(rules) do - if matches_rule(rule, event) then - run_rule(rule, event) + if matches_rule(rule, patched) then + run_rule(rule, patched) end end end) @@ -1811,13 +1870,11 @@ fn hyprland_request(request: &str) -> Result { use std::os::unix::net::UnixStream; let socket = hyprland_request_socket()?; - tokio::task::block_in_place(|| { - 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) - }) + 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 list_lua_files(root: &Path) -> Result> { diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 1a36db0..8ce69ee 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -20,6 +20,6 @@ build() { 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" } 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..961f792 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/bin}" +SERVICE_DIR="${HOME}/.config/systemd/user" + +# ── build ────────────────────────────────────────────────────────────────────── +echo "building bread (release)..." +cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" + +# ── install binaries ─────────────────────────────────────────────────────────── +echo "installing binaries to $INSTALL_PREFIX (requires sudo)..." +sudo install -Dm755 "$REPO_ROOT/target/release/breadd" "$INSTALL_PREFIX/breadd" +sudo install -Dm755 "$REPO_ROOT/target/release/bread" "$INSTALL_PREFIX/bread" +echo " installed $INSTALL_PREFIX/breadd" +echo " installed $INSTALL_PREFIX/bread" + +# ── systemd user service ─────────────────────────────────────────────────────── +echo "installing systemd user service..." +mkdir -p "$SERVICE_DIR" +install -Dm644 "$REPO_ROOT/packaging/systemd/breadd.service" "$SERVICE_DIR/breadd.service" +echo " installed $SERVICE_DIR/breadd.service" + +systemctl --user daemon-reload +systemctl --user enable --now breadd +echo " breadd enabled and started" + +# ── verify ───────────────────────────────────────────────────────────────────── +sleep 0.5 +if bread ping &>/dev/null; then + echo "" + bread doctor +else + echo "warning: daemon did not respond to ping — check: journalctl --user -u breadd -n 20" +fi From 55d103b3cfd2216040f2bc79501ad5b7ed55cdc7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 18:39:39 +0800 Subject: [PATCH 12/76] Enhance installation process, update service paths, and improve device classification --- .gitignore | 5 +- Cargo.lock | 1 + README.md | 84 ++++++++++++++++++++++++---- bread-cli/Cargo.toml | 4 ++ bread-cli/src/main.rs | 19 +------ breadd/src/adapters/udev.rs | 46 +++++++++++++--- breadd/src/core/normalizer.rs | 95 ++++++++++++++++++++++++++++---- breadd/src/ipc/mod.rs | 18 +++--- breadd/src/lua/mod.rs | 81 +++++++++++++++++++++++---- packaging/arch/PKGBUILD | 2 +- packaging/systemd/breadd.service | 2 +- scripts/install.sh | 36 ++++++++++++ 12 files changed, 323 insertions(+), 70 deletions(-) create mode 100755 scripts/install.sh diff --git a/.gitignore b/.gitignore index fdd4532..9472698 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ target/ Overview.md -DAEMON.md \ No newline at end of file +DAEMON.md +.claude +CLAUDE.md +.github/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d987076..313315f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,6 +289,7 @@ dependencies = [ "anyhow", "bread-shared", "clap", + "libc", "notify", "serde", "serde_json", diff --git a/README.md b/README.md index 8da6518..73512df 100644 --- a/README.md +++ b/README.md @@ -72,16 +72,20 @@ Optional but preferred: ```bash 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, installs to `/usr/bin`, sets up 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 do it step by step: + +```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) @@ -130,6 +134,15 @@ 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, etc.) +disable = [] # list of built-in module names to disable ``` Your automation lives in `~/.config/bread/init.lua`: @@ -153,15 +166,18 @@ All commands communicate with the running daemon over a Unix socket at `$XDG_RUN ```bash bread reload # Hot-reload all Lua modules +bread reload --watch # Watch config dir and reload on changes bread state # Dump full runtime state as JSON bread events # Stream live normalized events bread events --filter bread.device.* # Stream filtered events +bread events --since 60 # Replay events from the last 60 seconds 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) bread ping # Check daemon connectivity bread health # Daemon version, uptime, PID +bread doctor # Diagnose daemon and module health ``` --- @@ -201,16 +217,26 @@ Events follow the namespace convention `bread...`. ### Events ```lua --- Subscribe to an event -bread.on("bread.monitor.connected", function(event) +-- Subscribe to an event; returns a numeric ID +local id = bread.on("bread.monitor.connected", function(event) print(event.data.name) end) +-- Unsubscribe by ID +bread.off(id) + -- Subscribe once, then auto-unsubscribe bread.once("bread.system.startup", function(event) -- runs exactly once end) +-- Subscribe with a predicate filter +bread.filter("bread.device.connected", function(event) + return event.data.class == "keyboard" +end, function(event) + bread.exec("xset r rate 200 40") +end) + -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) ``` @@ -222,6 +248,12 @@ bread.emit("mymodule.something", { key = "value" }) local monitors = bread.state.get("monitors") local workspace = bread.state.get("active_workspace") local power = bread.state.get("power") +local devices = bread.state.get("devices") + +-- Watch a state key and fire on changes +bread.state.watch("active_workspace", function(new, old) + print("workspace changed from " .. tostring(old) .. " to " .. tostring(new)) +end) ``` ### Profiles @@ -231,12 +263,42 @@ bread.profile.activate("desk") bread.profile.activate("default") ``` -### Execution +### Execution and notifications ```lua -- Fire-and-forget: returns immediately, process runs in background bread.exec("kitty") -bread.exec("notify-send 'Dock connected'") + +-- Desktop notification +bread.notify("Dock connected", { urgency = "normal", timeout = 3000 }) +``` + +### Timers + +```lua +-- Run once after a delay (ms) +bread.after(500, function() + bread.exec("some-delayed-command") +end) + +-- Run on a repeating interval (ms); returns a timer ID +local id = bread.every(60000, function() + bread.log("tick") +end) +bread.cancel(id) + +-- Debounce a rapidly-firing handler +local fn = bread.debounce(200, function(event) + reconfigure_monitors() +end) +``` + +### Logging + +```lua +bread.log("Module loaded") +bread.warn("Unexpected state") +bread.error("Something failed") ``` --- @@ -255,7 +317,7 @@ 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: `ping`, `health`, `state.get`, `state.dump`, `modules.list`, `modules.reload`, `profile.list`, `profile.activate`, `events.subscribe`, `events.replay`, `emit`. `events.subscribe` upgrades the connection to a streaming mode — the daemon pushes events line by line until the client disconnects. diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 5f18678..69a2c49 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -3,6 +3,10 @@ name = "bread-cli" version = "0.1.0" edition = "2021" +[[bin]] +name = "bread" +path = "src/main.rs" + [dependencies] bread-shared = { path = "../bread-shared" } serde.workspace = true diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 9382494..0ca91df 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -5,7 +5,7 @@ use serde_json::{json, Value}; use std::env; use std::io; use std::path::{Path, PathBuf}; -use std::time::{Duration, UNIX_EPOCH}; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tokio::sync::mpsc; @@ -331,18 +331,6 @@ fn print_reload(value: &Value) { } } -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)?; - - use tokio::time::{sleep, Duration}; - async fn watch_reload(socket: &Path) -> Result<()> { let config_dir = config_directory(); println!("watching {} for changes...", config_dir.display()); @@ -360,7 +348,7 @@ async fn watch_reload(socket: &Path) -> Result<()> { // Debounce: drain any follow-up events that arrive within 150ms. // A single file save typically generates 2-3 fs events in rapid succession. - sleep(Duration::from_millis(150)).await; + tokio::time::sleep(Duration::from_millis(150)).await; while rx.try_recv().is_ok() {} let response = send_request(socket, "modules.reload", json!({})).await?; @@ -370,9 +358,6 @@ async fn watch_reload(socket: &Path) -> Result<()> { Ok(()) } - Ok(()) -} - async fn print_doctor(socket: &Path) -> Result<()> { let stream = match UnixStream::connect(socket).await { Ok(stream) => stream, diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index ffe4d15..ade7d2f 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -52,18 +52,23 @@ 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(()); + match run_udev_monitor(self.subsystems.clone(), tx.clone()).await { + Ok(()) => return Ok(()), + Err(err) => { + tracing::warn!(error = %err, "udev netlink monitor unavailable, falling back to sysfs polling (add user to 'plugdev' group for real-time events)"); + } } - // Fallback for environments where monitor sockets are unavailable. - let mut known: HashMap = scan_devices(&self.subsystems)? + // Fallback: poll sysfs every 2 seconds for environments where the + // netlink socket is unavailable (missing plugdev membership, containers, etc). + let mut known: HashMap = scan_devices(&self.subsystems) + .unwrap_or_default() .into_iter() .map(|d| (d.id.clone(), d)) .collect(); loop { - let current = scan_devices(&self.subsystems)?; + let current = scan_devices(&self.subsystems).unwrap_or_default(); let current_map: HashMap = current .into_iter() .map(|d| (d.id.clone(), d)) @@ -71,13 +76,17 @@ impl Adapter for UdevAdapter { for (id, dev) in ¤t_map { if !known.contains_key(id) { - tx.send(raw_change_event("add", dev)).await?; + if tx.send(raw_change_event("add", dev)).await.is_err() { + return Ok(()); + } } } for (id, dev) in &known { if !current_map.contains_key(id) { - tx.send(raw_change_event("remove", dev)).await?; + if tx.send(raw_change_event("remove", dev)).await.is_err() { + return Ok(()); + } } } @@ -130,6 +139,15 @@ async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) - "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"), }), timestamp: now_unix_ms(), }; @@ -263,3 +281,17 @@ fn scan_devices(subsystems: &[String]) -> Result> { Ok(out) } + +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 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/normalizer.rs b/breadd/src/core/normalizer.rs index b424e1c..3eaef88 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -289,33 +289,104 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> { } 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; - } - if subsystem == "input" && name.contains("keyboard") { + // --- Property-based classification (reliable, hardware-agnostic) --- + + // udev sets ID_INPUT_KEYBOARD=1 for anything that presents as a keyboard HID device. + if payload.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) { return DeviceClass::Keyboard; } - if subsystem == "input" && name.contains("mouse") { + + // ID_INPUT_MOUSE=1 covers mice and trackballs. + if payload.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) { return DeviceClass::Mouse; } + + // ID_INPUT_TABLET=1 covers drawing tablets (Wacom etc). + if payload.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) { + return DeviceClass::Tablet; + } + + // USB class 0x09 = Hub. Docks expose a hub interface; they also typically + // expose video (0x0e), audio (0x01), and ethernet (CDC 0x02) interfaces. + // We check for hub + at least one of those secondary interfaces. + if let Some(ifaces) = payload.get("id_usb_interfaces").and_then(Value::as_str) { + let ifaces_lc = ifaces.to_lowercase(); + let has_hub = ifaces_lc.contains(":0900") || ifaces_lc.contains(":0902"); + let has_secondary = ifaces_lc.contains(":0e") // video + || ifaces_lc.contains(":0200") // CDC ethernet + || ifaces_lc.contains(":0100") // audio + || ifaces_lc.contains(":0801"); // mass storage + if has_hub && has_secondary { + return DeviceClass::Dock; + } + } + + // USB class 0x01 = Audio. + if let Some(cls) = payload.get("id_usb_class").and_then(Value::as_str) { + if cls == "01" || cls.to_lowercase() == "0x01" { + return DeviceClass::Audio; + } + // USB class 0x08 = Mass Storage. + if cls == "08" || cls.to_lowercase() == "0x08" { + return DeviceClass::Storage; + } + } + + // DRM subsystem = display connector. if subsystem == "drm" { return DeviceClass::Display; } - if subsystem == "sound" || name.contains("audio") { + + // Block devices = storage. + if subsystem == "block" { + return DeviceClass::Storage; + } + + // Sound subsystem = audio. + if subsystem == "sound" { return DeviceClass::Audio; } - if subsystem == "block" || name.contains("storage") { + + // --- Name-based fallback (catches user-registered patterns and obvious names) --- + // This runs last so the property-based rules above always win. + + let name = payload + .get("name") + .and_then(Value::as_str) + .or_else(|| payload.get("id_model").and_then(Value::as_str)) + .unwrap_or_default() + .to_lowercase(); + + let vendor = payload + .get("id_vendor") + .and_then(Value::as_str) + .unwrap_or_default() + .to_lowercase(); + + let combined = format!("{name} {vendor}"); + + if combined.contains("dock") || combined.contains("hub") || combined.contains("thunderbolt") { + return DeviceClass::Dock; + } + if combined.contains("keyboard") || combined.contains("kbd") { + return DeviceClass::Keyboard; + } + if combined.contains("mouse") || combined.contains("trackball") || combined.contains("trackpoint") { + return DeviceClass::Mouse; + } + if combined.contains("tablet") || combined.contains("wacom") || combined.contains("stylus") { + return DeviceClass::Tablet; + } + if combined.contains("audio") || combined.contains("headset") || combined.contains("speaker") || combined.contains("dac") { + return DeviceClass::Audio; + } + if combined.contains("storage") || combined.contains("drive") || combined.contains("flash") || combined.contains("disk") { return DeviceClass::Storage; } diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index f99ea74..fff3368 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -270,14 +270,16 @@ impl Server { "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 mut replay = Vec::new(); - if let Ok(buf) = self.event_buffer.lock() { - for event in buf.iter() { - if event.timestamp >= cutoff { - replay.push(event); - } - } - } + 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()), diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 9acc814..228ac61 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -1499,7 +1499,15 @@ fn state_value_to_lua<'lua>( state_arc: &Arc>, path: &str, ) -> mlua::Result> { - let snapshot = state_arc.blocking_read(); + // 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() { @@ -1518,13 +1526,23 @@ fn state_value_to_lua<'lua>( } fn module_store_get(state_arc: &Arc>, module: &str, key: &str) -> Option { - let guard = state_arc.blocking_read(); + 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 = state_arc.blocking_write(); + 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; @@ -1616,6 +1634,7 @@ const BUILTIN_DEVICES: &str = r#" local M = bread.module({ name = "bread.devices", version = "1.0.0" }) local rules = {} +local user_patterns = {} -- { { pattern = "...", class = "..." }, ... } local function matches_rule(rule, event) local class = rule.class @@ -1651,15 +1670,55 @@ local function run_rule(rule, event) end end +-- Reclassify an event's data.class based on user-registered name patterns. +-- Called before rule matching so that user-registered patterns take effect +-- even for devices that the daemon classified as Unknown. +local function apply_user_patterns(event) + if not event.data then return event end + local name = tostring(event.data.name or ""):lower() + local vendor = tostring(event.data.vendor or ""):lower() + local combined = name .. " " .. vendor + for _, p in ipairs(user_patterns) do + if combined:find(p.pattern, 1, true) then + -- Return a shallow copy with the class overridden so we don't + -- mutate the original event that other handlers may receive. + local patched = {} + for k, v in pairs(event) do patched[k] = v end + patched.data = {} + for k, v in pairs(event.data) do patched.data[k] = v end + patched.data.class = p.class + return patched + end + end + return event +end + function M.on(opts) table.insert(rules, opts) end +-- Register a user-defined device pattern so the daemon can correctly classify +-- hardware that the automatic classifier doesn't recognise. +-- +-- Usage: +-- local devices = require("bread.devices") +-- devices.register("CalDigit", "dock") +-- devices.register("Keychron", "keyboard") +-- devices.register("MX Master", "mouse") +-- +-- The pattern is matched case-insensitively against the device name and vendor +-- combined. The class must be one of: dock, keyboard, mouse, tablet, display, +-- storage, audio, unknown. +function M.register(pattern, class) + table.insert(user_patterns, { pattern = pattern:lower(), class = class }) +end + function M.on_load() bread.on("bread.device.**", function(event) + local patched = apply_user_patterns(event) for _, rule in ipairs(rules) do - if matches_rule(rule, event) then - run_rule(rule, event) + if matches_rule(rule, patched) then + run_rule(rule, patched) end end end) @@ -1811,13 +1870,11 @@ fn hyprland_request(request: &str) -> Result { use std::os::unix::net::UnixStream; let socket = hyprland_request_socket()?; - tokio::task::block_in_place(|| { - 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) - }) + 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 list_lua_files(root: &Path) -> Result> { diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 1a36db0..8ce69ee 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -20,6 +20,6 @@ build() { 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" } 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..961f792 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/bin}" +SERVICE_DIR="${HOME}/.config/systemd/user" + +# ── build ────────────────────────────────────────────────────────────────────── +echo "building bread (release)..." +cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" + +# ── install binaries ─────────────────────────────────────────────────────────── +echo "installing binaries to $INSTALL_PREFIX (requires sudo)..." +sudo install -Dm755 "$REPO_ROOT/target/release/breadd" "$INSTALL_PREFIX/breadd" +sudo install -Dm755 "$REPO_ROOT/target/release/bread" "$INSTALL_PREFIX/bread" +echo " installed $INSTALL_PREFIX/breadd" +echo " installed $INSTALL_PREFIX/bread" + +# ── systemd user service ─────────────────────────────────────────────────────── +echo "installing systemd user service..." +mkdir -p "$SERVICE_DIR" +install -Dm644 "$REPO_ROOT/packaging/systemd/breadd.service" "$SERVICE_DIR/breadd.service" +echo " installed $SERVICE_DIR/breadd.service" + +systemctl --user daemon-reload +systemctl --user enable --now breadd +echo " breadd enabled and started" + +# ── verify ───────────────────────────────────────────────────────────────────── +sleep 0.5 +if bread ping &>/dev/null; then + echo "" + bread doctor +else + echo "warning: daemon did not respond to ping — check: journalctl --user -u breadd -n 20" +fi From e5611567c26ffe06e9aa082873960ee8637c19e4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 20:56:10 +0800 Subject: [PATCH 13/76] Begin Implementing V2 features --- .github/workflows/ci.yml | 42 -- .gitignore | 3 +- CLAUDE_SPEC.md | 604 +++++++++++++++++++ Cargo.lock | 1090 ++++++++++++++++++++++++++++++++++- Cargo.toml | 3 +- README.md | 7 +- bread-cli/Cargo.toml | 7 + bread-cli/src/main.rs | 904 +++++++++++++++++++++++++++-- bread-sync/Cargo.toml | 19 + bread-sync/README.md | 10 + bread-sync/src/config.rs | 124 ++++ bread-sync/src/delegates.rs | 205 +++++++ bread-sync/src/git.rs | 227 ++++++++ bread-sync/src/lib.rs | 10 + bread-sync/src/machine.rs | 102 ++++ bread-sync/src/packages.rs | 137 +++++ bread-sync/tests/sync.rs | 1 + breadd/src/adapters/udev.rs | 59 +- 18 files changed, 3433 insertions(+), 121 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 CLAUDE_SPEC.md create mode 100644 bread-sync/Cargo.toml create mode 100644 bread-sync/README.md create mode 100644 bread-sync/src/config.rs create mode 100644 bread-sync/src/delegates.rs create mode 100644 bread-sync/src/git.rs create mode 100644 bread-sync/src/lib.rs create mode 100644 bread-sync/src/machine.rs create mode 100644 bread-sync/src/packages.rs create mode 100644 bread-sync/tests/sync.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 7409b04..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,42 +0,0 @@ -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 9472698..6902b11 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ Overview.md DAEMON.md .claude CLAUDE.md -.github/ \ No newline at end of file +.github/workflows/ci.yml +.github \ No newline at end of file diff --git a/CLAUDE_SPEC.md b/CLAUDE_SPEC.md new file mode 100644 index 0000000..2a2d1df --- /dev/null +++ b/CLAUDE_SPEC.md @@ -0,0 +1,604 @@ +# Bread — Sync & Module System Implementation Spec +### Instructions for Claude Code + +This document defines exactly what to build, how it must behave, and what conditions must be met before iteration stops. Read it fully before writing any code. Do not stop iterating until every condition in the **Completion Checklist** at the bottom is met. + +--- + +## Context + +Bread is a reactive desktop automation daemon for Linux. The existing codebase is a Rust workspace with three crates: + +- `breadd/` — the runtime daemon (Rust + Lua via mlua) +- `bread-cli/` — the CLI binary (Rust, talks to daemon over Unix socket IPC) +- `bread-shared/` — shared types (`BreadEvent`, `RawEvent`, `AdapterSource`) + +The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The IPC protocol is newline-delimited JSON request/response. The Lua runtime runs on a dedicated OS thread. All existing code compiles and tests pass — do not break anything that currently works. + +The two things being added in this iteration: + +1. **Module system** — install, list, remove, and update Lua modules from GitHub URLs +2. **Sync** — snapshot and restore system state (Bread config + arbitrary config files + package manifests) via a Git remote + +--- + +## Part 1: Module System + +### What a module is + +A Bread module is a directory (or single `.lua` file) that gets installed into `~/.config/bread/modules/`. Modules are already loaded by the daemon — what's missing is the install/manage layer. + +A module directory looks like: + +``` +~/.config/bread/modules/ +└── wifi/ + ├── bread.module.toml ← module manifest (required) + ├── init.lua ← entry point (required) + └── lib/ ← optional support files +``` + +### Module manifest (`bread.module.toml`) + +Every installed module must have a manifest: + +```toml +name = "wifi" +version = "1.0.0" +description = "WiFi management for Bread" +author = "someuser" +source = "github:someuser/bread-wifi" # where it was installed from +installed_at = "2026-05-11T09:00:00Z" # RFC 3339 timestamp, set on install +``` + +All fields are required. `source` is the original install source string. `installed_at` is written by Bread at install time, not by the module author. + +### Install sources + +The module installer must support these source formats: + +``` +github:user/repo # installs default branch +github:user/repo@v1.2.0 # installs specific tag +github:user/repo@abc1234 # installs specific commit +/path/to/local/dir # installs from local directory (copies it) +``` + +Anything else is an error with a clear message. + +### New Cargo dependencies allowed + +Add to `bread-cli/Cargo.toml` as needed: +- `git2 = "0.18"` for Git operations +- `reqwest = { version = "0.11", features = ["blocking", "json"] }` for GitHub API +- `flate2`, `tar` for archive extraction + +Add to `breadd/Cargo.toml` as needed: +- `git2 = "0.18"` +- `toml = "0.8"` (already present) + +### CLI commands to implement + +All module commands live under `bread modules`: + +``` +bread modules install Install a module +bread modules remove Remove an installed module +bread modules list List installed modules with name, version, status +bread modules update Update all installed modules to latest +bread modules update Update a specific module +bread modules info Show full manifest details for a module +``` + +**`bread modules install `** + +1. Parse the source string. +2. For `github:user/repo[@ref]`: + - Use the GitHub API to resolve the ref (or default branch if none specified). + - Download the repository archive as a `.tar.gz`. + - Extract to a temp directory. + - Verify a `bread.module.toml` exists at the root. If not, error cleanly. + - Copy the module directory to `~/.config/bread/modules//`. + - Write `installed_at` into the manifest. +3. For local paths: + - Verify the path exists and contains `bread.module.toml`. + - Copy to `~/.config/bread/modules//`. + - Write `installed_at`. +4. Print `installed v` on success. +5. Tell the daemon to reload via IPC (`modules.reload`) after install. + +**`bread modules remove `** + +1. Find `~/.config/bread/modules//`. +2. Ask for confirmation: `remove ? (y/n)`. Skip if `--yes` flag is passed. +3. Delete the directory. +4. Tell the daemon to reload via IPC. +5. Print `removed `. + +**`bread modules list`** + +Scan `~/.config/bread/modules/` for directories containing `bread.module.toml`. For each, print: + +``` + wifi 1.0.0 loaded github:someuser/bread-wifi + redox 0.3.1 loaded github:breadway/bread-redox + broken-mod 0.1.0 error /home/user/local-module +``` + +Status (`loaded`, `error`, `not_found`, `degraded`) comes from the daemon's IPC `modules.list` response, matched by module name. If the daemon is unreachable, show `unknown` for status. + +**`bread modules update [name]`** + +1. Read `bread.module.toml` for each module to update. +2. If `source` starts with `github:`, re-run the install for that source. +3. If `source` is a local path, error with `cannot update local module — reinstall manually`. +4. Print `updated v → v` or ` already up to date`. + +**`bread modules info `** + +Print full manifest contents plus daemon-reported status. Example: + +``` +name: wifi +version: 1.0.0 +description: WiFi management for Bread +author: someuser +source: github:someuser/bread-wifi +installed_at: 2026-05-11T09:00:00Z +status: loaded +``` + +### Daemon-side: expose `ID_VENDOR_ID` and `ID_MODEL_ID` in udev events + +In `breadd/src/adapters/udev.rs`, the `run_udev_monitor` function builds the payload for each udev event. Add `vendor_id` and `product_id` to the payload: + +```rust +"vendor_id": prop_str(&event, "ID_VENDOR_ID"), +"product_id": prop_str(&event, "ID_MODEL_ID"), +``` + +These are the raw hex USB IDs (e.g. `"4d44"` and `"5244"`). Do the same in `raw_change_event` for the fallback poller — read them from sysfs at `/idVendor` and `/idProduct` if available. Also add `vendor_id` and `product_id` to the `Device` struct in `breadd/src/core/types.rs` as `Option`. + +--- + +## Part 2: Sync System + +### Overview + +Sync saves and restores a complete description of the user's environment. It is not a disk image. It saves: + +1. **Bread config** — everything in `~/.config/bread/` (always included) +2. **Delegated configs** — other config directories the user explicitly opts in (e.g. `~/.config/nvim/`) +3. **Package manifest** — lists of explicitly-installed packages per package manager +4. **Machine profile** — machine name and tags for machine-aware config + +Everything is stored in a Git repository. `bread sync push` commits and pushes. `bread sync pull` pulls and applies. + +### New crate: `bread-sync` + +Create a new crate `bread-sync/` in the workspace. Add it to `[workspace.members]` in the root `Cargo.toml`. + +``` +bread-sync/ +├── Cargo.toml +└── src/ + ├── lib.rs + ├── config.rs ← SyncConfig type, load/save + ├── git.rs ← Git operations via git2 + ├── packages.rs ← Package manifest generation + ├── delegates.rs ← Config file delegation + └── machine.rs ← Machine profile +``` + +`bread-cli` depends on `bread-sync`. `breadd` does not — sync is a CLI-only feature. + +### Sync configuration (`~/.config/bread/sync.toml`) + +This file is created by `bread sync init` and edited by the user. It is committed to the sync repo. + +```toml +[remote] +url = "git@github.com:user/bread-sync.git" # required, set by bread sync init +branch = "main" # default: "main" + +[machine] +name = "laptop" # required, set by bread sync init +tags = ["mobile", "battery", "single-monitor"] # user-defined, optional + +[packages] +enabled = true +managers = ["pacman", "pip", "npm"] # which package managers to snapshot + +[delegates] +# Additional config directories to include in sync. +# ~/.config/bread/ is always included and does not need to be listed here. +include = [ + "~/.config/nvim", + "~/.config/fish", + "~/.config/kitty", +] +exclude = [ + "**/.git", + "**/node_modules", + "**/__pycache__", + "**/*.log", + "**/*.cache", + "~/.config/nvim/.repro", +] +``` + +All paths support `~` expansion. Globs in `exclude` use standard glob syntax. + +### Sync repo layout + +The Git repository managed by Bread has this structure: + +``` +/ +├── bread/ ← copy of ~/.config/bread/ (minus sync.toml secrets if any) +├── configs/ +│ ├── nvim/ ← copy of ~/.config/nvim/ +│ ├── fish/ ← copy of ~/.config/fish/ +│ └── kitty/ ← copy of ~/.config/kitty/ +├── packages/ +│ ├── pacman.txt ← output of `pacman -Qe` +│ ├── pip.txt ← output of `pip list --user --format=freeze` +│ └── npm.txt ← output of `npm list -g --depth=0` +├── machines/ +│ └── laptop.toml ← machine profile for this machine +└── .bread-sync ← sync metadata (not committed to Git) +``` + +`machines/.toml` contains: + +```toml +name = "laptop" +hostname = "breadway-laptop" # auto-detected via gethostname +tags = ["mobile", "battery", "single-monitor"] +last_sync = "2026-05-11T09:15:00Z" +``` + +### CLI commands to implement + +All sync commands live under `bread sync`: + +``` +bread sync init [--remote ] Initialize sync for this machine +bread sync push [--message ] Snapshot and push current state +bread sync pull Pull and apply latest state +bread sync status Show what has changed since last push +bread sync diff Show file-level diff vs remote +bread sync machines List known machines from sync repo +``` + +**`bread sync init [--remote ]`** + +1. Check if `~/.config/bread/sync.toml` already exists. If so, error: `sync already initialized. Edit ~/.config/bread/sync.toml to reconfigure.` +2. If `--remote` is not provided, prompt: `Sync remote URL (git remote or path): `. +3. Prompt: `Machine name [laptop]: ` (default: hostname). +4. Prompt: `Machine tags (comma-separated, e.g. mobile,battery): `. +5. Create `~/.config/bread/sync.toml` with the provided values. +6. If the remote is a URL (not a local path), check if the repo exists: + - If it exists, clone it to a temp location and verify it looks like a Bread sync repo (has a `bread/` directory or is empty). + - If it doesn't exist, print: `remote does not exist yet — it will be created on first push`. +7. Print setup summary. + +**`bread sync push [--message ]`** + +1. Load `~/.config/bread/sync.toml`. Error if not initialized. +2. Resolve the local sync repo path (`~/.local/share/bread/sync-repo/`). Clone from remote if it doesn't exist locally. +3. Snapshot each section: + - Copy `~/.config/bread/` → `/bread/` (rsync-style: delete files in dest that don't exist in source) + - For each path in `delegates.include`: copy to `/configs//` + - If `packages.enabled`: run package manager queries and write to `/packages/` + - Write `/machines/.toml` +4. Stage all changes (`git add -A`). +5. If there are no changes, print `nothing to push — already up to date` and exit. +6. Commit with message: `sync: ` or the user-provided `--message`. +7. Push to remote. +8. Print a summary of what was snapshotted. + +**`bread sync pull`** + +1. Load `~/.config/bread/sync.toml`. Error if not initialized. +2. Pull from remote (fetch + merge or rebase — use merge, simpler). +3. Apply each section in order: + - Copy `/bread/` → `~/.config/bread/` (same rsync-style) + - For each path in `delegates.include` that exists in `/configs/`: copy back + - If `packages.enabled` and `--install-packages` flag is passed: run package installs (see below) +4. Tell the daemon to reload via IPC (`modules.reload`) after applying. +5. Print a summary of what was applied. + +**Package install on pull** (only when `--install-packages` is explicitly passed): + +- `pacman.txt` → `sudo pacman -S --needed $(cat pacman.txt | awk '{print $1}')` +- `pip.txt` → `pip install --user -r pip.txt` +- `npm.txt` → parse package names and run `npm install -g` + +Never run package installs automatically without the flag. Print a note at the end of `pull` if packages differ: `run 'bread sync pull --install-packages' to install missing packages`. + +**`bread sync status`** + +1. Load sync config and local repo. +2. Pull remote refs without merging (fetch only). +3. Compare working tree to last commit and compare last commit to remote HEAD. +4. Print: + +``` +bread sync status + machine laptop + remote git@github.com:user/bread-sync.git + last push 2026-05-11 09:15:00 + +local changes (not yet pushed): + M bread/init.lua + A bread/modules/wifi/init.lua + +remote changes (not yet pulled): + none +``` + +**`bread sync diff`** + +Run `git diff HEAD` in the sync repo and print it. If `--remote` flag is passed, run `git diff HEAD..origin/`. + +**`bread sync machines`** + +List all `machines/*.toml` files from the sync repo: + +``` + laptop last sync: 2026-05-11 09:15 tags: mobile, battery, single-monitor + desktop last sync: 2026-05-10 22:00 tags: stationary, multi-monitor, docked +``` + +### Package manager support + +Implement these four. Each must handle the case where the package manager is not installed (skip with a warning, don't error). + +| Manager | Snapshot command | Install command | +|---------|-----------------|-----------------| +| `pacman` | `pacman -Qe` | `sudo pacman -S --needed ` | +| `pip` | `pip list --user --format=freeze` | `pip install --user -r ` | +| `npm` | `npm list -g --depth=0 --parseable` | `npm install -g ` | +| `cargo` | `cargo install --list` | `cargo install ` | + +For `cargo`, the snapshot format is one package per line: ` `. Parse `cargo install --list` output accordingly. + +### Git operations + +Use the `git2` crate for all Git operations. Do not shell out to `git`. Required operations: + +- Clone a remote repo +- Open an existing repo +- Stage all changes (`add -A` equivalent: index all tracked and untracked files) +- Create a commit with a message and the current timestamp as author date +- Push to remote (support SSH and HTTPS — `git2` handles this via callbacks) +- Pull (fetch + merge fast-forward; if non-fast-forward, error with clear message) +- Fetch (without merging) +- Get diff between working tree and HEAD +- Get diff between HEAD and remote branch HEAD + +For SSH auth, use the user's default SSH agent (`git2::transport::smart::SmartSubtransport` with `SshKey` credential). For HTTPS, use the system credential store or prompt for credentials. + +--- + +## Part 3: Daemon additions (IPC) + +Add these IPC methods to `breadd/src/ipc/mod.rs`: + +**`sync.status`** — returns current sync state from `sync.toml` if it exists: +```json +{ "initialized": true, "machine": "laptop", "remote": "git@github.com:..." } +``` +or `{ "initialized": false }` if no sync.toml. + +**`modules.install`** — triggers a reload after external install (already covered by `modules.reload`, no new method needed — `bread modules install` calls `modules.reload` via IPC after installing). + +No other daemon changes are needed for sync — it is entirely CLI-side. + +--- + +## Part 4: Lua API additions + +Add to `breadd/src/lua/mod.rs` in `install_api`: + +**`bread.machine`** table: + +```lua +bread.machine.name() -- returns machine name from sync.toml, or hostname if no sync.toml +bread.machine.tags() -- returns array of tags, or empty array +bread.machine.has_tag("mobile") -- returns bool +``` + +Read `~/.config/bread/sync.toml` directly from Lua (parse it in Rust, expose via the API). If `sync.toml` doesn't exist, `name()` returns `os.getenv("HOSTNAME")` and `tags()` returns `{}`. + +**`bread.fs`** table: + +```lua +bread.fs.write(path, content) -- write string to file, create dirs as needed +bread.fs.read(path) -- read file to string, returns nil if not found +bread.fs.exists(path) -- returns bool +bread.fs.expand(path) -- expand ~ to home directory +``` + +All paths support `~` expansion. `bread.fs.write` creates parent directories automatically. Errors in `write` propagate as Lua errors. + +--- + +## Error handling requirements + +Every command must handle these cases cleanly: + +- Daemon not running: print `bread: daemon is not running. Start it with: systemctl --user start breadd` and exit 1. +- No sync.toml: print `bread: sync not initialized. Run: bread sync init` and exit 1. +- Network unreachable during push/pull: print the error clearly and exit 1. Do not leave the repo in a partial state. +- Module not found during remove/info: print `bread: module '' is not installed` and exit 1. +- Git conflicts on pull: print `bread: sync conflict — resolve manually in ~/.local/share/bread/sync-repo/` and exit 1. Do not auto-merge or discard changes. +- Package manager not installed: warn and skip, do not fail the whole operation. + +--- + +## File locations + +| Purpose | Path | +|---------|------| +| Sync config | `~/.config/bread/sync.toml` | +| Local sync repo | `~/.local/share/bread/sync-repo/` | +| Module manifests | `~/.config/bread/modules//bread.module.toml` | +| Bread config | `~/.config/bread/` | +| Daemon socket | `$XDG_RUNTIME_DIR/bread/breadd.sock` | + +All paths must use `dirs` crate or manual `$HOME`/`$XDG_*` expansion — never hardcode `/home/breadway` or any username. + +Add to `bread-cli/Cargo.toml`: `dirs = "5.0"`. + +--- + +## Tests + +### Module system tests (`bread-cli/tests/modules.rs`) + +```rust +// 1. Install from local path succeeds when bread.module.toml exists +// 2. Install from local path fails when bread.module.toml is missing +// 3. Remove deletes the module directory +// 4. List reads manifests correctly from disk +// 5. Manifest is written correctly on install (all fields present, installed_at is valid RFC 3339) +``` + +### Sync tests (`bread-sync/tests/sync.rs`) + +```rust +// 1. bread sync init creates sync.toml with correct fields +// 2. bread sync push with a local bare Git repo as remote: creates correct directory structure +// 3. bread sync push snapshots bread/ directory correctly +// 4. bread sync pull copies files from repo to correct locations +// 5. Package manifest for pacman: parses output correctly +// 6. Package manifest for pip: parses output correctly +// 7. Delegates: exclude globs filter correctly +// 8. Machine profile is written to machines/.toml with correct fields +// 9. Status shows no changes when working tree matches last commit +// 10. Push with no changes prints "nothing to push" and does not create a commit +``` + +All tests must pass with `cargo test --workspace`. Tests that require network access must be feature-gated with `#[cfg(feature = "network-tests")]` and not run by default. + +--- + +## Completion Checklist + +Do not stop iterating until every item on this list is true. + +### Compilation +- [ ] `cargo build --workspace` succeeds with zero errors +- [ ] `cargo build --workspace --release` succeeds with zero errors +- [ ] Zero compiler warnings in new code (existing warnings are acceptable) +- [ ] `cargo clippy --workspace` produces no errors in new code + +### Tests +- [ ] `cargo test --workspace` passes with zero failures +- [ ] All tests listed in the Tests section above exist and pass +- [ ] Integration tests in `breadd/tests/ipc_integration.rs` still pass + +### Module system — functional +- [ ] `bread modules install github:user/repo` downloads and installs a module +- [ ] `bread modules install /local/path` copies and installs a local module +- [ ] `bread modules install` with an invalid source prints a clear error and exits 1 +- [ ] `bread modules install` writes a valid `bread.module.toml` with all required fields including `installed_at` +- [ ] `bread modules install` calls `modules.reload` IPC after successful install +- [ ] `bread modules remove ` removes the module directory +- [ ] `bread modules remove ` with `--yes` skips confirmation +- [ ] `bread modules remove ` prints a clear error and exits 1 +- [ ] `bread modules list` reads all installed module manifests +- [ ] `bread modules list` shows daemon-reported status when daemon is running +- [ ] `bread modules list` shows `unknown` status when daemon is not running (no crash) +- [ ] `bread modules update` re-installs all github-sourced modules +- [ ] `bread modules update` skips local-path modules with a warning +- [ ] `bread modules info ` shows all manifest fields and daemon status + +### Sync — functional +- [ ] `bread sync init` creates `~/.config/bread/sync.toml` with all required fields +- [ ] `bread sync init` errors if already initialized +- [ ] `bread sync push` creates the correct repo directory structure +- [ ] `bread sync push` copies `~/.config/bread/` to `bread/` in the repo +- [ ] `bread sync push` copies each delegate path to `configs//` +- [ ] `bread sync push` writes package manifests to `packages/` +- [ ] `bread sync push` writes `machines/.toml` +- [ ] `bread sync push` creates a Git commit with a sensible message +- [ ] `bread sync push` pushes to the configured remote +- [ ] `bread sync push` with no changes prints `nothing to push` and exits 0 +- [ ] `bread sync pull` copies `bread/` from repo to `~/.config/bread/` +- [ ] `bread sync pull` copies `configs/` entries back to their original locations +- [ ] `bread sync pull` calls `modules.reload` IPC after applying +- [ ] `bread sync pull --install-packages` runs package installs +- [ ] `bread sync pull` without `--install-packages` does not run package installs +- [ ] `bread sync status` shows local uncommitted changes +- [ ] `bread sync status` shows remote changes not yet pulled +- [ ] `bread sync status` prints `nothing to push — already up to date` when clean +- [ ] `bread sync machines` lists all `machines/*.toml` entries +- [ ] `bread sync init` without `--remote` prompts for URL interactively + +### Sync — error handling +- [ ] `bread sync push` without init prints clear error and exits 1 +- [ ] `bread sync pull` without init prints clear error and exits 1 +- [ ] Git conflict on pull prints clear message pointing to sync repo path and exits 1 +- [ ] Package manager not installed is warned and skipped, not a fatal error + +### Lua API +- [ ] `bread.machine.name()` returns machine name from sync.toml +- [ ] `bread.machine.name()` returns hostname when sync.toml does not exist +- [ ] `bread.machine.tags()` returns array of tags +- [ ] `bread.machine.has_tag("x")` returns true/false correctly +- [ ] `bread.fs.write(path, content)` writes the file and creates parent dirs +- [ ] `bread.fs.read(path)` returns file content as string +- [ ] `bread.fs.read(nonexistent)` returns nil, does not error +- [ ] `bread.fs.exists(path)` returns correct bool +- [ ] `bread.fs.expand("~/foo")` returns the correct absolute path +- [ ] All `bread.fs` paths handle `~` expansion + +### Udev vendor/product ID +- [ ] `vendor_id` and `product_id` fields are present in udev device events +- [ ] `Device` struct in `types.rs` has `vendor_id: Option` and `product_id: Option` +- [ ] `bread events` output shows `vendor_id` and `product_id` when available + +### No regressions +- [ ] `bread reload` still works +- [ ] `bread state` still works +- [ ] `bread events` still works +- [ ] `bread doctor` still works +- [ ] `bread ping` still works +- [ ] `bread emit` still works +- [ ] Daemon starts cleanly with no existing `sync.toml` +- [ ] Daemon starts cleanly with a valid `sync.toml` +- [ ] All existing IPC methods still respond correctly + +### Code quality +- [ ] No hardcoded paths containing usernames or `/home/` +- [ ] No `unwrap()` calls in new code that can fail at runtime — use `?` or explicit error handling +- [ ] No `expect("...")` calls in new async code — only in tests and truly-impossible cases +- [ ] All new public functions have doc comments +- [ ] `bread-sync` crate has a `README.md` explaining its purpose and public API + +--- + +## Implementation order + +Work in this order. Do not move to the next step until the current one compiles and its tests pass. + +1. Add `bread-sync` crate skeleton to workspace (compiles, no logic yet) +2. Implement `SyncConfig` (load/save `sync.toml`) +3. Implement `bread sync init` +4. Implement Git backend in `bread-sync/src/git.rs` +5. Implement `bread sync push` (bread config only, no delegates or packages yet) +6. Implement delegate file handling +7. Implement package manifest generation +8. Implement `bread sync pull` +9. Implement `bread sync status`, `diff`, `machines` +10. Implement `bread modules install` (local path first, then GitHub) +11. Implement `bread modules remove`, `list`, `update`, `info` +12. Add `vendor_id`/`product_id` to udev adapter and `Device` type +13. Add `bread.machine` Lua API +14. Add `bread.fs` Lua API +15. Write all tests +16. Run full checklist — fix anything not passing +17. Run `cargo clippy --workspace` — fix any new warnings diff --git a/Cargo.lock b/Cargo.lock index 313315f..72cecc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,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" @@ -248,6 +263,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "1.3.2" @@ -304,6 +325,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-sync" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "flate2", + "git2", + "reqwest", + "serde", + "serde_json", + "tar", + "tempfile", + "toml", +] + [[package]] name = "breadd" version = "0.1.0" @@ -339,6 +377,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byteorder" version = "1.5.0" @@ -358,6 +402,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -367,6 +413,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" @@ -422,6 +482,32 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -431,6 +517,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -477,12 +572,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" @@ -606,12 +742,52 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +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" @@ -758,6 +934,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" @@ -766,11 +954,45 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "git2" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" +dependencies = [ + "bitflags 2.11.1", + "libc", + "libgit2-sys", + "log", + "openssl-probe 0.1.6", + "openssl-sys", + "url", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -810,12 +1032,210 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[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]] name = "id-arena" 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" @@ -868,6 +1288,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -880,6 +1306,28 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -918,6 +1366,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" @@ -928,6 +1413,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" @@ -946,6 +1443,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" @@ -1022,6 +1525,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "0.8.11" @@ -1075,6 +1594,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1214,6 +1750,61 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "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" @@ -1268,6 +1859,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" @@ -1321,6 +1918,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1368,6 +1974,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" @@ -1413,6 +2025,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" @@ -1442,6 +2065,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" @@ -1503,6 +2166,27 @@ 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" @@ -1512,12 +2196,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1597,6 +2313,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" @@ -1633,6 +2361,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -1665,6 +2399,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" @@ -1699,6 +2439,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" @@ -1741,6 +2530,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" @@ -1770,6 +2569,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1822,6 +2644,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1883,6 +2711,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typeid" version = "1.0.3" @@ -1930,6 +2764,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" @@ -1942,6 +2794,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1964,6 +2822,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1988,6 +2855,61 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2022,6 +2944,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "which" version = "7.0.3" @@ -2065,12 +2997,65 @@ 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" @@ -2237,6 +3222,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" @@ -2337,6 +3332,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" @@ -2347,6 +3358,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" @@ -2434,6 +3468,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..ee8711e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,8 @@ members = [ "bread-shared", "breadd", - "bread-cli" + "bread-cli", + "bread-sync" ] resolver = "2" diff --git a/README.md b/README.md index 73512df..ae1e55a 100644 --- a/README.md +++ b/README.md @@ -231,11 +231,12 @@ bread.once("bread.system.startup", function(event) end) -- Subscribe with a predicate filter +-- Third arg is an opts table with a 'filter' key whose value is the predicate bread.filter("bread.device.connected", function(event) - return event.data.class == "keyboard" -end, function(event) bread.exec("xset r rate 200 40") -end) +end, { filter = function(event) + return event.data.class == "keyboard" +end }) -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 69a2c49..43c17a9 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -9,6 +9,7 @@ path = "src/main.rs" [dependencies] bread-shared = { path = "../bread-shared" } +bread-sync = { path = "../bread-sync" } serde.workspace = true serde_json.workspace = true tokio.workspace = true @@ -16,3 +17,9 @@ anyhow.workspace = true clap = { version = "4.5", features = ["derive"] } notify = "6.1" libc = "0.2" +dirs = "5.0" +reqwest = { version = "0.11", features = ["blocking", "json"] } +flate2 = "1.0" +tar = "0.4" +chrono = { version = "0.4", features = ["serde"] } +toml = "0.8" diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 0ca91df..d57890a 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,6 +1,7 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::env; use std::io; @@ -10,6 +11,16 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tokio::sync::mpsc; +use bread_sync::{ + config::{bread_config_dir, sync_repo_path, SyncConfig}, + delegates::{copy_delegates_to_repo, expand_tilde, restore_delegates_from_repo, sync_dir}, + git, + machine::{list_machines, machine_name, MachineProfile}, + packages::snapshot_packages, +}; + +// ─── CLI structure ──────────────────────────────────────────────────────────── + #[derive(Parser, Debug)] #[command(author, version, about = "Bread CLI - the reactive desktop automation fabric")] struct Cli { @@ -47,8 +58,16 @@ enum Commands { #[arg(long)] since: Option, }, - /// List loaded modules and status - Modules, + /// Manage installed Lua modules + Modules { + #[command(subcommand)] + action: ModulesAction, + }, + /// Sync system state to/from a Git remote + Sync { + #[command(subcommand)] + action: SyncAction, + }, /// List available profiles ProfileList, /// Activate a profile @@ -71,6 +90,79 @@ enum Commands { }, } +#[derive(Subcommand, Debug)] +enum ModulesAction { + /// Install a module from a source (github:user/repo[@ref] or /local/path) + Install { + source: String, + }, + /// Remove an installed module + Remove { + name: String, + /// Skip confirmation prompt + #[arg(long, short = 'y')] + yes: bool, + }, + /// List installed modules with status + List, + /// Update installed modules to latest + Update { + /// Update only this specific module + name: Option, + }, + /// Show detailed manifest info for a module + Info { + name: String, + }, +} + +#[derive(Subcommand, Debug)] +enum SyncAction { + /// Initialize sync for this machine + Init { + /// Git remote URL + #[arg(long)] + remote: Option, + }, + /// Snapshot and push current state + Push { + /// Commit message + #[arg(long, short = 'm')] + message: Option, + }, + /// Pull and apply latest state from remote + Pull { + /// Also run package install commands + #[arg(long)] + install_packages: bool, + }, + /// Show what has changed since last push + Status, + /// Show file-level diff vs remote + Diff { + /// Diff against remote HEAD instead of working tree + #[arg(long)] + remote: bool, + }, + /// List known machines from sync repo + Machines, +} + +// ─── Module manifest ────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct ModuleManifest { + name: String, + version: String, + description: String, + author: String, + source: String, + #[serde(skip_serializing_if = "Option::is_none")] + installed_at: Option, +} + +// ─── Entry point ────────────────────────────────────────────────────────────── + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -81,71 +173,65 @@ async fn main() -> Result<()> { if *watch { watch_reload(&socket).await?; } else { - let response = send_request(&socket, "modules.reload", json!({})).await?; + let response = send_request_or_die(&socket, "modules.reload", json!({})).await?; print_reload(&response); } } Commands::State { path, json } => { + let response = if let Some(path) = path { + send_request_or_die(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request_or_die(&socket, "state.dump", json!({})).await? + }; if *json { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; print_json(&response)?; } else { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; print_state_formatted(path.as_deref(), &response); } } - Commands::Events { - filter, - json, - fields, - since, - } => { + Commands::Events { filter, json, fields, since } => { stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?; } - Commands::Modules => { - let response = send_request(&socket, "modules.list", json!({})).await?; - print_json(&response)?; + Commands::Modules { action } => { + handle_modules(action, &socket).await?; + } + Commands::Sync { action } => { + handle_sync(action, &socket).await?; } Commands::ProfileList => { - let response = send_request(&socket, "profile.list", json!({})).await?; + let response = send_request_or_die(&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_or_die( + &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 response = send_request( + let response = send_request_or_die( &socket, "emit", - json!({ - "event": event, - "data": parsed, - }), + json!({ "event": event, "data": parsed }), ) .await?; print_json(&response)?; } Commands::Ping => { - let response = send_request(&socket, "ping", json!({})).await?; + let response = send_request_or_die(&socket, "ping", json!({})).await?; print_json(&response)?; } Commands::Health => { - let response = send_request(&socket, "health", json!({})).await?; + let response = send_request_or_die(&socket, "health", json!({})).await?; print_json(&response)?; } Commands::Doctor { json } => { if *json { - let response = send_request(&socket, "health", json!({})).await?; + let response = send_request_or_die(&socket, "health", json!({})).await?; print_json(&response)?; } else { print_doctor(&socket).await?; @@ -156,6 +242,699 @@ async fn main() -> Result<()> { Ok(()) } +// ─── Modules sub-commands ───────────────────────────────────────────────────── + +async fn handle_modules(action: &ModulesAction, socket: &Path) -> Result<()> { + match action { + ModulesAction::Install { source } => { + modules_install(source, socket).await?; + } + ModulesAction::Remove { name, yes } => { + modules_remove(name, *yes, socket).await?; + } + ModulesAction::List => { + modules_list(socket).await?; + } + ModulesAction::Update { name } => { + modules_update(name.as_deref(), socket).await?; + } + ModulesAction::Info { name } => { + modules_info(name, socket).await?; + } + } + Ok(()) +} + +async fn modules_install(source: &str, socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + + if let Some(rest) = source.strip_prefix("github:") { + install_github_module(rest, source, &modules_dir)?; + } else if source.starts_with('/') || source.starts_with("./") || source.starts_with("~/") { + let local_path = expand_tilde(source); + install_local_module(&local_path, &modules_dir)?; + } else { + eprintln!("bread: unknown source format '{source}'"); + eprintln!(" expected: github:user/repo[@ref] or /local/path"); + std::process::exit(1); + } + + // Reload daemon + if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { + let _ = response; + } + Ok(()) +} + +fn install_local_module(src: &Path, modules_dir: &Path) -> Result<()> { + let manifest_path = src.join("bread.module.toml"); + if !manifest_path.exists() { + eprintln!( + "bread: no bread.module.toml found at {}", + manifest_path.display() + ); + std::process::exit(1); + } + let raw = std::fs::read_to_string(&manifest_path)?; + let mut manifest: ModuleManifest = toml::from_str(&raw)?; + manifest.installed_at = Some(chrono::Utc::now().to_rfc3339()); + + let dest = modules_dir.join(&manifest.name); + if dest.exists() { + std::fs::remove_dir_all(&dest)?; + } + copy_dir_all(src, &dest)?; + + // Write updated manifest with installed_at + let manifest_dest = dest.join("bread.module.toml"); + std::fs::write(&manifest_dest, toml::to_string_pretty(&manifest)?)?; + + println!("installed {} v{}", manifest.name, manifest.version); + Ok(()) +} + +fn install_github_module(spec: &str, source_str: &str, modules_dir: &Path) -> Result<()> { + let (repo_spec, git_ref) = if let Some((r, v)) = spec.split_once('@') { + (r, Some(v.to_string())) + } else { + (spec, None) + }; + + let (user, repo) = repo_spec + .split_once('/') + .ok_or_else(|| anyhow!("invalid github spec '{}': expected user/repo", spec))?; + + let client = reqwest::blocking::Client::builder() + .user_agent("bread-cli/0.1") + .build()?; + + let resolved_ref = match git_ref { + Some(r) => r, + None => { + let url = format!("https://api.github.com/repos/{user}/{repo}"); + let resp: Value = client.get(&url).send()?.json()?; + 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/{resolved_ref}" + ); + let bytes = client.get(&tarball_url).send()?.bytes()?; + + // Extract to a temp dir + let tmp = tempfile_dir()?; + let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&bytes)); + let mut archive = tar::Archive::new(gz); + archive.unpack(&tmp)?; + + // The tarball has a single top-level directory; find it + let extracted_dir = std::fs::read_dir(&tmp)? + .filter_map(|e| e.ok()) + .find(|e| e.path().is_dir()) + .map(|e| e.path()) + .ok_or_else(|| anyhow!("tarball contained no directory"))?; + + let manifest_path = extracted_dir.join("bread.module.toml"); + if !manifest_path.exists() { + let _ = std::fs::remove_dir_all(&tmp); + eprintln!( + "bread: no bread.module.toml found in github:{}/{} (ref {})", + user, repo, resolved_ref + ); + std::process::exit(1); + } + + let raw = std::fs::read_to_string(&manifest_path)?; + let mut manifest: ModuleManifest = toml::from_str(&raw)?; + manifest.installed_at = Some(chrono::Utc::now().to_rfc3339()); + manifest.source = source_str.to_string(); + + let dest = modules_dir.join(&manifest.name); + if dest.exists() { + std::fs::remove_dir_all(&dest)?; + } + copy_dir_all(&extracted_dir, &dest)?; + + let manifest_dest = dest.join("bread.module.toml"); + std::fs::write(&manifest_dest, toml::to_string_pretty(&manifest)?)?; + + let _ = std::fs::remove_dir_all(&tmp); + println!("installed {} v{}", manifest.name, manifest.version); + Ok(()) +} + +async fn modules_remove(name: &str, yes: bool, socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + let module_dir = modules_dir.join(name); + + if !module_dir.exists() { + eprintln!("bread: module '{name}' is not installed"); + std::process::exit(1); + } + + if !yes { + eprint!("remove {name}? (y/n) "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if !input.trim().eq_ignore_ascii_case("y") { + println!("cancelled"); + return Ok(()); + } + } + + std::fs::remove_dir_all(&module_dir)?; + println!("removed {name}"); + + if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { + let _ = response; + } + Ok(()) +} + +async fn modules_list(socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + let manifests = scan_modules(&modules_dir)?; + + // Try to get daemon status + let daemon_modules = send_request(socket, "modules.list", json!({})) + .await + .ok() + .and_then(|v| v.as_array().cloned()); + + for manifest in &manifests { + let status = daemon_modules + .as_ref() + .and_then(|mods| { + mods.iter().find(|m| { + m.get("name").and_then(Value::as_str) == Some(&manifest.name) + }) + }) + .and_then(|m| m.get("status").and_then(Value::as_str)) + .unwrap_or(if daemon_modules.is_some() { "unknown" } else { "unknown" }); + + println!( + " {:<20} {:<10} {:<12} {}", + manifest.name, manifest.version, status, manifest.source + ); + } + Ok(()) +} + +async fn modules_update(name: Option<&str>, socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + + let to_update: Vec = if let Some(name) = name { + let manifest = load_manifest(&modules_dir.join(name))?; + vec![manifest] + } else { + scan_modules(&modules_dir)? + }; + + for manifest in to_update { + if !manifest.source.starts_with("github:") { + eprintln!( + "warn: cannot update '{}' — local module, reinstall manually", + manifest.name + ); + continue; + } + let old_version = manifest.version.clone(); + let source = manifest.source.clone(); + let rest = source.trim_start_matches("github:"); + install_github_module(rest, &source, &modules_dir)?; + let new_manifest = load_manifest(&modules_dir.join(&manifest.name))?; + if new_manifest.version == old_version { + println!("{} already up to date", manifest.name); + } else { + println!( + "updated {} v{} → v{}", + manifest.name, old_version, new_manifest.version + ); + } + } + + if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { + let _ = response; + } + Ok(()) +} + +async fn modules_info(name: &str, socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + let module_dir = modules_dir.join(name); + + if !module_dir.exists() { + eprintln!("bread: module '{name}' is not installed"); + std::process::exit(1); + } + + let manifest = load_manifest(&module_dir)?; + let status = send_request(socket, "modules.list", json!({})) + .await + .ok() + .and_then(|v| v.as_array().cloned()) + .and_then(|mods| { + mods.iter() + .find(|m| m.get("name").and_then(Value::as_str) == Some(name)) + .and_then(|m| m.get("status").and_then(Value::as_str)) + .map(ToString::to_string) + }) + .unwrap_or_else(|| "unknown".to_string()); + + println!("name: {}", manifest.name); + println!("version: {}", manifest.version); + println!("description: {}", manifest.description); + println!("author: {}", manifest.author); + println!("source: {}", manifest.source); + println!( + "installed_at: {}", + manifest.installed_at.as_deref().unwrap_or("unknown") + ); + println!("status: {status}"); + Ok(()) +} + +// ─── Sync sub-commands ──────────────────────────────────────────────────────── + +async fn handle_sync(action: &SyncAction, socket: &Path) -> Result<()> { + match action { + SyncAction::Init { remote } => sync_init(remote.as_deref()).await?, + SyncAction::Push { message } => sync_push(message.as_deref()).await?, + SyncAction::Pull { install_packages } => sync_pull(*install_packages, socket).await?, + SyncAction::Status => sync_status().await?, + SyncAction::Diff { remote } => sync_diff(*remote).await?, + SyncAction::Machines => sync_machines().await?, + } + Ok(()) +} + +async fn sync_init(remote_arg: Option<&str>) -> Result<()> { + if SyncConfig::is_initialized()? { + eprintln!( + "bread: sync already initialized. Edit {} to reconfigure.", + bread_sync::config::config_path()?.display() + ); + std::process::exit(1); + } + + let remote_url = if let Some(url) = remote_arg { + url.to_string() + } else { + eprint!("Sync remote URL (git remote or path): "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let url = input.trim().to_string(); + if url.is_empty() { + anyhow::bail!("remote URL is required"); + } + url + }; + + let default_hostname = hostname_or_unknown(); + eprint!("Machine name [{}]: ", default_hostname); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let machine_name = { + let trimmed = input.trim().to_string(); + if trimmed.is_empty() { + default_hostname.clone() + } else { + trimmed + } + }; + + eprint!("Machine tags (comma-separated, e.g. mobile,battery): "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let tags: Vec = input + .trim() + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + let cfg = SyncConfig { + remote: bread_sync::config::RemoteConfig { + url: Some(remote_url.clone()), + branch: "main".to_string(), + }, + machine: bread_sync::config::MachineConfig { + name: Some(machine_name.clone()), + tags, + }, + ..Default::default() + }; + cfg.save()?; + + // Validate remote if it looks like a URL + if !remote_url.starts_with('/') { + println!("remote does not exist yet — it will be created on first push"); + } + + println!("sync initialized:"); + println!(" machine: {machine_name}"); + println!(" remote: {remote_url}"); + Ok(()) +} + +async fn sync_push(message: Option<&str>) -> Result<()> { + let cfg = require_sync_config()?; + let remote_url = cfg.remote.url.as_deref().ok_or_else(|| { + anyhow!("sync.toml has no remote URL — run: bread sync init") + })?; + let branch = cfg.remote.branch.clone(); + let repo_path = sync_repo_path()?; + + let repo = tokio::task::spawn_blocking({ + let remote_url = remote_url.to_string(); + let repo_path = repo_path.clone(); + move || git::clone_or_open(&remote_url, &repo_path) + }) + .await??; + + // Snapshot bread config + let bread_dir = bread_config_dir()?; + let bread_dest = repo_path.join("bread"); + sync_dir(&bread_dir, &bread_dest, &cfg.delegates.exclude)?; + + // Snapshot delegates + copy_delegates_to_repo(&cfg.delegates, &repo_path)?; + + // Snapshot packages + if cfg.packages.enabled { + snapshot_packages(&cfg.packages.managers, &repo_path)?; + } + + // Write machine profile + let profile = MachineProfile::new(&cfg)?; + profile.write_to_repo(&repo_path)?; + + // Stage all + git::stage_all(&repo)?; + + // Check for changes + if !git::has_changes(&repo)? { + println!("nothing to push — already up to date"); + return Ok(()); + } + + // Commit + let machine = machine_name(&cfg)?; + let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"); + let commit_msg = message + .map(ToString::to_string) + .unwrap_or_else(|| format!("sync: {machine} {timestamp}")); + git::commit(&repo, &commit_msg)?; + + // Set remote and push + if let Ok(()) = git::set_remote(&repo, "origin", remote_url) {} + git::push(&repo, "origin", &branch)?; + + println!("pushed: {commit_msg}"); + println!(" bread config: {}", bread_dir.display()); + if cfg.packages.enabled { + println!(" packages: {}", cfg.packages.managers.join(", ")); + } + Ok(()) +} + +async fn sync_pull(install_packages: bool, socket: &Path) -> Result<()> { + let cfg = require_sync_config()?; + let remote_url = cfg.remote.url.as_deref().ok_or_else(|| { + anyhow!("sync.toml has no remote URL — run: bread sync init") + })?; + let branch = cfg.remote.branch.clone(); + let repo_path = sync_repo_path()?; + + let repo = tokio::task::spawn_blocking({ + let remote_url = remote_url.to_string(); + let repo_path = repo_path.clone(); + move || git::clone_or_open(&remote_url, &repo_path) + }) + .await??; + + git::pull(&repo, "origin", &branch)?; + + // Restore bread config + let bread_src = repo_path.join("bread"); + let bread_dest = bread_config_dir()?; + if bread_src.exists() { + sync_dir(&bread_src, &bread_dest, &[])?; + } + + // Restore delegates + restore_delegates_from_repo(&cfg.delegates, &repo_path)?; + + // Package installs + if install_packages && cfg.packages.enabled { + run_package_installs(&repo_path, &cfg.packages.managers)?; + } else if cfg.packages.enabled { + let pkg_dir = repo_path.join("packages"); + if pkg_dir.exists() { + println!( + "note: run 'bread sync pull --install-packages' to install missing packages" + ); + } + } + + // Reload daemon + if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { + let _ = response; + } + + println!("pulled and applied latest state"); + Ok(()) +} + +async fn sync_status() -> Result<()> { + let cfg = require_sync_config()?; + let repo_path = sync_repo_path()?; + + if !repo_path.join(".git").exists() { + println!("sync repo not yet initialised — run: bread sync push"); + return Ok(()); + } + + let repo = git::init_or_open(&repo_path)?; + let machine = machine_name(&cfg)?; + let remote_url = cfg.remote.url.as_deref().unwrap_or("(none)"); + let last_push = git::last_commit_time(&repo); + + println!("bread sync status"); + println!(" machine {machine}"); + println!(" remote {remote_url}"); + println!(" last push {last_push}"); + + let local_changes = git::status_lines(&repo)?; + println!(); + println!("local changes (not yet pushed):"); + if local_changes.is_empty() { + println!(" none"); + } else { + for (ch, path) in &local_changes { + println!(" {ch} {path}"); + } + } + + // Fetch to check remote + let _ = git::fetch(&repo, "origin"); + let has_remote = git::remote_has_changes(&repo, "origin", &cfg.remote.branch); + println!(); + println!("remote changes (not yet pulled):"); + if has_remote { + println!(" (run 'bread sync pull' to apply)"); + } else { + println!(" none"); + } + Ok(()) +} + +async fn sync_diff(show_remote: bool) -> Result<()> { + let cfg = require_sync_config()?; + let repo_path = sync_repo_path()?; + + if !repo_path.join(".git").exists() { + println!("sync repo not initialised — run: bread sync push"); + return Ok(()); + } + + let repo = git::init_or_open(&repo_path)?; + + let diff = if show_remote { + git::fetch(&repo, "origin")?; + git::diff_remote(&repo, "origin", &cfg.remote.branch)? + } else { + git::diff_workdir(&repo)? + }; + + if diff.is_empty() { + println!("no differences"); + } else { + print!("{diff}"); + } + Ok(()) +} + +async fn sync_machines() -> Result<()> { + let repo_path = sync_repo_path()?; + if !repo_path.join(".git").exists() { + println!("sync repo not initialised — run: bread sync push"); + return Ok(()); + } + let machines = list_machines(&repo_path); + if machines.is_empty() { + println!("no machines found in sync repo"); + return Ok(()); + } + for m in machines { + let tags = if m.tags.is_empty() { + "(none)".to_string() + } else { + m.tags.join(", ") + }; + println!( + " {:<20} last sync: {:<20} tags: {}", + m.name, m.last_sync, tags + ); + } + Ok(()) +} + +fn run_package_installs(repo_root: &Path, managers: &[String]) -> Result<()> { + let pkg_dir = repo_root.join("packages"); + + for mgr in managers { + match mgr.as_str() { + "pacman" => { + let f = pkg_dir.join("pacman.txt"); + if f.exists() { + let names = bread_sync::packages::parse_pacman(&std::fs::read_to_string(&f)?); + let status = std::process::Command::new("sudo") + .args(["pacman", "-S", "--needed"]) + .args(&names) + .status(); + if let Err(e) = status { + eprintln!("warn: pacman install failed: {e}"); + } + } + } + "pip" => { + let f = pkg_dir.join("pip.txt"); + if f.exists() { + let status = std::process::Command::new("pip") + .args(["install", "--user", "-r"]) + .arg(&f) + .status(); + if let Err(e) = status { + eprintln!("warn: pip install failed: {e}"); + } + } + } + "npm" => { + let f = pkg_dir.join("npm.txt"); + if f.exists() { + let names = bread_sync::packages::parse_npm(&std::fs::read_to_string(&f)?); + for name in names { + let _ = std::process::Command::new("npm") + .args(["install", "-g", &name]) + .status(); + } + } + } + "cargo" => { + let f = pkg_dir.join("cargo.txt"); + if f.exists() { + let entries = bread_sync::packages::parse_cargo(&std::fs::read_to_string(&f)?); + for entry in entries { + let name = entry.split_whitespace().next().unwrap_or(&entry); + let _ = std::process::Command::new("cargo") + .args(["install", name]) + .status(); + } + } + } + _ => {} + } + } + Ok(()) +} + +// ─── Helper functions ───────────────────────────────────────────────────────── + +fn require_sync_config() -> Result { + if !SyncConfig::is_initialized()? { + eprintln!("bread: sync not initialized. Run: bread sync init"); + std::process::exit(1); + } + SyncConfig::load() +} + +fn modules_directory() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| anyhow!("cannot determine config directory"))?; + let dir = config_dir.join("bread").join("modules"); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +fn scan_modules(modules_dir: &Path) -> Result> { + let mut out = Vec::new(); + if !modules_dir.exists() { + return Ok(out); + } + for entry in std::fs::read_dir(modules_dir)? { + let entry = entry?; + if !entry.path().is_dir() { + continue; + } + if let Ok(manifest) = load_manifest(&entry.path()) { + out.push(manifest); + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +fn load_manifest(module_dir: &Path) -> Result { + let path = module_dir.join("bread.module.toml"); + let raw = std::fs::read_to_string(&path)?; + Ok(toml::from_str(&raw)?) +} + +fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> { + std::fs::create_dir_all(dest)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let dest_path = dest.join(entry.file_name()); + if entry.path().is_dir() { + copy_dir_all(&entry.path(), &dest_path)?; + } else { + std::fs::copy(entry.path(), dest_path)?; + } + } + Ok(()) +} + +fn tempfile_dir() -> Result { + let tmp = std::env::temp_dir().join(format!("bread-install-{}", std::process::id())); + std::fs::create_dir_all(&tmp)?; + Ok(tmp) +} + +fn hostname_or_unknown() -> String { + std::fs::read_to_string("/etc/hostname") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + +// ─── IPC helpers ────────────────────────────────────────────────────────────── + fn daemon_socket_path() -> PathBuf { if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") { return Path::new(&runtime).join("bread").join("breadd.sock"); @@ -163,6 +942,26 @@ fn daemon_socket_path() -> PathBuf { PathBuf::from("/tmp/bread/breadd.sock") } +/// Like `send_request` but prints a nice message and exits 1 if daemon is unreachable. +async fn send_request_or_die(socket: &Path, method: &str, params: Value) -> Result { + match send_request(socket, method, params).await { + Ok(v) => Ok(v), + Err(err) => { + let msg = err.to_string(); + if msg.contains("No such file") + || msg.contains("Connection refused") + || msg.contains("not found") + { + eprintln!( + "bread: daemon is not running. Start it with: systemctl --user start breadd" + ); + std::process::exit(1); + } + Err(err) + } + } +} + async fn send_request(socket: &Path, method: &str, params: Value) -> Result { let stream = UnixStream::connect(socket).await?; let (read_half, mut write_half) = stream.into_split(); @@ -195,7 +994,8 @@ async fn stream_events( since: Option, ) -> Result<()> { if let Some(seconds) = since { - let replay = send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?; + 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 { @@ -212,9 +1012,7 @@ async fn stream_events( let request = json!({ "id": "1", "method": "events.subscribe", - "params": { - "filter": filter, - }, + "params": { "filter": filter }, }); write_half @@ -230,10 +1028,11 @@ async fn stream_events( print_event(&value, fields.as_deref()); } } - Ok(()) } +// ─── Display helpers ────────────────────────────────────────────────────────── + fn print_json(value: &Value) -> Result<()> { println!("{}", serde_json::to_string_pretty(value)?); Ok(()) @@ -297,15 +1096,11 @@ 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 + tm.tm_hour as u64 * 3600 + tm.tm_min as u64 * 60 + tm.tm_sec as u64 }; let h = (local_secs / 3600) % 24; @@ -345,16 +1140,11 @@ async fn watch_reload(socket: &Path) -> Result<()> { if msg.is_err() { continue; } - - // Debounce: drain any follow-up events that arrive within 150ms. - // A single file save typically generates 2-3 fs events in rapid succession. tokio::time::sleep(Duration::from_millis(150)).await; while rx.try_recv().is_ok() {} - - let response = send_request(socket, "modules.reload", json!({})).await?; + let response = send_request_or_die(socket, "modules.reload", json!({})).await?; print_reload(&response); } - Ok(()) } @@ -387,7 +1177,11 @@ fn render_doctor(health: &Value) { 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!( + " daemon {} (pid {})", + if ok { "✓ running" } else { "✗ unreachable" }, + pid + ); println!(" version {version}"); println!(" uptime {}s", uptime_ms / 1000); println!(" socket {socket}"); @@ -458,11 +1252,7 @@ async fn send_request_with_stream( } 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") + dirs::config_dir() + .map(|d| d.join("bread")) + .unwrap_or_else(|| PathBuf::from(".config/bread")) } diff --git a/bread-sync/Cargo.toml b/bread-sync/Cargo.toml new file mode 100644 index 0000000..c4860dc --- /dev/null +++ b/bread-sync/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "bread-sync" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +toml = "0.8" +chrono = { version = "0.4", features = ["serde"] } +dirs = "5.0" +git2 = { version = "0.18", features = ["vendored-libgit2"] } +reqwest = { version = "0.11", features = ["blocking", "json"] } +flate2 = "1.0" +tar = "0.4" + +[dev-dependencies] +tempfile = "3.13" diff --git a/bread-sync/README.md b/bread-sync/README.md new file mode 100644 index 0000000..079b8d6 --- /dev/null +++ b/bread-sync/README.md @@ -0,0 +1,10 @@ +# bread-sync + +Sync and module management library for the Bread reactive desktop automation daemon. + +Provides: +- `SyncConfig` — load/save `~/.config/bread/sync.toml` +- Git backend (via git2) for push/pull of bread config to a remote repository +- Delegate file handling — copy arbitrary config files into the sync repo +- Package manifest generation for pacman/pip/npm/cargo +- Machine profile — name and tags read from sync.toml diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs new file mode 100644 index 0000000..d0b7506 --- /dev/null +++ b/bread-sync/src/config.rs @@ -0,0 +1,124 @@ +use std::path::PathBuf; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +/// Top-level sync configuration stored in `~/.config/bread/sync.toml`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SyncConfig { + #[serde(default)] + pub remote: RemoteConfig, + #[serde(default)] + pub machine: MachineConfig, + #[serde(default)] + pub packages: PackagesConfig, + #[serde(default)] + pub delegates: DelegatesConfig, +} + +/// Git remote configuration. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RemoteConfig { + pub url: Option, + #[serde(default = "default_branch")] + pub branch: String, +} + +fn default_branch() -> String { + "main".to_string() +} + +/// Machine identity — name comes from here, falls back to hostname. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MachineConfig { + pub name: Option, + #[serde(default)] + pub tags: Vec, +} + +/// Which package managers to snapshot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackagesConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_managers")] + pub managers: Vec, +} + +impl Default for PackagesConfig { + fn default() -> Self { + Self { + enabled: true, + managers: default_managers(), + } + } +} + +fn default_true() -> bool { + true +} + +fn default_managers() -> Vec { + vec!["pacman".to_string(), "pip".to_string(), "npm".to_string()] +} + +/// Config file delegation — which extra paths to include in the sync repo. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DelegatesConfig { + /// Absolute or `~`-prefixed paths to copy into `configs//`. + #[serde(default)] + pub include: Vec, + /// Glob patterns to exclude when copying. + #[serde(default)] + pub exclude: Vec, +} + +impl SyncConfig { + /// Load from `~/.config/bread/sync.toml`, returning `Default` if not present. + pub fn load() -> Result { + let path = config_path()?; + if !path.exists() { + return Ok(Self::default()); + } + let raw = std::fs::read_to_string(&path)?; + let cfg: Self = toml::from_str(&raw)?; + Ok(cfg) + } + + /// Write to `~/.config/bread/sync.toml`, creating parent dirs as needed. + pub fn save(&self) -> Result<()> { + let path = config_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let raw = toml::to_string_pretty(self)?; + std::fs::write(&path, raw)?; + Ok(()) + } + + /// Returns `true` if `~/.config/bread/sync.toml` exists on disk. + pub fn is_initialized() -> Result { + Ok(config_path()?.exists()) + } +} + +/// Path to `~/.config/bread/sync.toml`. +pub fn config_path() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?; + Ok(config_dir.join("bread").join("sync.toml")) +} + +/// Path to `~/.local/share/bread/sync-repo/`. +pub fn sync_repo_path() -> Result { + let data_dir = dirs::data_local_dir() + .ok_or_else(|| anyhow::anyhow!("cannot determine data directory"))?; + Ok(data_dir.join("bread").join("sync-repo")) +} + +/// Path to `~/.config/bread/`. +pub fn bread_config_dir() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?; + Ok(config_dir.join("bread")) +} diff --git a/bread-sync/src/delegates.rs b/bread-sync/src/delegates.rs new file mode 100644 index 0000000..aadab3b --- /dev/null +++ b/bread-sync/src/delegates.rs @@ -0,0 +1,205 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use crate::config::DelegatesConfig; + +/// Expand `~` in a path string to the user's home directory. +pub fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + dirs::home_dir() + .map(|h| h.join(rest)) + .unwrap_or_else(|| PathBuf::from(path)) + } else if path == "~" { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(path)) + } else { + PathBuf::from(path) + } +} + +/// Returns `true` if `path` (relative to `base`) matches any of the `exclude` globs. +fn is_excluded(base: &Path, path: &Path, excludes: &[String]) -> bool { + let rel = path.strip_prefix(base).unwrap_or(path); + let rel_str = rel.to_string_lossy(); + for pattern in excludes { + if glob_matches(pattern, &rel_str) { + return true; + } + } + false +} + +/// Copy all files under `src` dir to `dest` dir, honouring `excludes`. +/// Creates `dest` if it doesn't exist. Deletes files in `dest` that are +/// absent in `src` (rsync `--delete` behaviour). +pub fn sync_dir(src: &Path, dest: &Path, excludes: &[String]) -> Result<()> { + std::fs::create_dir_all(dest)?; + copy_recursive(src, src, dest, excludes)?; + delete_extra(src, dest)?; + Ok(()) +} + +fn copy_recursive(root: &Path, src: &Path, dest: &Path, excludes: &[String]) -> Result<()> { + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + + if is_excluded(root, &src_path, excludes) { + continue; + } + + let file_name = entry.file_name(); + let dest_path = dest.join(&file_name); + + if src_path.is_dir() { + std::fs::create_dir_all(&dest_path)?; + copy_recursive(root, &src_path, &dest_path, excludes)?; + } else { + std::fs::copy(&src_path, &dest_path)?; + } + } + Ok(()) +} + +/// Remove files/dirs from `dest` that don't exist in `src`. +fn delete_extra(src: &Path, dest: &Path) -> Result<()> { + if !dest.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(dest)? { + let entry = entry?; + let dest_path = entry.path(); + let file_name = entry.file_name(); + let src_path = src.join(&file_name); + if !src_path.exists() { + if dest_path.is_dir() { + std::fs::remove_dir_all(&dest_path)?; + } else { + std::fs::remove_file(&dest_path)?; + } + } + } + Ok(()) +} + +/// Copy each `include` path into `/configs//`. +pub fn copy_delegates_to_repo( + cfg: &DelegatesConfig, + repo_root: &Path, +) -> Result<()> { + let configs_dir = repo_root.join("configs"); + std::fs::create_dir_all(&configs_dir)?; + + for raw_path in &cfg.include { + let src = expand_tilde(raw_path); + if !src.exists() { + tracing_warn(&format!( + "delegate path does not exist, skipping: {}", + src.display() + )); + continue; + } + let basename = src + .file_name() + .ok_or_else(|| anyhow::anyhow!("delegate path has no filename: {}", src.display()))?; + let dest = configs_dir.join(basename); + if src.is_dir() { + sync_dir(&src, &dest, &cfg.exclude)?; + } else { + std::fs::copy(&src, &dest)?; + } + } + Ok(()) +} + +/// Restore each delegate path from `/configs//` to its original location. +pub fn restore_delegates_from_repo( + cfg: &DelegatesConfig, + repo_root: &Path, +) -> Result<()> { + let configs_dir = repo_root.join("configs"); + + for raw_path in &cfg.include { + let dest = expand_tilde(raw_path); + let basename = match dest.file_name() { + Some(n) => n.to_os_string(), + None => continue, + }; + let src = configs_dir.join(&basename); + if !src.exists() { + continue; + } + if src.is_dir() { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + sync_dir(&src, &dest, &[])?; + } else { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&src, &dest)?; + } + } + Ok(()) +} + +/// Simple glob match for `**` and `*` patterns against a path string. +fn glob_matches(pattern: &str, path: &str) -> bool { + glob_match_bytes(pattern.as_bytes(), path.as_bytes()) +} + +fn glob_match_bytes(pattern: &[u8], text: &[u8]) -> bool { + if pattern.is_empty() { + return text.is_empty(); + } + + // `**` matches any sequence including path separators + if pattern.starts_with(b"**") { + let rest = &pattern[2..]; + if rest.is_empty() { + return true; + } + // skip leading separator in rest + let rest = if rest.starts_with(b"/") { &rest[1..] } else { rest }; + for offset in 0..=text.len() { + if glob_match_bytes(rest, &text[offset..]) { + return true; + } + } + return false; + } + + match pattern[0] { + b'*' => { + let mut offset = 0; + loop { + if glob_match_bytes(&pattern[1..], &text[offset..]) { + return true; + } + if offset == text.len() { + break; + } + offset += 1; + } + false + } + b'?' => { + if text.is_empty() { + return false; + } + glob_match_bytes(&pattern[1..], &text[1..]) + } + ch => { + if text.first().copied() != Some(ch) { + return false; + } + glob_match_bytes(&pattern[1..], &text[1..]) + } + } +} + +fn tracing_warn(msg: &str) { + // Use eprintln since tracing may not be configured in library context + eprintln!("warn: {msg}"); +} diff --git a/bread-sync/src/git.rs b/bread-sync/src/git.rs new file mode 100644 index 0000000..581efbc --- /dev/null +++ b/bread-sync/src/git.rs @@ -0,0 +1,227 @@ +use std::path::Path; + +use anyhow::{anyhow, Result}; + +/// Open an existing repo or initialise a new one at `path`. +pub fn init_or_open(path: &Path) -> Result { + if path.join(".git").exists() || is_bare(path) { + Ok(git2::Repository::open(path)?) + } else { + std::fs::create_dir_all(path)?; + Ok(git2::Repository::init(path)?) + } +} + +/// Clone `url` to `path` if `path` is not already a repo, otherwise open it. +pub fn clone_or_open(url: &str, path: &Path) -> Result { + if path.join(".git").exists() || is_bare(path) { + return Ok(git2::Repository::open(path)?); + } + let mut builder = git2::build::RepoBuilder::new(); + let mut fetch_opts = git2::FetchOptions::new(); + fetch_opts.remote_callbacks(make_callbacks()); + builder.fetch_options(fetch_opts); + std::fs::create_dir_all(path)?; + Ok(builder.clone(url, path)?) +} + +/// Stage every tracked and untracked change (equivalent to `git add -A`). +pub fn stage_all(repo: &git2::Repository) -> Result<()> { + let mut index = repo.index()?; + index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?; + // Remove entries for deleted files + index.update_all(["*"].iter(), None)?; + index.write()?; + Ok(()) +} + +/// Returns `true` if the index has staged changes compared to HEAD (or repo is new). +pub fn has_changes(repo: &git2::Repository) -> Result { + let mut index = repo.index()?; + index.read(false)?; + + // New repo with no commits yet + if repo.head().is_err() { + return Ok(index.len() > 0); + } + + let head = repo.head()?.peel_to_tree()?; + let diff = repo.diff_tree_to_index(Some(&head), Some(&index), None)?; + Ok(diff.deltas().count() > 0) +} + +/// Commit all staged changes with `message`. Returns the new commit OID. +pub fn commit(repo: &git2::Repository, message: &str) -> Result { + let mut index = repo.index()?; + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + let sig = repo.signature().unwrap_or_else(|_| { + git2::Signature::now("bread", "bread@localhost").expect("signature") + }); + + let oid = if let Ok(head) = repo.head() { + let parent = head.peel_to_commit()?; + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])? + } else { + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])? + }; + Ok(oid) +} + +/// Push `branch` to `remote_name` (defaults to "origin"). +pub fn push(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> { + let mut remote = repo.find_remote(remote_name)?; + let mut opts = git2::PushOptions::new(); + opts.remote_callbacks(make_callbacks()); + remote.push( + &[&format!("refs/heads/{branch}:refs/heads/{branch}")], + Some(&mut opts), + )?; + Ok(()) +} + +/// Fetch from `remote_name` without merging. +pub fn fetch(repo: &git2::Repository, remote_name: &str) -> Result<()> { + let mut remote = repo.find_remote(remote_name)?; + let mut opts = git2::FetchOptions::new(); + opts.remote_callbacks(make_callbacks()); + remote.fetch(&[] as &[&str], Some(&mut opts), None)?; + Ok(()) +} + +/// Fetch and fast-forward merge from `remote_name/branch`. Errors on conflict. +pub fn pull(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> { + fetch(repo, remote_name)?; + + let fetch_head = repo + .find_reference(&format!("refs/remotes/{remote_name}/{branch}")) + .map_err(|_| anyhow!("remote branch {remote_name}/{branch} not found after fetch"))?; + let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?; + + let analysis = repo.merge_analysis(&[&fetch_commit])?; + if analysis.0.is_up_to_date() { + return Ok(()); + } + if !analysis.0.is_fast_forward() { + return Err(anyhow!( + "sync conflict — resolve manually in {}", + repo.workdir() + .unwrap_or_else(|| Path::new("?")) + .display() + )); + } + + // Fast-forward: update HEAD and checkout + let head_ref = repo.find_reference("HEAD")?; + let resolved = head_ref.resolve()?; + let refname = resolved.name().unwrap_or("HEAD").to_string(); + repo.find_reference(&refname)? + .set_target(fetch_commit.id(), "fast-forward")?; + repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?; + Ok(()) +} + +/// Add a remote named `name` pointing at `url`, or update it if it already exists. +pub fn set_remote(repo: &git2::Repository, name: &str, url: &str) -> Result<()> { + if repo.find_remote(name).is_ok() { + repo.remote_set_url(name, url)?; + } else { + repo.remote(name, url)?; + } + Ok(()) +} + +/// Return working-tree diff against HEAD as a unified diff string. +pub fn diff_workdir(repo: &git2::Repository) -> Result { + let mut buf = Vec::new(); + if let Ok(head_tree) = repo.head().and_then(|h| h.peel_to_tree()) { + let diff = repo.diff_tree_to_workdir_with_index(Some(&head_tree), None)?; + diff.print(git2::DiffFormat::Patch, |_, _, line| { + buf.extend_from_slice(line.content()); + true + })?; + } + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +/// Return diff between HEAD and `remote/branch` as a unified diff string. +pub fn diff_remote(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result { + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let remote_tree = repo + .find_reference(&remote_ref) + .map_err(|_| anyhow!("remote ref {remote_ref} not found — try fetching first"))? + .peel_to_tree()?; + let local_tree = repo.head()?.peel_to_tree()?; + let diff = repo.diff_tree_to_tree(Some(&local_tree), Some(&remote_tree), None)?; + let mut buf = Vec::new(); + diff.print(git2::DiffFormat::Patch, |_, _, line| { + buf.extend_from_slice(line.content()); + true + })?; + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +/// Return a list of `(status_char, path)` for the working tree. +pub fn status_lines(repo: &git2::Repository) -> Result> { + let statuses = repo.statuses(None)?; + let mut out = Vec::new(); + for entry in statuses.iter() { + let path = entry.path().unwrap_or("?").to_string(); + let flag = entry.status(); + let ch = if flag.contains(git2::Status::INDEX_NEW) || flag.contains(git2::Status::WT_NEW) { + 'A' + } else if flag.contains(git2::Status::INDEX_DELETED) || flag.contains(git2::Status::WT_DELETED) { + 'D' + } else { + 'M' + }; + out.push((ch, path)); + } + Ok(out) +} + +/// Returns true if the local HEAD is behind the remote. +pub fn remote_has_changes(repo: &git2::Repository, remote_name: &str, branch: &str) -> bool { + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let Ok(remote_ref) = repo.find_reference(&remote_ref) else { + return false; + }; + let Ok(remote_commit) = remote_ref.peel_to_commit() else { + return false; + }; + let Ok(local_commit) = repo.head().and_then(|h| h.peel_to_commit()) else { + return false; + }; + remote_commit.id() != local_commit.id() +} + +/// Timestamp of the HEAD commit (or "never"). +pub fn last_commit_time(repo: &git2::Repository) -> String { + let Ok(commit) = repo.head().and_then(|h| h.peel_to_commit()) else { + return "never".to_string(); + }; + let ts = commit.time().seconds(); + let dt = chrono::DateTime::::from_timestamp(ts, 0) + .unwrap_or_else(chrono::Utc::now); + dt.format("%Y-%m-%d %H:%M:%S").to_string() +} + +fn is_bare(path: &Path) -> bool { + path.join("HEAD").exists() && path.join("objects").exists() +} + +fn make_callbacks<'a>() -> git2::RemoteCallbacks<'a> { + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.credentials(|_url, username_from_url, allowed_types| { + if allowed_types.contains(git2::CredentialType::SSH_KEY) { + git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) + } else if allowed_types.contains(git2::CredentialType::DEFAULT) { + git2::Cred::default() + } else { + Err(git2::Error::from_str( + "no supported credential type (SSH agent or default)", + )) + } + }); + callbacks +} diff --git a/bread-sync/src/lib.rs b/bread-sync/src/lib.rs new file mode 100644 index 0000000..454a78a --- /dev/null +++ b/bread-sync/src/lib.rs @@ -0,0 +1,10 @@ +pub mod config; +pub mod delegates; +pub mod git; +pub mod machine; +pub mod packages; + +pub use config::{ + bread_config_dir, config_path, sync_repo_path, DelegatesConfig, MachineConfig, PackagesConfig, + RemoteConfig, SyncConfig, +}; diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs new file mode 100644 index 0000000..e4e4bb1 --- /dev/null +++ b/bread-sync/src/machine.rs @@ -0,0 +1,102 @@ +use std::path::Path; + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::config::SyncConfig; + +/// Machine profile persisted to `/machines/.toml`. +#[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 { + pub fn new(cfg: &SyncConfig) -> Result { + let host = hostname()?; + let name = cfg.machine.name.clone().unwrap_or_else(|| host.clone()); + Ok(Self { + name, + hostname: host, + tags: cfg.machine.tags.clone(), + last_sync: Utc::now().to_rfc3339(), + }) + } + + /// Write profile to `/machines/.toml`. + pub fn write_to_repo(&self, repo_root: &Path) -> Result<()> { + let machines_dir = repo_root.join("machines"); + std::fs::create_dir_all(&machines_dir)?; + let path = machines_dir.join(format!("{}.toml", self.name)); + let raw = toml::to_string_pretty(self)?; + std::fs::write(&path, raw)?; + Ok(()) + } + + /// Load from `/machines/.toml`. + pub fn load_from_repo(repo_root: &Path, name: &str) -> Result { + let path = repo_root.join("machines").join(format!("{name}.toml")); + let raw = std::fs::read_to_string(&path)?; + Ok(toml::from_str(&raw)?) + } +} + +/// List all machine profiles in `/machines/`. +pub fn list_machines(repo_root: &Path) -> Vec { + let machines_dir = repo_root.join("machines"); + let Ok(entries) = std::fs::read_dir(&machines_dir) else { + return Vec::new(); + }; + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("toml")) + .filter_map(|e| { + std::fs::read_to_string(e.path()) + .ok() + .and_then(|raw| toml::from_str::(&raw).ok()) + }) + .collect() +} + +/// Returns the machine name from sync.toml, falling back to hostname. +pub fn machine_name(cfg: &SyncConfig) -> Result { + if let Some(name) = cfg.machine.name.as_deref() { + return Ok(name.to_string()); + } + hostname() +} + +/// Returns the machine tags from sync.toml. +pub fn machine_tags(cfg: &SyncConfig) -> Vec { + cfg.machine.tags.clone() +} + +/// Returns true if `tag` is in the machine's tag list. +pub fn machine_has_tag(cfg: &SyncConfig, tag: &str) -> bool { + cfg.machine.tags.iter().any(|t| t == tag) +} + +fn hostname() -> Result { + // Try /etc/hostname first (no subprocess) + if let Ok(raw) = std::fs::read_to_string("/etc/hostname") { + let trimmed = raw.trim().to_string(); + if !trimmed.is_empty() { + return Ok(trimmed); + } + } + // Fall back to hostname(1) + let out = std::process::Command::new("hostname") + .output() + .map_err(anyhow::Error::from)?; + let s = String::from_utf8(out.stdout).map_err(anyhow::Error::from)?; + Ok(s.trim().to_string()) +} + +#[allow(dead_code)] +fn format_last_sync(dt: &DateTime) -> String { + dt.format("%Y-%m-%d %H:%M").to_string() +} diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs new file mode 100644 index 0000000..333e0aa --- /dev/null +++ b/bread-sync/src/packages.rs @@ -0,0 +1,137 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::Result; + +/// Write package manifests to `/packages/`. +/// Skips package managers that are not installed (warns instead of erroring). +pub fn snapshot_packages(managers: &[String], repo_root: &Path) -> Result<()> { + let pkg_dir = repo_root.join("packages"); + std::fs::create_dir_all(&pkg_dir)?; + + for mgr in managers { + match mgr.as_str() { + "pacman" => { + if let Some(content) = run_pacman() { + std::fs::write(pkg_dir.join("pacman.txt"), content)?; + } else { + eprintln!("warn: pacman not found, skipping package snapshot"); + } + } + "pip" => { + if let Some(content) = run_pip() { + std::fs::write(pkg_dir.join("pip.txt"), content)?; + } else { + eprintln!("warn: pip not found, skipping package snapshot"); + } + } + "npm" => { + if let Some(content) = run_npm() { + std::fs::write(pkg_dir.join("npm.txt"), content)?; + } else { + eprintln!("warn: npm not found, skipping package snapshot"); + } + } + "cargo" => { + if let Some(content) = run_cargo() { + std::fs::write(pkg_dir.join("cargo.txt"), content)?; + } else { + eprintln!("warn: cargo not found, skipping package snapshot"); + } + } + other => { + eprintln!("warn: unknown package manager '{other}', skipping"); + } + } + } + Ok(()) +} + +/// Parse a `pacman.txt` snapshot into a list of package names. +pub fn parse_pacman(content: &str) -> Vec { + content.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect() +} + +/// Parse a `pip.txt` (freeze format) snapshot into package names. +pub fn parse_pip(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .filter_map(|l| l.split("==").next().map(|s| s.trim().to_string())) + .collect() +} + +/// Parse an `npm.txt` (parseable) snapshot into package names. +pub fn parse_npm(content: &str) -> Vec { + content + .lines() + .skip(1) // first line is the npm global prefix path + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| { + Path::new(l.trim()) + .file_name() + .and_then(|n| n.to_str()) + .map(ToString::to_string) + }) + .collect() +} + +/// Parse `cargo install --list` output into `name version` lines. +pub fn parse_cargo(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.starts_with(' ') && !l.trim().is_empty()) + .filter_map(|l| { + // Format: `name v1.2.3 (...):` or `name v1.2.3:` + let parts: Vec<&str> = l.splitn(2, ' ').collect(); + if parts.len() == 2 { + let name = parts[0]; + let version = parts[1].trim_start_matches('v').split_whitespace().next().unwrap_or("?").trim_end_matches(':'); + Some(format!("{name} {version}")) + } else { + None + } + }) + .collect() +} + +fn run_pacman() -> Option { + let output = Command::new("pacman").args(["-Qe"]).output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + +fn run_pip() -> Option { + let output = Command::new("pip") + .args(["list", "--user", "--format=freeze"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + +fn run_npm() -> Option { + let output = Command::new("npm") + .args(["list", "-g", "--depth=0", "--parseable"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + +fn run_cargo() -> Option { + let output = Command::new("cargo") + .args(["install", "--list"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} diff --git a/bread-sync/tests/sync.rs b/bread-sync/tests/sync.rs new file mode 100644 index 0000000..ce76abf --- /dev/null +++ b/bread-sync/tests/sync.rs @@ -0,0 +1 @@ +// Placeholder - tests will be added in step 15 diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index ade7d2f..36189a0 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -22,20 +22,25 @@ 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 payloads = match enumerate_with_udev(&self.subsystems) { + Ok(p) => p, + Err(_) => scan_devices(&self.subsystems) + .unwrap_or_default() + .into_iter() + .map(|d| json!({ + "action": "add", + "id": d.id, + "name": d.name, + "subsystem": d.subsystem, + })) + .collect(), + }; - for device in devices { + for payload in payloads { tx.send(RawEvent { source: AdapterSource::Udev, kind: "udev.enumerate".to_string(), - payload: json!({ - "action": "add", - "id": device.id, - "name": device.name, - "subsystem": device.subsystem, - }), + payload, timestamp: now_unix_ms(), }) .await?; @@ -164,7 +169,7 @@ async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) - Ok(()) } -fn enumerate_with_udev(subsystems: &[String]) -> Result> { +fn enumerate_with_udev(subsystems: &[String]) -> Result> { let mut enumerator = udev::Enumerator::new()?; for subsystem in subsystems { enumerator.match_subsystem(subsystem)?; @@ -184,16 +189,38 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result> { .unwrap_or_else(|| "unknown".to_string()); let id = dev.syspath().to_string_lossy().to_string(); - out.push(ScannedDevice { - id, - name, - subsystem, - }); + out.push(json!({ + "action": "add", + "id": id, + "name": name, + "subsystem": subsystem, + "id_input_keyboard": dev_prop_bool(&dev, "ID_INPUT_KEYBOARD"), + "id_input_mouse": dev_prop_bool(&dev, "ID_INPUT_MOUSE"), + "id_input_joystick": dev_prop_bool(&dev, "ID_INPUT_JOYSTICK"), + "id_input_touchpad": dev_prop_bool(&dev, "ID_INPUT_TOUCHPAD"), + "id_input_tablet": dev_prop_bool(&dev, "ID_INPUT_TABLET"), + "id_usb_class": dev_prop_str(&dev, "ID_USB_CLASS"), + "id_usb_interfaces": dev_prop_str(&dev, "ID_USB_INTERFACES"), + "id_vendor": dev_prop_str(&dev, "ID_VENDOR"), + "id_model": dev_prop_str(&dev, "ID_MODEL"), + })); } Ok(out) } +fn dev_prop_bool(dev: &udev::Device, key: &str) -> bool { + dev.property_value(key) + .and_then(|v| v.to_str()) + .map(|v| v == "1") + .unwrap_or(false) +} + +fn dev_prop_str(dev: &udev::Device, key: &str) -> Option { + dev.property_value(key) + .map(|v| v.to_string_lossy().to_string()) +} + fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent { RawEvent { source: AdapterSource::Udev, From 5adcfb3854ee31379fb3925004c60bbcbbcaec81 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 20:56:10 +0800 Subject: [PATCH 14/76] Begin Implementing V2 features --- .github/workflows/ci.yml | 42 -- .gitignore | 3 +- CLAUDE_SPEC.md | 604 +++++++++++++++++++ Cargo.lock | 1090 ++++++++++++++++++++++++++++++++++- Cargo.toml | 3 +- README.md | 7 +- bread-cli/Cargo.toml | 7 + bread-cli/src/main.rs | 904 +++++++++++++++++++++++++++-- bread-sync/Cargo.toml | 19 + bread-sync/README.md | 10 + bread-sync/src/config.rs | 124 ++++ bread-sync/src/delegates.rs | 205 +++++++ bread-sync/src/git.rs | 227 ++++++++ bread-sync/src/lib.rs | 10 + bread-sync/src/machine.rs | 102 ++++ bread-sync/src/packages.rs | 137 +++++ bread-sync/tests/sync.rs | 1 + breadd/src/adapters/udev.rs | 59 +- 18 files changed, 3433 insertions(+), 121 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 CLAUDE_SPEC.md create mode 100644 bread-sync/Cargo.toml create mode 100644 bread-sync/README.md create mode 100644 bread-sync/src/config.rs create mode 100644 bread-sync/src/delegates.rs create mode 100644 bread-sync/src/git.rs create mode 100644 bread-sync/src/lib.rs create mode 100644 bread-sync/src/machine.rs create mode 100644 bread-sync/src/packages.rs create mode 100644 bread-sync/tests/sync.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 7409b04..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,42 +0,0 @@ -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 9472698..6902b11 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ Overview.md DAEMON.md .claude CLAUDE.md -.github/ \ No newline at end of file +.github/workflows/ci.yml +.github \ No newline at end of file diff --git a/CLAUDE_SPEC.md b/CLAUDE_SPEC.md new file mode 100644 index 0000000..2a2d1df --- /dev/null +++ b/CLAUDE_SPEC.md @@ -0,0 +1,604 @@ +# Bread — Sync & Module System Implementation Spec +### Instructions for Claude Code + +This document defines exactly what to build, how it must behave, and what conditions must be met before iteration stops. Read it fully before writing any code. Do not stop iterating until every condition in the **Completion Checklist** at the bottom is met. + +--- + +## Context + +Bread is a reactive desktop automation daemon for Linux. The existing codebase is a Rust workspace with three crates: + +- `breadd/` — the runtime daemon (Rust + Lua via mlua) +- `bread-cli/` — the CLI binary (Rust, talks to daemon over Unix socket IPC) +- `bread-shared/` — shared types (`BreadEvent`, `RawEvent`, `AdapterSource`) + +The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The IPC protocol is newline-delimited JSON request/response. The Lua runtime runs on a dedicated OS thread. All existing code compiles and tests pass — do not break anything that currently works. + +The two things being added in this iteration: + +1. **Module system** — install, list, remove, and update Lua modules from GitHub URLs +2. **Sync** — snapshot and restore system state (Bread config + arbitrary config files + package manifests) via a Git remote + +--- + +## Part 1: Module System + +### What a module is + +A Bread module is a directory (or single `.lua` file) that gets installed into `~/.config/bread/modules/`. Modules are already loaded by the daemon — what's missing is the install/manage layer. + +A module directory looks like: + +``` +~/.config/bread/modules/ +└── wifi/ + ├── bread.module.toml ← module manifest (required) + ├── init.lua ← entry point (required) + └── lib/ ← optional support files +``` + +### Module manifest (`bread.module.toml`) + +Every installed module must have a manifest: + +```toml +name = "wifi" +version = "1.0.0" +description = "WiFi management for Bread" +author = "someuser" +source = "github:someuser/bread-wifi" # where it was installed from +installed_at = "2026-05-11T09:00:00Z" # RFC 3339 timestamp, set on install +``` + +All fields are required. `source` is the original install source string. `installed_at` is written by Bread at install time, not by the module author. + +### Install sources + +The module installer must support these source formats: + +``` +github:user/repo # installs default branch +github:user/repo@v1.2.0 # installs specific tag +github:user/repo@abc1234 # installs specific commit +/path/to/local/dir # installs from local directory (copies it) +``` + +Anything else is an error with a clear message. + +### New Cargo dependencies allowed + +Add to `bread-cli/Cargo.toml` as needed: +- `git2 = "0.18"` for Git operations +- `reqwest = { version = "0.11", features = ["blocking", "json"] }` for GitHub API +- `flate2`, `tar` for archive extraction + +Add to `breadd/Cargo.toml` as needed: +- `git2 = "0.18"` +- `toml = "0.8"` (already present) + +### CLI commands to implement + +All module commands live under `bread modules`: + +``` +bread modules install Install a module +bread modules remove Remove an installed module +bread modules list List installed modules with name, version, status +bread modules update Update all installed modules to latest +bread modules update Update a specific module +bread modules info Show full manifest details for a module +``` + +**`bread modules install `** + +1. Parse the source string. +2. For `github:user/repo[@ref]`: + - Use the GitHub API to resolve the ref (or default branch if none specified). + - Download the repository archive as a `.tar.gz`. + - Extract to a temp directory. + - Verify a `bread.module.toml` exists at the root. If not, error cleanly. + - Copy the module directory to `~/.config/bread/modules//`. + - Write `installed_at` into the manifest. +3. For local paths: + - Verify the path exists and contains `bread.module.toml`. + - Copy to `~/.config/bread/modules//`. + - Write `installed_at`. +4. Print `installed v` on success. +5. Tell the daemon to reload via IPC (`modules.reload`) after install. + +**`bread modules remove `** + +1. Find `~/.config/bread/modules//`. +2. Ask for confirmation: `remove ? (y/n)`. Skip if `--yes` flag is passed. +3. Delete the directory. +4. Tell the daemon to reload via IPC. +5. Print `removed `. + +**`bread modules list`** + +Scan `~/.config/bread/modules/` for directories containing `bread.module.toml`. For each, print: + +``` + wifi 1.0.0 loaded github:someuser/bread-wifi + redox 0.3.1 loaded github:breadway/bread-redox + broken-mod 0.1.0 error /home/user/local-module +``` + +Status (`loaded`, `error`, `not_found`, `degraded`) comes from the daemon's IPC `modules.list` response, matched by module name. If the daemon is unreachable, show `unknown` for status. + +**`bread modules update [name]`** + +1. Read `bread.module.toml` for each module to update. +2. If `source` starts with `github:`, re-run the install for that source. +3. If `source` is a local path, error with `cannot update local module — reinstall manually`. +4. Print `updated v → v` or ` already up to date`. + +**`bread modules info `** + +Print full manifest contents plus daemon-reported status. Example: + +``` +name: wifi +version: 1.0.0 +description: WiFi management for Bread +author: someuser +source: github:someuser/bread-wifi +installed_at: 2026-05-11T09:00:00Z +status: loaded +``` + +### Daemon-side: expose `ID_VENDOR_ID` and `ID_MODEL_ID` in udev events + +In `breadd/src/adapters/udev.rs`, the `run_udev_monitor` function builds the payload for each udev event. Add `vendor_id` and `product_id` to the payload: + +```rust +"vendor_id": prop_str(&event, "ID_VENDOR_ID"), +"product_id": prop_str(&event, "ID_MODEL_ID"), +``` + +These are the raw hex USB IDs (e.g. `"4d44"` and `"5244"`). Do the same in `raw_change_event` for the fallback poller — read them from sysfs at `/idVendor` and `/idProduct` if available. Also add `vendor_id` and `product_id` to the `Device` struct in `breadd/src/core/types.rs` as `Option`. + +--- + +## Part 2: Sync System + +### Overview + +Sync saves and restores a complete description of the user's environment. It is not a disk image. It saves: + +1. **Bread config** — everything in `~/.config/bread/` (always included) +2. **Delegated configs** — other config directories the user explicitly opts in (e.g. `~/.config/nvim/`) +3. **Package manifest** — lists of explicitly-installed packages per package manager +4. **Machine profile** — machine name and tags for machine-aware config + +Everything is stored in a Git repository. `bread sync push` commits and pushes. `bread sync pull` pulls and applies. + +### New crate: `bread-sync` + +Create a new crate `bread-sync/` in the workspace. Add it to `[workspace.members]` in the root `Cargo.toml`. + +``` +bread-sync/ +├── Cargo.toml +└── src/ + ├── lib.rs + ├── config.rs ← SyncConfig type, load/save + ├── git.rs ← Git operations via git2 + ├── packages.rs ← Package manifest generation + ├── delegates.rs ← Config file delegation + └── machine.rs ← Machine profile +``` + +`bread-cli` depends on `bread-sync`. `breadd` does not — sync is a CLI-only feature. + +### Sync configuration (`~/.config/bread/sync.toml`) + +This file is created by `bread sync init` and edited by the user. It is committed to the sync repo. + +```toml +[remote] +url = "git@github.com:user/bread-sync.git" # required, set by bread sync init +branch = "main" # default: "main" + +[machine] +name = "laptop" # required, set by bread sync init +tags = ["mobile", "battery", "single-monitor"] # user-defined, optional + +[packages] +enabled = true +managers = ["pacman", "pip", "npm"] # which package managers to snapshot + +[delegates] +# Additional config directories to include in sync. +# ~/.config/bread/ is always included and does not need to be listed here. +include = [ + "~/.config/nvim", + "~/.config/fish", + "~/.config/kitty", +] +exclude = [ + "**/.git", + "**/node_modules", + "**/__pycache__", + "**/*.log", + "**/*.cache", + "~/.config/nvim/.repro", +] +``` + +All paths support `~` expansion. Globs in `exclude` use standard glob syntax. + +### Sync repo layout + +The Git repository managed by Bread has this structure: + +``` +/ +├── bread/ ← copy of ~/.config/bread/ (minus sync.toml secrets if any) +├── configs/ +│ ├── nvim/ ← copy of ~/.config/nvim/ +│ ├── fish/ ← copy of ~/.config/fish/ +│ └── kitty/ ← copy of ~/.config/kitty/ +├── packages/ +│ ├── pacman.txt ← output of `pacman -Qe` +│ ├── pip.txt ← output of `pip list --user --format=freeze` +│ └── npm.txt ← output of `npm list -g --depth=0` +├── machines/ +│ └── laptop.toml ← machine profile for this machine +└── .bread-sync ← sync metadata (not committed to Git) +``` + +`machines/.toml` contains: + +```toml +name = "laptop" +hostname = "breadway-laptop" # auto-detected via gethostname +tags = ["mobile", "battery", "single-monitor"] +last_sync = "2026-05-11T09:15:00Z" +``` + +### CLI commands to implement + +All sync commands live under `bread sync`: + +``` +bread sync init [--remote ] Initialize sync for this machine +bread sync push [--message ] Snapshot and push current state +bread sync pull Pull and apply latest state +bread sync status Show what has changed since last push +bread sync diff Show file-level diff vs remote +bread sync machines List known machines from sync repo +``` + +**`bread sync init [--remote ]`** + +1. Check if `~/.config/bread/sync.toml` already exists. If so, error: `sync already initialized. Edit ~/.config/bread/sync.toml to reconfigure.` +2. If `--remote` is not provided, prompt: `Sync remote URL (git remote or path): `. +3. Prompt: `Machine name [laptop]: ` (default: hostname). +4. Prompt: `Machine tags (comma-separated, e.g. mobile,battery): `. +5. Create `~/.config/bread/sync.toml` with the provided values. +6. If the remote is a URL (not a local path), check if the repo exists: + - If it exists, clone it to a temp location and verify it looks like a Bread sync repo (has a `bread/` directory or is empty). + - If it doesn't exist, print: `remote does not exist yet — it will be created on first push`. +7. Print setup summary. + +**`bread sync push [--message ]`** + +1. Load `~/.config/bread/sync.toml`. Error if not initialized. +2. Resolve the local sync repo path (`~/.local/share/bread/sync-repo/`). Clone from remote if it doesn't exist locally. +3. Snapshot each section: + - Copy `~/.config/bread/` → `/bread/` (rsync-style: delete files in dest that don't exist in source) + - For each path in `delegates.include`: copy to `/configs//` + - If `packages.enabled`: run package manager queries and write to `/packages/` + - Write `/machines/.toml` +4. Stage all changes (`git add -A`). +5. If there are no changes, print `nothing to push — already up to date` and exit. +6. Commit with message: `sync: ` or the user-provided `--message`. +7. Push to remote. +8. Print a summary of what was snapshotted. + +**`bread sync pull`** + +1. Load `~/.config/bread/sync.toml`. Error if not initialized. +2. Pull from remote (fetch + merge or rebase — use merge, simpler). +3. Apply each section in order: + - Copy `/bread/` → `~/.config/bread/` (same rsync-style) + - For each path in `delegates.include` that exists in `/configs/`: copy back + - If `packages.enabled` and `--install-packages` flag is passed: run package installs (see below) +4. Tell the daemon to reload via IPC (`modules.reload`) after applying. +5. Print a summary of what was applied. + +**Package install on pull** (only when `--install-packages` is explicitly passed): + +- `pacman.txt` → `sudo pacman -S --needed $(cat pacman.txt | awk '{print $1}')` +- `pip.txt` → `pip install --user -r pip.txt` +- `npm.txt` → parse package names and run `npm install -g` + +Never run package installs automatically without the flag. Print a note at the end of `pull` if packages differ: `run 'bread sync pull --install-packages' to install missing packages`. + +**`bread sync status`** + +1. Load sync config and local repo. +2. Pull remote refs without merging (fetch only). +3. Compare working tree to last commit and compare last commit to remote HEAD. +4. Print: + +``` +bread sync status + machine laptop + remote git@github.com:user/bread-sync.git + last push 2026-05-11 09:15:00 + +local changes (not yet pushed): + M bread/init.lua + A bread/modules/wifi/init.lua + +remote changes (not yet pulled): + none +``` + +**`bread sync diff`** + +Run `git diff HEAD` in the sync repo and print it. If `--remote` flag is passed, run `git diff HEAD..origin/`. + +**`bread sync machines`** + +List all `machines/*.toml` files from the sync repo: + +``` + laptop last sync: 2026-05-11 09:15 tags: mobile, battery, single-monitor + desktop last sync: 2026-05-10 22:00 tags: stationary, multi-monitor, docked +``` + +### Package manager support + +Implement these four. Each must handle the case where the package manager is not installed (skip with a warning, don't error). + +| Manager | Snapshot command | Install command | +|---------|-----------------|-----------------| +| `pacman` | `pacman -Qe` | `sudo pacman -S --needed ` | +| `pip` | `pip list --user --format=freeze` | `pip install --user -r ` | +| `npm` | `npm list -g --depth=0 --parseable` | `npm install -g ` | +| `cargo` | `cargo install --list` | `cargo install ` | + +For `cargo`, the snapshot format is one package per line: ` `. Parse `cargo install --list` output accordingly. + +### Git operations + +Use the `git2` crate for all Git operations. Do not shell out to `git`. Required operations: + +- Clone a remote repo +- Open an existing repo +- Stage all changes (`add -A` equivalent: index all tracked and untracked files) +- Create a commit with a message and the current timestamp as author date +- Push to remote (support SSH and HTTPS — `git2` handles this via callbacks) +- Pull (fetch + merge fast-forward; if non-fast-forward, error with clear message) +- Fetch (without merging) +- Get diff between working tree and HEAD +- Get diff between HEAD and remote branch HEAD + +For SSH auth, use the user's default SSH agent (`git2::transport::smart::SmartSubtransport` with `SshKey` credential). For HTTPS, use the system credential store or prompt for credentials. + +--- + +## Part 3: Daemon additions (IPC) + +Add these IPC methods to `breadd/src/ipc/mod.rs`: + +**`sync.status`** — returns current sync state from `sync.toml` if it exists: +```json +{ "initialized": true, "machine": "laptop", "remote": "git@github.com:..." } +``` +or `{ "initialized": false }` if no sync.toml. + +**`modules.install`** — triggers a reload after external install (already covered by `modules.reload`, no new method needed — `bread modules install` calls `modules.reload` via IPC after installing). + +No other daemon changes are needed for sync — it is entirely CLI-side. + +--- + +## Part 4: Lua API additions + +Add to `breadd/src/lua/mod.rs` in `install_api`: + +**`bread.machine`** table: + +```lua +bread.machine.name() -- returns machine name from sync.toml, or hostname if no sync.toml +bread.machine.tags() -- returns array of tags, or empty array +bread.machine.has_tag("mobile") -- returns bool +``` + +Read `~/.config/bread/sync.toml` directly from Lua (parse it in Rust, expose via the API). If `sync.toml` doesn't exist, `name()` returns `os.getenv("HOSTNAME")` and `tags()` returns `{}`. + +**`bread.fs`** table: + +```lua +bread.fs.write(path, content) -- write string to file, create dirs as needed +bread.fs.read(path) -- read file to string, returns nil if not found +bread.fs.exists(path) -- returns bool +bread.fs.expand(path) -- expand ~ to home directory +``` + +All paths support `~` expansion. `bread.fs.write` creates parent directories automatically. Errors in `write` propagate as Lua errors. + +--- + +## Error handling requirements + +Every command must handle these cases cleanly: + +- Daemon not running: print `bread: daemon is not running. Start it with: systemctl --user start breadd` and exit 1. +- No sync.toml: print `bread: sync not initialized. Run: bread sync init` and exit 1. +- Network unreachable during push/pull: print the error clearly and exit 1. Do not leave the repo in a partial state. +- Module not found during remove/info: print `bread: module '' is not installed` and exit 1. +- Git conflicts on pull: print `bread: sync conflict — resolve manually in ~/.local/share/bread/sync-repo/` and exit 1. Do not auto-merge or discard changes. +- Package manager not installed: warn and skip, do not fail the whole operation. + +--- + +## File locations + +| Purpose | Path | +|---------|------| +| Sync config | `~/.config/bread/sync.toml` | +| Local sync repo | `~/.local/share/bread/sync-repo/` | +| Module manifests | `~/.config/bread/modules//bread.module.toml` | +| Bread config | `~/.config/bread/` | +| Daemon socket | `$XDG_RUNTIME_DIR/bread/breadd.sock` | + +All paths must use `dirs` crate or manual `$HOME`/`$XDG_*` expansion — never hardcode `/home/breadway` or any username. + +Add to `bread-cli/Cargo.toml`: `dirs = "5.0"`. + +--- + +## Tests + +### Module system tests (`bread-cli/tests/modules.rs`) + +```rust +// 1. Install from local path succeeds when bread.module.toml exists +// 2. Install from local path fails when bread.module.toml is missing +// 3. Remove deletes the module directory +// 4. List reads manifests correctly from disk +// 5. Manifest is written correctly on install (all fields present, installed_at is valid RFC 3339) +``` + +### Sync tests (`bread-sync/tests/sync.rs`) + +```rust +// 1. bread sync init creates sync.toml with correct fields +// 2. bread sync push with a local bare Git repo as remote: creates correct directory structure +// 3. bread sync push snapshots bread/ directory correctly +// 4. bread sync pull copies files from repo to correct locations +// 5. Package manifest for pacman: parses output correctly +// 6. Package manifest for pip: parses output correctly +// 7. Delegates: exclude globs filter correctly +// 8. Machine profile is written to machines/.toml with correct fields +// 9. Status shows no changes when working tree matches last commit +// 10. Push with no changes prints "nothing to push" and does not create a commit +``` + +All tests must pass with `cargo test --workspace`. Tests that require network access must be feature-gated with `#[cfg(feature = "network-tests")]` and not run by default. + +--- + +## Completion Checklist + +Do not stop iterating until every item on this list is true. + +### Compilation +- [ ] `cargo build --workspace` succeeds with zero errors +- [ ] `cargo build --workspace --release` succeeds with zero errors +- [ ] Zero compiler warnings in new code (existing warnings are acceptable) +- [ ] `cargo clippy --workspace` produces no errors in new code + +### Tests +- [ ] `cargo test --workspace` passes with zero failures +- [ ] All tests listed in the Tests section above exist and pass +- [ ] Integration tests in `breadd/tests/ipc_integration.rs` still pass + +### Module system — functional +- [ ] `bread modules install github:user/repo` downloads and installs a module +- [ ] `bread modules install /local/path` copies and installs a local module +- [ ] `bread modules install` with an invalid source prints a clear error and exits 1 +- [ ] `bread modules install` writes a valid `bread.module.toml` with all required fields including `installed_at` +- [ ] `bread modules install` calls `modules.reload` IPC after successful install +- [ ] `bread modules remove ` removes the module directory +- [ ] `bread modules remove ` with `--yes` skips confirmation +- [ ] `bread modules remove ` prints a clear error and exits 1 +- [ ] `bread modules list` reads all installed module manifests +- [ ] `bread modules list` shows daemon-reported status when daemon is running +- [ ] `bread modules list` shows `unknown` status when daemon is not running (no crash) +- [ ] `bread modules update` re-installs all github-sourced modules +- [ ] `bread modules update` skips local-path modules with a warning +- [ ] `bread modules info ` shows all manifest fields and daemon status + +### Sync — functional +- [ ] `bread sync init` creates `~/.config/bread/sync.toml` with all required fields +- [ ] `bread sync init` errors if already initialized +- [ ] `bread sync push` creates the correct repo directory structure +- [ ] `bread sync push` copies `~/.config/bread/` to `bread/` in the repo +- [ ] `bread sync push` copies each delegate path to `configs//` +- [ ] `bread sync push` writes package manifests to `packages/` +- [ ] `bread sync push` writes `machines/.toml` +- [ ] `bread sync push` creates a Git commit with a sensible message +- [ ] `bread sync push` pushes to the configured remote +- [ ] `bread sync push` with no changes prints `nothing to push` and exits 0 +- [ ] `bread sync pull` copies `bread/` from repo to `~/.config/bread/` +- [ ] `bread sync pull` copies `configs/` entries back to their original locations +- [ ] `bread sync pull` calls `modules.reload` IPC after applying +- [ ] `bread sync pull --install-packages` runs package installs +- [ ] `bread sync pull` without `--install-packages` does not run package installs +- [ ] `bread sync status` shows local uncommitted changes +- [ ] `bread sync status` shows remote changes not yet pulled +- [ ] `bread sync status` prints `nothing to push — already up to date` when clean +- [ ] `bread sync machines` lists all `machines/*.toml` entries +- [ ] `bread sync init` without `--remote` prompts for URL interactively + +### Sync — error handling +- [ ] `bread sync push` without init prints clear error and exits 1 +- [ ] `bread sync pull` without init prints clear error and exits 1 +- [ ] Git conflict on pull prints clear message pointing to sync repo path and exits 1 +- [ ] Package manager not installed is warned and skipped, not a fatal error + +### Lua API +- [ ] `bread.machine.name()` returns machine name from sync.toml +- [ ] `bread.machine.name()` returns hostname when sync.toml does not exist +- [ ] `bread.machine.tags()` returns array of tags +- [ ] `bread.machine.has_tag("x")` returns true/false correctly +- [ ] `bread.fs.write(path, content)` writes the file and creates parent dirs +- [ ] `bread.fs.read(path)` returns file content as string +- [ ] `bread.fs.read(nonexistent)` returns nil, does not error +- [ ] `bread.fs.exists(path)` returns correct bool +- [ ] `bread.fs.expand("~/foo")` returns the correct absolute path +- [ ] All `bread.fs` paths handle `~` expansion + +### Udev vendor/product ID +- [ ] `vendor_id` and `product_id` fields are present in udev device events +- [ ] `Device` struct in `types.rs` has `vendor_id: Option` and `product_id: Option` +- [ ] `bread events` output shows `vendor_id` and `product_id` when available + +### No regressions +- [ ] `bread reload` still works +- [ ] `bread state` still works +- [ ] `bread events` still works +- [ ] `bread doctor` still works +- [ ] `bread ping` still works +- [ ] `bread emit` still works +- [ ] Daemon starts cleanly with no existing `sync.toml` +- [ ] Daemon starts cleanly with a valid `sync.toml` +- [ ] All existing IPC methods still respond correctly + +### Code quality +- [ ] No hardcoded paths containing usernames or `/home/` +- [ ] No `unwrap()` calls in new code that can fail at runtime — use `?` or explicit error handling +- [ ] No `expect("...")` calls in new async code — only in tests and truly-impossible cases +- [ ] All new public functions have doc comments +- [ ] `bread-sync` crate has a `README.md` explaining its purpose and public API + +--- + +## Implementation order + +Work in this order. Do not move to the next step until the current one compiles and its tests pass. + +1. Add `bread-sync` crate skeleton to workspace (compiles, no logic yet) +2. Implement `SyncConfig` (load/save `sync.toml`) +3. Implement `bread sync init` +4. Implement Git backend in `bread-sync/src/git.rs` +5. Implement `bread sync push` (bread config only, no delegates or packages yet) +6. Implement delegate file handling +7. Implement package manifest generation +8. Implement `bread sync pull` +9. Implement `bread sync status`, `diff`, `machines` +10. Implement `bread modules install` (local path first, then GitHub) +11. Implement `bread modules remove`, `list`, `update`, `info` +12. Add `vendor_id`/`product_id` to udev adapter and `Device` type +13. Add `bread.machine` Lua API +14. Add `bread.fs` Lua API +15. Write all tests +16. Run full checklist — fix anything not passing +17. Run `cargo clippy --workspace` — fix any new warnings diff --git a/Cargo.lock b/Cargo.lock index 313315f..72cecc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,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" @@ -248,6 +263,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "1.3.2" @@ -304,6 +325,23 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-sync" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "flate2", + "git2", + "reqwest", + "serde", + "serde_json", + "tar", + "tempfile", + "toml", +] + [[package]] name = "breadd" version = "0.1.0" @@ -339,6 +377,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byteorder" version = "1.5.0" @@ -358,6 +402,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -367,6 +413,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" @@ -422,6 +482,32 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -431,6 +517,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -477,12 +572,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" @@ -606,12 +742,52 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +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" @@ -758,6 +934,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" @@ -766,11 +954,45 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "git2" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" +dependencies = [ + "bitflags 2.11.1", + "libc", + "libgit2-sys", + "log", + "openssl-probe 0.1.6", + "openssl-sys", + "url", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -810,12 +1032,210 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[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]] name = "id-arena" 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" @@ -868,6 +1288,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -880,6 +1306,28 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -918,6 +1366,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" @@ -928,6 +1413,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" @@ -946,6 +1443,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" @@ -1022,6 +1525,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "0.8.11" @@ -1075,6 +1594,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1214,6 +1750,61 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "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" @@ -1268,6 +1859,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" @@ -1321,6 +1918,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1368,6 +1974,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" @@ -1413,6 +2025,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" @@ -1442,6 +2065,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" @@ -1503,6 +2166,27 @@ 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" @@ -1512,12 +2196,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1597,6 +2313,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" @@ -1633,6 +2361,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -1665,6 +2399,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" @@ -1699,6 +2439,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" @@ -1741,6 +2530,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" @@ -1770,6 +2569,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1822,6 +2644,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1883,6 +2711,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typeid" version = "1.0.3" @@ -1930,6 +2764,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" @@ -1942,6 +2794,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1964,6 +2822,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1988,6 +2855,61 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2022,6 +2944,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "which" version = "7.0.3" @@ -2065,12 +2997,65 @@ 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" @@ -2237,6 +3222,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" @@ -2337,6 +3332,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" @@ -2347,6 +3358,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" @@ -2434,6 +3468,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..ee8711e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,8 @@ members = [ "bread-shared", "breadd", - "bread-cli" + "bread-cli", + "bread-sync" ] resolver = "2" diff --git a/README.md b/README.md index 73512df..ae1e55a 100644 --- a/README.md +++ b/README.md @@ -231,11 +231,12 @@ bread.once("bread.system.startup", function(event) end) -- Subscribe with a predicate filter +-- Third arg is an opts table with a 'filter' key whose value is the predicate bread.filter("bread.device.connected", function(event) - return event.data.class == "keyboard" -end, function(event) bread.exec("xset r rate 200 40") -end) +end, { filter = function(event) + return event.data.class == "keyboard" +end }) -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 69a2c49..43c17a9 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -9,6 +9,7 @@ path = "src/main.rs" [dependencies] bread-shared = { path = "../bread-shared" } +bread-sync = { path = "../bread-sync" } serde.workspace = true serde_json.workspace = true tokio.workspace = true @@ -16,3 +17,9 @@ anyhow.workspace = true clap = { version = "4.5", features = ["derive"] } notify = "6.1" libc = "0.2" +dirs = "5.0" +reqwest = { version = "0.11", features = ["blocking", "json"] } +flate2 = "1.0" +tar = "0.4" +chrono = { version = "0.4", features = ["serde"] } +toml = "0.8" diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 0ca91df..d57890a 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,6 +1,7 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::env; use std::io; @@ -10,6 +11,16 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tokio::sync::mpsc; +use bread_sync::{ + config::{bread_config_dir, sync_repo_path, SyncConfig}, + delegates::{copy_delegates_to_repo, expand_tilde, restore_delegates_from_repo, sync_dir}, + git, + machine::{list_machines, machine_name, MachineProfile}, + packages::snapshot_packages, +}; + +// ─── CLI structure ──────────────────────────────────────────────────────────── + #[derive(Parser, Debug)] #[command(author, version, about = "Bread CLI - the reactive desktop automation fabric")] struct Cli { @@ -47,8 +58,16 @@ enum Commands { #[arg(long)] since: Option, }, - /// List loaded modules and status - Modules, + /// Manage installed Lua modules + Modules { + #[command(subcommand)] + action: ModulesAction, + }, + /// Sync system state to/from a Git remote + Sync { + #[command(subcommand)] + action: SyncAction, + }, /// List available profiles ProfileList, /// Activate a profile @@ -71,6 +90,79 @@ enum Commands { }, } +#[derive(Subcommand, Debug)] +enum ModulesAction { + /// Install a module from a source (github:user/repo[@ref] or /local/path) + Install { + source: String, + }, + /// Remove an installed module + Remove { + name: String, + /// Skip confirmation prompt + #[arg(long, short = 'y')] + yes: bool, + }, + /// List installed modules with status + List, + /// Update installed modules to latest + Update { + /// Update only this specific module + name: Option, + }, + /// Show detailed manifest info for a module + Info { + name: String, + }, +} + +#[derive(Subcommand, Debug)] +enum SyncAction { + /// Initialize sync for this machine + Init { + /// Git remote URL + #[arg(long)] + remote: Option, + }, + /// Snapshot and push current state + Push { + /// Commit message + #[arg(long, short = 'm')] + message: Option, + }, + /// Pull and apply latest state from remote + Pull { + /// Also run package install commands + #[arg(long)] + install_packages: bool, + }, + /// Show what has changed since last push + Status, + /// Show file-level diff vs remote + Diff { + /// Diff against remote HEAD instead of working tree + #[arg(long)] + remote: bool, + }, + /// List known machines from sync repo + Machines, +} + +// ─── Module manifest ────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct ModuleManifest { + name: String, + version: String, + description: String, + author: String, + source: String, + #[serde(skip_serializing_if = "Option::is_none")] + installed_at: Option, +} + +// ─── Entry point ────────────────────────────────────────────────────────────── + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -81,71 +173,65 @@ async fn main() -> Result<()> { if *watch { watch_reload(&socket).await?; } else { - let response = send_request(&socket, "modules.reload", json!({})).await?; + let response = send_request_or_die(&socket, "modules.reload", json!({})).await?; print_reload(&response); } } Commands::State { path, json } => { + let response = if let Some(path) = path { + send_request_or_die(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request_or_die(&socket, "state.dump", json!({})).await? + }; if *json { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; print_json(&response)?; } else { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; print_state_formatted(path.as_deref(), &response); } } - Commands::Events { - filter, - json, - fields, - since, - } => { + Commands::Events { filter, json, fields, since } => { stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?; } - Commands::Modules => { - let response = send_request(&socket, "modules.list", json!({})).await?; - print_json(&response)?; + Commands::Modules { action } => { + handle_modules(action, &socket).await?; + } + Commands::Sync { action } => { + handle_sync(action, &socket).await?; } Commands::ProfileList => { - let response = send_request(&socket, "profile.list", json!({})).await?; + let response = send_request_or_die(&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_or_die( + &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 response = send_request( + let response = send_request_or_die( &socket, "emit", - json!({ - "event": event, - "data": parsed, - }), + json!({ "event": event, "data": parsed }), ) .await?; print_json(&response)?; } Commands::Ping => { - let response = send_request(&socket, "ping", json!({})).await?; + let response = send_request_or_die(&socket, "ping", json!({})).await?; print_json(&response)?; } Commands::Health => { - let response = send_request(&socket, "health", json!({})).await?; + let response = send_request_or_die(&socket, "health", json!({})).await?; print_json(&response)?; } Commands::Doctor { json } => { if *json { - let response = send_request(&socket, "health", json!({})).await?; + let response = send_request_or_die(&socket, "health", json!({})).await?; print_json(&response)?; } else { print_doctor(&socket).await?; @@ -156,6 +242,699 @@ async fn main() -> Result<()> { Ok(()) } +// ─── Modules sub-commands ───────────────────────────────────────────────────── + +async fn handle_modules(action: &ModulesAction, socket: &Path) -> Result<()> { + match action { + ModulesAction::Install { source } => { + modules_install(source, socket).await?; + } + ModulesAction::Remove { name, yes } => { + modules_remove(name, *yes, socket).await?; + } + ModulesAction::List => { + modules_list(socket).await?; + } + ModulesAction::Update { name } => { + modules_update(name.as_deref(), socket).await?; + } + ModulesAction::Info { name } => { + modules_info(name, socket).await?; + } + } + Ok(()) +} + +async fn modules_install(source: &str, socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + + if let Some(rest) = source.strip_prefix("github:") { + install_github_module(rest, source, &modules_dir)?; + } else if source.starts_with('/') || source.starts_with("./") || source.starts_with("~/") { + let local_path = expand_tilde(source); + install_local_module(&local_path, &modules_dir)?; + } else { + eprintln!("bread: unknown source format '{source}'"); + eprintln!(" expected: github:user/repo[@ref] or /local/path"); + std::process::exit(1); + } + + // Reload daemon + if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { + let _ = response; + } + Ok(()) +} + +fn install_local_module(src: &Path, modules_dir: &Path) -> Result<()> { + let manifest_path = src.join("bread.module.toml"); + if !manifest_path.exists() { + eprintln!( + "bread: no bread.module.toml found at {}", + manifest_path.display() + ); + std::process::exit(1); + } + let raw = std::fs::read_to_string(&manifest_path)?; + let mut manifest: ModuleManifest = toml::from_str(&raw)?; + manifest.installed_at = Some(chrono::Utc::now().to_rfc3339()); + + let dest = modules_dir.join(&manifest.name); + if dest.exists() { + std::fs::remove_dir_all(&dest)?; + } + copy_dir_all(src, &dest)?; + + // Write updated manifest with installed_at + let manifest_dest = dest.join("bread.module.toml"); + std::fs::write(&manifest_dest, toml::to_string_pretty(&manifest)?)?; + + println!("installed {} v{}", manifest.name, manifest.version); + Ok(()) +} + +fn install_github_module(spec: &str, source_str: &str, modules_dir: &Path) -> Result<()> { + let (repo_spec, git_ref) = if let Some((r, v)) = spec.split_once('@') { + (r, Some(v.to_string())) + } else { + (spec, None) + }; + + let (user, repo) = repo_spec + .split_once('/') + .ok_or_else(|| anyhow!("invalid github spec '{}': expected user/repo", spec))?; + + let client = reqwest::blocking::Client::builder() + .user_agent("bread-cli/0.1") + .build()?; + + let resolved_ref = match git_ref { + Some(r) => r, + None => { + let url = format!("https://api.github.com/repos/{user}/{repo}"); + let resp: Value = client.get(&url).send()?.json()?; + 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/{resolved_ref}" + ); + let bytes = client.get(&tarball_url).send()?.bytes()?; + + // Extract to a temp dir + let tmp = tempfile_dir()?; + let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&bytes)); + let mut archive = tar::Archive::new(gz); + archive.unpack(&tmp)?; + + // The tarball has a single top-level directory; find it + let extracted_dir = std::fs::read_dir(&tmp)? + .filter_map(|e| e.ok()) + .find(|e| e.path().is_dir()) + .map(|e| e.path()) + .ok_or_else(|| anyhow!("tarball contained no directory"))?; + + let manifest_path = extracted_dir.join("bread.module.toml"); + if !manifest_path.exists() { + let _ = std::fs::remove_dir_all(&tmp); + eprintln!( + "bread: no bread.module.toml found in github:{}/{} (ref {})", + user, repo, resolved_ref + ); + std::process::exit(1); + } + + let raw = std::fs::read_to_string(&manifest_path)?; + let mut manifest: ModuleManifest = toml::from_str(&raw)?; + manifest.installed_at = Some(chrono::Utc::now().to_rfc3339()); + manifest.source = source_str.to_string(); + + let dest = modules_dir.join(&manifest.name); + if dest.exists() { + std::fs::remove_dir_all(&dest)?; + } + copy_dir_all(&extracted_dir, &dest)?; + + let manifest_dest = dest.join("bread.module.toml"); + std::fs::write(&manifest_dest, toml::to_string_pretty(&manifest)?)?; + + let _ = std::fs::remove_dir_all(&tmp); + println!("installed {} v{}", manifest.name, manifest.version); + Ok(()) +} + +async fn modules_remove(name: &str, yes: bool, socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + let module_dir = modules_dir.join(name); + + if !module_dir.exists() { + eprintln!("bread: module '{name}' is not installed"); + std::process::exit(1); + } + + if !yes { + eprint!("remove {name}? (y/n) "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if !input.trim().eq_ignore_ascii_case("y") { + println!("cancelled"); + return Ok(()); + } + } + + std::fs::remove_dir_all(&module_dir)?; + println!("removed {name}"); + + if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { + let _ = response; + } + Ok(()) +} + +async fn modules_list(socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + let manifests = scan_modules(&modules_dir)?; + + // Try to get daemon status + let daemon_modules = send_request(socket, "modules.list", json!({})) + .await + .ok() + .and_then(|v| v.as_array().cloned()); + + for manifest in &manifests { + let status = daemon_modules + .as_ref() + .and_then(|mods| { + mods.iter().find(|m| { + m.get("name").and_then(Value::as_str) == Some(&manifest.name) + }) + }) + .and_then(|m| m.get("status").and_then(Value::as_str)) + .unwrap_or(if daemon_modules.is_some() { "unknown" } else { "unknown" }); + + println!( + " {:<20} {:<10} {:<12} {}", + manifest.name, manifest.version, status, manifest.source + ); + } + Ok(()) +} + +async fn modules_update(name: Option<&str>, socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + + let to_update: Vec = if let Some(name) = name { + let manifest = load_manifest(&modules_dir.join(name))?; + vec![manifest] + } else { + scan_modules(&modules_dir)? + }; + + for manifest in to_update { + if !manifest.source.starts_with("github:") { + eprintln!( + "warn: cannot update '{}' — local module, reinstall manually", + manifest.name + ); + continue; + } + let old_version = manifest.version.clone(); + let source = manifest.source.clone(); + let rest = source.trim_start_matches("github:"); + install_github_module(rest, &source, &modules_dir)?; + let new_manifest = load_manifest(&modules_dir.join(&manifest.name))?; + if new_manifest.version == old_version { + println!("{} already up to date", manifest.name); + } else { + println!( + "updated {} v{} → v{}", + manifest.name, old_version, new_manifest.version + ); + } + } + + if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { + let _ = response; + } + Ok(()) +} + +async fn modules_info(name: &str, socket: &Path) -> Result<()> { + let modules_dir = modules_directory()?; + let module_dir = modules_dir.join(name); + + if !module_dir.exists() { + eprintln!("bread: module '{name}' is not installed"); + std::process::exit(1); + } + + let manifest = load_manifest(&module_dir)?; + let status = send_request(socket, "modules.list", json!({})) + .await + .ok() + .and_then(|v| v.as_array().cloned()) + .and_then(|mods| { + mods.iter() + .find(|m| m.get("name").and_then(Value::as_str) == Some(name)) + .and_then(|m| m.get("status").and_then(Value::as_str)) + .map(ToString::to_string) + }) + .unwrap_or_else(|| "unknown".to_string()); + + println!("name: {}", manifest.name); + println!("version: {}", manifest.version); + println!("description: {}", manifest.description); + println!("author: {}", manifest.author); + println!("source: {}", manifest.source); + println!( + "installed_at: {}", + manifest.installed_at.as_deref().unwrap_or("unknown") + ); + println!("status: {status}"); + Ok(()) +} + +// ─── Sync sub-commands ──────────────────────────────────────────────────────── + +async fn handle_sync(action: &SyncAction, socket: &Path) -> Result<()> { + match action { + SyncAction::Init { remote } => sync_init(remote.as_deref()).await?, + SyncAction::Push { message } => sync_push(message.as_deref()).await?, + SyncAction::Pull { install_packages } => sync_pull(*install_packages, socket).await?, + SyncAction::Status => sync_status().await?, + SyncAction::Diff { remote } => sync_diff(*remote).await?, + SyncAction::Machines => sync_machines().await?, + } + Ok(()) +} + +async fn sync_init(remote_arg: Option<&str>) -> Result<()> { + if SyncConfig::is_initialized()? { + eprintln!( + "bread: sync already initialized. Edit {} to reconfigure.", + bread_sync::config::config_path()?.display() + ); + std::process::exit(1); + } + + let remote_url = if let Some(url) = remote_arg { + url.to_string() + } else { + eprint!("Sync remote URL (git remote or path): "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let url = input.trim().to_string(); + if url.is_empty() { + anyhow::bail!("remote URL is required"); + } + url + }; + + let default_hostname = hostname_or_unknown(); + eprint!("Machine name [{}]: ", default_hostname); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let machine_name = { + let trimmed = input.trim().to_string(); + if trimmed.is_empty() { + default_hostname.clone() + } else { + trimmed + } + }; + + eprint!("Machine tags (comma-separated, e.g. mobile,battery): "); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let tags: Vec = input + .trim() + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + let cfg = SyncConfig { + remote: bread_sync::config::RemoteConfig { + url: Some(remote_url.clone()), + branch: "main".to_string(), + }, + machine: bread_sync::config::MachineConfig { + name: Some(machine_name.clone()), + tags, + }, + ..Default::default() + }; + cfg.save()?; + + // Validate remote if it looks like a URL + if !remote_url.starts_with('/') { + println!("remote does not exist yet — it will be created on first push"); + } + + println!("sync initialized:"); + println!(" machine: {machine_name}"); + println!(" remote: {remote_url}"); + Ok(()) +} + +async fn sync_push(message: Option<&str>) -> Result<()> { + let cfg = require_sync_config()?; + let remote_url = cfg.remote.url.as_deref().ok_or_else(|| { + anyhow!("sync.toml has no remote URL — run: bread sync init") + })?; + let branch = cfg.remote.branch.clone(); + let repo_path = sync_repo_path()?; + + let repo = tokio::task::spawn_blocking({ + let remote_url = remote_url.to_string(); + let repo_path = repo_path.clone(); + move || git::clone_or_open(&remote_url, &repo_path) + }) + .await??; + + // Snapshot bread config + let bread_dir = bread_config_dir()?; + let bread_dest = repo_path.join("bread"); + sync_dir(&bread_dir, &bread_dest, &cfg.delegates.exclude)?; + + // Snapshot delegates + copy_delegates_to_repo(&cfg.delegates, &repo_path)?; + + // Snapshot packages + if cfg.packages.enabled { + snapshot_packages(&cfg.packages.managers, &repo_path)?; + } + + // Write machine profile + let profile = MachineProfile::new(&cfg)?; + profile.write_to_repo(&repo_path)?; + + // Stage all + git::stage_all(&repo)?; + + // Check for changes + if !git::has_changes(&repo)? { + println!("nothing to push — already up to date"); + return Ok(()); + } + + // Commit + let machine = machine_name(&cfg)?; + let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"); + let commit_msg = message + .map(ToString::to_string) + .unwrap_or_else(|| format!("sync: {machine} {timestamp}")); + git::commit(&repo, &commit_msg)?; + + // Set remote and push + if let Ok(()) = git::set_remote(&repo, "origin", remote_url) {} + git::push(&repo, "origin", &branch)?; + + println!("pushed: {commit_msg}"); + println!(" bread config: {}", bread_dir.display()); + if cfg.packages.enabled { + println!(" packages: {}", cfg.packages.managers.join(", ")); + } + Ok(()) +} + +async fn sync_pull(install_packages: bool, socket: &Path) -> Result<()> { + let cfg = require_sync_config()?; + let remote_url = cfg.remote.url.as_deref().ok_or_else(|| { + anyhow!("sync.toml has no remote URL — run: bread sync init") + })?; + let branch = cfg.remote.branch.clone(); + let repo_path = sync_repo_path()?; + + let repo = tokio::task::spawn_blocking({ + let remote_url = remote_url.to_string(); + let repo_path = repo_path.clone(); + move || git::clone_or_open(&remote_url, &repo_path) + }) + .await??; + + git::pull(&repo, "origin", &branch)?; + + // Restore bread config + let bread_src = repo_path.join("bread"); + let bread_dest = bread_config_dir()?; + if bread_src.exists() { + sync_dir(&bread_src, &bread_dest, &[])?; + } + + // Restore delegates + restore_delegates_from_repo(&cfg.delegates, &repo_path)?; + + // Package installs + if install_packages && cfg.packages.enabled { + run_package_installs(&repo_path, &cfg.packages.managers)?; + } else if cfg.packages.enabled { + let pkg_dir = repo_path.join("packages"); + if pkg_dir.exists() { + println!( + "note: run 'bread sync pull --install-packages' to install missing packages" + ); + } + } + + // Reload daemon + if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { + let _ = response; + } + + println!("pulled and applied latest state"); + Ok(()) +} + +async fn sync_status() -> Result<()> { + let cfg = require_sync_config()?; + let repo_path = sync_repo_path()?; + + if !repo_path.join(".git").exists() { + println!("sync repo not yet initialised — run: bread sync push"); + return Ok(()); + } + + let repo = git::init_or_open(&repo_path)?; + let machine = machine_name(&cfg)?; + let remote_url = cfg.remote.url.as_deref().unwrap_or("(none)"); + let last_push = git::last_commit_time(&repo); + + println!("bread sync status"); + println!(" machine {machine}"); + println!(" remote {remote_url}"); + println!(" last push {last_push}"); + + let local_changes = git::status_lines(&repo)?; + println!(); + println!("local changes (not yet pushed):"); + if local_changes.is_empty() { + println!(" none"); + } else { + for (ch, path) in &local_changes { + println!(" {ch} {path}"); + } + } + + // Fetch to check remote + let _ = git::fetch(&repo, "origin"); + let has_remote = git::remote_has_changes(&repo, "origin", &cfg.remote.branch); + println!(); + println!("remote changes (not yet pulled):"); + if has_remote { + println!(" (run 'bread sync pull' to apply)"); + } else { + println!(" none"); + } + Ok(()) +} + +async fn sync_diff(show_remote: bool) -> Result<()> { + let cfg = require_sync_config()?; + let repo_path = sync_repo_path()?; + + if !repo_path.join(".git").exists() { + println!("sync repo not initialised — run: bread sync push"); + return Ok(()); + } + + let repo = git::init_or_open(&repo_path)?; + + let diff = if show_remote { + git::fetch(&repo, "origin")?; + git::diff_remote(&repo, "origin", &cfg.remote.branch)? + } else { + git::diff_workdir(&repo)? + }; + + if diff.is_empty() { + println!("no differences"); + } else { + print!("{diff}"); + } + Ok(()) +} + +async fn sync_machines() -> Result<()> { + let repo_path = sync_repo_path()?; + if !repo_path.join(".git").exists() { + println!("sync repo not initialised — run: bread sync push"); + return Ok(()); + } + let machines = list_machines(&repo_path); + if machines.is_empty() { + println!("no machines found in sync repo"); + return Ok(()); + } + for m in machines { + let tags = if m.tags.is_empty() { + "(none)".to_string() + } else { + m.tags.join(", ") + }; + println!( + " {:<20} last sync: {:<20} tags: {}", + m.name, m.last_sync, tags + ); + } + Ok(()) +} + +fn run_package_installs(repo_root: &Path, managers: &[String]) -> Result<()> { + let pkg_dir = repo_root.join("packages"); + + for mgr in managers { + match mgr.as_str() { + "pacman" => { + let f = pkg_dir.join("pacman.txt"); + if f.exists() { + let names = bread_sync::packages::parse_pacman(&std::fs::read_to_string(&f)?); + let status = std::process::Command::new("sudo") + .args(["pacman", "-S", "--needed"]) + .args(&names) + .status(); + if let Err(e) = status { + eprintln!("warn: pacman install failed: {e}"); + } + } + } + "pip" => { + let f = pkg_dir.join("pip.txt"); + if f.exists() { + let status = std::process::Command::new("pip") + .args(["install", "--user", "-r"]) + .arg(&f) + .status(); + if let Err(e) = status { + eprintln!("warn: pip install failed: {e}"); + } + } + } + "npm" => { + let f = pkg_dir.join("npm.txt"); + if f.exists() { + let names = bread_sync::packages::parse_npm(&std::fs::read_to_string(&f)?); + for name in names { + let _ = std::process::Command::new("npm") + .args(["install", "-g", &name]) + .status(); + } + } + } + "cargo" => { + let f = pkg_dir.join("cargo.txt"); + if f.exists() { + let entries = bread_sync::packages::parse_cargo(&std::fs::read_to_string(&f)?); + for entry in entries { + let name = entry.split_whitespace().next().unwrap_or(&entry); + let _ = std::process::Command::new("cargo") + .args(["install", name]) + .status(); + } + } + } + _ => {} + } + } + Ok(()) +} + +// ─── Helper functions ───────────────────────────────────────────────────────── + +fn require_sync_config() -> Result { + if !SyncConfig::is_initialized()? { + eprintln!("bread: sync not initialized. Run: bread sync init"); + std::process::exit(1); + } + SyncConfig::load() +} + +fn modules_directory() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| anyhow!("cannot determine config directory"))?; + let dir = config_dir.join("bread").join("modules"); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +fn scan_modules(modules_dir: &Path) -> Result> { + let mut out = Vec::new(); + if !modules_dir.exists() { + return Ok(out); + } + for entry in std::fs::read_dir(modules_dir)? { + let entry = entry?; + if !entry.path().is_dir() { + continue; + } + if let Ok(manifest) = load_manifest(&entry.path()) { + out.push(manifest); + } + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +fn load_manifest(module_dir: &Path) -> Result { + let path = module_dir.join("bread.module.toml"); + let raw = std::fs::read_to_string(&path)?; + Ok(toml::from_str(&raw)?) +} + +fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> { + std::fs::create_dir_all(dest)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let dest_path = dest.join(entry.file_name()); + if entry.path().is_dir() { + copy_dir_all(&entry.path(), &dest_path)?; + } else { + std::fs::copy(entry.path(), dest_path)?; + } + } + Ok(()) +} + +fn tempfile_dir() -> Result { + let tmp = std::env::temp_dir().join(format!("bread-install-{}", std::process::id())); + std::fs::create_dir_all(&tmp)?; + Ok(tmp) +} + +fn hostname_or_unknown() -> String { + std::fs::read_to_string("/etc/hostname") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + +// ─── IPC helpers ────────────────────────────────────────────────────────────── + fn daemon_socket_path() -> PathBuf { if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") { return Path::new(&runtime).join("bread").join("breadd.sock"); @@ -163,6 +942,26 @@ fn daemon_socket_path() -> PathBuf { PathBuf::from("/tmp/bread/breadd.sock") } +/// Like `send_request` but prints a nice message and exits 1 if daemon is unreachable. +async fn send_request_or_die(socket: &Path, method: &str, params: Value) -> Result { + match send_request(socket, method, params).await { + Ok(v) => Ok(v), + Err(err) => { + let msg = err.to_string(); + if msg.contains("No such file") + || msg.contains("Connection refused") + || msg.contains("not found") + { + eprintln!( + "bread: daemon is not running. Start it with: systemctl --user start breadd" + ); + std::process::exit(1); + } + Err(err) + } + } +} + async fn send_request(socket: &Path, method: &str, params: Value) -> Result { let stream = UnixStream::connect(socket).await?; let (read_half, mut write_half) = stream.into_split(); @@ -195,7 +994,8 @@ async fn stream_events( since: Option, ) -> Result<()> { if let Some(seconds) = since { - let replay = send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?; + 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 { @@ -212,9 +1012,7 @@ async fn stream_events( let request = json!({ "id": "1", "method": "events.subscribe", - "params": { - "filter": filter, - }, + "params": { "filter": filter }, }); write_half @@ -230,10 +1028,11 @@ async fn stream_events( print_event(&value, fields.as_deref()); } } - Ok(()) } +// ─── Display helpers ────────────────────────────────────────────────────────── + fn print_json(value: &Value) -> Result<()> { println!("{}", serde_json::to_string_pretty(value)?); Ok(()) @@ -297,15 +1096,11 @@ 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 + tm.tm_hour as u64 * 3600 + tm.tm_min as u64 * 60 + tm.tm_sec as u64 }; let h = (local_secs / 3600) % 24; @@ -345,16 +1140,11 @@ async fn watch_reload(socket: &Path) -> Result<()> { if msg.is_err() { continue; } - - // Debounce: drain any follow-up events that arrive within 150ms. - // A single file save typically generates 2-3 fs events in rapid succession. tokio::time::sleep(Duration::from_millis(150)).await; while rx.try_recv().is_ok() {} - - let response = send_request(socket, "modules.reload", json!({})).await?; + let response = send_request_or_die(socket, "modules.reload", json!({})).await?; print_reload(&response); } - Ok(()) } @@ -387,7 +1177,11 @@ fn render_doctor(health: &Value) { 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!( + " daemon {} (pid {})", + if ok { "✓ running" } else { "✗ unreachable" }, + pid + ); println!(" version {version}"); println!(" uptime {}s", uptime_ms / 1000); println!(" socket {socket}"); @@ -458,11 +1252,7 @@ async fn send_request_with_stream( } 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") + dirs::config_dir() + .map(|d| d.join("bread")) + .unwrap_or_else(|| PathBuf::from(".config/bread")) } diff --git a/bread-sync/Cargo.toml b/bread-sync/Cargo.toml new file mode 100644 index 0000000..c4860dc --- /dev/null +++ b/bread-sync/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "bread-sync" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +toml = "0.8" +chrono = { version = "0.4", features = ["serde"] } +dirs = "5.0" +git2 = { version = "0.18", features = ["vendored-libgit2"] } +reqwest = { version = "0.11", features = ["blocking", "json"] } +flate2 = "1.0" +tar = "0.4" + +[dev-dependencies] +tempfile = "3.13" diff --git a/bread-sync/README.md b/bread-sync/README.md new file mode 100644 index 0000000..079b8d6 --- /dev/null +++ b/bread-sync/README.md @@ -0,0 +1,10 @@ +# bread-sync + +Sync and module management library for the Bread reactive desktop automation daemon. + +Provides: +- `SyncConfig` — load/save `~/.config/bread/sync.toml` +- Git backend (via git2) for push/pull of bread config to a remote repository +- Delegate file handling — copy arbitrary config files into the sync repo +- Package manifest generation for pacman/pip/npm/cargo +- Machine profile — name and tags read from sync.toml diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs new file mode 100644 index 0000000..d0b7506 --- /dev/null +++ b/bread-sync/src/config.rs @@ -0,0 +1,124 @@ +use std::path::PathBuf; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +/// Top-level sync configuration stored in `~/.config/bread/sync.toml`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SyncConfig { + #[serde(default)] + pub remote: RemoteConfig, + #[serde(default)] + pub machine: MachineConfig, + #[serde(default)] + pub packages: PackagesConfig, + #[serde(default)] + pub delegates: DelegatesConfig, +} + +/// Git remote configuration. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RemoteConfig { + pub url: Option, + #[serde(default = "default_branch")] + pub branch: String, +} + +fn default_branch() -> String { + "main".to_string() +} + +/// Machine identity — name comes from here, falls back to hostname. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MachineConfig { + pub name: Option, + #[serde(default)] + pub tags: Vec, +} + +/// Which package managers to snapshot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackagesConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_managers")] + pub managers: Vec, +} + +impl Default for PackagesConfig { + fn default() -> Self { + Self { + enabled: true, + managers: default_managers(), + } + } +} + +fn default_true() -> bool { + true +} + +fn default_managers() -> Vec { + vec!["pacman".to_string(), "pip".to_string(), "npm".to_string()] +} + +/// Config file delegation — which extra paths to include in the sync repo. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DelegatesConfig { + /// Absolute or `~`-prefixed paths to copy into `configs//`. + #[serde(default)] + pub include: Vec, + /// Glob patterns to exclude when copying. + #[serde(default)] + pub exclude: Vec, +} + +impl SyncConfig { + /// Load from `~/.config/bread/sync.toml`, returning `Default` if not present. + pub fn load() -> Result { + let path = config_path()?; + if !path.exists() { + return Ok(Self::default()); + } + let raw = std::fs::read_to_string(&path)?; + let cfg: Self = toml::from_str(&raw)?; + Ok(cfg) + } + + /// Write to `~/.config/bread/sync.toml`, creating parent dirs as needed. + pub fn save(&self) -> Result<()> { + let path = config_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let raw = toml::to_string_pretty(self)?; + std::fs::write(&path, raw)?; + Ok(()) + } + + /// Returns `true` if `~/.config/bread/sync.toml` exists on disk. + pub fn is_initialized() -> Result { + Ok(config_path()?.exists()) + } +} + +/// Path to `~/.config/bread/sync.toml`. +pub fn config_path() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?; + Ok(config_dir.join("bread").join("sync.toml")) +} + +/// Path to `~/.local/share/bread/sync-repo/`. +pub fn sync_repo_path() -> Result { + let data_dir = dirs::data_local_dir() + .ok_or_else(|| anyhow::anyhow!("cannot determine data directory"))?; + Ok(data_dir.join("bread").join("sync-repo")) +} + +/// Path to `~/.config/bread/`. +pub fn bread_config_dir() -> Result { + let config_dir = dirs::config_dir() + .ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?; + Ok(config_dir.join("bread")) +} diff --git a/bread-sync/src/delegates.rs b/bread-sync/src/delegates.rs new file mode 100644 index 0000000..aadab3b --- /dev/null +++ b/bread-sync/src/delegates.rs @@ -0,0 +1,205 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use crate::config::DelegatesConfig; + +/// Expand `~` in a path string to the user's home directory. +pub fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + dirs::home_dir() + .map(|h| h.join(rest)) + .unwrap_or_else(|| PathBuf::from(path)) + } else if path == "~" { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(path)) + } else { + PathBuf::from(path) + } +} + +/// Returns `true` if `path` (relative to `base`) matches any of the `exclude` globs. +fn is_excluded(base: &Path, path: &Path, excludes: &[String]) -> bool { + let rel = path.strip_prefix(base).unwrap_or(path); + let rel_str = rel.to_string_lossy(); + for pattern in excludes { + if glob_matches(pattern, &rel_str) { + return true; + } + } + false +} + +/// Copy all files under `src` dir to `dest` dir, honouring `excludes`. +/// Creates `dest` if it doesn't exist. Deletes files in `dest` that are +/// absent in `src` (rsync `--delete` behaviour). +pub fn sync_dir(src: &Path, dest: &Path, excludes: &[String]) -> Result<()> { + std::fs::create_dir_all(dest)?; + copy_recursive(src, src, dest, excludes)?; + delete_extra(src, dest)?; + Ok(()) +} + +fn copy_recursive(root: &Path, src: &Path, dest: &Path, excludes: &[String]) -> Result<()> { + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + + if is_excluded(root, &src_path, excludes) { + continue; + } + + let file_name = entry.file_name(); + let dest_path = dest.join(&file_name); + + if src_path.is_dir() { + std::fs::create_dir_all(&dest_path)?; + copy_recursive(root, &src_path, &dest_path, excludes)?; + } else { + std::fs::copy(&src_path, &dest_path)?; + } + } + Ok(()) +} + +/// Remove files/dirs from `dest` that don't exist in `src`. +fn delete_extra(src: &Path, dest: &Path) -> Result<()> { + if !dest.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(dest)? { + let entry = entry?; + let dest_path = entry.path(); + let file_name = entry.file_name(); + let src_path = src.join(&file_name); + if !src_path.exists() { + if dest_path.is_dir() { + std::fs::remove_dir_all(&dest_path)?; + } else { + std::fs::remove_file(&dest_path)?; + } + } + } + Ok(()) +} + +/// Copy each `include` path into `/configs//`. +pub fn copy_delegates_to_repo( + cfg: &DelegatesConfig, + repo_root: &Path, +) -> Result<()> { + let configs_dir = repo_root.join("configs"); + std::fs::create_dir_all(&configs_dir)?; + + for raw_path in &cfg.include { + let src = expand_tilde(raw_path); + if !src.exists() { + tracing_warn(&format!( + "delegate path does not exist, skipping: {}", + src.display() + )); + continue; + } + let basename = src + .file_name() + .ok_or_else(|| anyhow::anyhow!("delegate path has no filename: {}", src.display()))?; + let dest = configs_dir.join(basename); + if src.is_dir() { + sync_dir(&src, &dest, &cfg.exclude)?; + } else { + std::fs::copy(&src, &dest)?; + } + } + Ok(()) +} + +/// Restore each delegate path from `/configs//` to its original location. +pub fn restore_delegates_from_repo( + cfg: &DelegatesConfig, + repo_root: &Path, +) -> Result<()> { + let configs_dir = repo_root.join("configs"); + + for raw_path in &cfg.include { + let dest = expand_tilde(raw_path); + let basename = match dest.file_name() { + Some(n) => n.to_os_string(), + None => continue, + }; + let src = configs_dir.join(&basename); + if !src.exists() { + continue; + } + if src.is_dir() { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + sync_dir(&src, &dest, &[])?; + } else { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&src, &dest)?; + } + } + Ok(()) +} + +/// Simple glob match for `**` and `*` patterns against a path string. +fn glob_matches(pattern: &str, path: &str) -> bool { + glob_match_bytes(pattern.as_bytes(), path.as_bytes()) +} + +fn glob_match_bytes(pattern: &[u8], text: &[u8]) -> bool { + if pattern.is_empty() { + return text.is_empty(); + } + + // `**` matches any sequence including path separators + if pattern.starts_with(b"**") { + let rest = &pattern[2..]; + if rest.is_empty() { + return true; + } + // skip leading separator in rest + let rest = if rest.starts_with(b"/") { &rest[1..] } else { rest }; + for offset in 0..=text.len() { + if glob_match_bytes(rest, &text[offset..]) { + return true; + } + } + return false; + } + + match pattern[0] { + b'*' => { + let mut offset = 0; + loop { + if glob_match_bytes(&pattern[1..], &text[offset..]) { + return true; + } + if offset == text.len() { + break; + } + offset += 1; + } + false + } + b'?' => { + if text.is_empty() { + return false; + } + glob_match_bytes(&pattern[1..], &text[1..]) + } + ch => { + if text.first().copied() != Some(ch) { + return false; + } + glob_match_bytes(&pattern[1..], &text[1..]) + } + } +} + +fn tracing_warn(msg: &str) { + // Use eprintln since tracing may not be configured in library context + eprintln!("warn: {msg}"); +} diff --git a/bread-sync/src/git.rs b/bread-sync/src/git.rs new file mode 100644 index 0000000..581efbc --- /dev/null +++ b/bread-sync/src/git.rs @@ -0,0 +1,227 @@ +use std::path::Path; + +use anyhow::{anyhow, Result}; + +/// Open an existing repo or initialise a new one at `path`. +pub fn init_or_open(path: &Path) -> Result { + if path.join(".git").exists() || is_bare(path) { + Ok(git2::Repository::open(path)?) + } else { + std::fs::create_dir_all(path)?; + Ok(git2::Repository::init(path)?) + } +} + +/// Clone `url` to `path` if `path` is not already a repo, otherwise open it. +pub fn clone_or_open(url: &str, path: &Path) -> Result { + if path.join(".git").exists() || is_bare(path) { + return Ok(git2::Repository::open(path)?); + } + let mut builder = git2::build::RepoBuilder::new(); + let mut fetch_opts = git2::FetchOptions::new(); + fetch_opts.remote_callbacks(make_callbacks()); + builder.fetch_options(fetch_opts); + std::fs::create_dir_all(path)?; + Ok(builder.clone(url, path)?) +} + +/// Stage every tracked and untracked change (equivalent to `git add -A`). +pub fn stage_all(repo: &git2::Repository) -> Result<()> { + let mut index = repo.index()?; + index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?; + // Remove entries for deleted files + index.update_all(["*"].iter(), None)?; + index.write()?; + Ok(()) +} + +/// Returns `true` if the index has staged changes compared to HEAD (or repo is new). +pub fn has_changes(repo: &git2::Repository) -> Result { + let mut index = repo.index()?; + index.read(false)?; + + // New repo with no commits yet + if repo.head().is_err() { + return Ok(index.len() > 0); + } + + let head = repo.head()?.peel_to_tree()?; + let diff = repo.diff_tree_to_index(Some(&head), Some(&index), None)?; + Ok(diff.deltas().count() > 0) +} + +/// Commit all staged changes with `message`. Returns the new commit OID. +pub fn commit(repo: &git2::Repository, message: &str) -> Result { + let mut index = repo.index()?; + let tree_id = index.write_tree()?; + let tree = repo.find_tree(tree_id)?; + let sig = repo.signature().unwrap_or_else(|_| { + git2::Signature::now("bread", "bread@localhost").expect("signature") + }); + + let oid = if let Ok(head) = repo.head() { + let parent = head.peel_to_commit()?; + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])? + } else { + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])? + }; + Ok(oid) +} + +/// Push `branch` to `remote_name` (defaults to "origin"). +pub fn push(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> { + let mut remote = repo.find_remote(remote_name)?; + let mut opts = git2::PushOptions::new(); + opts.remote_callbacks(make_callbacks()); + remote.push( + &[&format!("refs/heads/{branch}:refs/heads/{branch}")], + Some(&mut opts), + )?; + Ok(()) +} + +/// Fetch from `remote_name` without merging. +pub fn fetch(repo: &git2::Repository, remote_name: &str) -> Result<()> { + let mut remote = repo.find_remote(remote_name)?; + let mut opts = git2::FetchOptions::new(); + opts.remote_callbacks(make_callbacks()); + remote.fetch(&[] as &[&str], Some(&mut opts), None)?; + Ok(()) +} + +/// Fetch and fast-forward merge from `remote_name/branch`. Errors on conflict. +pub fn pull(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> { + fetch(repo, remote_name)?; + + let fetch_head = repo + .find_reference(&format!("refs/remotes/{remote_name}/{branch}")) + .map_err(|_| anyhow!("remote branch {remote_name}/{branch} not found after fetch"))?; + let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?; + + let analysis = repo.merge_analysis(&[&fetch_commit])?; + if analysis.0.is_up_to_date() { + return Ok(()); + } + if !analysis.0.is_fast_forward() { + return Err(anyhow!( + "sync conflict — resolve manually in {}", + repo.workdir() + .unwrap_or_else(|| Path::new("?")) + .display() + )); + } + + // Fast-forward: update HEAD and checkout + let head_ref = repo.find_reference("HEAD")?; + let resolved = head_ref.resolve()?; + let refname = resolved.name().unwrap_or("HEAD").to_string(); + repo.find_reference(&refname)? + .set_target(fetch_commit.id(), "fast-forward")?; + repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?; + Ok(()) +} + +/// Add a remote named `name` pointing at `url`, or update it if it already exists. +pub fn set_remote(repo: &git2::Repository, name: &str, url: &str) -> Result<()> { + if repo.find_remote(name).is_ok() { + repo.remote_set_url(name, url)?; + } else { + repo.remote(name, url)?; + } + Ok(()) +} + +/// Return working-tree diff against HEAD as a unified diff string. +pub fn diff_workdir(repo: &git2::Repository) -> Result { + let mut buf = Vec::new(); + if let Ok(head_tree) = repo.head().and_then(|h| h.peel_to_tree()) { + let diff = repo.diff_tree_to_workdir_with_index(Some(&head_tree), None)?; + diff.print(git2::DiffFormat::Patch, |_, _, line| { + buf.extend_from_slice(line.content()); + true + })?; + } + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +/// Return diff between HEAD and `remote/branch` as a unified diff string. +pub fn diff_remote(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result { + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let remote_tree = repo + .find_reference(&remote_ref) + .map_err(|_| anyhow!("remote ref {remote_ref} not found — try fetching first"))? + .peel_to_tree()?; + let local_tree = repo.head()?.peel_to_tree()?; + let diff = repo.diff_tree_to_tree(Some(&local_tree), Some(&remote_tree), None)?; + let mut buf = Vec::new(); + diff.print(git2::DiffFormat::Patch, |_, _, line| { + buf.extend_from_slice(line.content()); + true + })?; + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +/// Return a list of `(status_char, path)` for the working tree. +pub fn status_lines(repo: &git2::Repository) -> Result> { + let statuses = repo.statuses(None)?; + let mut out = Vec::new(); + for entry in statuses.iter() { + let path = entry.path().unwrap_or("?").to_string(); + let flag = entry.status(); + let ch = if flag.contains(git2::Status::INDEX_NEW) || flag.contains(git2::Status::WT_NEW) { + 'A' + } else if flag.contains(git2::Status::INDEX_DELETED) || flag.contains(git2::Status::WT_DELETED) { + 'D' + } else { + 'M' + }; + out.push((ch, path)); + } + Ok(out) +} + +/// Returns true if the local HEAD is behind the remote. +pub fn remote_has_changes(repo: &git2::Repository, remote_name: &str, branch: &str) -> bool { + let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); + let Ok(remote_ref) = repo.find_reference(&remote_ref) else { + return false; + }; + let Ok(remote_commit) = remote_ref.peel_to_commit() else { + return false; + }; + let Ok(local_commit) = repo.head().and_then(|h| h.peel_to_commit()) else { + return false; + }; + remote_commit.id() != local_commit.id() +} + +/// Timestamp of the HEAD commit (or "never"). +pub fn last_commit_time(repo: &git2::Repository) -> String { + let Ok(commit) = repo.head().and_then(|h| h.peel_to_commit()) else { + return "never".to_string(); + }; + let ts = commit.time().seconds(); + let dt = chrono::DateTime::::from_timestamp(ts, 0) + .unwrap_or_else(chrono::Utc::now); + dt.format("%Y-%m-%d %H:%M:%S").to_string() +} + +fn is_bare(path: &Path) -> bool { + path.join("HEAD").exists() && path.join("objects").exists() +} + +fn make_callbacks<'a>() -> git2::RemoteCallbacks<'a> { + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.credentials(|_url, username_from_url, allowed_types| { + if allowed_types.contains(git2::CredentialType::SSH_KEY) { + git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) + } else if allowed_types.contains(git2::CredentialType::DEFAULT) { + git2::Cred::default() + } else { + Err(git2::Error::from_str( + "no supported credential type (SSH agent or default)", + )) + } + }); + callbacks +} diff --git a/bread-sync/src/lib.rs b/bread-sync/src/lib.rs new file mode 100644 index 0000000..454a78a --- /dev/null +++ b/bread-sync/src/lib.rs @@ -0,0 +1,10 @@ +pub mod config; +pub mod delegates; +pub mod git; +pub mod machine; +pub mod packages; + +pub use config::{ + bread_config_dir, config_path, sync_repo_path, DelegatesConfig, MachineConfig, PackagesConfig, + RemoteConfig, SyncConfig, +}; diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs new file mode 100644 index 0000000..e4e4bb1 --- /dev/null +++ b/bread-sync/src/machine.rs @@ -0,0 +1,102 @@ +use std::path::Path; + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::config::SyncConfig; + +/// Machine profile persisted to `/machines/.toml`. +#[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 { + pub fn new(cfg: &SyncConfig) -> Result { + let host = hostname()?; + let name = cfg.machine.name.clone().unwrap_or_else(|| host.clone()); + Ok(Self { + name, + hostname: host, + tags: cfg.machine.tags.clone(), + last_sync: Utc::now().to_rfc3339(), + }) + } + + /// Write profile to `/machines/.toml`. + pub fn write_to_repo(&self, repo_root: &Path) -> Result<()> { + let machines_dir = repo_root.join("machines"); + std::fs::create_dir_all(&machines_dir)?; + let path = machines_dir.join(format!("{}.toml", self.name)); + let raw = toml::to_string_pretty(self)?; + std::fs::write(&path, raw)?; + Ok(()) + } + + /// Load from `/machines/.toml`. + pub fn load_from_repo(repo_root: &Path, name: &str) -> Result { + let path = repo_root.join("machines").join(format!("{name}.toml")); + let raw = std::fs::read_to_string(&path)?; + Ok(toml::from_str(&raw)?) + } +} + +/// List all machine profiles in `/machines/`. +pub fn list_machines(repo_root: &Path) -> Vec { + let machines_dir = repo_root.join("machines"); + let Ok(entries) = std::fs::read_dir(&machines_dir) else { + return Vec::new(); + }; + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("toml")) + .filter_map(|e| { + std::fs::read_to_string(e.path()) + .ok() + .and_then(|raw| toml::from_str::(&raw).ok()) + }) + .collect() +} + +/// Returns the machine name from sync.toml, falling back to hostname. +pub fn machine_name(cfg: &SyncConfig) -> Result { + if let Some(name) = cfg.machine.name.as_deref() { + return Ok(name.to_string()); + } + hostname() +} + +/// Returns the machine tags from sync.toml. +pub fn machine_tags(cfg: &SyncConfig) -> Vec { + cfg.machine.tags.clone() +} + +/// Returns true if `tag` is in the machine's tag list. +pub fn machine_has_tag(cfg: &SyncConfig, tag: &str) -> bool { + cfg.machine.tags.iter().any(|t| t == tag) +} + +fn hostname() -> Result { + // Try /etc/hostname first (no subprocess) + if let Ok(raw) = std::fs::read_to_string("/etc/hostname") { + let trimmed = raw.trim().to_string(); + if !trimmed.is_empty() { + return Ok(trimmed); + } + } + // Fall back to hostname(1) + let out = std::process::Command::new("hostname") + .output() + .map_err(anyhow::Error::from)?; + let s = String::from_utf8(out.stdout).map_err(anyhow::Error::from)?; + Ok(s.trim().to_string()) +} + +#[allow(dead_code)] +fn format_last_sync(dt: &DateTime) -> String { + dt.format("%Y-%m-%d %H:%M").to_string() +} diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs new file mode 100644 index 0000000..333e0aa --- /dev/null +++ b/bread-sync/src/packages.rs @@ -0,0 +1,137 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::Result; + +/// Write package manifests to `/packages/`. +/// Skips package managers that are not installed (warns instead of erroring). +pub fn snapshot_packages(managers: &[String], repo_root: &Path) -> Result<()> { + let pkg_dir = repo_root.join("packages"); + std::fs::create_dir_all(&pkg_dir)?; + + for mgr in managers { + match mgr.as_str() { + "pacman" => { + if let Some(content) = run_pacman() { + std::fs::write(pkg_dir.join("pacman.txt"), content)?; + } else { + eprintln!("warn: pacman not found, skipping package snapshot"); + } + } + "pip" => { + if let Some(content) = run_pip() { + std::fs::write(pkg_dir.join("pip.txt"), content)?; + } else { + eprintln!("warn: pip not found, skipping package snapshot"); + } + } + "npm" => { + if let Some(content) = run_npm() { + std::fs::write(pkg_dir.join("npm.txt"), content)?; + } else { + eprintln!("warn: npm not found, skipping package snapshot"); + } + } + "cargo" => { + if let Some(content) = run_cargo() { + std::fs::write(pkg_dir.join("cargo.txt"), content)?; + } else { + eprintln!("warn: cargo not found, skipping package snapshot"); + } + } + other => { + eprintln!("warn: unknown package manager '{other}', skipping"); + } + } + } + Ok(()) +} + +/// Parse a `pacman.txt` snapshot into a list of package names. +pub fn parse_pacman(content: &str) -> Vec { + content.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect() +} + +/// Parse a `pip.txt` (freeze format) snapshot into package names. +pub fn parse_pip(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .filter_map(|l| l.split("==").next().map(|s| s.trim().to_string())) + .collect() +} + +/// Parse an `npm.txt` (parseable) snapshot into package names. +pub fn parse_npm(content: &str) -> Vec { + content + .lines() + .skip(1) // first line is the npm global prefix path + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| { + Path::new(l.trim()) + .file_name() + .and_then(|n| n.to_str()) + .map(ToString::to_string) + }) + .collect() +} + +/// Parse `cargo install --list` output into `name version` lines. +pub fn parse_cargo(content: &str) -> Vec { + content + .lines() + .filter(|l| !l.starts_with(' ') && !l.trim().is_empty()) + .filter_map(|l| { + // Format: `name v1.2.3 (...):` or `name v1.2.3:` + let parts: Vec<&str> = l.splitn(2, ' ').collect(); + if parts.len() == 2 { + let name = parts[0]; + let version = parts[1].trim_start_matches('v').split_whitespace().next().unwrap_or("?").trim_end_matches(':'); + Some(format!("{name} {version}")) + } else { + None + } + }) + .collect() +} + +fn run_pacman() -> Option { + let output = Command::new("pacman").args(["-Qe"]).output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + +fn run_pip() -> Option { + let output = Command::new("pip") + .args(["list", "--user", "--format=freeze"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + +fn run_npm() -> Option { + let output = Command::new("npm") + .args(["list", "-g", "--depth=0", "--parseable"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + +fn run_cargo() -> Option { + let output = Command::new("cargo") + .args(["install", "--list"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} diff --git a/bread-sync/tests/sync.rs b/bread-sync/tests/sync.rs new file mode 100644 index 0000000..ce76abf --- /dev/null +++ b/bread-sync/tests/sync.rs @@ -0,0 +1 @@ +// Placeholder - tests will be added in step 15 diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index ade7d2f..36189a0 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -22,20 +22,25 @@ 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 payloads = match enumerate_with_udev(&self.subsystems) { + Ok(p) => p, + Err(_) => scan_devices(&self.subsystems) + .unwrap_or_default() + .into_iter() + .map(|d| json!({ + "action": "add", + "id": d.id, + "name": d.name, + "subsystem": d.subsystem, + })) + .collect(), + }; - for device in devices { + for payload in payloads { tx.send(RawEvent { source: AdapterSource::Udev, kind: "udev.enumerate".to_string(), - payload: json!({ - "action": "add", - "id": device.id, - "name": device.name, - "subsystem": device.subsystem, - }), + payload, timestamp: now_unix_ms(), }) .await?; @@ -164,7 +169,7 @@ async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) - Ok(()) } -fn enumerate_with_udev(subsystems: &[String]) -> Result> { +fn enumerate_with_udev(subsystems: &[String]) -> Result> { let mut enumerator = udev::Enumerator::new()?; for subsystem in subsystems { enumerator.match_subsystem(subsystem)?; @@ -184,16 +189,38 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result> { .unwrap_or_else(|| "unknown".to_string()); let id = dev.syspath().to_string_lossy().to_string(); - out.push(ScannedDevice { - id, - name, - subsystem, - }); + out.push(json!({ + "action": "add", + "id": id, + "name": name, + "subsystem": subsystem, + "id_input_keyboard": dev_prop_bool(&dev, "ID_INPUT_KEYBOARD"), + "id_input_mouse": dev_prop_bool(&dev, "ID_INPUT_MOUSE"), + "id_input_joystick": dev_prop_bool(&dev, "ID_INPUT_JOYSTICK"), + "id_input_touchpad": dev_prop_bool(&dev, "ID_INPUT_TOUCHPAD"), + "id_input_tablet": dev_prop_bool(&dev, "ID_INPUT_TABLET"), + "id_usb_class": dev_prop_str(&dev, "ID_USB_CLASS"), + "id_usb_interfaces": dev_prop_str(&dev, "ID_USB_INTERFACES"), + "id_vendor": dev_prop_str(&dev, "ID_VENDOR"), + "id_model": dev_prop_str(&dev, "ID_MODEL"), + })); } Ok(out) } +fn dev_prop_bool(dev: &udev::Device, key: &str) -> bool { + dev.property_value(key) + .and_then(|v| v.to_str()) + .map(|v| v == "1") + .unwrap_or(false) +} + +fn dev_prop_str(dev: &udev::Device, key: &str) -> Option { + dev.property_value(key) + .map(|v| v.to_string_lossy().to_string()) +} + fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent { RawEvent { source: AdapterSource::Udev, From 45d5979252c5602b8a56babe40b3a8fb4377c314 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 20:58:31 +0800 Subject: [PATCH 15/76] Remove markdown --- .gitignore | 3 +- LUA_RUNTIME.md | 527 ------------------------------------------------- 2 files changed, 2 insertions(+), 528 deletions(-) delete mode 100644 LUA_RUNTIME.md diff --git a/.gitignore b/.gitignore index f8f98d0..9529cb8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ Overview.md DAEMON.md .claude CLAUDE.md -.github \ No newline at end of file +.github +LUA_RUNTIME.md \ No newline at end of file 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. From 1ef29d1b3e1e62617800eb674199cce6a6c7073b Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 20:58:31 +0800 Subject: [PATCH 16/76] Remove markdown --- .gitignore | 3 +- LUA_RUNTIME.md | 527 ------------------------------------------------- 2 files changed, 2 insertions(+), 528 deletions(-) delete mode 100644 LUA_RUNTIME.md diff --git a/.gitignore b/.gitignore index f8f98d0..9529cb8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ Overview.md DAEMON.md .claude CLAUDE.md -.github \ No newline at end of file +.github +LUA_RUNTIME.md \ No newline at end of file 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. From f05d6ba602793fa64a9003799fa17f9da2e47532 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 21:54:43 +0800 Subject: [PATCH 17/76] Update README and add documentation and examples for Bread automation --- Documentation.md | 496 +++++++++++++++++++++++++++++++++++++++++++++++ Examples.md | 187 ++++++++++++++++++ README.md | 84 +++++++- 3 files changed, 761 insertions(+), 6 deletions(-) create mode 100644 Documentation.md create mode 100644 Examples.md diff --git a/Documentation.md b/Documentation.md new file mode 100644 index 0000000..c2ad50c --- /dev/null +++ b/Documentation.md @@ -0,0 +1,496 @@ +# Bread Documentation + +## Contents + +- [Overview](#overview) +- [Getting started](#getting-started) +- [Your first module](#your-first-module) +- [Run, reload, and watch](#run-reload-and-watch) +- [Debugging tips](#debugging-tips) +- [Dictionary: Lua API](#dictionary-lua-api) +- [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: long-running Rust process, source of truth for runtime state +- Lua runtime: dedicated thread inside the daemon; automation logic lives here +- CLI: talks to the daemon over a Unix socket + +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` +- Lua entry point: `~/.config/bread/init.lua` +- Lua modules: `~/.config/bread/modules/` + +### 2) Minimal `init.lua` + +```lua +require("modules.devices") +require("modules.workspaces") + +bread.on("bread.system.startup", function() + bread.profile.activate("default") +end) +``` + +### 3) Add your first module + +Create a Lua file under your modules directory and load it from `init.lua`. + +## Your first module + +```lua +local M = bread.module({ name = "hello", version = "0.1.0" }) + +function M.on_load() + bread.log("hello from bread") + + bread.on("bread.device.*", function(event) + bread.log(event.event) + end) +end + +return M +``` + +Why this shape? + +- Every module must call `bread.module` once. +- `on_load` is a good place to register subscriptions. +- Use `bread.log` early to verify handlers are firing. + +## Run, reload, and watch + +- Start the daemon, then use `bread reload` after editing Lua. +- `bread reload --watch` will keep reloading on changes. +- See [Examples.md](Examples.md) for real-world ports. + +## Debugging tips + +- Log event payloads with `bread.log(event.data.raw)` when matching devices. +- Use `bread.events` in the CLI to see live normalized events. +- Use `bread state` to see runtime state as JSON. + +## Lua module system + +### Entry point and module scanning + +- `init.lua` is executed first. +- Modules are discovered by scanning `~/.config/bread/modules/` for `.lua` files. +- Every module must call `bread.module` exactly once at top-level. +- Modules are ordered by the `after` dependency list. + +### Module declaration + +```lua +local M = bread.module({ + name = "my.module", + version = "0.1.0", + after = { "bread.devices" }, +}) + +return M +``` + +If a module does not call `bread.module`, it fails to load and is marked as a load error. + +### Require loader + +`require("bread.")` resolves to a Lua file under the module path. For example: + +```lua +local utils = require("bread.lib.utils") +``` + +This loads `~/.config/bread/modules/lib/utils.lua` if it exists. Non-`bread.*` `require` calls fall back to standard Lua behavior. + +### Lifecycle hooks + +Modules may export any of the following hooks. All are optional. + +```lua +function M.on_load() + -- register subscriptions, initialize module state +end + +function M.on_reload() + -- called after a hot reload completes +end + +function M.on_unload() + -- called before the Lua instance is dropped +end + +function M.on_error(err) + -- called when a handler throws + -- return true to keep the subscription, false to cancel it + return true +end +``` + +### Module storage + +Each module has a scoped key-value store that survives reloads: + +```lua +M.store.set("last_profile", "docked") +local value = M.store.get("last_profile") +``` + +The store lives in the daemon runtime state and is not shared across modules. + +## Dictionary: Lua API + +Every API is exposed through the `bread` global table. + +### Events + +#### `bread.on(pattern, fn) -> id` +Subscribe to matching events. Returns a numeric subscription ID. + +#### `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 filter. `opts` must contain `filter`: + +```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 or state watch by ID. + +#### `bread.emit(event, data)` +Emit a custom event into the system pipeline. + +#### `bread.wait(pattern, opts) -> event | nil` +Coroutine-only helper that waits for a matching event. + +```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 the coroutine fails. + +### State + +#### `bread.state.get(path)` +Read a state subtree by dotted path (e.g. `"network.online"`). + +#### Convenience helpers + +- `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. The callback receives `(new, old)`. + +```lua +bread.state.watch("power.ac_connected", function(new_val, old_val) + if new_val then + bread.exec("notify-send 'AC connected'") + end +end) +``` + +### Profiles + +#### `bread.profile.activate(name)` +Update the active profile. The CLI also emits `bread.profile.activated` over IPC; the Lua API does not emit this event on its own. + +### Execution + +#### `bread.exec(cmd)` +Runs `cmd` in a `sh -lc` shell. Fire-and-forget (async). + +### Notifications + +#### `bread.notify(message, opts)` +Sends a desktop notification via `notify-send`. + +Options: + +- `title` (string, default: `"bread"`) +- `urgency` (string, default from config) +- `timeout` (ms, default from config) +- `icon` (string, optional) + +Calling `bread.notify` emits `bread.notify.sent` with `{ title, message, urgency }`. + +### Timers + +#### `bread.after(delay_ms, fn) -> id` +Run once after delay. + +#### `bread.every(interval_ms, fn) -> id` +Run repeatedly on an interval. + +#### `bread.cancel(id)` +Cancel a timer created by `after` or `every`. + +### Utilities + +#### `bread.debounce(delay_ms, fn) -> wrapped_fn` +Returns a wrapper that only fires after quiet time. + +#### `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)` +Log helpers that accept any Lua value. + +### Hyprland + +The `bread.hyprland` namespace provides compositor bindings: + +- `bread.hyprland.dispatch(cmd, args)` +- `bread.hyprland.keyword(key, value)` +- `bread.hyprland.active_window()` +- `bread.hyprland.monitors()` +- `bread.hyprland.workspaces()` +- `bread.hyprland.clients()` +- `bread.hyprland.on_raw(kind, fn) -> id` + +`bread.hyprland.on_raw` filters raw Hyprland events by `kind` and delivers the full event payload (including the original raw string). + +## Dictionary: Built-in modules + +Built-ins are enabled by default. Disable them via `[modules].disable` in the config. + +### `bread.monitors` + +```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"), +}) +``` + +- `monitors.on({ when, monitors, run })` +- `monitors.layout(name, fn)` +- `monitors.apply(name) -> fn` + +`when` is one of `connected`, `disconnected`, `changed`. `run` may be a function or a shell command string. + +### `bread.devices` + +```lua +local devices = require("bread.devices") + +devices.register("Keychron", "keyboard") + +devices.on({ + when = "connected", + class = "keyboard", + run = function(event) + bread.exec("xset r rate 200 40") + end, +}) +``` + +- `devices.on({ when, class, name, run })` +- `devices.register(pattern, class)` + +`class` may be `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. + +### `bread.workspaces` + +```lua +local workspaces = require("bread.workspaces") + +workspaces.assign("1", "HDMI-A-1") +workspaces.pin({ app = "Firefox", workspace = "2" }) +``` + +- `workspaces.assign(workspace, monitor)` +- `workspaces.pin({ app, workspace })` +- `workspaces.apply_assignments()` + +### `bread.binds` + +```lua +local binds = require("bread.binds") + +binds.add({ + mods = { "SUPER" }, + key = "Return", + dispatch = "exec", + args = "kitty", +}) +``` + +- `binds.add({ mods, key, dispatch, args })` +- `binds.remove(key)` +- `binds.replace(key, opts)` + +## Dictionary: Event reference + +Events are delivered as a `BreadEvent`: + +```json +{ + "event": "bread.device.dock.connected", + "timestamp": 1710000000000, + "source": "Udev", + "data": {} +} +``` + +### Pattern matching + +Patterns match event names with glob-style syntax: + +- Exact match: `bread.device.dock.connected` +- `*` matches within a single segment (does not cross `.`) +- `**` matches across segments (recursive) +- `?` matches a single character within a segment + +Examples: + +```lua +bread.on("bread.device.*", handler) +bread.on("bread.device.**", handler) +bread.on("bread.monitor.?", handler) +``` + +### Normalized events + +#### System + +- `bread.system.startup` (data: `{}`) + +#### Devices (udev) + +- `bread.device.connected` +- `bread.device.disconnected` +- `bread.device.changed` +- `bread.device..connected` +- `bread.device..disconnected` +- `bread.device..changed` + +Payload notes: + +- Device events include `id` and `class`; the generic event also includes `raw`. +- `` is one of: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. + +#### Hyprland + +- `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` (raw payload for unhandled kinds) + +Raw Hyprland payloads contain `kind`, `raw`, and `data` fields. + +#### Power + +- `bread.power.ac.connected` +- `bread.power.ac.disconnected` +- `bread.power.battery.low` +- `bread.power.battery.very_low` +- `bread.power.battery.critical` +- `bread.power.battery.full` +- `bread.power.changed` (fallback) + +Payload includes `ac_connected` and `battery_percent`. + +#### Network + +- `bread.network.connected` +- `bread.network.disconnected` + +Payload includes `online` and `interfaces`. + +#### Other system events + +- `bread.profile.activated` (emitted by IPC profile activation) +- `bread.notify.sent` (emitted by `bread.notify`) +- `bread.state.changed.` (emitted when a state watch fires) + +## Dictionary: Runtime state schema + +`bread.state.get("")` returns the full `RuntimeState`: + +```json +{ + "monitors": [ { "name": "HDMI-A-1", "connected": true } ], + "workspaces": [ { "id": "1", "monitor": "HDMI-A-1" } ], + "active_workspace": "1", + "active_window": "Firefox", + "devices": { "connected": [] }, + "network": { "interfaces": {}, "online": false }, + "power": { "ac_connected": false, "battery_percent": null, "battery_low": false }, + "profile": { "active": "default", "history": [], "profiles": {} }, + "modules": [ { "name": "bread.devices", "status": "loaded", "last_error": null, "builtin": true, "store": {} } ] +} +``` + +## 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: + +- `ping` +- `health` +- `state.get` +- `state.dump` +- `modules.list` +- `modules.reload` +- `profile.list` +- `profile.activate` +- `events.subscribe` +- `events.replay` +- `emit` + +`events.subscribe` upgrades the socket to streaming mode and sends events as they occur. 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/README.md b/README.md index 73512df..ec62008 100644 --- a/README.md +++ b/README.md @@ -191,29 +191,42 @@ 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.changed` | Any device changed | +| `bread.device..connected` | Device attached by class | +| `bread.device..disconnected` | Device removed by class | +| `bread.device..changed` | Device changed by class | | `bread.monitor.connected` | Display connected | | `bread.monitor.disconnected` | Display disconnected | | `bread.workspace.changed` | Active workspace changed | +| `bread.workspace.created` | Workspace created | +| `bread.workspace.destroyed` | Workspace destroyed | | `bread.window.focus.changed` | Focused window changed | +| `bread.window.focused` | Focus moved (address only) | | `bread.window.opened` | Window opened | | `bread.window.closed` | Window closed | +| `bread.window.moved` | Window moved workspaces | | `bread.power.ac.connected` | AC adapter plugged in | | `bread.power.ac.disconnected` | AC adapter unplugged | | `bread.power.battery.low` | Battery ≤ 20% | | `bread.power.battery.very_low` | Battery ≤ 10% | | `bread.power.battery.critical` | Battery ≤ 5% | | `bread.power.battery.full` | Battery at 100% | -| `bread.network.connected` | Network interface came online | -| `bread.network.disconnected` | Network interface went offline | -| `bread.profile.activated` | Profile switched | +| `bread.power.changed` | Power state changed (fallback) | +| `bread.network.connected` | Network came online | +| `bread.network.disconnected` | Network went offline | +| `bread.profile.activated` | Profile switched via IPC | +| `bread.notify.sent` | Notification dispatched | +| `bread.state.changed.` | State watch fired | +| `bread.hyprland.event` | Raw Hyprland event (unhandled kind) | --- ## Lua API +Full reference and usage notes live in [documentation.md](documentation.md). This section is a compact quick-reference to every API that exists today. + +Practical walkthroughs and ports from existing Hyprland configs live in [Examples.md](Examples.md). + ### Events ```lua @@ -239,6 +252,14 @@ end) -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) + +-- Wait for an event (coroutine-only) +bread.spawn(function() + local event = bread.wait("bread.device.dock.connected", { timeout = 5000 }) + if event then + bread.log("dock arrived") + end +end) ``` ### State @@ -254,6 +275,15 @@ local devices = bread.state.get("devices") bread.state.watch("active_workspace", function(new, old) print("workspace changed from " .. tostring(old) .. " to " .. tostring(new)) end) + +-- Convenience helpers +local monitors = bread.state.monitors() +local active_ws = bread.state.active_workspace() +local active_win = bread.state.active_window() +local devices = bread.state.devices() +local power = bread.state.power() +local network = bread.state.network() +local profile = bread.state.profile() ``` ### Profiles @@ -291,6 +321,10 @@ bread.cancel(id) local fn = bread.debounce(200, function(event) reconfigure_monitors() end) + +-- Cancel a timer +local timer_id = bread.after(500, function() bread.exec("echo ready") end) +bread.cancel(timer_id) ``` ### Logging @@ -299,6 +333,44 @@ end) bread.log("Module loaded") bread.warn("Unexpected state") bread.error("Something failed") + +### Hyprland + +```lua +bread.hyprland.dispatch("workspace", "2") +bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1") + +local win = bread.hyprland.active_window() +local monitors = bread.hyprland.monitors() +local workspaces = bread.hyprland.workspaces() +local clients = bread.hyprland.clients() + +-- Raw Hyprland event filtering (kind matches hyprland event name) +bread.hyprland.on_raw("openwindow", function(event) + bread.log(event.data.raw) +end) +``` + +### Modules + +```lua +local M = bread.module({ name = "my.module", version = "0.1.0", after = { "bread.devices" } }) + +function M.on_load() + bread.on("bread.device.*", function(event) + bread.log(event.event) + end) +end + +function M.on_unload() + bread.log("unloaded") +end + +M.store.set("last_seen", os.time()) +local last = M.store.get("last_seen") + +return M +``` ``` --- From a2b7391a715fbce1dbe9dc058611b0aac2bfeb5b Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 21:54:43 +0800 Subject: [PATCH 18/76] Update README and add documentation and examples for Bread automation --- Documentation.md | 496 +++++++++++++++++++++++++++++++++++++++++++++++ Examples.md | 187 ++++++++++++++++++ README.md | 84 +++++++- 3 files changed, 761 insertions(+), 6 deletions(-) create mode 100644 Documentation.md create mode 100644 Examples.md diff --git a/Documentation.md b/Documentation.md new file mode 100644 index 0000000..c2ad50c --- /dev/null +++ b/Documentation.md @@ -0,0 +1,496 @@ +# Bread Documentation + +## Contents + +- [Overview](#overview) +- [Getting started](#getting-started) +- [Your first module](#your-first-module) +- [Run, reload, and watch](#run-reload-and-watch) +- [Debugging tips](#debugging-tips) +- [Dictionary: Lua API](#dictionary-lua-api) +- [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: long-running Rust process, source of truth for runtime state +- Lua runtime: dedicated thread inside the daemon; automation logic lives here +- CLI: talks to the daemon over a Unix socket + +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` +- Lua entry point: `~/.config/bread/init.lua` +- Lua modules: `~/.config/bread/modules/` + +### 2) Minimal `init.lua` + +```lua +require("modules.devices") +require("modules.workspaces") + +bread.on("bread.system.startup", function() + bread.profile.activate("default") +end) +``` + +### 3) Add your first module + +Create a Lua file under your modules directory and load it from `init.lua`. + +## Your first module + +```lua +local M = bread.module({ name = "hello", version = "0.1.0" }) + +function M.on_load() + bread.log("hello from bread") + + bread.on("bread.device.*", function(event) + bread.log(event.event) + end) +end + +return M +``` + +Why this shape? + +- Every module must call `bread.module` once. +- `on_load` is a good place to register subscriptions. +- Use `bread.log` early to verify handlers are firing. + +## Run, reload, and watch + +- Start the daemon, then use `bread reload` after editing Lua. +- `bread reload --watch` will keep reloading on changes. +- See [Examples.md](Examples.md) for real-world ports. + +## Debugging tips + +- Log event payloads with `bread.log(event.data.raw)` when matching devices. +- Use `bread.events` in the CLI to see live normalized events. +- Use `bread state` to see runtime state as JSON. + +## Lua module system + +### Entry point and module scanning + +- `init.lua` is executed first. +- Modules are discovered by scanning `~/.config/bread/modules/` for `.lua` files. +- Every module must call `bread.module` exactly once at top-level. +- Modules are ordered by the `after` dependency list. + +### Module declaration + +```lua +local M = bread.module({ + name = "my.module", + version = "0.1.0", + after = { "bread.devices" }, +}) + +return M +``` + +If a module does not call `bread.module`, it fails to load and is marked as a load error. + +### Require loader + +`require("bread.")` resolves to a Lua file under the module path. For example: + +```lua +local utils = require("bread.lib.utils") +``` + +This loads `~/.config/bread/modules/lib/utils.lua` if it exists. Non-`bread.*` `require` calls fall back to standard Lua behavior. + +### Lifecycle hooks + +Modules may export any of the following hooks. All are optional. + +```lua +function M.on_load() + -- register subscriptions, initialize module state +end + +function M.on_reload() + -- called after a hot reload completes +end + +function M.on_unload() + -- called before the Lua instance is dropped +end + +function M.on_error(err) + -- called when a handler throws + -- return true to keep the subscription, false to cancel it + return true +end +``` + +### Module storage + +Each module has a scoped key-value store that survives reloads: + +```lua +M.store.set("last_profile", "docked") +local value = M.store.get("last_profile") +``` + +The store lives in the daemon runtime state and is not shared across modules. + +## Dictionary: Lua API + +Every API is exposed through the `bread` global table. + +### Events + +#### `bread.on(pattern, fn) -> id` +Subscribe to matching events. Returns a numeric subscription ID. + +#### `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 filter. `opts` must contain `filter`: + +```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 or state watch by ID. + +#### `bread.emit(event, data)` +Emit a custom event into the system pipeline. + +#### `bread.wait(pattern, opts) -> event | nil` +Coroutine-only helper that waits for a matching event. + +```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 the coroutine fails. + +### State + +#### `bread.state.get(path)` +Read a state subtree by dotted path (e.g. `"network.online"`). + +#### Convenience helpers + +- `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. The callback receives `(new, old)`. + +```lua +bread.state.watch("power.ac_connected", function(new_val, old_val) + if new_val then + bread.exec("notify-send 'AC connected'") + end +end) +``` + +### Profiles + +#### `bread.profile.activate(name)` +Update the active profile. The CLI also emits `bread.profile.activated` over IPC; the Lua API does not emit this event on its own. + +### Execution + +#### `bread.exec(cmd)` +Runs `cmd` in a `sh -lc` shell. Fire-and-forget (async). + +### Notifications + +#### `bread.notify(message, opts)` +Sends a desktop notification via `notify-send`. + +Options: + +- `title` (string, default: `"bread"`) +- `urgency` (string, default from config) +- `timeout` (ms, default from config) +- `icon` (string, optional) + +Calling `bread.notify` emits `bread.notify.sent` with `{ title, message, urgency }`. + +### Timers + +#### `bread.after(delay_ms, fn) -> id` +Run once after delay. + +#### `bread.every(interval_ms, fn) -> id` +Run repeatedly on an interval. + +#### `bread.cancel(id)` +Cancel a timer created by `after` or `every`. + +### Utilities + +#### `bread.debounce(delay_ms, fn) -> wrapped_fn` +Returns a wrapper that only fires after quiet time. + +#### `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)` +Log helpers that accept any Lua value. + +### Hyprland + +The `bread.hyprland` namespace provides compositor bindings: + +- `bread.hyprland.dispatch(cmd, args)` +- `bread.hyprland.keyword(key, value)` +- `bread.hyprland.active_window()` +- `bread.hyprland.monitors()` +- `bread.hyprland.workspaces()` +- `bread.hyprland.clients()` +- `bread.hyprland.on_raw(kind, fn) -> id` + +`bread.hyprland.on_raw` filters raw Hyprland events by `kind` and delivers the full event payload (including the original raw string). + +## Dictionary: Built-in modules + +Built-ins are enabled by default. Disable them via `[modules].disable` in the config. + +### `bread.monitors` + +```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"), +}) +``` + +- `monitors.on({ when, monitors, run })` +- `monitors.layout(name, fn)` +- `monitors.apply(name) -> fn` + +`when` is one of `connected`, `disconnected`, `changed`. `run` may be a function or a shell command string. + +### `bread.devices` + +```lua +local devices = require("bread.devices") + +devices.register("Keychron", "keyboard") + +devices.on({ + when = "connected", + class = "keyboard", + run = function(event) + bread.exec("xset r rate 200 40") + end, +}) +``` + +- `devices.on({ when, class, name, run })` +- `devices.register(pattern, class)` + +`class` may be `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. + +### `bread.workspaces` + +```lua +local workspaces = require("bread.workspaces") + +workspaces.assign("1", "HDMI-A-1") +workspaces.pin({ app = "Firefox", workspace = "2" }) +``` + +- `workspaces.assign(workspace, monitor)` +- `workspaces.pin({ app, workspace })` +- `workspaces.apply_assignments()` + +### `bread.binds` + +```lua +local binds = require("bread.binds") + +binds.add({ + mods = { "SUPER" }, + key = "Return", + dispatch = "exec", + args = "kitty", +}) +``` + +- `binds.add({ mods, key, dispatch, args })` +- `binds.remove(key)` +- `binds.replace(key, opts)` + +## Dictionary: Event reference + +Events are delivered as a `BreadEvent`: + +```json +{ + "event": "bread.device.dock.connected", + "timestamp": 1710000000000, + "source": "Udev", + "data": {} +} +``` + +### Pattern matching + +Patterns match event names with glob-style syntax: + +- Exact match: `bread.device.dock.connected` +- `*` matches within a single segment (does not cross `.`) +- `**` matches across segments (recursive) +- `?` matches a single character within a segment + +Examples: + +```lua +bread.on("bread.device.*", handler) +bread.on("bread.device.**", handler) +bread.on("bread.monitor.?", handler) +``` + +### Normalized events + +#### System + +- `bread.system.startup` (data: `{}`) + +#### Devices (udev) + +- `bread.device.connected` +- `bread.device.disconnected` +- `bread.device.changed` +- `bread.device..connected` +- `bread.device..disconnected` +- `bread.device..changed` + +Payload notes: + +- Device events include `id` and `class`; the generic event also includes `raw`. +- `` is one of: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. + +#### Hyprland + +- `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` (raw payload for unhandled kinds) + +Raw Hyprland payloads contain `kind`, `raw`, and `data` fields. + +#### Power + +- `bread.power.ac.connected` +- `bread.power.ac.disconnected` +- `bread.power.battery.low` +- `bread.power.battery.very_low` +- `bread.power.battery.critical` +- `bread.power.battery.full` +- `bread.power.changed` (fallback) + +Payload includes `ac_connected` and `battery_percent`. + +#### Network + +- `bread.network.connected` +- `bread.network.disconnected` + +Payload includes `online` and `interfaces`. + +#### Other system events + +- `bread.profile.activated` (emitted by IPC profile activation) +- `bread.notify.sent` (emitted by `bread.notify`) +- `bread.state.changed.` (emitted when a state watch fires) + +## Dictionary: Runtime state schema + +`bread.state.get("")` returns the full `RuntimeState`: + +```json +{ + "monitors": [ { "name": "HDMI-A-1", "connected": true } ], + "workspaces": [ { "id": "1", "monitor": "HDMI-A-1" } ], + "active_workspace": "1", + "active_window": "Firefox", + "devices": { "connected": [] }, + "network": { "interfaces": {}, "online": false }, + "power": { "ac_connected": false, "battery_percent": null, "battery_low": false }, + "profile": { "active": "default", "history": [], "profiles": {} }, + "modules": [ { "name": "bread.devices", "status": "loaded", "last_error": null, "builtin": true, "store": {} } ] +} +``` + +## 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: + +- `ping` +- `health` +- `state.get` +- `state.dump` +- `modules.list` +- `modules.reload` +- `profile.list` +- `profile.activate` +- `events.subscribe` +- `events.replay` +- `emit` + +`events.subscribe` upgrades the socket to streaming mode and sends events as they occur. 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/README.md b/README.md index 73512df..ec62008 100644 --- a/README.md +++ b/README.md @@ -191,29 +191,42 @@ 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.changed` | Any device changed | +| `bread.device..connected` | Device attached by class | +| `bread.device..disconnected` | Device removed by class | +| `bread.device..changed` | Device changed by class | | `bread.monitor.connected` | Display connected | | `bread.monitor.disconnected` | Display disconnected | | `bread.workspace.changed` | Active workspace changed | +| `bread.workspace.created` | Workspace created | +| `bread.workspace.destroyed` | Workspace destroyed | | `bread.window.focus.changed` | Focused window changed | +| `bread.window.focused` | Focus moved (address only) | | `bread.window.opened` | Window opened | | `bread.window.closed` | Window closed | +| `bread.window.moved` | Window moved workspaces | | `bread.power.ac.connected` | AC adapter plugged in | | `bread.power.ac.disconnected` | AC adapter unplugged | | `bread.power.battery.low` | Battery ≤ 20% | | `bread.power.battery.very_low` | Battery ≤ 10% | | `bread.power.battery.critical` | Battery ≤ 5% | | `bread.power.battery.full` | Battery at 100% | -| `bread.network.connected` | Network interface came online | -| `bread.network.disconnected` | Network interface went offline | -| `bread.profile.activated` | Profile switched | +| `bread.power.changed` | Power state changed (fallback) | +| `bread.network.connected` | Network came online | +| `bread.network.disconnected` | Network went offline | +| `bread.profile.activated` | Profile switched via IPC | +| `bread.notify.sent` | Notification dispatched | +| `bread.state.changed.` | State watch fired | +| `bread.hyprland.event` | Raw Hyprland event (unhandled kind) | --- ## Lua API +Full reference and usage notes live in [documentation.md](documentation.md). This section is a compact quick-reference to every API that exists today. + +Practical walkthroughs and ports from existing Hyprland configs live in [Examples.md](Examples.md). + ### Events ```lua @@ -239,6 +252,14 @@ end) -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) + +-- Wait for an event (coroutine-only) +bread.spawn(function() + local event = bread.wait("bread.device.dock.connected", { timeout = 5000 }) + if event then + bread.log("dock arrived") + end +end) ``` ### State @@ -254,6 +275,15 @@ local devices = bread.state.get("devices") bread.state.watch("active_workspace", function(new, old) print("workspace changed from " .. tostring(old) .. " to " .. tostring(new)) end) + +-- Convenience helpers +local monitors = bread.state.monitors() +local active_ws = bread.state.active_workspace() +local active_win = bread.state.active_window() +local devices = bread.state.devices() +local power = bread.state.power() +local network = bread.state.network() +local profile = bread.state.profile() ``` ### Profiles @@ -291,6 +321,10 @@ bread.cancel(id) local fn = bread.debounce(200, function(event) reconfigure_monitors() end) + +-- Cancel a timer +local timer_id = bread.after(500, function() bread.exec("echo ready") end) +bread.cancel(timer_id) ``` ### Logging @@ -299,6 +333,44 @@ end) bread.log("Module loaded") bread.warn("Unexpected state") bread.error("Something failed") + +### Hyprland + +```lua +bread.hyprland.dispatch("workspace", "2") +bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1") + +local win = bread.hyprland.active_window() +local monitors = bread.hyprland.monitors() +local workspaces = bread.hyprland.workspaces() +local clients = bread.hyprland.clients() + +-- Raw Hyprland event filtering (kind matches hyprland event name) +bread.hyprland.on_raw("openwindow", function(event) + bread.log(event.data.raw) +end) +``` + +### Modules + +```lua +local M = bread.module({ name = "my.module", version = "0.1.0", after = { "bread.devices" } }) + +function M.on_load() + bread.on("bread.device.*", function(event) + bread.log(event.event) + end) +end + +function M.on_unload() + bread.log("unloaded") +end + +M.store.set("last_seen", os.time()) +local last = M.store.get("last_seen") + +return M +``` ``` --- From d27323d2a2004dab1cc8873483f38dcda27b36a8 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 22:48:49 +0800 Subject: [PATCH 19/76] Refactor UdevAdapter to remove udev monitor fallback and update PKGBUILD for consistent naming --- breadd/src/adapters/udev.rs | 69 +------------------------------------ packaging/arch/PKGBUILD | 7 ++-- 2 files changed, 4 insertions(+), 72 deletions(-) diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index ade7d2f..6c5cea1 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -52,13 +52,7 @@ impl Adapter for UdevAdapter { async fn run(&self, tx: mpsc::Sender) -> Result<()> { debug!("udev adapter started"); - match run_udev_monitor(self.subsystems.clone(), tx.clone()).await { - Ok(()) => return Ok(()), - Err(err) => { - tracing::warn!(error = %err, "udev netlink monitor unavailable, falling back to sysfs polling (add user to 'plugdev' group for real-time events)"); - } - } - + // Fallback: poll sysfs every 2 seconds for environments where the // netlink socket is unavailable (missing plugdev membership, containers, etc). let mut known: HashMap = scan_devices(&self.subsystems) @@ -103,67 +97,6 @@ struct ScannedDevice { subsystem: String, } -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()?; - - 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, - "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"), - }), - timestamp: now_unix_ms(), - }; - - if tx.blocking_send(msg).is_err() { - break; - } - } - - Ok(()) - }) - .await??; - - Ok(()) -} - fn enumerate_with_udev(subsystems: &[String]) -> Result> { let mut enumerator = udev::Enumerator::new()?; for subsystem in subsystems { diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 8ce69ee..66157a7 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,9 +1,9 @@ # Maintainer: Your Name -pkgname=breadd +pkgname=bread pkgver=0.1.0 pkgrel=1 -pkgdesc="Bread daemon - event normalizer and automation runtime" +pkgdesc="Bread - event normalizer and automation runtime" arch=('x86_64') url="https://github.com/Breadway/bread" license=('MIT') @@ -19,7 +19,6 @@ build() { package() { cd "${srcdir}/${pkgname}-${pkgver}" - install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd" install -Dm755 target/release/bread "${pkgdir}/usr/bin/bread" - install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service" + install -Dm644 packaging/systemd/bread.service "${pkgdir}/usr/lib/systemd/user/bread.service" } From c5102639f4efeaff45a5853879558d86fbfc82c0 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 22:48:49 +0800 Subject: [PATCH 20/76] Refactor UdevAdapter to remove udev monitor fallback and update PKGBUILD for consistent naming --- breadd/src/adapters/udev.rs | 69 +------------------------------------ packaging/arch/PKGBUILD | 7 ++-- 2 files changed, 4 insertions(+), 72 deletions(-) diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index ade7d2f..6c5cea1 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -52,13 +52,7 @@ impl Adapter for UdevAdapter { async fn run(&self, tx: mpsc::Sender) -> Result<()> { debug!("udev adapter started"); - match run_udev_monitor(self.subsystems.clone(), tx.clone()).await { - Ok(()) => return Ok(()), - Err(err) => { - tracing::warn!(error = %err, "udev netlink monitor unavailable, falling back to sysfs polling (add user to 'plugdev' group for real-time events)"); - } - } - + // Fallback: poll sysfs every 2 seconds for environments where the // netlink socket is unavailable (missing plugdev membership, containers, etc). let mut known: HashMap = scan_devices(&self.subsystems) @@ -103,67 +97,6 @@ struct ScannedDevice { subsystem: String, } -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()?; - - 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, - "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"), - }), - timestamp: now_unix_ms(), - }; - - if tx.blocking_send(msg).is_err() { - break; - } - } - - Ok(()) - }) - .await??; - - Ok(()) -} - fn enumerate_with_udev(subsystems: &[String]) -> Result> { let mut enumerator = udev::Enumerator::new()?; for subsystem in subsystems { diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 8ce69ee..66157a7 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,9 +1,9 @@ # Maintainer: Your Name -pkgname=breadd +pkgname=bread pkgver=0.1.0 pkgrel=1 -pkgdesc="Bread daemon - event normalizer and automation runtime" +pkgdesc="Bread - event normalizer and automation runtime" arch=('x86_64') url="https://github.com/Breadway/bread" license=('MIT') @@ -19,7 +19,6 @@ build() { package() { cd "${srcdir}/${pkgname}-${pkgver}" - install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd" install -Dm755 target/release/bread "${pkgdir}/usr/bin/bread" - install -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service" + install -Dm644 packaging/systemd/bread.service "${pkgdir}/usr/lib/systemd/user/bread.service" } From 251c586b6f8f6351d210016c81a107e405a226ac Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 22:51:32 +0800 Subject: [PATCH 21/76] revert --- .github/workflows/ci.yml | 42 ++ .gitignore | 6 +- CLAUDE_SPEC.md | 604 ------------------- Cargo.lock | 1090 +---------------------------------- Cargo.toml | 3 +- README.md | 7 +- bread-cli/Cargo.toml | 7 - bread-cli/src/main.rs | 904 ++--------------------------- bread-sync/Cargo.toml | 19 - bread-sync/README.md | 10 - bread-sync/src/config.rs | 124 ---- bread-sync/src/delegates.rs | 205 ------- bread-sync/src/git.rs | 227 -------- bread-sync/src/lib.rs | 10 - bread-sync/src/machine.rs | 102 ---- bread-sync/src/packages.rs | 137 ----- bread-sync/tests/sync.rs | 1 - breadd/src/adapters/udev.rs | 59 +- 18 files changed, 125 insertions(+), 3432 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 CLAUDE_SPEC.md delete mode 100644 bread-sync/Cargo.toml delete mode 100644 bread-sync/README.md delete mode 100644 bread-sync/src/config.rs delete mode 100644 bread-sync/src/delegates.rs delete mode 100644 bread-sync/src/git.rs delete mode 100644 bread-sync/src/lib.rs delete mode 100644 bread-sync/src/machine.rs delete mode 100644 bread-sync/src/packages.rs delete mode 100644 bread-sync/tests/sync.rs 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 f8f98d0..0c56659 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,8 @@ Overview.md DAEMON.md .claude CLAUDE.md -.github \ No newline at end of file +<<<<<<< HEAD +.github +======= +.github/ +>>>>>>> parent of e561156 (Begin Implementing V2 features) diff --git a/CLAUDE_SPEC.md b/CLAUDE_SPEC.md deleted file mode 100644 index 2a2d1df..0000000 --- a/CLAUDE_SPEC.md +++ /dev/null @@ -1,604 +0,0 @@ -# Bread — Sync & Module System Implementation Spec -### Instructions for Claude Code - -This document defines exactly what to build, how it must behave, and what conditions must be met before iteration stops. Read it fully before writing any code. Do not stop iterating until every condition in the **Completion Checklist** at the bottom is met. - ---- - -## Context - -Bread is a reactive desktop automation daemon for Linux. The existing codebase is a Rust workspace with three crates: - -- `breadd/` — the runtime daemon (Rust + Lua via mlua) -- `bread-cli/` — the CLI binary (Rust, talks to daemon over Unix socket IPC) -- `bread-shared/` — shared types (`BreadEvent`, `RawEvent`, `AdapterSource`) - -The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The IPC protocol is newline-delimited JSON request/response. The Lua runtime runs on a dedicated OS thread. All existing code compiles and tests pass — do not break anything that currently works. - -The two things being added in this iteration: - -1. **Module system** — install, list, remove, and update Lua modules from GitHub URLs -2. **Sync** — snapshot and restore system state (Bread config + arbitrary config files + package manifests) via a Git remote - ---- - -## Part 1: Module System - -### What a module is - -A Bread module is a directory (or single `.lua` file) that gets installed into `~/.config/bread/modules/`. Modules are already loaded by the daemon — what's missing is the install/manage layer. - -A module directory looks like: - -``` -~/.config/bread/modules/ -└── wifi/ - ├── bread.module.toml ← module manifest (required) - ├── init.lua ← entry point (required) - └── lib/ ← optional support files -``` - -### Module manifest (`bread.module.toml`) - -Every installed module must have a manifest: - -```toml -name = "wifi" -version = "1.0.0" -description = "WiFi management for Bread" -author = "someuser" -source = "github:someuser/bread-wifi" # where it was installed from -installed_at = "2026-05-11T09:00:00Z" # RFC 3339 timestamp, set on install -``` - -All fields are required. `source` is the original install source string. `installed_at` is written by Bread at install time, not by the module author. - -### Install sources - -The module installer must support these source formats: - -``` -github:user/repo # installs default branch -github:user/repo@v1.2.0 # installs specific tag -github:user/repo@abc1234 # installs specific commit -/path/to/local/dir # installs from local directory (copies it) -``` - -Anything else is an error with a clear message. - -### New Cargo dependencies allowed - -Add to `bread-cli/Cargo.toml` as needed: -- `git2 = "0.18"` for Git operations -- `reqwest = { version = "0.11", features = ["blocking", "json"] }` for GitHub API -- `flate2`, `tar` for archive extraction - -Add to `breadd/Cargo.toml` as needed: -- `git2 = "0.18"` -- `toml = "0.8"` (already present) - -### CLI commands to implement - -All module commands live under `bread modules`: - -``` -bread modules install Install a module -bread modules remove Remove an installed module -bread modules list List installed modules with name, version, status -bread modules update Update all installed modules to latest -bread modules update Update a specific module -bread modules info Show full manifest details for a module -``` - -**`bread modules install `** - -1. Parse the source string. -2. For `github:user/repo[@ref]`: - - Use the GitHub API to resolve the ref (or default branch if none specified). - - Download the repository archive as a `.tar.gz`. - - Extract to a temp directory. - - Verify a `bread.module.toml` exists at the root. If not, error cleanly. - - Copy the module directory to `~/.config/bread/modules//`. - - Write `installed_at` into the manifest. -3. For local paths: - - Verify the path exists and contains `bread.module.toml`. - - Copy to `~/.config/bread/modules//`. - - Write `installed_at`. -4. Print `installed v` on success. -5. Tell the daemon to reload via IPC (`modules.reload`) after install. - -**`bread modules remove `** - -1. Find `~/.config/bread/modules//`. -2. Ask for confirmation: `remove ? (y/n)`. Skip if `--yes` flag is passed. -3. Delete the directory. -4. Tell the daemon to reload via IPC. -5. Print `removed `. - -**`bread modules list`** - -Scan `~/.config/bread/modules/` for directories containing `bread.module.toml`. For each, print: - -``` - wifi 1.0.0 loaded github:someuser/bread-wifi - redox 0.3.1 loaded github:breadway/bread-redox - broken-mod 0.1.0 error /home/user/local-module -``` - -Status (`loaded`, `error`, `not_found`, `degraded`) comes from the daemon's IPC `modules.list` response, matched by module name. If the daemon is unreachable, show `unknown` for status. - -**`bread modules update [name]`** - -1. Read `bread.module.toml` for each module to update. -2. If `source` starts with `github:`, re-run the install for that source. -3. If `source` is a local path, error with `cannot update local module — reinstall manually`. -4. Print `updated v → v` or ` already up to date`. - -**`bread modules info `** - -Print full manifest contents plus daemon-reported status. Example: - -``` -name: wifi -version: 1.0.0 -description: WiFi management for Bread -author: someuser -source: github:someuser/bread-wifi -installed_at: 2026-05-11T09:00:00Z -status: loaded -``` - -### Daemon-side: expose `ID_VENDOR_ID` and `ID_MODEL_ID` in udev events - -In `breadd/src/adapters/udev.rs`, the `run_udev_monitor` function builds the payload for each udev event. Add `vendor_id` and `product_id` to the payload: - -```rust -"vendor_id": prop_str(&event, "ID_VENDOR_ID"), -"product_id": prop_str(&event, "ID_MODEL_ID"), -``` - -These are the raw hex USB IDs (e.g. `"4d44"` and `"5244"`). Do the same in `raw_change_event` for the fallback poller — read them from sysfs at `/idVendor` and `/idProduct` if available. Also add `vendor_id` and `product_id` to the `Device` struct in `breadd/src/core/types.rs` as `Option`. - ---- - -## Part 2: Sync System - -### Overview - -Sync saves and restores a complete description of the user's environment. It is not a disk image. It saves: - -1. **Bread config** — everything in `~/.config/bread/` (always included) -2. **Delegated configs** — other config directories the user explicitly opts in (e.g. `~/.config/nvim/`) -3. **Package manifest** — lists of explicitly-installed packages per package manager -4. **Machine profile** — machine name and tags for machine-aware config - -Everything is stored in a Git repository. `bread sync push` commits and pushes. `bread sync pull` pulls and applies. - -### New crate: `bread-sync` - -Create a new crate `bread-sync/` in the workspace. Add it to `[workspace.members]` in the root `Cargo.toml`. - -``` -bread-sync/ -├── Cargo.toml -└── src/ - ├── lib.rs - ├── config.rs ← SyncConfig type, load/save - ├── git.rs ← Git operations via git2 - ├── packages.rs ← Package manifest generation - ├── delegates.rs ← Config file delegation - └── machine.rs ← Machine profile -``` - -`bread-cli` depends on `bread-sync`. `breadd` does not — sync is a CLI-only feature. - -### Sync configuration (`~/.config/bread/sync.toml`) - -This file is created by `bread sync init` and edited by the user. It is committed to the sync repo. - -```toml -[remote] -url = "git@github.com:user/bread-sync.git" # required, set by bread sync init -branch = "main" # default: "main" - -[machine] -name = "laptop" # required, set by bread sync init -tags = ["mobile", "battery", "single-monitor"] # user-defined, optional - -[packages] -enabled = true -managers = ["pacman", "pip", "npm"] # which package managers to snapshot - -[delegates] -# Additional config directories to include in sync. -# ~/.config/bread/ is always included and does not need to be listed here. -include = [ - "~/.config/nvim", - "~/.config/fish", - "~/.config/kitty", -] -exclude = [ - "**/.git", - "**/node_modules", - "**/__pycache__", - "**/*.log", - "**/*.cache", - "~/.config/nvim/.repro", -] -``` - -All paths support `~` expansion. Globs in `exclude` use standard glob syntax. - -### Sync repo layout - -The Git repository managed by Bread has this structure: - -``` -/ -├── bread/ ← copy of ~/.config/bread/ (minus sync.toml secrets if any) -├── configs/ -│ ├── nvim/ ← copy of ~/.config/nvim/ -│ ├── fish/ ← copy of ~/.config/fish/ -│ └── kitty/ ← copy of ~/.config/kitty/ -├── packages/ -│ ├── pacman.txt ← output of `pacman -Qe` -│ ├── pip.txt ← output of `pip list --user --format=freeze` -│ └── npm.txt ← output of `npm list -g --depth=0` -├── machines/ -│ └── laptop.toml ← machine profile for this machine -└── .bread-sync ← sync metadata (not committed to Git) -``` - -`machines/.toml` contains: - -```toml -name = "laptop" -hostname = "breadway-laptop" # auto-detected via gethostname -tags = ["mobile", "battery", "single-monitor"] -last_sync = "2026-05-11T09:15:00Z" -``` - -### CLI commands to implement - -All sync commands live under `bread sync`: - -``` -bread sync init [--remote ] Initialize sync for this machine -bread sync push [--message ] Snapshot and push current state -bread sync pull Pull and apply latest state -bread sync status Show what has changed since last push -bread sync diff Show file-level diff vs remote -bread sync machines List known machines from sync repo -``` - -**`bread sync init [--remote ]`** - -1. Check if `~/.config/bread/sync.toml` already exists. If so, error: `sync already initialized. Edit ~/.config/bread/sync.toml to reconfigure.` -2. If `--remote` is not provided, prompt: `Sync remote URL (git remote or path): `. -3. Prompt: `Machine name [laptop]: ` (default: hostname). -4. Prompt: `Machine tags (comma-separated, e.g. mobile,battery): `. -5. Create `~/.config/bread/sync.toml` with the provided values. -6. If the remote is a URL (not a local path), check if the repo exists: - - If it exists, clone it to a temp location and verify it looks like a Bread sync repo (has a `bread/` directory or is empty). - - If it doesn't exist, print: `remote does not exist yet — it will be created on first push`. -7. Print setup summary. - -**`bread sync push [--message ]`** - -1. Load `~/.config/bread/sync.toml`. Error if not initialized. -2. Resolve the local sync repo path (`~/.local/share/bread/sync-repo/`). Clone from remote if it doesn't exist locally. -3. Snapshot each section: - - Copy `~/.config/bread/` → `/bread/` (rsync-style: delete files in dest that don't exist in source) - - For each path in `delegates.include`: copy to `/configs//` - - If `packages.enabled`: run package manager queries and write to `/packages/` - - Write `/machines/.toml` -4. Stage all changes (`git add -A`). -5. If there are no changes, print `nothing to push — already up to date` and exit. -6. Commit with message: `sync: ` or the user-provided `--message`. -7. Push to remote. -8. Print a summary of what was snapshotted. - -**`bread sync pull`** - -1. Load `~/.config/bread/sync.toml`. Error if not initialized. -2. Pull from remote (fetch + merge or rebase — use merge, simpler). -3. Apply each section in order: - - Copy `/bread/` → `~/.config/bread/` (same rsync-style) - - For each path in `delegates.include` that exists in `/configs/`: copy back - - If `packages.enabled` and `--install-packages` flag is passed: run package installs (see below) -4. Tell the daemon to reload via IPC (`modules.reload`) after applying. -5. Print a summary of what was applied. - -**Package install on pull** (only when `--install-packages` is explicitly passed): - -- `pacman.txt` → `sudo pacman -S --needed $(cat pacman.txt | awk '{print $1}')` -- `pip.txt` → `pip install --user -r pip.txt` -- `npm.txt` → parse package names and run `npm install -g` - -Never run package installs automatically without the flag. Print a note at the end of `pull` if packages differ: `run 'bread sync pull --install-packages' to install missing packages`. - -**`bread sync status`** - -1. Load sync config and local repo. -2. Pull remote refs without merging (fetch only). -3. Compare working tree to last commit and compare last commit to remote HEAD. -4. Print: - -``` -bread sync status - machine laptop - remote git@github.com:user/bread-sync.git - last push 2026-05-11 09:15:00 - -local changes (not yet pushed): - M bread/init.lua - A bread/modules/wifi/init.lua - -remote changes (not yet pulled): - none -``` - -**`bread sync diff`** - -Run `git diff HEAD` in the sync repo and print it. If `--remote` flag is passed, run `git diff HEAD..origin/`. - -**`bread sync machines`** - -List all `machines/*.toml` files from the sync repo: - -``` - laptop last sync: 2026-05-11 09:15 tags: mobile, battery, single-monitor - desktop last sync: 2026-05-10 22:00 tags: stationary, multi-monitor, docked -``` - -### Package manager support - -Implement these four. Each must handle the case where the package manager is not installed (skip with a warning, don't error). - -| Manager | Snapshot command | Install command | -|---------|-----------------|-----------------| -| `pacman` | `pacman -Qe` | `sudo pacman -S --needed ` | -| `pip` | `pip list --user --format=freeze` | `pip install --user -r ` | -| `npm` | `npm list -g --depth=0 --parseable` | `npm install -g ` | -| `cargo` | `cargo install --list` | `cargo install ` | - -For `cargo`, the snapshot format is one package per line: ` `. Parse `cargo install --list` output accordingly. - -### Git operations - -Use the `git2` crate for all Git operations. Do not shell out to `git`. Required operations: - -- Clone a remote repo -- Open an existing repo -- Stage all changes (`add -A` equivalent: index all tracked and untracked files) -- Create a commit with a message and the current timestamp as author date -- Push to remote (support SSH and HTTPS — `git2` handles this via callbacks) -- Pull (fetch + merge fast-forward; if non-fast-forward, error with clear message) -- Fetch (without merging) -- Get diff between working tree and HEAD -- Get diff between HEAD and remote branch HEAD - -For SSH auth, use the user's default SSH agent (`git2::transport::smart::SmartSubtransport` with `SshKey` credential). For HTTPS, use the system credential store or prompt for credentials. - ---- - -## Part 3: Daemon additions (IPC) - -Add these IPC methods to `breadd/src/ipc/mod.rs`: - -**`sync.status`** — returns current sync state from `sync.toml` if it exists: -```json -{ "initialized": true, "machine": "laptop", "remote": "git@github.com:..." } -``` -or `{ "initialized": false }` if no sync.toml. - -**`modules.install`** — triggers a reload after external install (already covered by `modules.reload`, no new method needed — `bread modules install` calls `modules.reload` via IPC after installing). - -No other daemon changes are needed for sync — it is entirely CLI-side. - ---- - -## Part 4: Lua API additions - -Add to `breadd/src/lua/mod.rs` in `install_api`: - -**`bread.machine`** table: - -```lua -bread.machine.name() -- returns machine name from sync.toml, or hostname if no sync.toml -bread.machine.tags() -- returns array of tags, or empty array -bread.machine.has_tag("mobile") -- returns bool -``` - -Read `~/.config/bread/sync.toml` directly from Lua (parse it in Rust, expose via the API). If `sync.toml` doesn't exist, `name()` returns `os.getenv("HOSTNAME")` and `tags()` returns `{}`. - -**`bread.fs`** table: - -```lua -bread.fs.write(path, content) -- write string to file, create dirs as needed -bread.fs.read(path) -- read file to string, returns nil if not found -bread.fs.exists(path) -- returns bool -bread.fs.expand(path) -- expand ~ to home directory -``` - -All paths support `~` expansion. `bread.fs.write` creates parent directories automatically. Errors in `write` propagate as Lua errors. - ---- - -## Error handling requirements - -Every command must handle these cases cleanly: - -- Daemon not running: print `bread: daemon is not running. Start it with: systemctl --user start breadd` and exit 1. -- No sync.toml: print `bread: sync not initialized. Run: bread sync init` and exit 1. -- Network unreachable during push/pull: print the error clearly and exit 1. Do not leave the repo in a partial state. -- Module not found during remove/info: print `bread: module '' is not installed` and exit 1. -- Git conflicts on pull: print `bread: sync conflict — resolve manually in ~/.local/share/bread/sync-repo/` and exit 1. Do not auto-merge or discard changes. -- Package manager not installed: warn and skip, do not fail the whole operation. - ---- - -## File locations - -| Purpose | Path | -|---------|------| -| Sync config | `~/.config/bread/sync.toml` | -| Local sync repo | `~/.local/share/bread/sync-repo/` | -| Module manifests | `~/.config/bread/modules//bread.module.toml` | -| Bread config | `~/.config/bread/` | -| Daemon socket | `$XDG_RUNTIME_DIR/bread/breadd.sock` | - -All paths must use `dirs` crate or manual `$HOME`/`$XDG_*` expansion — never hardcode `/home/breadway` or any username. - -Add to `bread-cli/Cargo.toml`: `dirs = "5.0"`. - ---- - -## Tests - -### Module system tests (`bread-cli/tests/modules.rs`) - -```rust -// 1. Install from local path succeeds when bread.module.toml exists -// 2. Install from local path fails when bread.module.toml is missing -// 3. Remove deletes the module directory -// 4. List reads manifests correctly from disk -// 5. Manifest is written correctly on install (all fields present, installed_at is valid RFC 3339) -``` - -### Sync tests (`bread-sync/tests/sync.rs`) - -```rust -// 1. bread sync init creates sync.toml with correct fields -// 2. bread sync push with a local bare Git repo as remote: creates correct directory structure -// 3. bread sync push snapshots bread/ directory correctly -// 4. bread sync pull copies files from repo to correct locations -// 5. Package manifest for pacman: parses output correctly -// 6. Package manifest for pip: parses output correctly -// 7. Delegates: exclude globs filter correctly -// 8. Machine profile is written to machines/.toml with correct fields -// 9. Status shows no changes when working tree matches last commit -// 10. Push with no changes prints "nothing to push" and does not create a commit -``` - -All tests must pass with `cargo test --workspace`. Tests that require network access must be feature-gated with `#[cfg(feature = "network-tests")]` and not run by default. - ---- - -## Completion Checklist - -Do not stop iterating until every item on this list is true. - -### Compilation -- [ ] `cargo build --workspace` succeeds with zero errors -- [ ] `cargo build --workspace --release` succeeds with zero errors -- [ ] Zero compiler warnings in new code (existing warnings are acceptable) -- [ ] `cargo clippy --workspace` produces no errors in new code - -### Tests -- [ ] `cargo test --workspace` passes with zero failures -- [ ] All tests listed in the Tests section above exist and pass -- [ ] Integration tests in `breadd/tests/ipc_integration.rs` still pass - -### Module system — functional -- [ ] `bread modules install github:user/repo` downloads and installs a module -- [ ] `bread modules install /local/path` copies and installs a local module -- [ ] `bread modules install` with an invalid source prints a clear error and exits 1 -- [ ] `bread modules install` writes a valid `bread.module.toml` with all required fields including `installed_at` -- [ ] `bread modules install` calls `modules.reload` IPC after successful install -- [ ] `bread modules remove ` removes the module directory -- [ ] `bread modules remove ` with `--yes` skips confirmation -- [ ] `bread modules remove ` prints a clear error and exits 1 -- [ ] `bread modules list` reads all installed module manifests -- [ ] `bread modules list` shows daemon-reported status when daemon is running -- [ ] `bread modules list` shows `unknown` status when daemon is not running (no crash) -- [ ] `bread modules update` re-installs all github-sourced modules -- [ ] `bread modules update` skips local-path modules with a warning -- [ ] `bread modules info ` shows all manifest fields and daemon status - -### Sync — functional -- [ ] `bread sync init` creates `~/.config/bread/sync.toml` with all required fields -- [ ] `bread sync init` errors if already initialized -- [ ] `bread sync push` creates the correct repo directory structure -- [ ] `bread sync push` copies `~/.config/bread/` to `bread/` in the repo -- [ ] `bread sync push` copies each delegate path to `configs//` -- [ ] `bread sync push` writes package manifests to `packages/` -- [ ] `bread sync push` writes `machines/.toml` -- [ ] `bread sync push` creates a Git commit with a sensible message -- [ ] `bread sync push` pushes to the configured remote -- [ ] `bread sync push` with no changes prints `nothing to push` and exits 0 -- [ ] `bread sync pull` copies `bread/` from repo to `~/.config/bread/` -- [ ] `bread sync pull` copies `configs/` entries back to their original locations -- [ ] `bread sync pull` calls `modules.reload` IPC after applying -- [ ] `bread sync pull --install-packages` runs package installs -- [ ] `bread sync pull` without `--install-packages` does not run package installs -- [ ] `bread sync status` shows local uncommitted changes -- [ ] `bread sync status` shows remote changes not yet pulled -- [ ] `bread sync status` prints `nothing to push — already up to date` when clean -- [ ] `bread sync machines` lists all `machines/*.toml` entries -- [ ] `bread sync init` without `--remote` prompts for URL interactively - -### Sync — error handling -- [ ] `bread sync push` without init prints clear error and exits 1 -- [ ] `bread sync pull` without init prints clear error and exits 1 -- [ ] Git conflict on pull prints clear message pointing to sync repo path and exits 1 -- [ ] Package manager not installed is warned and skipped, not a fatal error - -### Lua API -- [ ] `bread.machine.name()` returns machine name from sync.toml -- [ ] `bread.machine.name()` returns hostname when sync.toml does not exist -- [ ] `bread.machine.tags()` returns array of tags -- [ ] `bread.machine.has_tag("x")` returns true/false correctly -- [ ] `bread.fs.write(path, content)` writes the file and creates parent dirs -- [ ] `bread.fs.read(path)` returns file content as string -- [ ] `bread.fs.read(nonexistent)` returns nil, does not error -- [ ] `bread.fs.exists(path)` returns correct bool -- [ ] `bread.fs.expand("~/foo")` returns the correct absolute path -- [ ] All `bread.fs` paths handle `~` expansion - -### Udev vendor/product ID -- [ ] `vendor_id` and `product_id` fields are present in udev device events -- [ ] `Device` struct in `types.rs` has `vendor_id: Option` and `product_id: Option` -- [ ] `bread events` output shows `vendor_id` and `product_id` when available - -### No regressions -- [ ] `bread reload` still works -- [ ] `bread state` still works -- [ ] `bread events` still works -- [ ] `bread doctor` still works -- [ ] `bread ping` still works -- [ ] `bread emit` still works -- [ ] Daemon starts cleanly with no existing `sync.toml` -- [ ] Daemon starts cleanly with a valid `sync.toml` -- [ ] All existing IPC methods still respond correctly - -### Code quality -- [ ] No hardcoded paths containing usernames or `/home/` -- [ ] No `unwrap()` calls in new code that can fail at runtime — use `?` or explicit error handling -- [ ] No `expect("...")` calls in new async code — only in tests and truly-impossible cases -- [ ] All new public functions have doc comments -- [ ] `bread-sync` crate has a `README.md` explaining its purpose and public API - ---- - -## Implementation order - -Work in this order. Do not move to the next step until the current one compiles and its tests pass. - -1. Add `bread-sync` crate skeleton to workspace (compiles, no logic yet) -2. Implement `SyncConfig` (load/save `sync.toml`) -3. Implement `bread sync init` -4. Implement Git backend in `bread-sync/src/git.rs` -5. Implement `bread sync push` (bread config only, no delegates or packages yet) -6. Implement delegate file handling -7. Implement package manifest generation -8. Implement `bread sync pull` -9. Implement `bread sync status`, `diff`, `machines` -10. Implement `bread modules install` (local path first, then GitHub) -11. Implement `bread modules remove`, `list`, `update`, `info` -12. Add `vendor_id`/`product_id` to udev adapter and `Device` type -13. Add `bread.machine` Lua API -14. Add `bread.fs` Lua API -15. Write all tests -16. Run full checklist — fix anything not passing -17. Run `cargo clippy --workspace` — fix any new warnings diff --git a/Cargo.lock b/Cargo.lock index 72cecc3..313315f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -17,15 +11,6 @@ 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" @@ -263,12 +248,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "bitflags" version = "1.3.2" @@ -325,23 +304,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "bread-sync" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "dirs", - "flate2", - "git2", - "reqwest", - "serde", - "serde_json", - "tar", - "tempfile", - "toml", -] - [[package]] name = "breadd" version = "0.1.0" @@ -377,12 +339,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - [[package]] name = "byteorder" version = "1.5.0" @@ -402,8 +358,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -413,20 +367,6 @@ 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" @@ -482,32 +422,6 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -517,15 +431,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -572,53 +477,12 @@ 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" @@ -742,52 +606,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -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" @@ -934,18 +758,6 @@ 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" @@ -954,45 +766,11 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", "wasip3", ] -[[package]] -name = "git2" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" -dependencies = [ - "bitflags 2.11.1", - "libc", - "libgit2-sys", - "log", - "openssl-probe 0.1.6", - "openssl-sys", - "url", -] - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -1032,210 +810,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.4.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper", - "native-tls", - "tokio", - "tokio-native-tls", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[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]] name = "id-arena" 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" @@ -1288,12 +868,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1306,28 +880,6 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - [[package]] name = "kqueue" version = "1.1.1" @@ -1366,43 +918,6 @@ 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" @@ -1413,18 +928,6 @@ 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" @@ -1443,12 +946,6 @@ 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" @@ -1525,22 +1022,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "0.8.11" @@ -1594,23 +1075,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.2.1", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1750,61 +1214,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" -dependencies = [ - "cc", - "libc", - "pkg-config", - "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" @@ -1859,12 +1268,6 @@ 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" @@ -1918,15 +1321,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1974,12 +1368,6 @@ 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" @@ -2025,17 +1413,6 @@ 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" @@ -2065,46 +1442,6 @@ 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" @@ -2166,27 +1503,6 @@ 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" @@ -2196,44 +1512,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -2313,18 +1597,6 @@ 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" @@ -2361,12 +1633,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - [[package]] name = "slab" version = "0.4.12" @@ -2399,12 +1665,6 @@ 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" @@ -2439,55 +1699,6 @@ 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" @@ -2530,16 +1741,6 @@ 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" @@ -2569,29 +1770,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.8.23" @@ -2644,12 +1822,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - [[package]] name = "tracing" version = "0.1.44" @@ -2711,12 +1883,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typeid" version = "1.0.3" @@ -2764,24 +1930,6 @@ 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" @@ -2794,12 +1942,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -2822,15 +1964,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2855,61 +1988,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -dependencies = [ - "unicode-ident", -] - [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2944,16 +2022,6 @@ dependencies = [ "semver", ] -[[package]] -name = "web-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "which" version = "7.0.3" @@ -2997,65 +2065,12 @@ 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" @@ -3222,16 +2237,6 @@ 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" @@ -3332,22 +2337,6 @@ 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" @@ -3358,29 +2347,6 @@ 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" @@ -3468,60 +2434,6 @@ 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 ee8711e..ab4e899 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,7 @@ members = [ "bread-shared", "breadd", - "bread-cli", - "bread-sync" + "bread-cli" ] resolver = "2" diff --git a/README.md b/README.md index ae1e55a..73512df 100644 --- a/README.md +++ b/README.md @@ -231,12 +231,11 @@ bread.once("bread.system.startup", function(event) end) -- Subscribe with a predicate filter --- Third arg is an opts table with a 'filter' key whose value is the predicate bread.filter("bread.device.connected", function(event) - bread.exec("xset r rate 200 40") -end, { filter = function(event) return event.data.class == "keyboard" -end }) +end, function(event) + bread.exec("xset r rate 200 40") +end) -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 43c17a9..69a2c49 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -9,7 +9,6 @@ path = "src/main.rs" [dependencies] bread-shared = { path = "../bread-shared" } -bread-sync = { path = "../bread-sync" } serde.workspace = true serde_json.workspace = true tokio.workspace = true @@ -17,9 +16,3 @@ anyhow.workspace = true clap = { version = "4.5", features = ["derive"] } notify = "6.1" libc = "0.2" -dirs = "5.0" -reqwest = { version = "0.11", features = ["blocking", "json"] } -flate2 = "1.0" -tar = "0.4" -chrono = { version = "0.4", features = ["serde"] } -toml = "0.8" diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index d57890a..0ca91df 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,7 +1,6 @@ -use anyhow::{anyhow, Result}; +use anyhow::Result; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; -use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::env; use std::io; @@ -11,16 +10,6 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tokio::sync::mpsc; -use bread_sync::{ - config::{bread_config_dir, sync_repo_path, SyncConfig}, - delegates::{copy_delegates_to_repo, expand_tilde, restore_delegates_from_repo, sync_dir}, - git, - machine::{list_machines, machine_name, MachineProfile}, - packages::snapshot_packages, -}; - -// ─── CLI structure ──────────────────────────────────────────────────────────── - #[derive(Parser, Debug)] #[command(author, version, about = "Bread CLI - the reactive desktop automation fabric")] struct Cli { @@ -58,16 +47,8 @@ enum Commands { #[arg(long)] since: Option, }, - /// Manage installed Lua modules - Modules { - #[command(subcommand)] - action: ModulesAction, - }, - /// Sync system state to/from a Git remote - Sync { - #[command(subcommand)] - action: SyncAction, - }, + /// List loaded modules and status + Modules, /// List available profiles ProfileList, /// Activate a profile @@ -90,79 +71,6 @@ enum Commands { }, } -#[derive(Subcommand, Debug)] -enum ModulesAction { - /// Install a module from a source (github:user/repo[@ref] or /local/path) - Install { - source: String, - }, - /// Remove an installed module - Remove { - name: String, - /// Skip confirmation prompt - #[arg(long, short = 'y')] - yes: bool, - }, - /// List installed modules with status - List, - /// Update installed modules to latest - Update { - /// Update only this specific module - name: Option, - }, - /// Show detailed manifest info for a module - Info { - name: String, - }, -} - -#[derive(Subcommand, Debug)] -enum SyncAction { - /// Initialize sync for this machine - Init { - /// Git remote URL - #[arg(long)] - remote: Option, - }, - /// Snapshot and push current state - Push { - /// Commit message - #[arg(long, short = 'm')] - message: Option, - }, - /// Pull and apply latest state from remote - Pull { - /// Also run package install commands - #[arg(long)] - install_packages: bool, - }, - /// Show what has changed since last push - Status, - /// Show file-level diff vs remote - Diff { - /// Diff against remote HEAD instead of working tree - #[arg(long)] - remote: bool, - }, - /// List known machines from sync repo - Machines, -} - -// ─── Module manifest ────────────────────────────────────────────────────────── - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct ModuleManifest { - name: String, - version: String, - description: String, - author: String, - source: String, - #[serde(skip_serializing_if = "Option::is_none")] - installed_at: Option, -} - -// ─── Entry point ────────────────────────────────────────────────────────────── - #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -173,65 +81,71 @@ async fn main() -> Result<()> { if *watch { watch_reload(&socket).await?; } else { - let response = send_request_or_die(&socket, "modules.reload", json!({})).await?; + let response = send_request(&socket, "modules.reload", json!({})).await?; print_reload(&response); } } Commands::State { path, json } => { - let response = if let Some(path) = path { - send_request_or_die(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request_or_die(&socket, "state.dump", json!({})).await? - }; if *json { + let response = if let Some(path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; print_json(&response)?; } else { + let response = if let Some(path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; print_state_formatted(path.as_deref(), &response); } } - Commands::Events { filter, json, fields, since } => { + Commands::Events { + filter, + json, + fields, + since, + } => { stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?; } - Commands::Modules { action } => { - handle_modules(action, &socket).await?; - } - Commands::Sync { action } => { - handle_sync(action, &socket).await?; + Commands::Modules => { + let response = send_request(&socket, "modules.list", json!({})).await?; + print_json(&response)?; } Commands::ProfileList => { - let response = send_request_or_die(&socket, "profile.list", json!({})).await?; + let response = send_request(&socket, "profile.list", json!({})).await?; print_json(&response)?; } Commands::ProfileActivate { name } => { - let response = send_request_or_die( - &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 response = send_request_or_die( + let response = send_request( &socket, "emit", - json!({ "event": event, "data": parsed }), + json!({ + "event": event, + "data": parsed, + }), ) .await?; print_json(&response)?; } Commands::Ping => { - let response = send_request_or_die(&socket, "ping", json!({})).await?; + let response = send_request(&socket, "ping", json!({})).await?; print_json(&response)?; } Commands::Health => { - let response = send_request_or_die(&socket, "health", json!({})).await?; + let response = send_request(&socket, "health", json!({})).await?; print_json(&response)?; } Commands::Doctor { json } => { if *json { - let response = send_request_or_die(&socket, "health", json!({})).await?; + let response = send_request(&socket, "health", json!({})).await?; print_json(&response)?; } else { print_doctor(&socket).await?; @@ -242,699 +156,6 @@ async fn main() -> Result<()> { Ok(()) } -// ─── Modules sub-commands ───────────────────────────────────────────────────── - -async fn handle_modules(action: &ModulesAction, socket: &Path) -> Result<()> { - match action { - ModulesAction::Install { source } => { - modules_install(source, socket).await?; - } - ModulesAction::Remove { name, yes } => { - modules_remove(name, *yes, socket).await?; - } - ModulesAction::List => { - modules_list(socket).await?; - } - ModulesAction::Update { name } => { - modules_update(name.as_deref(), socket).await?; - } - ModulesAction::Info { name } => { - modules_info(name, socket).await?; - } - } - Ok(()) -} - -async fn modules_install(source: &str, socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - - if let Some(rest) = source.strip_prefix("github:") { - install_github_module(rest, source, &modules_dir)?; - } else if source.starts_with('/') || source.starts_with("./") || source.starts_with("~/") { - let local_path = expand_tilde(source); - install_local_module(&local_path, &modules_dir)?; - } else { - eprintln!("bread: unknown source format '{source}'"); - eprintln!(" expected: github:user/repo[@ref] or /local/path"); - std::process::exit(1); - } - - // Reload daemon - if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { - let _ = response; - } - Ok(()) -} - -fn install_local_module(src: &Path, modules_dir: &Path) -> Result<()> { - let manifest_path = src.join("bread.module.toml"); - if !manifest_path.exists() { - eprintln!( - "bread: no bread.module.toml found at {}", - manifest_path.display() - ); - std::process::exit(1); - } - let raw = std::fs::read_to_string(&manifest_path)?; - let mut manifest: ModuleManifest = toml::from_str(&raw)?; - manifest.installed_at = Some(chrono::Utc::now().to_rfc3339()); - - let dest = modules_dir.join(&manifest.name); - if dest.exists() { - std::fs::remove_dir_all(&dest)?; - } - copy_dir_all(src, &dest)?; - - // Write updated manifest with installed_at - let manifest_dest = dest.join("bread.module.toml"); - std::fs::write(&manifest_dest, toml::to_string_pretty(&manifest)?)?; - - println!("installed {} v{}", manifest.name, manifest.version); - Ok(()) -} - -fn install_github_module(spec: &str, source_str: &str, modules_dir: &Path) -> Result<()> { - let (repo_spec, git_ref) = if let Some((r, v)) = spec.split_once('@') { - (r, Some(v.to_string())) - } else { - (spec, None) - }; - - let (user, repo) = repo_spec - .split_once('/') - .ok_or_else(|| anyhow!("invalid github spec '{}': expected user/repo", spec))?; - - let client = reqwest::blocking::Client::builder() - .user_agent("bread-cli/0.1") - .build()?; - - let resolved_ref = match git_ref { - Some(r) => r, - None => { - let url = format!("https://api.github.com/repos/{user}/{repo}"); - let resp: Value = client.get(&url).send()?.json()?; - 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/{resolved_ref}" - ); - let bytes = client.get(&tarball_url).send()?.bytes()?; - - // Extract to a temp dir - let tmp = tempfile_dir()?; - let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&bytes)); - let mut archive = tar::Archive::new(gz); - archive.unpack(&tmp)?; - - // The tarball has a single top-level directory; find it - let extracted_dir = std::fs::read_dir(&tmp)? - .filter_map(|e| e.ok()) - .find(|e| e.path().is_dir()) - .map(|e| e.path()) - .ok_or_else(|| anyhow!("tarball contained no directory"))?; - - let manifest_path = extracted_dir.join("bread.module.toml"); - if !manifest_path.exists() { - let _ = std::fs::remove_dir_all(&tmp); - eprintln!( - "bread: no bread.module.toml found in github:{}/{} (ref {})", - user, repo, resolved_ref - ); - std::process::exit(1); - } - - let raw = std::fs::read_to_string(&manifest_path)?; - let mut manifest: ModuleManifest = toml::from_str(&raw)?; - manifest.installed_at = Some(chrono::Utc::now().to_rfc3339()); - manifest.source = source_str.to_string(); - - let dest = modules_dir.join(&manifest.name); - if dest.exists() { - std::fs::remove_dir_all(&dest)?; - } - copy_dir_all(&extracted_dir, &dest)?; - - let manifest_dest = dest.join("bread.module.toml"); - std::fs::write(&manifest_dest, toml::to_string_pretty(&manifest)?)?; - - let _ = std::fs::remove_dir_all(&tmp); - println!("installed {} v{}", manifest.name, manifest.version); - Ok(()) -} - -async fn modules_remove(name: &str, yes: bool, socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - let module_dir = modules_dir.join(name); - - if !module_dir.exists() { - eprintln!("bread: module '{name}' is not installed"); - std::process::exit(1); - } - - if !yes { - eprint!("remove {name}? (y/n) "); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { - println!("cancelled"); - return Ok(()); - } - } - - std::fs::remove_dir_all(&module_dir)?; - println!("removed {name}"); - - if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { - let _ = response; - } - Ok(()) -} - -async fn modules_list(socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - let manifests = scan_modules(&modules_dir)?; - - // Try to get daemon status - let daemon_modules = send_request(socket, "modules.list", json!({})) - .await - .ok() - .and_then(|v| v.as_array().cloned()); - - for manifest in &manifests { - let status = daemon_modules - .as_ref() - .and_then(|mods| { - mods.iter().find(|m| { - m.get("name").and_then(Value::as_str) == Some(&manifest.name) - }) - }) - .and_then(|m| m.get("status").and_then(Value::as_str)) - .unwrap_or(if daemon_modules.is_some() { "unknown" } else { "unknown" }); - - println!( - " {:<20} {:<10} {:<12} {}", - manifest.name, manifest.version, status, manifest.source - ); - } - Ok(()) -} - -async fn modules_update(name: Option<&str>, socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - - let to_update: Vec = if let Some(name) = name { - let manifest = load_manifest(&modules_dir.join(name))?; - vec![manifest] - } else { - scan_modules(&modules_dir)? - }; - - for manifest in to_update { - if !manifest.source.starts_with("github:") { - eprintln!( - "warn: cannot update '{}' — local module, reinstall manually", - manifest.name - ); - continue; - } - let old_version = manifest.version.clone(); - let source = manifest.source.clone(); - let rest = source.trim_start_matches("github:"); - install_github_module(rest, &source, &modules_dir)?; - let new_manifest = load_manifest(&modules_dir.join(&manifest.name))?; - if new_manifest.version == old_version { - println!("{} already up to date", manifest.name); - } else { - println!( - "updated {} v{} → v{}", - manifest.name, old_version, new_manifest.version - ); - } - } - - if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { - let _ = response; - } - Ok(()) -} - -async fn modules_info(name: &str, socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - let module_dir = modules_dir.join(name); - - if !module_dir.exists() { - eprintln!("bread: module '{name}' is not installed"); - std::process::exit(1); - } - - let manifest = load_manifest(&module_dir)?; - let status = send_request(socket, "modules.list", json!({})) - .await - .ok() - .and_then(|v| v.as_array().cloned()) - .and_then(|mods| { - mods.iter() - .find(|m| m.get("name").and_then(Value::as_str) == Some(name)) - .and_then(|m| m.get("status").and_then(Value::as_str)) - .map(ToString::to_string) - }) - .unwrap_or_else(|| "unknown".to_string()); - - println!("name: {}", manifest.name); - println!("version: {}", manifest.version); - println!("description: {}", manifest.description); - println!("author: {}", manifest.author); - println!("source: {}", manifest.source); - println!( - "installed_at: {}", - manifest.installed_at.as_deref().unwrap_or("unknown") - ); - println!("status: {status}"); - Ok(()) -} - -// ─── Sync sub-commands ──────────────────────────────────────────────────────── - -async fn handle_sync(action: &SyncAction, socket: &Path) -> Result<()> { - match action { - SyncAction::Init { remote } => sync_init(remote.as_deref()).await?, - SyncAction::Push { message } => sync_push(message.as_deref()).await?, - SyncAction::Pull { install_packages } => sync_pull(*install_packages, socket).await?, - SyncAction::Status => sync_status().await?, - SyncAction::Diff { remote } => sync_diff(*remote).await?, - SyncAction::Machines => sync_machines().await?, - } - Ok(()) -} - -async fn sync_init(remote_arg: Option<&str>) -> Result<()> { - if SyncConfig::is_initialized()? { - eprintln!( - "bread: sync already initialized. Edit {} to reconfigure.", - bread_sync::config::config_path()?.display() - ); - std::process::exit(1); - } - - let remote_url = if let Some(url) = remote_arg { - url.to_string() - } else { - eprint!("Sync remote URL (git remote or path): "); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - let url = input.trim().to_string(); - if url.is_empty() { - anyhow::bail!("remote URL is required"); - } - url - }; - - let default_hostname = hostname_or_unknown(); - eprint!("Machine name [{}]: ", default_hostname); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - let machine_name = { - let trimmed = input.trim().to_string(); - if trimmed.is_empty() { - default_hostname.clone() - } else { - trimmed - } - }; - - eprint!("Machine tags (comma-separated, e.g. mobile,battery): "); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - let tags: Vec = input - .trim() - .split(',') - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) - .collect(); - - let cfg = SyncConfig { - remote: bread_sync::config::RemoteConfig { - url: Some(remote_url.clone()), - branch: "main".to_string(), - }, - machine: bread_sync::config::MachineConfig { - name: Some(machine_name.clone()), - tags, - }, - ..Default::default() - }; - cfg.save()?; - - // Validate remote if it looks like a URL - if !remote_url.starts_with('/') { - println!("remote does not exist yet — it will be created on first push"); - } - - println!("sync initialized:"); - println!(" machine: {machine_name}"); - println!(" remote: {remote_url}"); - Ok(()) -} - -async fn sync_push(message: Option<&str>) -> Result<()> { - let cfg = require_sync_config()?; - let remote_url = cfg.remote.url.as_deref().ok_or_else(|| { - anyhow!("sync.toml has no remote URL — run: bread sync init") - })?; - let branch = cfg.remote.branch.clone(); - let repo_path = sync_repo_path()?; - - let repo = tokio::task::spawn_blocking({ - let remote_url = remote_url.to_string(); - let repo_path = repo_path.clone(); - move || git::clone_or_open(&remote_url, &repo_path) - }) - .await??; - - // Snapshot bread config - let bread_dir = bread_config_dir()?; - let bread_dest = repo_path.join("bread"); - sync_dir(&bread_dir, &bread_dest, &cfg.delegates.exclude)?; - - // Snapshot delegates - copy_delegates_to_repo(&cfg.delegates, &repo_path)?; - - // Snapshot packages - if cfg.packages.enabled { - snapshot_packages(&cfg.packages.managers, &repo_path)?; - } - - // Write machine profile - let profile = MachineProfile::new(&cfg)?; - profile.write_to_repo(&repo_path)?; - - // Stage all - git::stage_all(&repo)?; - - // Check for changes - if !git::has_changes(&repo)? { - println!("nothing to push — already up to date"); - return Ok(()); - } - - // Commit - let machine = machine_name(&cfg)?; - let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"); - let commit_msg = message - .map(ToString::to_string) - .unwrap_or_else(|| format!("sync: {machine} {timestamp}")); - git::commit(&repo, &commit_msg)?; - - // Set remote and push - if let Ok(()) = git::set_remote(&repo, "origin", remote_url) {} - git::push(&repo, "origin", &branch)?; - - println!("pushed: {commit_msg}"); - println!(" bread config: {}", bread_dir.display()); - if cfg.packages.enabled { - println!(" packages: {}", cfg.packages.managers.join(", ")); - } - Ok(()) -} - -async fn sync_pull(install_packages: bool, socket: &Path) -> Result<()> { - let cfg = require_sync_config()?; - let remote_url = cfg.remote.url.as_deref().ok_or_else(|| { - anyhow!("sync.toml has no remote URL — run: bread sync init") - })?; - let branch = cfg.remote.branch.clone(); - let repo_path = sync_repo_path()?; - - let repo = tokio::task::spawn_blocking({ - let remote_url = remote_url.to_string(); - let repo_path = repo_path.clone(); - move || git::clone_or_open(&remote_url, &repo_path) - }) - .await??; - - git::pull(&repo, "origin", &branch)?; - - // Restore bread config - let bread_src = repo_path.join("bread"); - let bread_dest = bread_config_dir()?; - if bread_src.exists() { - sync_dir(&bread_src, &bread_dest, &[])?; - } - - // Restore delegates - restore_delegates_from_repo(&cfg.delegates, &repo_path)?; - - // Package installs - if install_packages && cfg.packages.enabled { - run_package_installs(&repo_path, &cfg.packages.managers)?; - } else if cfg.packages.enabled { - let pkg_dir = repo_path.join("packages"); - if pkg_dir.exists() { - println!( - "note: run 'bread sync pull --install-packages' to install missing packages" - ); - } - } - - // Reload daemon - if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { - let _ = response; - } - - println!("pulled and applied latest state"); - Ok(()) -} - -async fn sync_status() -> Result<()> { - let cfg = require_sync_config()?; - let repo_path = sync_repo_path()?; - - if !repo_path.join(".git").exists() { - println!("sync repo not yet initialised — run: bread sync push"); - return Ok(()); - } - - let repo = git::init_or_open(&repo_path)?; - let machine = machine_name(&cfg)?; - let remote_url = cfg.remote.url.as_deref().unwrap_or("(none)"); - let last_push = git::last_commit_time(&repo); - - println!("bread sync status"); - println!(" machine {machine}"); - println!(" remote {remote_url}"); - println!(" last push {last_push}"); - - let local_changes = git::status_lines(&repo)?; - println!(); - println!("local changes (not yet pushed):"); - if local_changes.is_empty() { - println!(" none"); - } else { - for (ch, path) in &local_changes { - println!(" {ch} {path}"); - } - } - - // Fetch to check remote - let _ = git::fetch(&repo, "origin"); - let has_remote = git::remote_has_changes(&repo, "origin", &cfg.remote.branch); - println!(); - println!("remote changes (not yet pulled):"); - if has_remote { - println!(" (run 'bread sync pull' to apply)"); - } else { - println!(" none"); - } - Ok(()) -} - -async fn sync_diff(show_remote: bool) -> Result<()> { - let cfg = require_sync_config()?; - let repo_path = sync_repo_path()?; - - if !repo_path.join(".git").exists() { - println!("sync repo not initialised — run: bread sync push"); - return Ok(()); - } - - let repo = git::init_or_open(&repo_path)?; - - let diff = if show_remote { - git::fetch(&repo, "origin")?; - git::diff_remote(&repo, "origin", &cfg.remote.branch)? - } else { - git::diff_workdir(&repo)? - }; - - if diff.is_empty() { - println!("no differences"); - } else { - print!("{diff}"); - } - Ok(()) -} - -async fn sync_machines() -> Result<()> { - let repo_path = sync_repo_path()?; - if !repo_path.join(".git").exists() { - println!("sync repo not initialised — run: bread sync push"); - return Ok(()); - } - let machines = list_machines(&repo_path); - if machines.is_empty() { - println!("no machines found in sync repo"); - return Ok(()); - } - for m in machines { - let tags = if m.tags.is_empty() { - "(none)".to_string() - } else { - m.tags.join(", ") - }; - println!( - " {:<20} last sync: {:<20} tags: {}", - m.name, m.last_sync, tags - ); - } - Ok(()) -} - -fn run_package_installs(repo_root: &Path, managers: &[String]) -> Result<()> { - let pkg_dir = repo_root.join("packages"); - - for mgr in managers { - match mgr.as_str() { - "pacman" => { - let f = pkg_dir.join("pacman.txt"); - if f.exists() { - let names = bread_sync::packages::parse_pacman(&std::fs::read_to_string(&f)?); - let status = std::process::Command::new("sudo") - .args(["pacman", "-S", "--needed"]) - .args(&names) - .status(); - if let Err(e) = status { - eprintln!("warn: pacman install failed: {e}"); - } - } - } - "pip" => { - let f = pkg_dir.join("pip.txt"); - if f.exists() { - let status = std::process::Command::new("pip") - .args(["install", "--user", "-r"]) - .arg(&f) - .status(); - if let Err(e) = status { - eprintln!("warn: pip install failed: {e}"); - } - } - } - "npm" => { - let f = pkg_dir.join("npm.txt"); - if f.exists() { - let names = bread_sync::packages::parse_npm(&std::fs::read_to_string(&f)?); - for name in names { - let _ = std::process::Command::new("npm") - .args(["install", "-g", &name]) - .status(); - } - } - } - "cargo" => { - let f = pkg_dir.join("cargo.txt"); - if f.exists() { - let entries = bread_sync::packages::parse_cargo(&std::fs::read_to_string(&f)?); - for entry in entries { - let name = entry.split_whitespace().next().unwrap_or(&entry); - let _ = std::process::Command::new("cargo") - .args(["install", name]) - .status(); - } - } - } - _ => {} - } - } - Ok(()) -} - -// ─── Helper functions ───────────────────────────────────────────────────────── - -fn require_sync_config() -> Result { - if !SyncConfig::is_initialized()? { - eprintln!("bread: sync not initialized. Run: bread sync init"); - std::process::exit(1); - } - SyncConfig::load() -} - -fn modules_directory() -> Result { - let config_dir = dirs::config_dir() - .ok_or_else(|| anyhow!("cannot determine config directory"))?; - let dir = config_dir.join("bread").join("modules"); - std::fs::create_dir_all(&dir)?; - Ok(dir) -} - -fn scan_modules(modules_dir: &Path) -> Result> { - let mut out = Vec::new(); - if !modules_dir.exists() { - return Ok(out); - } - for entry in std::fs::read_dir(modules_dir)? { - let entry = entry?; - if !entry.path().is_dir() { - continue; - } - if let Ok(manifest) = load_manifest(&entry.path()) { - out.push(manifest); - } - } - out.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(out) -} - -fn load_manifest(module_dir: &Path) -> Result { - let path = module_dir.join("bread.module.toml"); - let raw = std::fs::read_to_string(&path)?; - Ok(toml::from_str(&raw)?) -} - -fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> { - std::fs::create_dir_all(dest)?; - for entry in std::fs::read_dir(src)? { - let entry = entry?; - let dest_path = dest.join(entry.file_name()); - if entry.path().is_dir() { - copy_dir_all(&entry.path(), &dest_path)?; - } else { - std::fs::copy(entry.path(), dest_path)?; - } - } - Ok(()) -} - -fn tempfile_dir() -> Result { - let tmp = std::env::temp_dir().join(format!("bread-install-{}", std::process::id())); - std::fs::create_dir_all(&tmp)?; - Ok(tmp) -} - -fn hostname_or_unknown() -> String { - std::fs::read_to_string("/etc/hostname") - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "unknown".to_string()) -} - -// ─── IPC helpers ────────────────────────────────────────────────────────────── - fn daemon_socket_path() -> PathBuf { if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") { return Path::new(&runtime).join("bread").join("breadd.sock"); @@ -942,26 +163,6 @@ fn daemon_socket_path() -> PathBuf { PathBuf::from("/tmp/bread/breadd.sock") } -/// Like `send_request` but prints a nice message and exits 1 if daemon is unreachable. -async fn send_request_or_die(socket: &Path, method: &str, params: Value) -> Result { - match send_request(socket, method, params).await { - Ok(v) => Ok(v), - Err(err) => { - let msg = err.to_string(); - if msg.contains("No such file") - || msg.contains("Connection refused") - || msg.contains("not found") - { - eprintln!( - "bread: daemon is not running. Start it with: systemctl --user start breadd" - ); - std::process::exit(1); - } - Err(err) - } - } -} - async fn send_request(socket: &Path, method: &str, params: Value) -> Result { let stream = UnixStream::connect(socket).await?; let (read_half, mut write_half) = stream.into_split(); @@ -994,8 +195,7 @@ async fn stream_events( since: Option, ) -> Result<()> { if let Some(seconds) = since { - let replay = - send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?; + 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 { @@ -1012,7 +212,9 @@ async fn stream_events( let request = json!({ "id": "1", "method": "events.subscribe", - "params": { "filter": filter }, + "params": { + "filter": filter, + }, }); write_half @@ -1028,11 +230,10 @@ async fn stream_events( print_event(&value, fields.as_deref()); } } + Ok(()) } -// ─── Display helpers ────────────────────────────────────────────────────────── - fn print_json(value: &Value) -> Result<()> { println!("{}", serde_json::to_string_pretty(value)?); Ok(()) @@ -1096,11 +297,15 @@ 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 + tm.tm_hour as u64 * 3600 + + tm.tm_min as u64 * 60 + + tm.tm_sec as u64 }; let h = (local_secs / 3600) % 24; @@ -1140,11 +345,16 @@ async fn watch_reload(socket: &Path) -> Result<()> { if msg.is_err() { continue; } + + // Debounce: drain any follow-up events that arrive within 150ms. + // A single file save typically generates 2-3 fs events in rapid succession. tokio::time::sleep(Duration::from_millis(150)).await; while rx.try_recv().is_ok() {} - let response = send_request_or_die(socket, "modules.reload", json!({})).await?; + + let response = send_request(socket, "modules.reload", json!({})).await?; print_reload(&response); } + Ok(()) } @@ -1177,11 +387,7 @@ fn render_doctor(health: &Value) { 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!(" daemon {} (pid {})", if ok { "✓ running" } else { "✗ unreachable" }, pid); println!(" version {version}"); println!(" uptime {}s", uptime_ms / 1000); println!(" socket {socket}"); @@ -1252,7 +458,11 @@ async fn send_request_with_stream( } fn config_directory() -> PathBuf { - dirs::config_dir() - .map(|d| d.join("bread")) - .unwrap_or_else(|| PathBuf::from(".config/bread")) + 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-sync/Cargo.toml b/bread-sync/Cargo.toml deleted file mode 100644 index c4860dc..0000000 --- a/bread-sync/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "bread-sync" -version = "0.1.0" -edition = "2021" - -[dependencies] -serde.workspace = true -serde_json.workspace = true -anyhow.workspace = true -toml = "0.8" -chrono = { version = "0.4", features = ["serde"] } -dirs = "5.0" -git2 = { version = "0.18", features = ["vendored-libgit2"] } -reqwest = { version = "0.11", features = ["blocking", "json"] } -flate2 = "1.0" -tar = "0.4" - -[dev-dependencies] -tempfile = "3.13" diff --git a/bread-sync/README.md b/bread-sync/README.md deleted file mode 100644 index 079b8d6..0000000 --- a/bread-sync/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# bread-sync - -Sync and module management library for the Bread reactive desktop automation daemon. - -Provides: -- `SyncConfig` — load/save `~/.config/bread/sync.toml` -- Git backend (via git2) for push/pull of bread config to a remote repository -- Delegate file handling — copy arbitrary config files into the sync repo -- Package manifest generation for pacman/pip/npm/cargo -- Machine profile — name and tags read from sync.toml diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs deleted file mode 100644 index d0b7506..0000000 --- a/bread-sync/src/config.rs +++ /dev/null @@ -1,124 +0,0 @@ -use std::path::PathBuf; - -use anyhow::Result; -use serde::{Deserialize, Serialize}; - -/// Top-level sync configuration stored in `~/.config/bread/sync.toml`. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct SyncConfig { - #[serde(default)] - pub remote: RemoteConfig, - #[serde(default)] - pub machine: MachineConfig, - #[serde(default)] - pub packages: PackagesConfig, - #[serde(default)] - pub delegates: DelegatesConfig, -} - -/// Git remote configuration. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct RemoteConfig { - pub url: Option, - #[serde(default = "default_branch")] - pub branch: String, -} - -fn default_branch() -> String { - "main".to_string() -} - -/// Machine identity — name comes from here, falls back to hostname. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct MachineConfig { - pub name: Option, - #[serde(default)] - pub tags: Vec, -} - -/// Which package managers to snapshot. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PackagesConfig { - #[serde(default = "default_true")] - pub enabled: bool, - #[serde(default = "default_managers")] - pub managers: Vec, -} - -impl Default for PackagesConfig { - fn default() -> Self { - Self { - enabled: true, - managers: default_managers(), - } - } -} - -fn default_true() -> bool { - true -} - -fn default_managers() -> Vec { - vec!["pacman".to_string(), "pip".to_string(), "npm".to_string()] -} - -/// Config file delegation — which extra paths to include in the sync repo. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct DelegatesConfig { - /// Absolute or `~`-prefixed paths to copy into `configs//`. - #[serde(default)] - pub include: Vec, - /// Glob patterns to exclude when copying. - #[serde(default)] - pub exclude: Vec, -} - -impl SyncConfig { - /// Load from `~/.config/bread/sync.toml`, returning `Default` if not present. - pub fn load() -> Result { - let path = config_path()?; - if !path.exists() { - return Ok(Self::default()); - } - let raw = std::fs::read_to_string(&path)?; - let cfg: Self = toml::from_str(&raw)?; - Ok(cfg) - } - - /// Write to `~/.config/bread/sync.toml`, creating parent dirs as needed. - pub fn save(&self) -> Result<()> { - let path = config_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let raw = toml::to_string_pretty(self)?; - std::fs::write(&path, raw)?; - Ok(()) - } - - /// Returns `true` if `~/.config/bread/sync.toml` exists on disk. - pub fn is_initialized() -> Result { - Ok(config_path()?.exists()) - } -} - -/// Path to `~/.config/bread/sync.toml`. -pub fn config_path() -> Result { - let config_dir = dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?; - Ok(config_dir.join("bread").join("sync.toml")) -} - -/// Path to `~/.local/share/bread/sync-repo/`. -pub fn sync_repo_path() -> Result { - let data_dir = dirs::data_local_dir() - .ok_or_else(|| anyhow::anyhow!("cannot determine data directory"))?; - Ok(data_dir.join("bread").join("sync-repo")) -} - -/// Path to `~/.config/bread/`. -pub fn bread_config_dir() -> Result { - let config_dir = dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?; - Ok(config_dir.join("bread")) -} diff --git a/bread-sync/src/delegates.rs b/bread-sync/src/delegates.rs deleted file mode 100644 index aadab3b..0000000 --- a/bread-sync/src/delegates.rs +++ /dev/null @@ -1,205 +0,0 @@ -use std::path::{Path, PathBuf}; - -use anyhow::Result; - -use crate::config::DelegatesConfig; - -/// Expand `~` in a path string to the user's home directory. -pub fn expand_tilde(path: &str) -> PathBuf { - if let Some(rest) = path.strip_prefix("~/") { - dirs::home_dir() - .map(|h| h.join(rest)) - .unwrap_or_else(|| PathBuf::from(path)) - } else if path == "~" { - dirs::home_dir().unwrap_or_else(|| PathBuf::from(path)) - } else { - PathBuf::from(path) - } -} - -/// Returns `true` if `path` (relative to `base`) matches any of the `exclude` globs. -fn is_excluded(base: &Path, path: &Path, excludes: &[String]) -> bool { - let rel = path.strip_prefix(base).unwrap_or(path); - let rel_str = rel.to_string_lossy(); - for pattern in excludes { - if glob_matches(pattern, &rel_str) { - return true; - } - } - false -} - -/// Copy all files under `src` dir to `dest` dir, honouring `excludes`. -/// Creates `dest` if it doesn't exist. Deletes files in `dest` that are -/// absent in `src` (rsync `--delete` behaviour). -pub fn sync_dir(src: &Path, dest: &Path, excludes: &[String]) -> Result<()> { - std::fs::create_dir_all(dest)?; - copy_recursive(src, src, dest, excludes)?; - delete_extra(src, dest)?; - Ok(()) -} - -fn copy_recursive(root: &Path, src: &Path, dest: &Path, excludes: &[String]) -> Result<()> { - for entry in std::fs::read_dir(src)? { - let entry = entry?; - let src_path = entry.path(); - - if is_excluded(root, &src_path, excludes) { - continue; - } - - let file_name = entry.file_name(); - let dest_path = dest.join(&file_name); - - if src_path.is_dir() { - std::fs::create_dir_all(&dest_path)?; - copy_recursive(root, &src_path, &dest_path, excludes)?; - } else { - std::fs::copy(&src_path, &dest_path)?; - } - } - Ok(()) -} - -/// Remove files/dirs from `dest` that don't exist in `src`. -fn delete_extra(src: &Path, dest: &Path) -> Result<()> { - if !dest.exists() { - return Ok(()); - } - for entry in std::fs::read_dir(dest)? { - let entry = entry?; - let dest_path = entry.path(); - let file_name = entry.file_name(); - let src_path = src.join(&file_name); - if !src_path.exists() { - if dest_path.is_dir() { - std::fs::remove_dir_all(&dest_path)?; - } else { - std::fs::remove_file(&dest_path)?; - } - } - } - Ok(()) -} - -/// Copy each `include` path into `/configs//`. -pub fn copy_delegates_to_repo( - cfg: &DelegatesConfig, - repo_root: &Path, -) -> Result<()> { - let configs_dir = repo_root.join("configs"); - std::fs::create_dir_all(&configs_dir)?; - - for raw_path in &cfg.include { - let src = expand_tilde(raw_path); - if !src.exists() { - tracing_warn(&format!( - "delegate path does not exist, skipping: {}", - src.display() - )); - continue; - } - let basename = src - .file_name() - .ok_or_else(|| anyhow::anyhow!("delegate path has no filename: {}", src.display()))?; - let dest = configs_dir.join(basename); - if src.is_dir() { - sync_dir(&src, &dest, &cfg.exclude)?; - } else { - std::fs::copy(&src, &dest)?; - } - } - Ok(()) -} - -/// Restore each delegate path from `/configs//` to its original location. -pub fn restore_delegates_from_repo( - cfg: &DelegatesConfig, - repo_root: &Path, -) -> Result<()> { - let configs_dir = repo_root.join("configs"); - - for raw_path in &cfg.include { - let dest = expand_tilde(raw_path); - let basename = match dest.file_name() { - Some(n) => n.to_os_string(), - None => continue, - }; - let src = configs_dir.join(&basename); - if !src.exists() { - continue; - } - if src.is_dir() { - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent)?; - } - sync_dir(&src, &dest, &[])?; - } else { - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(&src, &dest)?; - } - } - Ok(()) -} - -/// Simple glob match for `**` and `*` patterns against a path string. -fn glob_matches(pattern: &str, path: &str) -> bool { - glob_match_bytes(pattern.as_bytes(), path.as_bytes()) -} - -fn glob_match_bytes(pattern: &[u8], text: &[u8]) -> bool { - if pattern.is_empty() { - return text.is_empty(); - } - - // `**` matches any sequence including path separators - if pattern.starts_with(b"**") { - let rest = &pattern[2..]; - if rest.is_empty() { - return true; - } - // skip leading separator in rest - let rest = if rest.starts_with(b"/") { &rest[1..] } else { rest }; - for offset in 0..=text.len() { - if glob_match_bytes(rest, &text[offset..]) { - return true; - } - } - return false; - } - - match pattern[0] { - b'*' => { - let mut offset = 0; - loop { - if glob_match_bytes(&pattern[1..], &text[offset..]) { - return true; - } - if offset == text.len() { - break; - } - offset += 1; - } - false - } - b'?' => { - if text.is_empty() { - return false; - } - glob_match_bytes(&pattern[1..], &text[1..]) - } - ch => { - if text.first().copied() != Some(ch) { - return false; - } - glob_match_bytes(&pattern[1..], &text[1..]) - } - } -} - -fn tracing_warn(msg: &str) { - // Use eprintln since tracing may not be configured in library context - eprintln!("warn: {msg}"); -} diff --git a/bread-sync/src/git.rs b/bread-sync/src/git.rs deleted file mode 100644 index 581efbc..0000000 --- a/bread-sync/src/git.rs +++ /dev/null @@ -1,227 +0,0 @@ -use std::path::Path; - -use anyhow::{anyhow, Result}; - -/// Open an existing repo or initialise a new one at `path`. -pub fn init_or_open(path: &Path) -> Result { - if path.join(".git").exists() || is_bare(path) { - Ok(git2::Repository::open(path)?) - } else { - std::fs::create_dir_all(path)?; - Ok(git2::Repository::init(path)?) - } -} - -/// Clone `url` to `path` if `path` is not already a repo, otherwise open it. -pub fn clone_or_open(url: &str, path: &Path) -> Result { - if path.join(".git").exists() || is_bare(path) { - return Ok(git2::Repository::open(path)?); - } - let mut builder = git2::build::RepoBuilder::new(); - let mut fetch_opts = git2::FetchOptions::new(); - fetch_opts.remote_callbacks(make_callbacks()); - builder.fetch_options(fetch_opts); - std::fs::create_dir_all(path)?; - Ok(builder.clone(url, path)?) -} - -/// Stage every tracked and untracked change (equivalent to `git add -A`). -pub fn stage_all(repo: &git2::Repository) -> Result<()> { - let mut index = repo.index()?; - index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?; - // Remove entries for deleted files - index.update_all(["*"].iter(), None)?; - index.write()?; - Ok(()) -} - -/// Returns `true` if the index has staged changes compared to HEAD (or repo is new). -pub fn has_changes(repo: &git2::Repository) -> Result { - let mut index = repo.index()?; - index.read(false)?; - - // New repo with no commits yet - if repo.head().is_err() { - return Ok(index.len() > 0); - } - - let head = repo.head()?.peel_to_tree()?; - let diff = repo.diff_tree_to_index(Some(&head), Some(&index), None)?; - Ok(diff.deltas().count() > 0) -} - -/// Commit all staged changes with `message`. Returns the new commit OID. -pub fn commit(repo: &git2::Repository, message: &str) -> Result { - let mut index = repo.index()?; - let tree_id = index.write_tree()?; - let tree = repo.find_tree(tree_id)?; - let sig = repo.signature().unwrap_or_else(|_| { - git2::Signature::now("bread", "bread@localhost").expect("signature") - }); - - let oid = if let Ok(head) = repo.head() { - let parent = head.peel_to_commit()?; - repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])? - } else { - repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])? - }; - Ok(oid) -} - -/// Push `branch` to `remote_name` (defaults to "origin"). -pub fn push(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> { - let mut remote = repo.find_remote(remote_name)?; - let mut opts = git2::PushOptions::new(); - opts.remote_callbacks(make_callbacks()); - remote.push( - &[&format!("refs/heads/{branch}:refs/heads/{branch}")], - Some(&mut opts), - )?; - Ok(()) -} - -/// Fetch from `remote_name` without merging. -pub fn fetch(repo: &git2::Repository, remote_name: &str) -> Result<()> { - let mut remote = repo.find_remote(remote_name)?; - let mut opts = git2::FetchOptions::new(); - opts.remote_callbacks(make_callbacks()); - remote.fetch(&[] as &[&str], Some(&mut opts), None)?; - Ok(()) -} - -/// Fetch and fast-forward merge from `remote_name/branch`. Errors on conflict. -pub fn pull(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> { - fetch(repo, remote_name)?; - - let fetch_head = repo - .find_reference(&format!("refs/remotes/{remote_name}/{branch}")) - .map_err(|_| anyhow!("remote branch {remote_name}/{branch} not found after fetch"))?; - let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?; - - let analysis = repo.merge_analysis(&[&fetch_commit])?; - if analysis.0.is_up_to_date() { - return Ok(()); - } - if !analysis.0.is_fast_forward() { - return Err(anyhow!( - "sync conflict — resolve manually in {}", - repo.workdir() - .unwrap_or_else(|| Path::new("?")) - .display() - )); - } - - // Fast-forward: update HEAD and checkout - let head_ref = repo.find_reference("HEAD")?; - let resolved = head_ref.resolve()?; - let refname = resolved.name().unwrap_or("HEAD").to_string(); - repo.find_reference(&refname)? - .set_target(fetch_commit.id(), "fast-forward")?; - repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?; - Ok(()) -} - -/// Add a remote named `name` pointing at `url`, or update it if it already exists. -pub fn set_remote(repo: &git2::Repository, name: &str, url: &str) -> Result<()> { - if repo.find_remote(name).is_ok() { - repo.remote_set_url(name, url)?; - } else { - repo.remote(name, url)?; - } - Ok(()) -} - -/// Return working-tree diff against HEAD as a unified diff string. -pub fn diff_workdir(repo: &git2::Repository) -> Result { - let mut buf = Vec::new(); - if let Ok(head_tree) = repo.head().and_then(|h| h.peel_to_tree()) { - let diff = repo.diff_tree_to_workdir_with_index(Some(&head_tree), None)?; - diff.print(git2::DiffFormat::Patch, |_, _, line| { - buf.extend_from_slice(line.content()); - true - })?; - } - Ok(String::from_utf8_lossy(&buf).into_owned()) -} - -/// Return diff between HEAD and `remote/branch` as a unified diff string. -pub fn diff_remote(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result { - let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); - let remote_tree = repo - .find_reference(&remote_ref) - .map_err(|_| anyhow!("remote ref {remote_ref} not found — try fetching first"))? - .peel_to_tree()?; - let local_tree = repo.head()?.peel_to_tree()?; - let diff = repo.diff_tree_to_tree(Some(&local_tree), Some(&remote_tree), None)?; - let mut buf = Vec::new(); - diff.print(git2::DiffFormat::Patch, |_, _, line| { - buf.extend_from_slice(line.content()); - true - })?; - Ok(String::from_utf8_lossy(&buf).into_owned()) -} - -/// Return a list of `(status_char, path)` for the working tree. -pub fn status_lines(repo: &git2::Repository) -> Result> { - let statuses = repo.statuses(None)?; - let mut out = Vec::new(); - for entry in statuses.iter() { - let path = entry.path().unwrap_or("?").to_string(); - let flag = entry.status(); - let ch = if flag.contains(git2::Status::INDEX_NEW) || flag.contains(git2::Status::WT_NEW) { - 'A' - } else if flag.contains(git2::Status::INDEX_DELETED) || flag.contains(git2::Status::WT_DELETED) { - 'D' - } else { - 'M' - }; - out.push((ch, path)); - } - Ok(out) -} - -/// Returns true if the local HEAD is behind the remote. -pub fn remote_has_changes(repo: &git2::Repository, remote_name: &str, branch: &str) -> bool { - let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); - let Ok(remote_ref) = repo.find_reference(&remote_ref) else { - return false; - }; - let Ok(remote_commit) = remote_ref.peel_to_commit() else { - return false; - }; - let Ok(local_commit) = repo.head().and_then(|h| h.peel_to_commit()) else { - return false; - }; - remote_commit.id() != local_commit.id() -} - -/// Timestamp of the HEAD commit (or "never"). -pub fn last_commit_time(repo: &git2::Repository) -> String { - let Ok(commit) = repo.head().and_then(|h| h.peel_to_commit()) else { - return "never".to_string(); - }; - let ts = commit.time().seconds(); - let dt = chrono::DateTime::::from_timestamp(ts, 0) - .unwrap_or_else(chrono::Utc::now); - dt.format("%Y-%m-%d %H:%M:%S").to_string() -} - -fn is_bare(path: &Path) -> bool { - path.join("HEAD").exists() && path.join("objects").exists() -} - -fn make_callbacks<'a>() -> git2::RemoteCallbacks<'a> { - let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.credentials(|_url, username_from_url, allowed_types| { - if allowed_types.contains(git2::CredentialType::SSH_KEY) { - git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) - } else if allowed_types.contains(git2::CredentialType::DEFAULT) { - git2::Cred::default() - } else { - Err(git2::Error::from_str( - "no supported credential type (SSH agent or default)", - )) - } - }); - callbacks -} diff --git a/bread-sync/src/lib.rs b/bread-sync/src/lib.rs deleted file mode 100644 index 454a78a..0000000 --- a/bread-sync/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod config; -pub mod delegates; -pub mod git; -pub mod machine; -pub mod packages; - -pub use config::{ - bread_config_dir, config_path, sync_repo_path, DelegatesConfig, MachineConfig, PackagesConfig, - RemoteConfig, SyncConfig, -}; diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs deleted file mode 100644 index e4e4bb1..0000000 --- a/bread-sync/src/machine.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::path::Path; - -use anyhow::Result; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -use crate::config::SyncConfig; - -/// Machine profile persisted to `/machines/.toml`. -#[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 { - pub fn new(cfg: &SyncConfig) -> Result { - let host = hostname()?; - let name = cfg.machine.name.clone().unwrap_or_else(|| host.clone()); - Ok(Self { - name, - hostname: host, - tags: cfg.machine.tags.clone(), - last_sync: Utc::now().to_rfc3339(), - }) - } - - /// Write profile to `/machines/.toml`. - pub fn write_to_repo(&self, repo_root: &Path) -> Result<()> { - let machines_dir = repo_root.join("machines"); - std::fs::create_dir_all(&machines_dir)?; - let path = machines_dir.join(format!("{}.toml", self.name)); - let raw = toml::to_string_pretty(self)?; - std::fs::write(&path, raw)?; - Ok(()) - } - - /// Load from `/machines/.toml`. - pub fn load_from_repo(repo_root: &Path, name: &str) -> Result { - let path = repo_root.join("machines").join(format!("{name}.toml")); - let raw = std::fs::read_to_string(&path)?; - Ok(toml::from_str(&raw)?) - } -} - -/// List all machine profiles in `/machines/`. -pub fn list_machines(repo_root: &Path) -> Vec { - let machines_dir = repo_root.join("machines"); - let Ok(entries) = std::fs::read_dir(&machines_dir) else { - return Vec::new(); - }; - entries - .filter_map(|e| e.ok()) - .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("toml")) - .filter_map(|e| { - std::fs::read_to_string(e.path()) - .ok() - .and_then(|raw| toml::from_str::(&raw).ok()) - }) - .collect() -} - -/// Returns the machine name from sync.toml, falling back to hostname. -pub fn machine_name(cfg: &SyncConfig) -> Result { - if let Some(name) = cfg.machine.name.as_deref() { - return Ok(name.to_string()); - } - hostname() -} - -/// Returns the machine tags from sync.toml. -pub fn machine_tags(cfg: &SyncConfig) -> Vec { - cfg.machine.tags.clone() -} - -/// Returns true if `tag` is in the machine's tag list. -pub fn machine_has_tag(cfg: &SyncConfig, tag: &str) -> bool { - cfg.machine.tags.iter().any(|t| t == tag) -} - -fn hostname() -> Result { - // Try /etc/hostname first (no subprocess) - if let Ok(raw) = std::fs::read_to_string("/etc/hostname") { - let trimmed = raw.trim().to_string(); - if !trimmed.is_empty() { - return Ok(trimmed); - } - } - // Fall back to hostname(1) - let out = std::process::Command::new("hostname") - .output() - .map_err(anyhow::Error::from)?; - let s = String::from_utf8(out.stdout).map_err(anyhow::Error::from)?; - Ok(s.trim().to_string()) -} - -#[allow(dead_code)] -fn format_last_sync(dt: &DateTime) -> String { - dt.format("%Y-%m-%d %H:%M").to_string() -} diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs deleted file mode 100644 index 333e0aa..0000000 --- a/bread-sync/src/packages.rs +++ /dev/null @@ -1,137 +0,0 @@ -use std::path::Path; -use std::process::Command; - -use anyhow::Result; - -/// Write package manifests to `/packages/`. -/// Skips package managers that are not installed (warns instead of erroring). -pub fn snapshot_packages(managers: &[String], repo_root: &Path) -> Result<()> { - let pkg_dir = repo_root.join("packages"); - std::fs::create_dir_all(&pkg_dir)?; - - for mgr in managers { - match mgr.as_str() { - "pacman" => { - if let Some(content) = run_pacman() { - std::fs::write(pkg_dir.join("pacman.txt"), content)?; - } else { - eprintln!("warn: pacman not found, skipping package snapshot"); - } - } - "pip" => { - if let Some(content) = run_pip() { - std::fs::write(pkg_dir.join("pip.txt"), content)?; - } else { - eprintln!("warn: pip not found, skipping package snapshot"); - } - } - "npm" => { - if let Some(content) = run_npm() { - std::fs::write(pkg_dir.join("npm.txt"), content)?; - } else { - eprintln!("warn: npm not found, skipping package snapshot"); - } - } - "cargo" => { - if let Some(content) = run_cargo() { - std::fs::write(pkg_dir.join("cargo.txt"), content)?; - } else { - eprintln!("warn: cargo not found, skipping package snapshot"); - } - } - other => { - eprintln!("warn: unknown package manager '{other}', skipping"); - } - } - } - Ok(()) -} - -/// Parse a `pacman.txt` snapshot into a list of package names. -pub fn parse_pacman(content: &str) -> Vec { - content.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect() -} - -/// Parse a `pip.txt` (freeze format) snapshot into package names. -pub fn parse_pip(content: &str) -> Vec { - content - .lines() - .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) - .filter_map(|l| l.split("==").next().map(|s| s.trim().to_string())) - .collect() -} - -/// Parse an `npm.txt` (parseable) snapshot into package names. -pub fn parse_npm(content: &str) -> Vec { - content - .lines() - .skip(1) // first line is the npm global prefix path - .filter(|l| !l.trim().is_empty()) - .filter_map(|l| { - Path::new(l.trim()) - .file_name() - .and_then(|n| n.to_str()) - .map(ToString::to_string) - }) - .collect() -} - -/// Parse `cargo install --list` output into `name version` lines. -pub fn parse_cargo(content: &str) -> Vec { - content - .lines() - .filter(|l| !l.starts_with(' ') && !l.trim().is_empty()) - .filter_map(|l| { - // Format: `name v1.2.3 (...):` or `name v1.2.3:` - let parts: Vec<&str> = l.splitn(2, ' ').collect(); - if parts.len() == 2 { - let name = parts[0]; - let version = parts[1].trim_start_matches('v').split_whitespace().next().unwrap_or("?").trim_end_matches(':'); - Some(format!("{name} {version}")) - } else { - None - } - }) - .collect() -} - -fn run_pacman() -> Option { - let output = Command::new("pacman").args(["-Qe"]).output().ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -fn run_pip() -> Option { - let output = Command::new("pip") - .args(["list", "--user", "--format=freeze"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -fn run_npm() -> Option { - let output = Command::new("npm") - .args(["list", "-g", "--depth=0", "--parseable"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -fn run_cargo() -> Option { - let output = Command::new("cargo") - .args(["install", "--list"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} diff --git a/bread-sync/tests/sync.rs b/bread-sync/tests/sync.rs deleted file mode 100644 index ce76abf..0000000 --- a/bread-sync/tests/sync.rs +++ /dev/null @@ -1 +0,0 @@ -// Placeholder - tests will be added in step 15 diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index 36189a0..ade7d2f 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -22,25 +22,20 @@ impl UdevAdapter { } pub async fn enumerate_existing(&self, tx: &mpsc::Sender) -> Result<()> { - let payloads = match enumerate_with_udev(&self.subsystems) { - Ok(p) => p, - Err(_) => scan_devices(&self.subsystems) - .unwrap_or_default() - .into_iter() - .map(|d| json!({ - "action": "add", - "id": d.id, - "name": d.name, - "subsystem": d.subsystem, - })) - .collect(), - }; + let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| { + scan_devices(&self.subsystems).unwrap_or_default() + }); - for payload in payloads { + for device in devices { tx.send(RawEvent { source: AdapterSource::Udev, kind: "udev.enumerate".to_string(), - payload, + payload: json!({ + "action": "add", + "id": device.id, + "name": device.name, + "subsystem": device.subsystem, + }), timestamp: now_unix_ms(), }) .await?; @@ -169,7 +164,7 @@ async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) - Ok(()) } -fn enumerate_with_udev(subsystems: &[String]) -> Result> { +fn enumerate_with_udev(subsystems: &[String]) -> Result> { let mut enumerator = udev::Enumerator::new()?; for subsystem in subsystems { enumerator.match_subsystem(subsystem)?; @@ -189,38 +184,16 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result> .unwrap_or_else(|| "unknown".to_string()); let id = dev.syspath().to_string_lossy().to_string(); - out.push(json!({ - "action": "add", - "id": id, - "name": name, - "subsystem": subsystem, - "id_input_keyboard": dev_prop_bool(&dev, "ID_INPUT_KEYBOARD"), - "id_input_mouse": dev_prop_bool(&dev, "ID_INPUT_MOUSE"), - "id_input_joystick": dev_prop_bool(&dev, "ID_INPUT_JOYSTICK"), - "id_input_touchpad": dev_prop_bool(&dev, "ID_INPUT_TOUCHPAD"), - "id_input_tablet": dev_prop_bool(&dev, "ID_INPUT_TABLET"), - "id_usb_class": dev_prop_str(&dev, "ID_USB_CLASS"), - "id_usb_interfaces": dev_prop_str(&dev, "ID_USB_INTERFACES"), - "id_vendor": dev_prop_str(&dev, "ID_VENDOR"), - "id_model": dev_prop_str(&dev, "ID_MODEL"), - })); + out.push(ScannedDevice { + id, + name, + subsystem, + }); } Ok(out) } -fn dev_prop_bool(dev: &udev::Device, key: &str) -> bool { - dev.property_value(key) - .and_then(|v| v.to_str()) - .map(|v| v == "1") - .unwrap_or(false) -} - -fn dev_prop_str(dev: &udev::Device, key: &str) -> Option { - dev.property_value(key) - .map(|v| v.to_string_lossy().to_string()) -} - fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent { RawEvent { source: AdapterSource::Udev, From 96e42bc3704ec021e151b869bc41d2fe05a255b1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 22:51:32 +0800 Subject: [PATCH 22/76] revert --- .github/workflows/ci.yml | 42 ++ .gitignore | 6 +- CLAUDE_SPEC.md | 604 ------------------- Cargo.lock | 1090 +---------------------------------- Cargo.toml | 3 +- README.md | 7 +- bread-cli/Cargo.toml | 7 - bread-cli/src/main.rs | 904 ++--------------------------- bread-sync/Cargo.toml | 19 - bread-sync/README.md | 10 - bread-sync/src/config.rs | 124 ---- bread-sync/src/delegates.rs | 205 ------- bread-sync/src/git.rs | 227 -------- bread-sync/src/lib.rs | 10 - bread-sync/src/machine.rs | 102 ---- bread-sync/src/packages.rs | 137 ----- bread-sync/tests/sync.rs | 1 - breadd/src/adapters/udev.rs | 59 +- 18 files changed, 125 insertions(+), 3432 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 CLAUDE_SPEC.md delete mode 100644 bread-sync/Cargo.toml delete mode 100644 bread-sync/README.md delete mode 100644 bread-sync/src/config.rs delete mode 100644 bread-sync/src/delegates.rs delete mode 100644 bread-sync/src/git.rs delete mode 100644 bread-sync/src/lib.rs delete mode 100644 bread-sync/src/machine.rs delete mode 100644 bread-sync/src/packages.rs delete mode 100644 bread-sync/tests/sync.rs 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 f8f98d0..0c56659 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,8 @@ Overview.md DAEMON.md .claude CLAUDE.md -.github \ No newline at end of file +<<<<<<< HEAD +.github +======= +.github/ +>>>>>>> parent of e561156 (Begin Implementing V2 features) diff --git a/CLAUDE_SPEC.md b/CLAUDE_SPEC.md deleted file mode 100644 index 2a2d1df..0000000 --- a/CLAUDE_SPEC.md +++ /dev/null @@ -1,604 +0,0 @@ -# Bread — Sync & Module System Implementation Spec -### Instructions for Claude Code - -This document defines exactly what to build, how it must behave, and what conditions must be met before iteration stops. Read it fully before writing any code. Do not stop iterating until every condition in the **Completion Checklist** at the bottom is met. - ---- - -## Context - -Bread is a reactive desktop automation daemon for Linux. The existing codebase is a Rust workspace with three crates: - -- `breadd/` — the runtime daemon (Rust + Lua via mlua) -- `bread-cli/` — the CLI binary (Rust, talks to daemon over Unix socket IPC) -- `bread-shared/` — shared types (`BreadEvent`, `RawEvent`, `AdapterSource`) - -The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The IPC protocol is newline-delimited JSON request/response. The Lua runtime runs on a dedicated OS thread. All existing code compiles and tests pass — do not break anything that currently works. - -The two things being added in this iteration: - -1. **Module system** — install, list, remove, and update Lua modules from GitHub URLs -2. **Sync** — snapshot and restore system state (Bread config + arbitrary config files + package manifests) via a Git remote - ---- - -## Part 1: Module System - -### What a module is - -A Bread module is a directory (or single `.lua` file) that gets installed into `~/.config/bread/modules/`. Modules are already loaded by the daemon — what's missing is the install/manage layer. - -A module directory looks like: - -``` -~/.config/bread/modules/ -└── wifi/ - ├── bread.module.toml ← module manifest (required) - ├── init.lua ← entry point (required) - └── lib/ ← optional support files -``` - -### Module manifest (`bread.module.toml`) - -Every installed module must have a manifest: - -```toml -name = "wifi" -version = "1.0.0" -description = "WiFi management for Bread" -author = "someuser" -source = "github:someuser/bread-wifi" # where it was installed from -installed_at = "2026-05-11T09:00:00Z" # RFC 3339 timestamp, set on install -``` - -All fields are required. `source` is the original install source string. `installed_at` is written by Bread at install time, not by the module author. - -### Install sources - -The module installer must support these source formats: - -``` -github:user/repo # installs default branch -github:user/repo@v1.2.0 # installs specific tag -github:user/repo@abc1234 # installs specific commit -/path/to/local/dir # installs from local directory (copies it) -``` - -Anything else is an error with a clear message. - -### New Cargo dependencies allowed - -Add to `bread-cli/Cargo.toml` as needed: -- `git2 = "0.18"` for Git operations -- `reqwest = { version = "0.11", features = ["blocking", "json"] }` for GitHub API -- `flate2`, `tar` for archive extraction - -Add to `breadd/Cargo.toml` as needed: -- `git2 = "0.18"` -- `toml = "0.8"` (already present) - -### CLI commands to implement - -All module commands live under `bread modules`: - -``` -bread modules install Install a module -bread modules remove Remove an installed module -bread modules list List installed modules with name, version, status -bread modules update Update all installed modules to latest -bread modules update Update a specific module -bread modules info Show full manifest details for a module -``` - -**`bread modules install `** - -1. Parse the source string. -2. For `github:user/repo[@ref]`: - - Use the GitHub API to resolve the ref (or default branch if none specified). - - Download the repository archive as a `.tar.gz`. - - Extract to a temp directory. - - Verify a `bread.module.toml` exists at the root. If not, error cleanly. - - Copy the module directory to `~/.config/bread/modules//`. - - Write `installed_at` into the manifest. -3. For local paths: - - Verify the path exists and contains `bread.module.toml`. - - Copy to `~/.config/bread/modules//`. - - Write `installed_at`. -4. Print `installed v` on success. -5. Tell the daemon to reload via IPC (`modules.reload`) after install. - -**`bread modules remove `** - -1. Find `~/.config/bread/modules//`. -2. Ask for confirmation: `remove ? (y/n)`. Skip if `--yes` flag is passed. -3. Delete the directory. -4. Tell the daemon to reload via IPC. -5. Print `removed `. - -**`bread modules list`** - -Scan `~/.config/bread/modules/` for directories containing `bread.module.toml`. For each, print: - -``` - wifi 1.0.0 loaded github:someuser/bread-wifi - redox 0.3.1 loaded github:breadway/bread-redox - broken-mod 0.1.0 error /home/user/local-module -``` - -Status (`loaded`, `error`, `not_found`, `degraded`) comes from the daemon's IPC `modules.list` response, matched by module name. If the daemon is unreachable, show `unknown` for status. - -**`bread modules update [name]`** - -1. Read `bread.module.toml` for each module to update. -2. If `source` starts with `github:`, re-run the install for that source. -3. If `source` is a local path, error with `cannot update local module — reinstall manually`. -4. Print `updated v → v` or ` already up to date`. - -**`bread modules info `** - -Print full manifest contents plus daemon-reported status. Example: - -``` -name: wifi -version: 1.0.0 -description: WiFi management for Bread -author: someuser -source: github:someuser/bread-wifi -installed_at: 2026-05-11T09:00:00Z -status: loaded -``` - -### Daemon-side: expose `ID_VENDOR_ID` and `ID_MODEL_ID` in udev events - -In `breadd/src/adapters/udev.rs`, the `run_udev_monitor` function builds the payload for each udev event. Add `vendor_id` and `product_id` to the payload: - -```rust -"vendor_id": prop_str(&event, "ID_VENDOR_ID"), -"product_id": prop_str(&event, "ID_MODEL_ID"), -``` - -These are the raw hex USB IDs (e.g. `"4d44"` and `"5244"`). Do the same in `raw_change_event` for the fallback poller — read them from sysfs at `/idVendor` and `/idProduct` if available. Also add `vendor_id` and `product_id` to the `Device` struct in `breadd/src/core/types.rs` as `Option`. - ---- - -## Part 2: Sync System - -### Overview - -Sync saves and restores a complete description of the user's environment. It is not a disk image. It saves: - -1. **Bread config** — everything in `~/.config/bread/` (always included) -2. **Delegated configs** — other config directories the user explicitly opts in (e.g. `~/.config/nvim/`) -3. **Package manifest** — lists of explicitly-installed packages per package manager -4. **Machine profile** — machine name and tags for machine-aware config - -Everything is stored in a Git repository. `bread sync push` commits and pushes. `bread sync pull` pulls and applies. - -### New crate: `bread-sync` - -Create a new crate `bread-sync/` in the workspace. Add it to `[workspace.members]` in the root `Cargo.toml`. - -``` -bread-sync/ -├── Cargo.toml -└── src/ - ├── lib.rs - ├── config.rs ← SyncConfig type, load/save - ├── git.rs ← Git operations via git2 - ├── packages.rs ← Package manifest generation - ├── delegates.rs ← Config file delegation - └── machine.rs ← Machine profile -``` - -`bread-cli` depends on `bread-sync`. `breadd` does not — sync is a CLI-only feature. - -### Sync configuration (`~/.config/bread/sync.toml`) - -This file is created by `bread sync init` and edited by the user. It is committed to the sync repo. - -```toml -[remote] -url = "git@github.com:user/bread-sync.git" # required, set by bread sync init -branch = "main" # default: "main" - -[machine] -name = "laptop" # required, set by bread sync init -tags = ["mobile", "battery", "single-monitor"] # user-defined, optional - -[packages] -enabled = true -managers = ["pacman", "pip", "npm"] # which package managers to snapshot - -[delegates] -# Additional config directories to include in sync. -# ~/.config/bread/ is always included and does not need to be listed here. -include = [ - "~/.config/nvim", - "~/.config/fish", - "~/.config/kitty", -] -exclude = [ - "**/.git", - "**/node_modules", - "**/__pycache__", - "**/*.log", - "**/*.cache", - "~/.config/nvim/.repro", -] -``` - -All paths support `~` expansion. Globs in `exclude` use standard glob syntax. - -### Sync repo layout - -The Git repository managed by Bread has this structure: - -``` -/ -├── bread/ ← copy of ~/.config/bread/ (minus sync.toml secrets if any) -├── configs/ -│ ├── nvim/ ← copy of ~/.config/nvim/ -│ ├── fish/ ← copy of ~/.config/fish/ -│ └── kitty/ ← copy of ~/.config/kitty/ -├── packages/ -│ ├── pacman.txt ← output of `pacman -Qe` -│ ├── pip.txt ← output of `pip list --user --format=freeze` -│ └── npm.txt ← output of `npm list -g --depth=0` -├── machines/ -│ └── laptop.toml ← machine profile for this machine -└── .bread-sync ← sync metadata (not committed to Git) -``` - -`machines/.toml` contains: - -```toml -name = "laptop" -hostname = "breadway-laptop" # auto-detected via gethostname -tags = ["mobile", "battery", "single-monitor"] -last_sync = "2026-05-11T09:15:00Z" -``` - -### CLI commands to implement - -All sync commands live under `bread sync`: - -``` -bread sync init [--remote ] Initialize sync for this machine -bread sync push [--message ] Snapshot and push current state -bread sync pull Pull and apply latest state -bread sync status Show what has changed since last push -bread sync diff Show file-level diff vs remote -bread sync machines List known machines from sync repo -``` - -**`bread sync init [--remote ]`** - -1. Check if `~/.config/bread/sync.toml` already exists. If so, error: `sync already initialized. Edit ~/.config/bread/sync.toml to reconfigure.` -2. If `--remote` is not provided, prompt: `Sync remote URL (git remote or path): `. -3. Prompt: `Machine name [laptop]: ` (default: hostname). -4. Prompt: `Machine tags (comma-separated, e.g. mobile,battery): `. -5. Create `~/.config/bread/sync.toml` with the provided values. -6. If the remote is a URL (not a local path), check if the repo exists: - - If it exists, clone it to a temp location and verify it looks like a Bread sync repo (has a `bread/` directory or is empty). - - If it doesn't exist, print: `remote does not exist yet — it will be created on first push`. -7. Print setup summary. - -**`bread sync push [--message ]`** - -1. Load `~/.config/bread/sync.toml`. Error if not initialized. -2. Resolve the local sync repo path (`~/.local/share/bread/sync-repo/`). Clone from remote if it doesn't exist locally. -3. Snapshot each section: - - Copy `~/.config/bread/` → `/bread/` (rsync-style: delete files in dest that don't exist in source) - - For each path in `delegates.include`: copy to `/configs//` - - If `packages.enabled`: run package manager queries and write to `/packages/` - - Write `/machines/.toml` -4. Stage all changes (`git add -A`). -5. If there are no changes, print `nothing to push — already up to date` and exit. -6. Commit with message: `sync: ` or the user-provided `--message`. -7. Push to remote. -8. Print a summary of what was snapshotted. - -**`bread sync pull`** - -1. Load `~/.config/bread/sync.toml`. Error if not initialized. -2. Pull from remote (fetch + merge or rebase — use merge, simpler). -3. Apply each section in order: - - Copy `/bread/` → `~/.config/bread/` (same rsync-style) - - For each path in `delegates.include` that exists in `/configs/`: copy back - - If `packages.enabled` and `--install-packages` flag is passed: run package installs (see below) -4. Tell the daemon to reload via IPC (`modules.reload`) after applying. -5. Print a summary of what was applied. - -**Package install on pull** (only when `--install-packages` is explicitly passed): - -- `pacman.txt` → `sudo pacman -S --needed $(cat pacman.txt | awk '{print $1}')` -- `pip.txt` → `pip install --user -r pip.txt` -- `npm.txt` → parse package names and run `npm install -g` - -Never run package installs automatically without the flag. Print a note at the end of `pull` if packages differ: `run 'bread sync pull --install-packages' to install missing packages`. - -**`bread sync status`** - -1. Load sync config and local repo. -2. Pull remote refs without merging (fetch only). -3. Compare working tree to last commit and compare last commit to remote HEAD. -4. Print: - -``` -bread sync status - machine laptop - remote git@github.com:user/bread-sync.git - last push 2026-05-11 09:15:00 - -local changes (not yet pushed): - M bread/init.lua - A bread/modules/wifi/init.lua - -remote changes (not yet pulled): - none -``` - -**`bread sync diff`** - -Run `git diff HEAD` in the sync repo and print it. If `--remote` flag is passed, run `git diff HEAD..origin/`. - -**`bread sync machines`** - -List all `machines/*.toml` files from the sync repo: - -``` - laptop last sync: 2026-05-11 09:15 tags: mobile, battery, single-monitor - desktop last sync: 2026-05-10 22:00 tags: stationary, multi-monitor, docked -``` - -### Package manager support - -Implement these four. Each must handle the case where the package manager is not installed (skip with a warning, don't error). - -| Manager | Snapshot command | Install command | -|---------|-----------------|-----------------| -| `pacman` | `pacman -Qe` | `sudo pacman -S --needed ` | -| `pip` | `pip list --user --format=freeze` | `pip install --user -r ` | -| `npm` | `npm list -g --depth=0 --parseable` | `npm install -g ` | -| `cargo` | `cargo install --list` | `cargo install ` | - -For `cargo`, the snapshot format is one package per line: ` `. Parse `cargo install --list` output accordingly. - -### Git operations - -Use the `git2` crate for all Git operations. Do not shell out to `git`. Required operations: - -- Clone a remote repo -- Open an existing repo -- Stage all changes (`add -A` equivalent: index all tracked and untracked files) -- Create a commit with a message and the current timestamp as author date -- Push to remote (support SSH and HTTPS — `git2` handles this via callbacks) -- Pull (fetch + merge fast-forward; if non-fast-forward, error with clear message) -- Fetch (without merging) -- Get diff between working tree and HEAD -- Get diff between HEAD and remote branch HEAD - -For SSH auth, use the user's default SSH agent (`git2::transport::smart::SmartSubtransport` with `SshKey` credential). For HTTPS, use the system credential store or prompt for credentials. - ---- - -## Part 3: Daemon additions (IPC) - -Add these IPC methods to `breadd/src/ipc/mod.rs`: - -**`sync.status`** — returns current sync state from `sync.toml` if it exists: -```json -{ "initialized": true, "machine": "laptop", "remote": "git@github.com:..." } -``` -or `{ "initialized": false }` if no sync.toml. - -**`modules.install`** — triggers a reload after external install (already covered by `modules.reload`, no new method needed — `bread modules install` calls `modules.reload` via IPC after installing). - -No other daemon changes are needed for sync — it is entirely CLI-side. - ---- - -## Part 4: Lua API additions - -Add to `breadd/src/lua/mod.rs` in `install_api`: - -**`bread.machine`** table: - -```lua -bread.machine.name() -- returns machine name from sync.toml, or hostname if no sync.toml -bread.machine.tags() -- returns array of tags, or empty array -bread.machine.has_tag("mobile") -- returns bool -``` - -Read `~/.config/bread/sync.toml` directly from Lua (parse it in Rust, expose via the API). If `sync.toml` doesn't exist, `name()` returns `os.getenv("HOSTNAME")` and `tags()` returns `{}`. - -**`bread.fs`** table: - -```lua -bread.fs.write(path, content) -- write string to file, create dirs as needed -bread.fs.read(path) -- read file to string, returns nil if not found -bread.fs.exists(path) -- returns bool -bread.fs.expand(path) -- expand ~ to home directory -``` - -All paths support `~` expansion. `bread.fs.write` creates parent directories automatically. Errors in `write` propagate as Lua errors. - ---- - -## Error handling requirements - -Every command must handle these cases cleanly: - -- Daemon not running: print `bread: daemon is not running. Start it with: systemctl --user start breadd` and exit 1. -- No sync.toml: print `bread: sync not initialized. Run: bread sync init` and exit 1. -- Network unreachable during push/pull: print the error clearly and exit 1. Do not leave the repo in a partial state. -- Module not found during remove/info: print `bread: module '' is not installed` and exit 1. -- Git conflicts on pull: print `bread: sync conflict — resolve manually in ~/.local/share/bread/sync-repo/` and exit 1. Do not auto-merge or discard changes. -- Package manager not installed: warn and skip, do not fail the whole operation. - ---- - -## File locations - -| Purpose | Path | -|---------|------| -| Sync config | `~/.config/bread/sync.toml` | -| Local sync repo | `~/.local/share/bread/sync-repo/` | -| Module manifests | `~/.config/bread/modules//bread.module.toml` | -| Bread config | `~/.config/bread/` | -| Daemon socket | `$XDG_RUNTIME_DIR/bread/breadd.sock` | - -All paths must use `dirs` crate or manual `$HOME`/`$XDG_*` expansion — never hardcode `/home/breadway` or any username. - -Add to `bread-cli/Cargo.toml`: `dirs = "5.0"`. - ---- - -## Tests - -### Module system tests (`bread-cli/tests/modules.rs`) - -```rust -// 1. Install from local path succeeds when bread.module.toml exists -// 2. Install from local path fails when bread.module.toml is missing -// 3. Remove deletes the module directory -// 4. List reads manifests correctly from disk -// 5. Manifest is written correctly on install (all fields present, installed_at is valid RFC 3339) -``` - -### Sync tests (`bread-sync/tests/sync.rs`) - -```rust -// 1. bread sync init creates sync.toml with correct fields -// 2. bread sync push with a local bare Git repo as remote: creates correct directory structure -// 3. bread sync push snapshots bread/ directory correctly -// 4. bread sync pull copies files from repo to correct locations -// 5. Package manifest for pacman: parses output correctly -// 6. Package manifest for pip: parses output correctly -// 7. Delegates: exclude globs filter correctly -// 8. Machine profile is written to machines/.toml with correct fields -// 9. Status shows no changes when working tree matches last commit -// 10. Push with no changes prints "nothing to push" and does not create a commit -``` - -All tests must pass with `cargo test --workspace`. Tests that require network access must be feature-gated with `#[cfg(feature = "network-tests")]` and not run by default. - ---- - -## Completion Checklist - -Do not stop iterating until every item on this list is true. - -### Compilation -- [ ] `cargo build --workspace` succeeds with zero errors -- [ ] `cargo build --workspace --release` succeeds with zero errors -- [ ] Zero compiler warnings in new code (existing warnings are acceptable) -- [ ] `cargo clippy --workspace` produces no errors in new code - -### Tests -- [ ] `cargo test --workspace` passes with zero failures -- [ ] All tests listed in the Tests section above exist and pass -- [ ] Integration tests in `breadd/tests/ipc_integration.rs` still pass - -### Module system — functional -- [ ] `bread modules install github:user/repo` downloads and installs a module -- [ ] `bread modules install /local/path` copies and installs a local module -- [ ] `bread modules install` with an invalid source prints a clear error and exits 1 -- [ ] `bread modules install` writes a valid `bread.module.toml` with all required fields including `installed_at` -- [ ] `bread modules install` calls `modules.reload` IPC after successful install -- [ ] `bread modules remove ` removes the module directory -- [ ] `bread modules remove ` with `--yes` skips confirmation -- [ ] `bread modules remove ` prints a clear error and exits 1 -- [ ] `bread modules list` reads all installed module manifests -- [ ] `bread modules list` shows daemon-reported status when daemon is running -- [ ] `bread modules list` shows `unknown` status when daemon is not running (no crash) -- [ ] `bread modules update` re-installs all github-sourced modules -- [ ] `bread modules update` skips local-path modules with a warning -- [ ] `bread modules info ` shows all manifest fields and daemon status - -### Sync — functional -- [ ] `bread sync init` creates `~/.config/bread/sync.toml` with all required fields -- [ ] `bread sync init` errors if already initialized -- [ ] `bread sync push` creates the correct repo directory structure -- [ ] `bread sync push` copies `~/.config/bread/` to `bread/` in the repo -- [ ] `bread sync push` copies each delegate path to `configs//` -- [ ] `bread sync push` writes package manifests to `packages/` -- [ ] `bread sync push` writes `machines/.toml` -- [ ] `bread sync push` creates a Git commit with a sensible message -- [ ] `bread sync push` pushes to the configured remote -- [ ] `bread sync push` with no changes prints `nothing to push` and exits 0 -- [ ] `bread sync pull` copies `bread/` from repo to `~/.config/bread/` -- [ ] `bread sync pull` copies `configs/` entries back to their original locations -- [ ] `bread sync pull` calls `modules.reload` IPC after applying -- [ ] `bread sync pull --install-packages` runs package installs -- [ ] `bread sync pull` without `--install-packages` does not run package installs -- [ ] `bread sync status` shows local uncommitted changes -- [ ] `bread sync status` shows remote changes not yet pulled -- [ ] `bread sync status` prints `nothing to push — already up to date` when clean -- [ ] `bread sync machines` lists all `machines/*.toml` entries -- [ ] `bread sync init` without `--remote` prompts for URL interactively - -### Sync — error handling -- [ ] `bread sync push` without init prints clear error and exits 1 -- [ ] `bread sync pull` without init prints clear error and exits 1 -- [ ] Git conflict on pull prints clear message pointing to sync repo path and exits 1 -- [ ] Package manager not installed is warned and skipped, not a fatal error - -### Lua API -- [ ] `bread.machine.name()` returns machine name from sync.toml -- [ ] `bread.machine.name()` returns hostname when sync.toml does not exist -- [ ] `bread.machine.tags()` returns array of tags -- [ ] `bread.machine.has_tag("x")` returns true/false correctly -- [ ] `bread.fs.write(path, content)` writes the file and creates parent dirs -- [ ] `bread.fs.read(path)` returns file content as string -- [ ] `bread.fs.read(nonexistent)` returns nil, does not error -- [ ] `bread.fs.exists(path)` returns correct bool -- [ ] `bread.fs.expand("~/foo")` returns the correct absolute path -- [ ] All `bread.fs` paths handle `~` expansion - -### Udev vendor/product ID -- [ ] `vendor_id` and `product_id` fields are present in udev device events -- [ ] `Device` struct in `types.rs` has `vendor_id: Option` and `product_id: Option` -- [ ] `bread events` output shows `vendor_id` and `product_id` when available - -### No regressions -- [ ] `bread reload` still works -- [ ] `bread state` still works -- [ ] `bread events` still works -- [ ] `bread doctor` still works -- [ ] `bread ping` still works -- [ ] `bread emit` still works -- [ ] Daemon starts cleanly with no existing `sync.toml` -- [ ] Daemon starts cleanly with a valid `sync.toml` -- [ ] All existing IPC methods still respond correctly - -### Code quality -- [ ] No hardcoded paths containing usernames or `/home/` -- [ ] No `unwrap()` calls in new code that can fail at runtime — use `?` or explicit error handling -- [ ] No `expect("...")` calls in new async code — only in tests and truly-impossible cases -- [ ] All new public functions have doc comments -- [ ] `bread-sync` crate has a `README.md` explaining its purpose and public API - ---- - -## Implementation order - -Work in this order. Do not move to the next step until the current one compiles and its tests pass. - -1. Add `bread-sync` crate skeleton to workspace (compiles, no logic yet) -2. Implement `SyncConfig` (load/save `sync.toml`) -3. Implement `bread sync init` -4. Implement Git backend in `bread-sync/src/git.rs` -5. Implement `bread sync push` (bread config only, no delegates or packages yet) -6. Implement delegate file handling -7. Implement package manifest generation -8. Implement `bread sync pull` -9. Implement `bread sync status`, `diff`, `machines` -10. Implement `bread modules install` (local path first, then GitHub) -11. Implement `bread modules remove`, `list`, `update`, `info` -12. Add `vendor_id`/`product_id` to udev adapter and `Device` type -13. Add `bread.machine` Lua API -14. Add `bread.fs` Lua API -15. Write all tests -16. Run full checklist — fix anything not passing -17. Run `cargo clippy --workspace` — fix any new warnings diff --git a/Cargo.lock b/Cargo.lock index 72cecc3..313315f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -17,15 +11,6 @@ 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" @@ -263,12 +248,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "bitflags" version = "1.3.2" @@ -325,23 +304,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "bread-sync" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "dirs", - "flate2", - "git2", - "reqwest", - "serde", - "serde_json", - "tar", - "tempfile", - "toml", -] - [[package]] name = "breadd" version = "0.1.0" @@ -377,12 +339,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - [[package]] name = "byteorder" version = "1.5.0" @@ -402,8 +358,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -413,20 +367,6 @@ 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" @@ -482,32 +422,6 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -517,15 +431,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -572,53 +477,12 @@ 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" @@ -742,52 +606,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -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" @@ -934,18 +758,6 @@ 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" @@ -954,45 +766,11 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", "wasip3", ] -[[package]] -name = "git2" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" -dependencies = [ - "bitflags 2.11.1", - "libc", - "libgit2-sys", - "log", - "openssl-probe 0.1.6", - "openssl-sys", - "url", -] - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -1032,210 +810,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.4.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper", - "native-tls", - "tokio", - "tokio-native-tls", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[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]] name = "id-arena" 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" @@ -1288,12 +868,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1306,28 +880,6 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - [[package]] name = "kqueue" version = "1.1.1" @@ -1366,43 +918,6 @@ 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" @@ -1413,18 +928,6 @@ 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" @@ -1443,12 +946,6 @@ 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" @@ -1525,22 +1022,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "0.8.11" @@ -1594,23 +1075,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.2.1", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1750,61 +1214,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" -dependencies = [ - "cc", - "libc", - "pkg-config", - "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" @@ -1859,12 +1268,6 @@ 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" @@ -1918,15 +1321,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1974,12 +1368,6 @@ 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" @@ -2025,17 +1413,6 @@ 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" @@ -2065,46 +1442,6 @@ 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" @@ -2166,27 +1503,6 @@ 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" @@ -2196,44 +1512,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -2313,18 +1597,6 @@ 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" @@ -2361,12 +1633,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - [[package]] name = "slab" version = "0.4.12" @@ -2399,12 +1665,6 @@ 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" @@ -2439,55 +1699,6 @@ 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" @@ -2530,16 +1741,6 @@ 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" @@ -2569,29 +1770,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.8.23" @@ -2644,12 +1822,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - [[package]] name = "tracing" version = "0.1.44" @@ -2711,12 +1883,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typeid" version = "1.0.3" @@ -2764,24 +1930,6 @@ 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" @@ -2794,12 +1942,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -2822,15 +1964,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2855,61 +1988,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -dependencies = [ - "unicode-ident", -] - [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2944,16 +2022,6 @@ dependencies = [ "semver", ] -[[package]] -name = "web-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "which" version = "7.0.3" @@ -2997,65 +2065,12 @@ 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" @@ -3222,16 +2237,6 @@ 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" @@ -3332,22 +2337,6 @@ 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" @@ -3358,29 +2347,6 @@ 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" @@ -3468,60 +2434,6 @@ 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 ee8711e..ab4e899 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,7 @@ members = [ "bread-shared", "breadd", - "bread-cli", - "bread-sync" + "bread-cli" ] resolver = "2" diff --git a/README.md b/README.md index ae1e55a..73512df 100644 --- a/README.md +++ b/README.md @@ -231,12 +231,11 @@ bread.once("bread.system.startup", function(event) end) -- Subscribe with a predicate filter --- Third arg is an opts table with a 'filter' key whose value is the predicate bread.filter("bread.device.connected", function(event) - bread.exec("xset r rate 200 40") -end, { filter = function(event) return event.data.class == "keyboard" -end }) +end, function(event) + bread.exec("xset r rate 200 40") +end) -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 43c17a9..69a2c49 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -9,7 +9,6 @@ path = "src/main.rs" [dependencies] bread-shared = { path = "../bread-shared" } -bread-sync = { path = "../bread-sync" } serde.workspace = true serde_json.workspace = true tokio.workspace = true @@ -17,9 +16,3 @@ anyhow.workspace = true clap = { version = "4.5", features = ["derive"] } notify = "6.1" libc = "0.2" -dirs = "5.0" -reqwest = { version = "0.11", features = ["blocking", "json"] } -flate2 = "1.0" -tar = "0.4" -chrono = { version = "0.4", features = ["serde"] } -toml = "0.8" diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index d57890a..0ca91df 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,7 +1,6 @@ -use anyhow::{anyhow, Result}; +use anyhow::Result; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; -use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::env; use std::io; @@ -11,16 +10,6 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tokio::sync::mpsc; -use bread_sync::{ - config::{bread_config_dir, sync_repo_path, SyncConfig}, - delegates::{copy_delegates_to_repo, expand_tilde, restore_delegates_from_repo, sync_dir}, - git, - machine::{list_machines, machine_name, MachineProfile}, - packages::snapshot_packages, -}; - -// ─── CLI structure ──────────────────────────────────────────────────────────── - #[derive(Parser, Debug)] #[command(author, version, about = "Bread CLI - the reactive desktop automation fabric")] struct Cli { @@ -58,16 +47,8 @@ enum Commands { #[arg(long)] since: Option, }, - /// Manage installed Lua modules - Modules { - #[command(subcommand)] - action: ModulesAction, - }, - /// Sync system state to/from a Git remote - Sync { - #[command(subcommand)] - action: SyncAction, - }, + /// List loaded modules and status + Modules, /// List available profiles ProfileList, /// Activate a profile @@ -90,79 +71,6 @@ enum Commands { }, } -#[derive(Subcommand, Debug)] -enum ModulesAction { - /// Install a module from a source (github:user/repo[@ref] or /local/path) - Install { - source: String, - }, - /// Remove an installed module - Remove { - name: String, - /// Skip confirmation prompt - #[arg(long, short = 'y')] - yes: bool, - }, - /// List installed modules with status - List, - /// Update installed modules to latest - Update { - /// Update only this specific module - name: Option, - }, - /// Show detailed manifest info for a module - Info { - name: String, - }, -} - -#[derive(Subcommand, Debug)] -enum SyncAction { - /// Initialize sync for this machine - Init { - /// Git remote URL - #[arg(long)] - remote: Option, - }, - /// Snapshot and push current state - Push { - /// Commit message - #[arg(long, short = 'm')] - message: Option, - }, - /// Pull and apply latest state from remote - Pull { - /// Also run package install commands - #[arg(long)] - install_packages: bool, - }, - /// Show what has changed since last push - Status, - /// Show file-level diff vs remote - Diff { - /// Diff against remote HEAD instead of working tree - #[arg(long)] - remote: bool, - }, - /// List known machines from sync repo - Machines, -} - -// ─── Module manifest ────────────────────────────────────────────────────────── - -#[derive(Debug, Serialize, Deserialize, Clone)] -struct ModuleManifest { - name: String, - version: String, - description: String, - author: String, - source: String, - #[serde(skip_serializing_if = "Option::is_none")] - installed_at: Option, -} - -// ─── Entry point ────────────────────────────────────────────────────────────── - #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -173,65 +81,71 @@ async fn main() -> Result<()> { if *watch { watch_reload(&socket).await?; } else { - let response = send_request_or_die(&socket, "modules.reload", json!({})).await?; + let response = send_request(&socket, "modules.reload", json!({})).await?; print_reload(&response); } } Commands::State { path, json } => { - let response = if let Some(path) = path { - send_request_or_die(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request_or_die(&socket, "state.dump", json!({})).await? - }; if *json { + let response = if let Some(path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; print_json(&response)?; } else { + let response = if let Some(path) = path { + send_request(&socket, "state.get", json!({ "key": path })).await? + } else { + send_request(&socket, "state.dump", json!({})).await? + }; print_state_formatted(path.as_deref(), &response); } } - Commands::Events { filter, json, fields, since } => { + Commands::Events { + filter, + json, + fields, + since, + } => { stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?; } - Commands::Modules { action } => { - handle_modules(action, &socket).await?; - } - Commands::Sync { action } => { - handle_sync(action, &socket).await?; + Commands::Modules => { + let response = send_request(&socket, "modules.list", json!({})).await?; + print_json(&response)?; } Commands::ProfileList => { - let response = send_request_or_die(&socket, "profile.list", json!({})).await?; + let response = send_request(&socket, "profile.list", json!({})).await?; print_json(&response)?; } Commands::ProfileActivate { name } => { - let response = send_request_or_die( - &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 response = send_request_or_die( + let response = send_request( &socket, "emit", - json!({ "event": event, "data": parsed }), + json!({ + "event": event, + "data": parsed, + }), ) .await?; print_json(&response)?; } Commands::Ping => { - let response = send_request_or_die(&socket, "ping", json!({})).await?; + let response = send_request(&socket, "ping", json!({})).await?; print_json(&response)?; } Commands::Health => { - let response = send_request_or_die(&socket, "health", json!({})).await?; + let response = send_request(&socket, "health", json!({})).await?; print_json(&response)?; } Commands::Doctor { json } => { if *json { - let response = send_request_or_die(&socket, "health", json!({})).await?; + let response = send_request(&socket, "health", json!({})).await?; print_json(&response)?; } else { print_doctor(&socket).await?; @@ -242,699 +156,6 @@ async fn main() -> Result<()> { Ok(()) } -// ─── Modules sub-commands ───────────────────────────────────────────────────── - -async fn handle_modules(action: &ModulesAction, socket: &Path) -> Result<()> { - match action { - ModulesAction::Install { source } => { - modules_install(source, socket).await?; - } - ModulesAction::Remove { name, yes } => { - modules_remove(name, *yes, socket).await?; - } - ModulesAction::List => { - modules_list(socket).await?; - } - ModulesAction::Update { name } => { - modules_update(name.as_deref(), socket).await?; - } - ModulesAction::Info { name } => { - modules_info(name, socket).await?; - } - } - Ok(()) -} - -async fn modules_install(source: &str, socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - - if let Some(rest) = source.strip_prefix("github:") { - install_github_module(rest, source, &modules_dir)?; - } else if source.starts_with('/') || source.starts_with("./") || source.starts_with("~/") { - let local_path = expand_tilde(source); - install_local_module(&local_path, &modules_dir)?; - } else { - eprintln!("bread: unknown source format '{source}'"); - eprintln!(" expected: github:user/repo[@ref] or /local/path"); - std::process::exit(1); - } - - // Reload daemon - if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { - let _ = response; - } - Ok(()) -} - -fn install_local_module(src: &Path, modules_dir: &Path) -> Result<()> { - let manifest_path = src.join("bread.module.toml"); - if !manifest_path.exists() { - eprintln!( - "bread: no bread.module.toml found at {}", - manifest_path.display() - ); - std::process::exit(1); - } - let raw = std::fs::read_to_string(&manifest_path)?; - let mut manifest: ModuleManifest = toml::from_str(&raw)?; - manifest.installed_at = Some(chrono::Utc::now().to_rfc3339()); - - let dest = modules_dir.join(&manifest.name); - if dest.exists() { - std::fs::remove_dir_all(&dest)?; - } - copy_dir_all(src, &dest)?; - - // Write updated manifest with installed_at - let manifest_dest = dest.join("bread.module.toml"); - std::fs::write(&manifest_dest, toml::to_string_pretty(&manifest)?)?; - - println!("installed {} v{}", manifest.name, manifest.version); - Ok(()) -} - -fn install_github_module(spec: &str, source_str: &str, modules_dir: &Path) -> Result<()> { - let (repo_spec, git_ref) = if let Some((r, v)) = spec.split_once('@') { - (r, Some(v.to_string())) - } else { - (spec, None) - }; - - let (user, repo) = repo_spec - .split_once('/') - .ok_or_else(|| anyhow!("invalid github spec '{}': expected user/repo", spec))?; - - let client = reqwest::blocking::Client::builder() - .user_agent("bread-cli/0.1") - .build()?; - - let resolved_ref = match git_ref { - Some(r) => r, - None => { - let url = format!("https://api.github.com/repos/{user}/{repo}"); - let resp: Value = client.get(&url).send()?.json()?; - 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/{resolved_ref}" - ); - let bytes = client.get(&tarball_url).send()?.bytes()?; - - // Extract to a temp dir - let tmp = tempfile_dir()?; - let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&bytes)); - let mut archive = tar::Archive::new(gz); - archive.unpack(&tmp)?; - - // The tarball has a single top-level directory; find it - let extracted_dir = std::fs::read_dir(&tmp)? - .filter_map(|e| e.ok()) - .find(|e| e.path().is_dir()) - .map(|e| e.path()) - .ok_or_else(|| anyhow!("tarball contained no directory"))?; - - let manifest_path = extracted_dir.join("bread.module.toml"); - if !manifest_path.exists() { - let _ = std::fs::remove_dir_all(&tmp); - eprintln!( - "bread: no bread.module.toml found in github:{}/{} (ref {})", - user, repo, resolved_ref - ); - std::process::exit(1); - } - - let raw = std::fs::read_to_string(&manifest_path)?; - let mut manifest: ModuleManifest = toml::from_str(&raw)?; - manifest.installed_at = Some(chrono::Utc::now().to_rfc3339()); - manifest.source = source_str.to_string(); - - let dest = modules_dir.join(&manifest.name); - if dest.exists() { - std::fs::remove_dir_all(&dest)?; - } - copy_dir_all(&extracted_dir, &dest)?; - - let manifest_dest = dest.join("bread.module.toml"); - std::fs::write(&manifest_dest, toml::to_string_pretty(&manifest)?)?; - - let _ = std::fs::remove_dir_all(&tmp); - println!("installed {} v{}", manifest.name, manifest.version); - Ok(()) -} - -async fn modules_remove(name: &str, yes: bool, socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - let module_dir = modules_dir.join(name); - - if !module_dir.exists() { - eprintln!("bread: module '{name}' is not installed"); - std::process::exit(1); - } - - if !yes { - eprint!("remove {name}? (y/n) "); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { - println!("cancelled"); - return Ok(()); - } - } - - std::fs::remove_dir_all(&module_dir)?; - println!("removed {name}"); - - if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { - let _ = response; - } - Ok(()) -} - -async fn modules_list(socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - let manifests = scan_modules(&modules_dir)?; - - // Try to get daemon status - let daemon_modules = send_request(socket, "modules.list", json!({})) - .await - .ok() - .and_then(|v| v.as_array().cloned()); - - for manifest in &manifests { - let status = daemon_modules - .as_ref() - .and_then(|mods| { - mods.iter().find(|m| { - m.get("name").and_then(Value::as_str) == Some(&manifest.name) - }) - }) - .and_then(|m| m.get("status").and_then(Value::as_str)) - .unwrap_or(if daemon_modules.is_some() { "unknown" } else { "unknown" }); - - println!( - " {:<20} {:<10} {:<12} {}", - manifest.name, manifest.version, status, manifest.source - ); - } - Ok(()) -} - -async fn modules_update(name: Option<&str>, socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - - let to_update: Vec = if let Some(name) = name { - let manifest = load_manifest(&modules_dir.join(name))?; - vec![manifest] - } else { - scan_modules(&modules_dir)? - }; - - for manifest in to_update { - if !manifest.source.starts_with("github:") { - eprintln!( - "warn: cannot update '{}' — local module, reinstall manually", - manifest.name - ); - continue; - } - let old_version = manifest.version.clone(); - let source = manifest.source.clone(); - let rest = source.trim_start_matches("github:"); - install_github_module(rest, &source, &modules_dir)?; - let new_manifest = load_manifest(&modules_dir.join(&manifest.name))?; - if new_manifest.version == old_version { - println!("{} already up to date", manifest.name); - } else { - println!( - "updated {} v{} → v{}", - manifest.name, old_version, new_manifest.version - ); - } - } - - if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { - let _ = response; - } - Ok(()) -} - -async fn modules_info(name: &str, socket: &Path) -> Result<()> { - let modules_dir = modules_directory()?; - let module_dir = modules_dir.join(name); - - if !module_dir.exists() { - eprintln!("bread: module '{name}' is not installed"); - std::process::exit(1); - } - - let manifest = load_manifest(&module_dir)?; - let status = send_request(socket, "modules.list", json!({})) - .await - .ok() - .and_then(|v| v.as_array().cloned()) - .and_then(|mods| { - mods.iter() - .find(|m| m.get("name").and_then(Value::as_str) == Some(name)) - .and_then(|m| m.get("status").and_then(Value::as_str)) - .map(ToString::to_string) - }) - .unwrap_or_else(|| "unknown".to_string()); - - println!("name: {}", manifest.name); - println!("version: {}", manifest.version); - println!("description: {}", manifest.description); - println!("author: {}", manifest.author); - println!("source: {}", manifest.source); - println!( - "installed_at: {}", - manifest.installed_at.as_deref().unwrap_or("unknown") - ); - println!("status: {status}"); - Ok(()) -} - -// ─── Sync sub-commands ──────────────────────────────────────────────────────── - -async fn handle_sync(action: &SyncAction, socket: &Path) -> Result<()> { - match action { - SyncAction::Init { remote } => sync_init(remote.as_deref()).await?, - SyncAction::Push { message } => sync_push(message.as_deref()).await?, - SyncAction::Pull { install_packages } => sync_pull(*install_packages, socket).await?, - SyncAction::Status => sync_status().await?, - SyncAction::Diff { remote } => sync_diff(*remote).await?, - SyncAction::Machines => sync_machines().await?, - } - Ok(()) -} - -async fn sync_init(remote_arg: Option<&str>) -> Result<()> { - if SyncConfig::is_initialized()? { - eprintln!( - "bread: sync already initialized. Edit {} to reconfigure.", - bread_sync::config::config_path()?.display() - ); - std::process::exit(1); - } - - let remote_url = if let Some(url) = remote_arg { - url.to_string() - } else { - eprint!("Sync remote URL (git remote or path): "); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - let url = input.trim().to_string(); - if url.is_empty() { - anyhow::bail!("remote URL is required"); - } - url - }; - - let default_hostname = hostname_or_unknown(); - eprint!("Machine name [{}]: ", default_hostname); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - let machine_name = { - let trimmed = input.trim().to_string(); - if trimmed.is_empty() { - default_hostname.clone() - } else { - trimmed - } - }; - - eprint!("Machine tags (comma-separated, e.g. mobile,battery): "); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - let tags: Vec = input - .trim() - .split(',') - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) - .collect(); - - let cfg = SyncConfig { - remote: bread_sync::config::RemoteConfig { - url: Some(remote_url.clone()), - branch: "main".to_string(), - }, - machine: bread_sync::config::MachineConfig { - name: Some(machine_name.clone()), - tags, - }, - ..Default::default() - }; - cfg.save()?; - - // Validate remote if it looks like a URL - if !remote_url.starts_with('/') { - println!("remote does not exist yet — it will be created on first push"); - } - - println!("sync initialized:"); - println!(" machine: {machine_name}"); - println!(" remote: {remote_url}"); - Ok(()) -} - -async fn sync_push(message: Option<&str>) -> Result<()> { - let cfg = require_sync_config()?; - let remote_url = cfg.remote.url.as_deref().ok_or_else(|| { - anyhow!("sync.toml has no remote URL — run: bread sync init") - })?; - let branch = cfg.remote.branch.clone(); - let repo_path = sync_repo_path()?; - - let repo = tokio::task::spawn_blocking({ - let remote_url = remote_url.to_string(); - let repo_path = repo_path.clone(); - move || git::clone_or_open(&remote_url, &repo_path) - }) - .await??; - - // Snapshot bread config - let bread_dir = bread_config_dir()?; - let bread_dest = repo_path.join("bread"); - sync_dir(&bread_dir, &bread_dest, &cfg.delegates.exclude)?; - - // Snapshot delegates - copy_delegates_to_repo(&cfg.delegates, &repo_path)?; - - // Snapshot packages - if cfg.packages.enabled { - snapshot_packages(&cfg.packages.managers, &repo_path)?; - } - - // Write machine profile - let profile = MachineProfile::new(&cfg)?; - profile.write_to_repo(&repo_path)?; - - // Stage all - git::stage_all(&repo)?; - - // Check for changes - if !git::has_changes(&repo)? { - println!("nothing to push — already up to date"); - return Ok(()); - } - - // Commit - let machine = machine_name(&cfg)?; - let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"); - let commit_msg = message - .map(ToString::to_string) - .unwrap_or_else(|| format!("sync: {machine} {timestamp}")); - git::commit(&repo, &commit_msg)?; - - // Set remote and push - if let Ok(()) = git::set_remote(&repo, "origin", remote_url) {} - git::push(&repo, "origin", &branch)?; - - println!("pushed: {commit_msg}"); - println!(" bread config: {}", bread_dir.display()); - if cfg.packages.enabled { - println!(" packages: {}", cfg.packages.managers.join(", ")); - } - Ok(()) -} - -async fn sync_pull(install_packages: bool, socket: &Path) -> Result<()> { - let cfg = require_sync_config()?; - let remote_url = cfg.remote.url.as_deref().ok_or_else(|| { - anyhow!("sync.toml has no remote URL — run: bread sync init") - })?; - let branch = cfg.remote.branch.clone(); - let repo_path = sync_repo_path()?; - - let repo = tokio::task::spawn_blocking({ - let remote_url = remote_url.to_string(); - let repo_path = repo_path.clone(); - move || git::clone_or_open(&remote_url, &repo_path) - }) - .await??; - - git::pull(&repo, "origin", &branch)?; - - // Restore bread config - let bread_src = repo_path.join("bread"); - let bread_dest = bread_config_dir()?; - if bread_src.exists() { - sync_dir(&bread_src, &bread_dest, &[])?; - } - - // Restore delegates - restore_delegates_from_repo(&cfg.delegates, &repo_path)?; - - // Package installs - if install_packages && cfg.packages.enabled { - run_package_installs(&repo_path, &cfg.packages.managers)?; - } else if cfg.packages.enabled { - let pkg_dir = repo_path.join("packages"); - if pkg_dir.exists() { - println!( - "note: run 'bread sync pull --install-packages' to install missing packages" - ); - } - } - - // Reload daemon - if let Ok(response) = send_request(socket, "modules.reload", json!({})).await { - let _ = response; - } - - println!("pulled and applied latest state"); - Ok(()) -} - -async fn sync_status() -> Result<()> { - let cfg = require_sync_config()?; - let repo_path = sync_repo_path()?; - - if !repo_path.join(".git").exists() { - println!("sync repo not yet initialised — run: bread sync push"); - return Ok(()); - } - - let repo = git::init_or_open(&repo_path)?; - let machine = machine_name(&cfg)?; - let remote_url = cfg.remote.url.as_deref().unwrap_or("(none)"); - let last_push = git::last_commit_time(&repo); - - println!("bread sync status"); - println!(" machine {machine}"); - println!(" remote {remote_url}"); - println!(" last push {last_push}"); - - let local_changes = git::status_lines(&repo)?; - println!(); - println!("local changes (not yet pushed):"); - if local_changes.is_empty() { - println!(" none"); - } else { - for (ch, path) in &local_changes { - println!(" {ch} {path}"); - } - } - - // Fetch to check remote - let _ = git::fetch(&repo, "origin"); - let has_remote = git::remote_has_changes(&repo, "origin", &cfg.remote.branch); - println!(); - println!("remote changes (not yet pulled):"); - if has_remote { - println!(" (run 'bread sync pull' to apply)"); - } else { - println!(" none"); - } - Ok(()) -} - -async fn sync_diff(show_remote: bool) -> Result<()> { - let cfg = require_sync_config()?; - let repo_path = sync_repo_path()?; - - if !repo_path.join(".git").exists() { - println!("sync repo not initialised — run: bread sync push"); - return Ok(()); - } - - let repo = git::init_or_open(&repo_path)?; - - let diff = if show_remote { - git::fetch(&repo, "origin")?; - git::diff_remote(&repo, "origin", &cfg.remote.branch)? - } else { - git::diff_workdir(&repo)? - }; - - if diff.is_empty() { - println!("no differences"); - } else { - print!("{diff}"); - } - Ok(()) -} - -async fn sync_machines() -> Result<()> { - let repo_path = sync_repo_path()?; - if !repo_path.join(".git").exists() { - println!("sync repo not initialised — run: bread sync push"); - return Ok(()); - } - let machines = list_machines(&repo_path); - if machines.is_empty() { - println!("no machines found in sync repo"); - return Ok(()); - } - for m in machines { - let tags = if m.tags.is_empty() { - "(none)".to_string() - } else { - m.tags.join(", ") - }; - println!( - " {:<20} last sync: {:<20} tags: {}", - m.name, m.last_sync, tags - ); - } - Ok(()) -} - -fn run_package_installs(repo_root: &Path, managers: &[String]) -> Result<()> { - let pkg_dir = repo_root.join("packages"); - - for mgr in managers { - match mgr.as_str() { - "pacman" => { - let f = pkg_dir.join("pacman.txt"); - if f.exists() { - let names = bread_sync::packages::parse_pacman(&std::fs::read_to_string(&f)?); - let status = std::process::Command::new("sudo") - .args(["pacman", "-S", "--needed"]) - .args(&names) - .status(); - if let Err(e) = status { - eprintln!("warn: pacman install failed: {e}"); - } - } - } - "pip" => { - let f = pkg_dir.join("pip.txt"); - if f.exists() { - let status = std::process::Command::new("pip") - .args(["install", "--user", "-r"]) - .arg(&f) - .status(); - if let Err(e) = status { - eprintln!("warn: pip install failed: {e}"); - } - } - } - "npm" => { - let f = pkg_dir.join("npm.txt"); - if f.exists() { - let names = bread_sync::packages::parse_npm(&std::fs::read_to_string(&f)?); - for name in names { - let _ = std::process::Command::new("npm") - .args(["install", "-g", &name]) - .status(); - } - } - } - "cargo" => { - let f = pkg_dir.join("cargo.txt"); - if f.exists() { - let entries = bread_sync::packages::parse_cargo(&std::fs::read_to_string(&f)?); - for entry in entries { - let name = entry.split_whitespace().next().unwrap_or(&entry); - let _ = std::process::Command::new("cargo") - .args(["install", name]) - .status(); - } - } - } - _ => {} - } - } - Ok(()) -} - -// ─── Helper functions ───────────────────────────────────────────────────────── - -fn require_sync_config() -> Result { - if !SyncConfig::is_initialized()? { - eprintln!("bread: sync not initialized. Run: bread sync init"); - std::process::exit(1); - } - SyncConfig::load() -} - -fn modules_directory() -> Result { - let config_dir = dirs::config_dir() - .ok_or_else(|| anyhow!("cannot determine config directory"))?; - let dir = config_dir.join("bread").join("modules"); - std::fs::create_dir_all(&dir)?; - Ok(dir) -} - -fn scan_modules(modules_dir: &Path) -> Result> { - let mut out = Vec::new(); - if !modules_dir.exists() { - return Ok(out); - } - for entry in std::fs::read_dir(modules_dir)? { - let entry = entry?; - if !entry.path().is_dir() { - continue; - } - if let Ok(manifest) = load_manifest(&entry.path()) { - out.push(manifest); - } - } - out.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(out) -} - -fn load_manifest(module_dir: &Path) -> Result { - let path = module_dir.join("bread.module.toml"); - let raw = std::fs::read_to_string(&path)?; - Ok(toml::from_str(&raw)?) -} - -fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> { - std::fs::create_dir_all(dest)?; - for entry in std::fs::read_dir(src)? { - let entry = entry?; - let dest_path = dest.join(entry.file_name()); - if entry.path().is_dir() { - copy_dir_all(&entry.path(), &dest_path)?; - } else { - std::fs::copy(entry.path(), dest_path)?; - } - } - Ok(()) -} - -fn tempfile_dir() -> Result { - let tmp = std::env::temp_dir().join(format!("bread-install-{}", std::process::id())); - std::fs::create_dir_all(&tmp)?; - Ok(tmp) -} - -fn hostname_or_unknown() -> String { - std::fs::read_to_string("/etc/hostname") - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "unknown".to_string()) -} - -// ─── IPC helpers ────────────────────────────────────────────────────────────── - fn daemon_socket_path() -> PathBuf { if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") { return Path::new(&runtime).join("bread").join("breadd.sock"); @@ -942,26 +163,6 @@ fn daemon_socket_path() -> PathBuf { PathBuf::from("/tmp/bread/breadd.sock") } -/// Like `send_request` but prints a nice message and exits 1 if daemon is unreachable. -async fn send_request_or_die(socket: &Path, method: &str, params: Value) -> Result { - match send_request(socket, method, params).await { - Ok(v) => Ok(v), - Err(err) => { - let msg = err.to_string(); - if msg.contains("No such file") - || msg.contains("Connection refused") - || msg.contains("not found") - { - eprintln!( - "bread: daemon is not running. Start it with: systemctl --user start breadd" - ); - std::process::exit(1); - } - Err(err) - } - } -} - async fn send_request(socket: &Path, method: &str, params: Value) -> Result { let stream = UnixStream::connect(socket).await?; let (read_half, mut write_half) = stream.into_split(); @@ -994,8 +195,7 @@ async fn stream_events( since: Option, ) -> Result<()> { if let Some(seconds) = since { - let replay = - send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?; + 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 { @@ -1012,7 +212,9 @@ async fn stream_events( let request = json!({ "id": "1", "method": "events.subscribe", - "params": { "filter": filter }, + "params": { + "filter": filter, + }, }); write_half @@ -1028,11 +230,10 @@ async fn stream_events( print_event(&value, fields.as_deref()); } } + Ok(()) } -// ─── Display helpers ────────────────────────────────────────────────────────── - fn print_json(value: &Value) -> Result<()> { println!("{}", serde_json::to_string_pretty(value)?); Ok(()) @@ -1096,11 +297,15 @@ 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 + tm.tm_hour as u64 * 3600 + + tm.tm_min as u64 * 60 + + tm.tm_sec as u64 }; let h = (local_secs / 3600) % 24; @@ -1140,11 +345,16 @@ async fn watch_reload(socket: &Path) -> Result<()> { if msg.is_err() { continue; } + + // Debounce: drain any follow-up events that arrive within 150ms. + // A single file save typically generates 2-3 fs events in rapid succession. tokio::time::sleep(Duration::from_millis(150)).await; while rx.try_recv().is_ok() {} - let response = send_request_or_die(socket, "modules.reload", json!({})).await?; + + let response = send_request(socket, "modules.reload", json!({})).await?; print_reload(&response); } + Ok(()) } @@ -1177,11 +387,7 @@ fn render_doctor(health: &Value) { 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!(" daemon {} (pid {})", if ok { "✓ running" } else { "✗ unreachable" }, pid); println!(" version {version}"); println!(" uptime {}s", uptime_ms / 1000); println!(" socket {socket}"); @@ -1252,7 +458,11 @@ async fn send_request_with_stream( } fn config_directory() -> PathBuf { - dirs::config_dir() - .map(|d| d.join("bread")) - .unwrap_or_else(|| PathBuf::from(".config/bread")) + 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-sync/Cargo.toml b/bread-sync/Cargo.toml deleted file mode 100644 index c4860dc..0000000 --- a/bread-sync/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "bread-sync" -version = "0.1.0" -edition = "2021" - -[dependencies] -serde.workspace = true -serde_json.workspace = true -anyhow.workspace = true -toml = "0.8" -chrono = { version = "0.4", features = ["serde"] } -dirs = "5.0" -git2 = { version = "0.18", features = ["vendored-libgit2"] } -reqwest = { version = "0.11", features = ["blocking", "json"] } -flate2 = "1.0" -tar = "0.4" - -[dev-dependencies] -tempfile = "3.13" diff --git a/bread-sync/README.md b/bread-sync/README.md deleted file mode 100644 index 079b8d6..0000000 --- a/bread-sync/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# bread-sync - -Sync and module management library for the Bread reactive desktop automation daemon. - -Provides: -- `SyncConfig` — load/save `~/.config/bread/sync.toml` -- Git backend (via git2) for push/pull of bread config to a remote repository -- Delegate file handling — copy arbitrary config files into the sync repo -- Package manifest generation for pacman/pip/npm/cargo -- Machine profile — name and tags read from sync.toml diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs deleted file mode 100644 index d0b7506..0000000 --- a/bread-sync/src/config.rs +++ /dev/null @@ -1,124 +0,0 @@ -use std::path::PathBuf; - -use anyhow::Result; -use serde::{Deserialize, Serialize}; - -/// Top-level sync configuration stored in `~/.config/bread/sync.toml`. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct SyncConfig { - #[serde(default)] - pub remote: RemoteConfig, - #[serde(default)] - pub machine: MachineConfig, - #[serde(default)] - pub packages: PackagesConfig, - #[serde(default)] - pub delegates: DelegatesConfig, -} - -/// Git remote configuration. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct RemoteConfig { - pub url: Option, - #[serde(default = "default_branch")] - pub branch: String, -} - -fn default_branch() -> String { - "main".to_string() -} - -/// Machine identity — name comes from here, falls back to hostname. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct MachineConfig { - pub name: Option, - #[serde(default)] - pub tags: Vec, -} - -/// Which package managers to snapshot. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PackagesConfig { - #[serde(default = "default_true")] - pub enabled: bool, - #[serde(default = "default_managers")] - pub managers: Vec, -} - -impl Default for PackagesConfig { - fn default() -> Self { - Self { - enabled: true, - managers: default_managers(), - } - } -} - -fn default_true() -> bool { - true -} - -fn default_managers() -> Vec { - vec!["pacman".to_string(), "pip".to_string(), "npm".to_string()] -} - -/// Config file delegation — which extra paths to include in the sync repo. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct DelegatesConfig { - /// Absolute or `~`-prefixed paths to copy into `configs//`. - #[serde(default)] - pub include: Vec, - /// Glob patterns to exclude when copying. - #[serde(default)] - pub exclude: Vec, -} - -impl SyncConfig { - /// Load from `~/.config/bread/sync.toml`, returning `Default` if not present. - pub fn load() -> Result { - let path = config_path()?; - if !path.exists() { - return Ok(Self::default()); - } - let raw = std::fs::read_to_string(&path)?; - let cfg: Self = toml::from_str(&raw)?; - Ok(cfg) - } - - /// Write to `~/.config/bread/sync.toml`, creating parent dirs as needed. - pub fn save(&self) -> Result<()> { - let path = config_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let raw = toml::to_string_pretty(self)?; - std::fs::write(&path, raw)?; - Ok(()) - } - - /// Returns `true` if `~/.config/bread/sync.toml` exists on disk. - pub fn is_initialized() -> Result { - Ok(config_path()?.exists()) - } -} - -/// Path to `~/.config/bread/sync.toml`. -pub fn config_path() -> Result { - let config_dir = dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?; - Ok(config_dir.join("bread").join("sync.toml")) -} - -/// Path to `~/.local/share/bread/sync-repo/`. -pub fn sync_repo_path() -> Result { - let data_dir = dirs::data_local_dir() - .ok_or_else(|| anyhow::anyhow!("cannot determine data directory"))?; - Ok(data_dir.join("bread").join("sync-repo")) -} - -/// Path to `~/.config/bread/`. -pub fn bread_config_dir() -> Result { - let config_dir = dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?; - Ok(config_dir.join("bread")) -} diff --git a/bread-sync/src/delegates.rs b/bread-sync/src/delegates.rs deleted file mode 100644 index aadab3b..0000000 --- a/bread-sync/src/delegates.rs +++ /dev/null @@ -1,205 +0,0 @@ -use std::path::{Path, PathBuf}; - -use anyhow::Result; - -use crate::config::DelegatesConfig; - -/// Expand `~` in a path string to the user's home directory. -pub fn expand_tilde(path: &str) -> PathBuf { - if let Some(rest) = path.strip_prefix("~/") { - dirs::home_dir() - .map(|h| h.join(rest)) - .unwrap_or_else(|| PathBuf::from(path)) - } else if path == "~" { - dirs::home_dir().unwrap_or_else(|| PathBuf::from(path)) - } else { - PathBuf::from(path) - } -} - -/// Returns `true` if `path` (relative to `base`) matches any of the `exclude` globs. -fn is_excluded(base: &Path, path: &Path, excludes: &[String]) -> bool { - let rel = path.strip_prefix(base).unwrap_or(path); - let rel_str = rel.to_string_lossy(); - for pattern in excludes { - if glob_matches(pattern, &rel_str) { - return true; - } - } - false -} - -/// Copy all files under `src` dir to `dest` dir, honouring `excludes`. -/// Creates `dest` if it doesn't exist. Deletes files in `dest` that are -/// absent in `src` (rsync `--delete` behaviour). -pub fn sync_dir(src: &Path, dest: &Path, excludes: &[String]) -> Result<()> { - std::fs::create_dir_all(dest)?; - copy_recursive(src, src, dest, excludes)?; - delete_extra(src, dest)?; - Ok(()) -} - -fn copy_recursive(root: &Path, src: &Path, dest: &Path, excludes: &[String]) -> Result<()> { - for entry in std::fs::read_dir(src)? { - let entry = entry?; - let src_path = entry.path(); - - if is_excluded(root, &src_path, excludes) { - continue; - } - - let file_name = entry.file_name(); - let dest_path = dest.join(&file_name); - - if src_path.is_dir() { - std::fs::create_dir_all(&dest_path)?; - copy_recursive(root, &src_path, &dest_path, excludes)?; - } else { - std::fs::copy(&src_path, &dest_path)?; - } - } - Ok(()) -} - -/// Remove files/dirs from `dest` that don't exist in `src`. -fn delete_extra(src: &Path, dest: &Path) -> Result<()> { - if !dest.exists() { - return Ok(()); - } - for entry in std::fs::read_dir(dest)? { - let entry = entry?; - let dest_path = entry.path(); - let file_name = entry.file_name(); - let src_path = src.join(&file_name); - if !src_path.exists() { - if dest_path.is_dir() { - std::fs::remove_dir_all(&dest_path)?; - } else { - std::fs::remove_file(&dest_path)?; - } - } - } - Ok(()) -} - -/// Copy each `include` path into `/configs//`. -pub fn copy_delegates_to_repo( - cfg: &DelegatesConfig, - repo_root: &Path, -) -> Result<()> { - let configs_dir = repo_root.join("configs"); - std::fs::create_dir_all(&configs_dir)?; - - for raw_path in &cfg.include { - let src = expand_tilde(raw_path); - if !src.exists() { - tracing_warn(&format!( - "delegate path does not exist, skipping: {}", - src.display() - )); - continue; - } - let basename = src - .file_name() - .ok_or_else(|| anyhow::anyhow!("delegate path has no filename: {}", src.display()))?; - let dest = configs_dir.join(basename); - if src.is_dir() { - sync_dir(&src, &dest, &cfg.exclude)?; - } else { - std::fs::copy(&src, &dest)?; - } - } - Ok(()) -} - -/// Restore each delegate path from `/configs//` to its original location. -pub fn restore_delegates_from_repo( - cfg: &DelegatesConfig, - repo_root: &Path, -) -> Result<()> { - let configs_dir = repo_root.join("configs"); - - for raw_path in &cfg.include { - let dest = expand_tilde(raw_path); - let basename = match dest.file_name() { - Some(n) => n.to_os_string(), - None => continue, - }; - let src = configs_dir.join(&basename); - if !src.exists() { - continue; - } - if src.is_dir() { - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent)?; - } - sync_dir(&src, &dest, &[])?; - } else { - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(&src, &dest)?; - } - } - Ok(()) -} - -/// Simple glob match for `**` and `*` patterns against a path string. -fn glob_matches(pattern: &str, path: &str) -> bool { - glob_match_bytes(pattern.as_bytes(), path.as_bytes()) -} - -fn glob_match_bytes(pattern: &[u8], text: &[u8]) -> bool { - if pattern.is_empty() { - return text.is_empty(); - } - - // `**` matches any sequence including path separators - if pattern.starts_with(b"**") { - let rest = &pattern[2..]; - if rest.is_empty() { - return true; - } - // skip leading separator in rest - let rest = if rest.starts_with(b"/") { &rest[1..] } else { rest }; - for offset in 0..=text.len() { - if glob_match_bytes(rest, &text[offset..]) { - return true; - } - } - return false; - } - - match pattern[0] { - b'*' => { - let mut offset = 0; - loop { - if glob_match_bytes(&pattern[1..], &text[offset..]) { - return true; - } - if offset == text.len() { - break; - } - offset += 1; - } - false - } - b'?' => { - if text.is_empty() { - return false; - } - glob_match_bytes(&pattern[1..], &text[1..]) - } - ch => { - if text.first().copied() != Some(ch) { - return false; - } - glob_match_bytes(&pattern[1..], &text[1..]) - } - } -} - -fn tracing_warn(msg: &str) { - // Use eprintln since tracing may not be configured in library context - eprintln!("warn: {msg}"); -} diff --git a/bread-sync/src/git.rs b/bread-sync/src/git.rs deleted file mode 100644 index 581efbc..0000000 --- a/bread-sync/src/git.rs +++ /dev/null @@ -1,227 +0,0 @@ -use std::path::Path; - -use anyhow::{anyhow, Result}; - -/// Open an existing repo or initialise a new one at `path`. -pub fn init_or_open(path: &Path) -> Result { - if path.join(".git").exists() || is_bare(path) { - Ok(git2::Repository::open(path)?) - } else { - std::fs::create_dir_all(path)?; - Ok(git2::Repository::init(path)?) - } -} - -/// Clone `url` to `path` if `path` is not already a repo, otherwise open it. -pub fn clone_or_open(url: &str, path: &Path) -> Result { - if path.join(".git").exists() || is_bare(path) { - return Ok(git2::Repository::open(path)?); - } - let mut builder = git2::build::RepoBuilder::new(); - let mut fetch_opts = git2::FetchOptions::new(); - fetch_opts.remote_callbacks(make_callbacks()); - builder.fetch_options(fetch_opts); - std::fs::create_dir_all(path)?; - Ok(builder.clone(url, path)?) -} - -/// Stage every tracked and untracked change (equivalent to `git add -A`). -pub fn stage_all(repo: &git2::Repository) -> Result<()> { - let mut index = repo.index()?; - index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?; - // Remove entries for deleted files - index.update_all(["*"].iter(), None)?; - index.write()?; - Ok(()) -} - -/// Returns `true` if the index has staged changes compared to HEAD (or repo is new). -pub fn has_changes(repo: &git2::Repository) -> Result { - let mut index = repo.index()?; - index.read(false)?; - - // New repo with no commits yet - if repo.head().is_err() { - return Ok(index.len() > 0); - } - - let head = repo.head()?.peel_to_tree()?; - let diff = repo.diff_tree_to_index(Some(&head), Some(&index), None)?; - Ok(diff.deltas().count() > 0) -} - -/// Commit all staged changes with `message`. Returns the new commit OID. -pub fn commit(repo: &git2::Repository, message: &str) -> Result { - let mut index = repo.index()?; - let tree_id = index.write_tree()?; - let tree = repo.find_tree(tree_id)?; - let sig = repo.signature().unwrap_or_else(|_| { - git2::Signature::now("bread", "bread@localhost").expect("signature") - }); - - let oid = if let Ok(head) = repo.head() { - let parent = head.peel_to_commit()?; - repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])? - } else { - repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])? - }; - Ok(oid) -} - -/// Push `branch` to `remote_name` (defaults to "origin"). -pub fn push(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> { - let mut remote = repo.find_remote(remote_name)?; - let mut opts = git2::PushOptions::new(); - opts.remote_callbacks(make_callbacks()); - remote.push( - &[&format!("refs/heads/{branch}:refs/heads/{branch}")], - Some(&mut opts), - )?; - Ok(()) -} - -/// Fetch from `remote_name` without merging. -pub fn fetch(repo: &git2::Repository, remote_name: &str) -> Result<()> { - let mut remote = repo.find_remote(remote_name)?; - let mut opts = git2::FetchOptions::new(); - opts.remote_callbacks(make_callbacks()); - remote.fetch(&[] as &[&str], Some(&mut opts), None)?; - Ok(()) -} - -/// Fetch and fast-forward merge from `remote_name/branch`. Errors on conflict. -pub fn pull(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result<()> { - fetch(repo, remote_name)?; - - let fetch_head = repo - .find_reference(&format!("refs/remotes/{remote_name}/{branch}")) - .map_err(|_| anyhow!("remote branch {remote_name}/{branch} not found after fetch"))?; - let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?; - - let analysis = repo.merge_analysis(&[&fetch_commit])?; - if analysis.0.is_up_to_date() { - return Ok(()); - } - if !analysis.0.is_fast_forward() { - return Err(anyhow!( - "sync conflict — resolve manually in {}", - repo.workdir() - .unwrap_or_else(|| Path::new("?")) - .display() - )); - } - - // Fast-forward: update HEAD and checkout - let head_ref = repo.find_reference("HEAD")?; - let resolved = head_ref.resolve()?; - let refname = resolved.name().unwrap_or("HEAD").to_string(); - repo.find_reference(&refname)? - .set_target(fetch_commit.id(), "fast-forward")?; - repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?; - Ok(()) -} - -/// Add a remote named `name` pointing at `url`, or update it if it already exists. -pub fn set_remote(repo: &git2::Repository, name: &str, url: &str) -> Result<()> { - if repo.find_remote(name).is_ok() { - repo.remote_set_url(name, url)?; - } else { - repo.remote(name, url)?; - } - Ok(()) -} - -/// Return working-tree diff against HEAD as a unified diff string. -pub fn diff_workdir(repo: &git2::Repository) -> Result { - let mut buf = Vec::new(); - if let Ok(head_tree) = repo.head().and_then(|h| h.peel_to_tree()) { - let diff = repo.diff_tree_to_workdir_with_index(Some(&head_tree), None)?; - diff.print(git2::DiffFormat::Patch, |_, _, line| { - buf.extend_from_slice(line.content()); - true - })?; - } - Ok(String::from_utf8_lossy(&buf).into_owned()) -} - -/// Return diff between HEAD and `remote/branch` as a unified diff string. -pub fn diff_remote(repo: &git2::Repository, remote_name: &str, branch: &str) -> Result { - let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); - let remote_tree = repo - .find_reference(&remote_ref) - .map_err(|_| anyhow!("remote ref {remote_ref} not found — try fetching first"))? - .peel_to_tree()?; - let local_tree = repo.head()?.peel_to_tree()?; - let diff = repo.diff_tree_to_tree(Some(&local_tree), Some(&remote_tree), None)?; - let mut buf = Vec::new(); - diff.print(git2::DiffFormat::Patch, |_, _, line| { - buf.extend_from_slice(line.content()); - true - })?; - Ok(String::from_utf8_lossy(&buf).into_owned()) -} - -/// Return a list of `(status_char, path)` for the working tree. -pub fn status_lines(repo: &git2::Repository) -> Result> { - let statuses = repo.statuses(None)?; - let mut out = Vec::new(); - for entry in statuses.iter() { - let path = entry.path().unwrap_or("?").to_string(); - let flag = entry.status(); - let ch = if flag.contains(git2::Status::INDEX_NEW) || flag.contains(git2::Status::WT_NEW) { - 'A' - } else if flag.contains(git2::Status::INDEX_DELETED) || flag.contains(git2::Status::WT_DELETED) { - 'D' - } else { - 'M' - }; - out.push((ch, path)); - } - Ok(out) -} - -/// Returns true if the local HEAD is behind the remote. -pub fn remote_has_changes(repo: &git2::Repository, remote_name: &str, branch: &str) -> bool { - let remote_ref = format!("refs/remotes/{remote_name}/{branch}"); - let Ok(remote_ref) = repo.find_reference(&remote_ref) else { - return false; - }; - let Ok(remote_commit) = remote_ref.peel_to_commit() else { - return false; - }; - let Ok(local_commit) = repo.head().and_then(|h| h.peel_to_commit()) else { - return false; - }; - remote_commit.id() != local_commit.id() -} - -/// Timestamp of the HEAD commit (or "never"). -pub fn last_commit_time(repo: &git2::Repository) -> String { - let Ok(commit) = repo.head().and_then(|h| h.peel_to_commit()) else { - return "never".to_string(); - }; - let ts = commit.time().seconds(); - let dt = chrono::DateTime::::from_timestamp(ts, 0) - .unwrap_or_else(chrono::Utc::now); - dt.format("%Y-%m-%d %H:%M:%S").to_string() -} - -fn is_bare(path: &Path) -> bool { - path.join("HEAD").exists() && path.join("objects").exists() -} - -fn make_callbacks<'a>() -> git2::RemoteCallbacks<'a> { - let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.credentials(|_url, username_from_url, allowed_types| { - if allowed_types.contains(git2::CredentialType::SSH_KEY) { - git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) - } else if allowed_types.contains(git2::CredentialType::DEFAULT) { - git2::Cred::default() - } else { - Err(git2::Error::from_str( - "no supported credential type (SSH agent or default)", - )) - } - }); - callbacks -} diff --git a/bread-sync/src/lib.rs b/bread-sync/src/lib.rs deleted file mode 100644 index 454a78a..0000000 --- a/bread-sync/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod config; -pub mod delegates; -pub mod git; -pub mod machine; -pub mod packages; - -pub use config::{ - bread_config_dir, config_path, sync_repo_path, DelegatesConfig, MachineConfig, PackagesConfig, - RemoteConfig, SyncConfig, -}; diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs deleted file mode 100644 index e4e4bb1..0000000 --- a/bread-sync/src/machine.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::path::Path; - -use anyhow::Result; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -use crate::config::SyncConfig; - -/// Machine profile persisted to `/machines/.toml`. -#[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 { - pub fn new(cfg: &SyncConfig) -> Result { - let host = hostname()?; - let name = cfg.machine.name.clone().unwrap_or_else(|| host.clone()); - Ok(Self { - name, - hostname: host, - tags: cfg.machine.tags.clone(), - last_sync: Utc::now().to_rfc3339(), - }) - } - - /// Write profile to `/machines/.toml`. - pub fn write_to_repo(&self, repo_root: &Path) -> Result<()> { - let machines_dir = repo_root.join("machines"); - std::fs::create_dir_all(&machines_dir)?; - let path = machines_dir.join(format!("{}.toml", self.name)); - let raw = toml::to_string_pretty(self)?; - std::fs::write(&path, raw)?; - Ok(()) - } - - /// Load from `/machines/.toml`. - pub fn load_from_repo(repo_root: &Path, name: &str) -> Result { - let path = repo_root.join("machines").join(format!("{name}.toml")); - let raw = std::fs::read_to_string(&path)?; - Ok(toml::from_str(&raw)?) - } -} - -/// List all machine profiles in `/machines/`. -pub fn list_machines(repo_root: &Path) -> Vec { - let machines_dir = repo_root.join("machines"); - let Ok(entries) = std::fs::read_dir(&machines_dir) else { - return Vec::new(); - }; - entries - .filter_map(|e| e.ok()) - .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("toml")) - .filter_map(|e| { - std::fs::read_to_string(e.path()) - .ok() - .and_then(|raw| toml::from_str::(&raw).ok()) - }) - .collect() -} - -/// Returns the machine name from sync.toml, falling back to hostname. -pub fn machine_name(cfg: &SyncConfig) -> Result { - if let Some(name) = cfg.machine.name.as_deref() { - return Ok(name.to_string()); - } - hostname() -} - -/// Returns the machine tags from sync.toml. -pub fn machine_tags(cfg: &SyncConfig) -> Vec { - cfg.machine.tags.clone() -} - -/// Returns true if `tag` is in the machine's tag list. -pub fn machine_has_tag(cfg: &SyncConfig, tag: &str) -> bool { - cfg.machine.tags.iter().any(|t| t == tag) -} - -fn hostname() -> Result { - // Try /etc/hostname first (no subprocess) - if let Ok(raw) = std::fs::read_to_string("/etc/hostname") { - let trimmed = raw.trim().to_string(); - if !trimmed.is_empty() { - return Ok(trimmed); - } - } - // Fall back to hostname(1) - let out = std::process::Command::new("hostname") - .output() - .map_err(anyhow::Error::from)?; - let s = String::from_utf8(out.stdout).map_err(anyhow::Error::from)?; - Ok(s.trim().to_string()) -} - -#[allow(dead_code)] -fn format_last_sync(dt: &DateTime) -> String { - dt.format("%Y-%m-%d %H:%M").to_string() -} diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs deleted file mode 100644 index 333e0aa..0000000 --- a/bread-sync/src/packages.rs +++ /dev/null @@ -1,137 +0,0 @@ -use std::path::Path; -use std::process::Command; - -use anyhow::Result; - -/// Write package manifests to `/packages/`. -/// Skips package managers that are not installed (warns instead of erroring). -pub fn snapshot_packages(managers: &[String], repo_root: &Path) -> Result<()> { - let pkg_dir = repo_root.join("packages"); - std::fs::create_dir_all(&pkg_dir)?; - - for mgr in managers { - match mgr.as_str() { - "pacman" => { - if let Some(content) = run_pacman() { - std::fs::write(pkg_dir.join("pacman.txt"), content)?; - } else { - eprintln!("warn: pacman not found, skipping package snapshot"); - } - } - "pip" => { - if let Some(content) = run_pip() { - std::fs::write(pkg_dir.join("pip.txt"), content)?; - } else { - eprintln!("warn: pip not found, skipping package snapshot"); - } - } - "npm" => { - if let Some(content) = run_npm() { - std::fs::write(pkg_dir.join("npm.txt"), content)?; - } else { - eprintln!("warn: npm not found, skipping package snapshot"); - } - } - "cargo" => { - if let Some(content) = run_cargo() { - std::fs::write(pkg_dir.join("cargo.txt"), content)?; - } else { - eprintln!("warn: cargo not found, skipping package snapshot"); - } - } - other => { - eprintln!("warn: unknown package manager '{other}', skipping"); - } - } - } - Ok(()) -} - -/// Parse a `pacman.txt` snapshot into a list of package names. -pub fn parse_pacman(content: &str) -> Vec { - content.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect() -} - -/// Parse a `pip.txt` (freeze format) snapshot into package names. -pub fn parse_pip(content: &str) -> Vec { - content - .lines() - .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) - .filter_map(|l| l.split("==").next().map(|s| s.trim().to_string())) - .collect() -} - -/// Parse an `npm.txt` (parseable) snapshot into package names. -pub fn parse_npm(content: &str) -> Vec { - content - .lines() - .skip(1) // first line is the npm global prefix path - .filter(|l| !l.trim().is_empty()) - .filter_map(|l| { - Path::new(l.trim()) - .file_name() - .and_then(|n| n.to_str()) - .map(ToString::to_string) - }) - .collect() -} - -/// Parse `cargo install --list` output into `name version` lines. -pub fn parse_cargo(content: &str) -> Vec { - content - .lines() - .filter(|l| !l.starts_with(' ') && !l.trim().is_empty()) - .filter_map(|l| { - // Format: `name v1.2.3 (...):` or `name v1.2.3:` - let parts: Vec<&str> = l.splitn(2, ' ').collect(); - if parts.len() == 2 { - let name = parts[0]; - let version = parts[1].trim_start_matches('v').split_whitespace().next().unwrap_or("?").trim_end_matches(':'); - Some(format!("{name} {version}")) - } else { - None - } - }) - .collect() -} - -fn run_pacman() -> Option { - let output = Command::new("pacman").args(["-Qe"]).output().ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -fn run_pip() -> Option { - let output = Command::new("pip") - .args(["list", "--user", "--format=freeze"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -fn run_npm() -> Option { - let output = Command::new("npm") - .args(["list", "-g", "--depth=0", "--parseable"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -fn run_cargo() -> Option { - let output = Command::new("cargo") - .args(["install", "--list"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} diff --git a/bread-sync/tests/sync.rs b/bread-sync/tests/sync.rs deleted file mode 100644 index ce76abf..0000000 --- a/bread-sync/tests/sync.rs +++ /dev/null @@ -1 +0,0 @@ -// Placeholder - tests will be added in step 15 diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index 36189a0..ade7d2f 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -22,25 +22,20 @@ impl UdevAdapter { } pub async fn enumerate_existing(&self, tx: &mpsc::Sender) -> Result<()> { - let payloads = match enumerate_with_udev(&self.subsystems) { - Ok(p) => p, - Err(_) => scan_devices(&self.subsystems) - .unwrap_or_default() - .into_iter() - .map(|d| json!({ - "action": "add", - "id": d.id, - "name": d.name, - "subsystem": d.subsystem, - })) - .collect(), - }; + let devices = enumerate_with_udev(&self.subsystems).unwrap_or_else(|_| { + scan_devices(&self.subsystems).unwrap_or_default() + }); - for payload in payloads { + for device in devices { tx.send(RawEvent { source: AdapterSource::Udev, kind: "udev.enumerate".to_string(), - payload, + payload: json!({ + "action": "add", + "id": device.id, + "name": device.name, + "subsystem": device.subsystem, + }), timestamp: now_unix_ms(), }) .await?; @@ -169,7 +164,7 @@ async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) - Ok(()) } -fn enumerate_with_udev(subsystems: &[String]) -> Result> { +fn enumerate_with_udev(subsystems: &[String]) -> Result> { let mut enumerator = udev::Enumerator::new()?; for subsystem in subsystems { enumerator.match_subsystem(subsystem)?; @@ -189,38 +184,16 @@ fn enumerate_with_udev(subsystems: &[String]) -> Result> .unwrap_or_else(|| "unknown".to_string()); let id = dev.syspath().to_string_lossy().to_string(); - out.push(json!({ - "action": "add", - "id": id, - "name": name, - "subsystem": subsystem, - "id_input_keyboard": dev_prop_bool(&dev, "ID_INPUT_KEYBOARD"), - "id_input_mouse": dev_prop_bool(&dev, "ID_INPUT_MOUSE"), - "id_input_joystick": dev_prop_bool(&dev, "ID_INPUT_JOYSTICK"), - "id_input_touchpad": dev_prop_bool(&dev, "ID_INPUT_TOUCHPAD"), - "id_input_tablet": dev_prop_bool(&dev, "ID_INPUT_TABLET"), - "id_usb_class": dev_prop_str(&dev, "ID_USB_CLASS"), - "id_usb_interfaces": dev_prop_str(&dev, "ID_USB_INTERFACES"), - "id_vendor": dev_prop_str(&dev, "ID_VENDOR"), - "id_model": dev_prop_str(&dev, "ID_MODEL"), - })); + out.push(ScannedDevice { + id, + name, + subsystem, + }); } Ok(out) } -fn dev_prop_bool(dev: &udev::Device, key: &str) -> bool { - dev.property_value(key) - .and_then(|v| v.to_str()) - .map(|v| v == "1") - .unwrap_or(false) -} - -fn dev_prop_str(dev: &udev::Device, key: &str) -> Option { - dev.property_value(key) - .map(|v| v.to_string_lossy().to_string()) -} - fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent { RawEvent { source: AdapterSource::Udev, From 364a35142e22ff7b3617a9b1baae854641c73359 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 12 May 2026 00:20:45 +0800 Subject: [PATCH 23/76] feat: add bread-sync module for snapshot and restore functionality - Introduced `bread-sync` module with core functionalities for syncing system state via Git. - Implemented `MachineProfile` struct for managing machine profiles, including methods for reading and writing profiles. - Added package management support with snapshot capabilities for `pacman`, `pip`, `npm`, and `cargo`. - Created comprehensive tests for sync operations, package parsing, and machine profile management. - Enhanced `udev` adapter to include vendor and product IDs for scanned devices. - Updated state engine to handle module clearing commands. - Introduced Lua integration for accessing machine information and file system operations. - Improved packaging documentation for Arch Linux and systemd service setup. --- .gitignore | 6 +- Cargo.lock | 1103 ++++++++++++++++++++++++++++++- Cargo.toml | 8 +- README.md | 316 +++++++-- bread-cli/Cargo.toml | 12 + bread-cli/src/lib.rs | 2 + bread-cli/src/main.rs | 730 +++++++++++++++++++- bread-cli/src/modules_mgmt.rs | 175 +++++ bread-cli/tests/modules.rs | 133 ++++ bread-sync/Cargo.toml | 18 + bread-sync/README.md | 88 +++ bread-sync/src/config.rs | 135 ++++ bread-sync/src/delegates.rs | 109 +++ bread-sync/src/git.rs | 366 ++++++++++ bread-sync/src/lib.rs | 9 + bread-sync/src/machine.rs | 79 +++ bread-sync/src/packages.rs | 144 ++++ bread-sync/tests/sync.rs | 257 +++++++ breadd/src/adapters/udev.rs | 29 + breadd/src/core/state_engine.rs | 16 + breadd/src/core/types.rs | 4 + breadd/src/ipc/mod.rs | 33 + breadd/src/lua/mod.rs | 170 ++++- packaging/README.md | 50 +- packaging/arch/README.md | 30 +- 25 files changed, 3930 insertions(+), 92 deletions(-) create mode 100644 bread-cli/src/lib.rs create mode 100644 bread-cli/src/modules_mgmt.rs create mode 100644 bread-cli/tests/modules.rs create mode 100644 bread-sync/Cargo.toml create mode 100644 bread-sync/README.md create mode 100644 bread-sync/src/config.rs create mode 100644 bread-sync/src/delegates.rs create mode 100644 bread-sync/src/git.rs create mode 100644 bread-sync/src/lib.rs create mode 100644 bread-sync/src/machine.rs create mode 100644 bread-sync/src/packages.rs create mode 100644 bread-sync/tests/sync.rs diff --git a/.gitignore b/.gitignore index 0c56659..a253843 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,8 @@ target/ Overview.md DAEMON.md +LUA_RUNTIME.md +CLAUDE_SPEC.md .claude CLAUDE.md -<<<<<<< HEAD .github -======= -.github/ ->>>>>>> parent of e561156 (Begin Implementing V2 features) diff --git a/Cargo.lock b/Cargo.lock index 313315f..c04bd41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,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" @@ -248,6 +263,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "1.3.2" @@ -288,12 +309,20 @@ version = "0.1.0" dependencies = [ "anyhow", "bread-shared", + "bread-sync", + "chrono", "clap", + "dirs", + "flate2", "libc", "notify", + "reqwest", "serde", "serde_json", + "tar", + "tempfile", "tokio", + "toml", ] [[package]] @@ -304,6 +333,22 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-sync" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "git2", + "glob", + "libc", + "serde", + "serde_json", + "tempfile", + "toml", +] + [[package]] name = "breadd" version = "0.1.0" @@ -339,6 +384,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byteorder" version = "1.5.0" @@ -358,6 +409,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -367,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" @@ -422,6 +489,32 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -431,6 +524,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -477,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" @@ -606,12 +749,52 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +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" @@ -758,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" @@ -766,11 +961,51 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "git2" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -810,12 +1045,210 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[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]] name = "id-arena" 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" @@ -868,6 +1301,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -880,6 +1319,28 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -918,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" @@ -928,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" @@ -946,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" @@ -1022,6 +1538,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "0.8.11" @@ -1075,6 +1607,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1214,6 +1763,61 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "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" @@ -1268,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" @@ -1321,6 +1931,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1368,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" @@ -1413,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" @@ -1442,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" @@ -1503,6 +2179,27 @@ 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" @@ -1512,12 +2209,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1597,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" @@ -1633,6 +2374,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -1665,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" @@ -1699,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" @@ -1741,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" @@ -1770,6 +2582,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1822,6 +2657,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1883,6 +2724,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typeid" version = "1.0.3" @@ -1930,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" @@ -1942,6 +2807,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1964,6 +2835,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1988,6 +2868,61 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2022,6 +2957,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "which" version = "7.0.3" @@ -2065,12 +3010,65 @@ 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" @@ -2237,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" @@ -2337,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" @@ -2347,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" @@ -2434,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/README.md b/README.md index 73512df..adbff67 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,6 +45,7 @@ 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 ``` @@ -80,7 +86,7 @@ Run the install script — it builds, installs to `/usr/bin`, sets up the system bash scripts/install.sh ``` -Or do it step by step: +Or step by step: ```bash cargo build --release @@ -141,19 +147,16 @@ default_urgency = "normal" notify_send_path = "notify-send" [modules] -builtin = true # load built-in modules (monitors, devices, etc.) -disable = [] # list of built-in module names to disable +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) ``` @@ -165,19 +168,144 @@ end) All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. ```bash +# 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 --filter bread.device.* # Stream filtered events bread events --since 60 # Replay events from the last 60 seconds -bread modules # List loaded modules and status +bread emit # Manually fire an event (for testing) + +# Profiles bread profile-list # List defined profiles bread profile-activate # Activate a named profile -bread emit --data '{}' # Manually fire an event (for testing) -bread ping # Check daemon connectivity -bread health # Daemon version, uptime, PID -bread doctor # Diagnose daemon and module health + +# 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 +bread sync push # Snapshot and push current state to remote +bread sync pull # Pull and apply latest state from remote +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 diff --remote # Show diff vs remote +bread sync machines # List known machines from sync repo +``` + +--- + +## 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, arbitrary dotfiles, and package lists — and stores it in a Git repository. Pull it on another machine to restore. + +```bash +# First-time setup +bread sync init --remote git@github.com:you/bread-config.git + +# Push current state +bread sync push + +# On another machine: pull and apply +bread sync pull + +# Check what's pending +bread sync status +``` + +Configure what gets synced 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"] +``` + +The sync repo stores: + +``` +sync-repo/ +├── bread/ ← ~/.config/bread/ snapshot +├── configs/ ← delegate paths (nvim, waybar, etc.) +├── machines/ ← per-machine profiles +└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.) ``` --- @@ -209,28 +337,48 @@ Events follow the namespace convention `bread...`. | `bread.network.connected` | Network interface came online | | `bread.network.disconnected` | Network interface went offline | | `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; returns a numeric ID +-- Subscribe to events; returns a subscription ID local id = bread.on("bread.monitor.connected", function(event) - print(event.data.name) + -- event.event → "bread.monitor.connected" + -- event.data → table of event-specific fields + -- event.source → adapter that produced it + bread.log(event.event) end) -- Unsubscribe by ID bread.off(id) --- Subscribe once, then auto-unsubscribe +-- 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 predicate filter +-- Subscribe with a filter predicate bread.filter("bread.device.connected", function(event) return event.data.class == "keyboard" end, function(event) @@ -241,18 +389,33 @@ end) 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 devices = bread.state.get("devices") +local online = bread.state.get("network.online") --- Watch a state key and fire on changes -bread.state.watch("active_workspace", function(new, old) - print("workspace changed from " .. tostring(old) .. " to " .. tostring(new)) +-- 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) ``` @@ -266,39 +429,99 @@ bread.profile.activate("default") ### Execution and notifications ```lua --- Fire-and-forget: returns immediately, process runs in background +-- Fire-and-forget shell command bread.exec("kitty") --- Desktop notification -bread.notify("Dock connected", { urgency = "normal", timeout = 3000 }) +-- 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) -bread.after(500, function() +local id = bread.after(500, function() bread.exec("some-delayed-command") end) --- Run on a repeating interval (ms); returns a timer ID +-- 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") -bread.warn("Unexpected state") -bread.error("Something failed") +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) +``` + +### 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" ``` --- @@ -317,9 +540,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`, `events.replay`, `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. --- @@ -327,7 +565,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 69a2c49..7d40088 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -7,12 +7,24 @@ edition = "2021" 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 0ca91df..eadd679 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,9 +1,16 @@ -use anyhow::Result; +mod modules_mgmt; + +use anyhow::{Context, Result}; +use bread_sync::{ + config::{bread_config_dir, SyncConfig}, + delegates, machine, packages, + SyncRepo, +}; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{json, Value}; use std::env; -use std::io; +use std::io::{self, Write as IoWrite}; use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -47,8 +54,16 @@ enum Commands { #[arg(long)] since: Option, }, - /// List loaded modules and status - Modules, + /// Manage installed Lua modules + Modules { + #[command(subcommand)] + subcommand: ModulesCommand, + }, + /// Manage sync (snapshot and restore system state) + Sync { + #[command(subcommand)] + subcommand: SyncCommand, + }, /// List available profiles ProfileList, /// Activate a profile @@ -71,14 +86,70 @@ enum Commands { }, } +#[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, +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); let socket = daemon_socket_path(); - match &cli.command { + match cli.command { Commands::Reload { watch } => { - if *watch { + if watch { watch_reload(&socket).await?; } else { let response = send_request(&socket, "modules.reload", json!({})).await?; @@ -86,19 +157,14 @@ async fn main() -> Result<()> { } } Commands::State { path, json } => { - if *json { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; + 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 { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; print_state_formatted(path.as_deref(), &response); } } @@ -108,22 +174,25 @@ async fn main() -> Result<()> { fields, since, } => { - stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?; + stream_events(&socket, filter, 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", @@ -144,7 +213,7 @@ async fn main() -> Result<()> { print_json(&response)?; } Commands::Doctor { json } => { - if *json { + if json { let response = send_request(&socket, "health", json!({})).await?; print_json(&response)?; } else { @@ -156,6 +225,580 @@ async fn main() -> Result<()> { 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?, + } + 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 (git remote or path): "); + 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)?; + + // If it looks like a URL (not a local path), check if it exists + if !remote_url.starts_with('/') && !remote_url.starts_with('.') { + println!("remote does not exist yet — it will be created on first push"); + } + + println!(); + println!("sync initialized"); + println!(" machine: {}", machine_name); + println!(" remote: {}", remote_url); + 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(); + + // Clone or open the local sync repo + let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?; + + // Snapshot bread/ directory + let bread_dest = repo_path.join("bread"); + delegates::sync_dir( + cfg_dir, + &bread_dest, + &[ + // Don't recurse into the sync repo itself + ".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 {} failed: {}", manager, e); + } + } + } + + // Write machine profile + let machines_dir = repo_path.join("machines"); + let profile = + machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()); + profile.write(&machines_dir)?; + + // Set remote and commit + repo.set_remote("origin", &config.remote.url)?; + 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 push — already up to date"); + return Ok(()); + } + + repo.push("origin", &config.remote.branch)?; + + println!("pushed sync for {}", config.machine.name); + println!(" bread config: {}", cfg_dir.display()); + if !config.delegates.include.is_empty() { + println!(" delegates: {}", config.delegates.include.len()); + } + 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(); + + let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?; + repo.set_remote("origin", &config.remote.url)?; + + match repo.pull("origin", &config.remote.branch) { + Ok(()) => {} + Err(e) => { + eprintln!("{}", e); + 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 pushed"); + return Ok(()); + } + + let repo = SyncRepo::open(&repo_path)?; + repo.set_remote("origin", &config.remote.url)?; + + // Fetch remote refs without merging + let _ = repo.fetch("origin", &config.remote.branch); + + let last_push = 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!(" remote {}", config.remote.url); + println!(" last push {}", last_push); + + let local_changes = repo.local_changes()?; + println!(); + println!("local changes (not yet pushed):"); + if local_changes.is_empty() { + println!(" none"); + } else { + for (ch, path) in &local_changes { + println!(" {} {}", ch, path); + } + } + + let remote_changes = repo.remote_changes("origin", &config.remote.branch)?; + println!(); + println!("remote changes (not yet pulled):"); + if remote_changes.is_empty() { + println!(" none"); + } else { + for (ch, path) in &remote_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 = if vs_remote { + repo.set_remote("origin", &config.remote.url)?; + let _ = repo.fetch("origin", &config.remote.branch); + repo.remote_diff("origin", &config.remote.branch)? + } else { + 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(()) +} + +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.to_str().unwrap_or("")); + 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"); @@ -164,7 +807,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", @@ -195,7 +849,9 @@ async fn stream_events( since: Option, ) -> Result<()> { if let Some(seconds) = since { - let replay = send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?; + 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 { @@ -303,9 +959,7 @@ fn format_timestamp(ms: u64) -> String { 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 + tm.tm_hour as u64 * 3600 + tm.tm_min as u64 * 60 + tm.tm_sec as u64 }; let h = (local_secs / 3600) % 24; @@ -346,8 +1000,6 @@ async fn watch_reload(socket: &Path) -> Result<()> { continue; } - // Debounce: drain any follow-up events that arrive within 150ms. - // A single file save typically generates 2-3 fs events in rapid succession. tokio::time::sleep(Duration::from_millis(150)).await; while rx.try_recv().is_ok() {} @@ -384,10 +1036,20 @@ 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 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!( + " daemon {} (pid {})", + if ok { "✓ running" } else { "✗ unreachable" }, + pid + ); println!(" version {version}"); println!(" uptime {}s", uptime_ms / 1000); println!(" socket {socket}"); diff --git a/bread-cli/src/modules_mgmt.rs b/bread-cli/src/modules_mgmt.rs new file mode 100644 index 0000000..17c0a7b --- /dev/null +++ b/bread-cli/src/modules_mgmt.rs @@ -0,0 +1,175 @@ +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..d05374c --- /dev/null +++ b/bread-cli/tests/modules.rs @@ -0,0 +1,133 @@ +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-sync/Cargo.toml b/bread-sync/Cargo.toml new file mode 100644 index 0000000..232b592 --- /dev/null +++ b/bread-sync/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bread-sync" +version = "0.1.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..55a8dd3 --- /dev/null +++ b/bread-sync/src/config.rs @@ -0,0 +1,135 @@ +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(), + "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) +} diff --git a/bread-sync/src/delegates.rs b/bread-sync/src/delegates.rs new file mode 100644 index 0000000..2c59792 --- /dev/null +++ b/bread-sync/src/delegates.rs @@ -0,0 +1,109 @@ +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() +} diff --git a/bread-sync/src/git.rs b/bread-sync/src/git.rs new file mode 100644 index 0000000..a3740f8 --- /dev/null +++ b/bread-sync/src/git.rs @@ -0,0 +1,366 @@ +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..4b89f1a --- /dev/null +++ b/bread-sync/src/lib.rs @@ -0,0 +1,9 @@ +/// Bread sync: snapshot and restore system state via a Git remote. +pub mod config; +pub mod delegates; +pub mod git; +pub mod machine; +pub mod packages; + +pub use config::SyncConfig; +pub use git::SyncRepo; diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs new file mode 100644 index 0000000..325ef5a --- /dev/null +++ b/bread-sync/src/machine.rs @@ -0,0 +1,79 @@ +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()) +} diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs new file mode 100644 index 0000000..96ad7b3 --- /dev/null +++ b/bread-sync/src/packages.rs @@ -0,0 +1,144 @@ +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()?, + "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_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()), + } +} diff --git a/bread-sync/tests/sync.rs b/bread-sync/tests/sync.rs new file mode 100644 index 0000000..484120c --- /dev/null +++ b/bread-sync/tests/sync.rs @@ -0,0 +1,257 @@ +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 + ); +} diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index ade7d2f..c3aba56 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -101,6 +101,8 @@ struct ScannedDevice { id: String, name: String, subsystem: String, + vendor_id: Option, + product_id: Option, } async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) -> Result<()> { @@ -148,6 +150,8 @@ async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) - "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(), }; @@ -183,11 +187,19 @@ 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(); + let vendor_id = dev + .property_value("ID_VENDOR_ID") + .map(|v| v.to_string_lossy().to_string()); + let product_id = dev + .property_value("ID_MODEL_ID") + .map(|v| v.to_string_lossy().to_string()); out.push(ScannedDevice { id, name, subsystem, + vendor_id, + product_id, }); } @@ -203,6 +215,8 @@ fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent { "id": dev.id, "name": dev.name, "subsystem": dev.subsystem, + "vendor_id": dev.vendor_id, + "product_id": dev.product_id, }), timestamp: now_unix_ms(), } @@ -226,6 +240,8 @@ fn scan_devices(subsystems: &[String]) -> Result> { id: format!("drm:{name}"), name, subsystem: "drm".to_string(), + vendor_id: None, + product_id: None, }); } } @@ -242,6 +258,8 @@ fn scan_devices(subsystems: &[String]) -> Result> { id: format!("input:{name}"), name, subsystem: "input".to_string(), + vendor_id: None, + product_id: None, }); } } @@ -257,6 +275,8 @@ fn scan_devices(subsystems: &[String]) -> Result> { id: format!("power_supply:{name}"), name, subsystem: "power_supply".to_string(), + vendor_id: None, + product_id: None, }); } } @@ -269,10 +289,19 @@ fn scan_devices(subsystems: &[String]) -> Result> { let entry = entry?; let name = entry.file_name().to_string_lossy().to_string(); if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) { + let syspath = entry.path(); + let vendor_id = fs::read_to_string(syspath.join("idVendor")) + .ok() + .map(|s| s.trim().to_string()); + let product_id = fs::read_to_string(syspath.join("idProduct")) + .ok() + .map(|s| s.trim().to_string()); out.push(ScannedDevice { id: format!("usb:{name}"), name, subsystem: "usb".to_string(), + vendor_id, + product_id, }); } } diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index caea8dc..6e69e6a 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -36,6 +36,7 @@ pub enum StateCommand { id: SubscriptionId, }, ClearSubscriptions, + ClearModules, SetModuleStatus { name: String, status: ModuleLoadState, @@ -112,6 +113,10 @@ impl StateHandle { let _ = self.command_tx.send(StateCommand::ClearSubscriptions); } + pub fn clear_modules(&self) { + let _ = self.command_tx.send(StateCommand::ClearModules); + } + pub fn set_module_status( &self, name: String, @@ -236,6 +241,9 @@ async fn handle_command( watches.clear(); subscription_count.store(0, Ordering::Relaxed); } + StateCommand::ClearModules => { + state.write().await.modules.clear(); + } StateCommand::SetModuleStatus { name, status, @@ -421,6 +429,14 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) .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); diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 45ccfa5..119b7af 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -57,6 +57,10 @@ pub struct Device { pub name: String, pub class: DeviceClass, 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)] diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index fff3368..25fe66c 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -267,6 +267,39 @@ impl Server { "recent_errors": recent_errors, })) } + "sync.status" => { + let cfg_home = 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 sync_path = cfg_home.join("bread").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); diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 228ac61..7caa9c6 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -9,6 +9,7 @@ use std::time::Duration; use anyhow::{anyhow, Result}; use bread_shared::{AdapterSource, BreadEvent}; +use libc; use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value}; use serde::Serialize; use serde_json::Value as JsonValue; @@ -250,6 +251,7 @@ impl LuaEngine { 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() @@ -837,6 +839,66 @@ impl LuaEngine { })?; 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)?; + globals.set("bread", bread)?; self.install_require_loader()?; self.install_wait_helper()?; @@ -927,7 +989,7 @@ impl LuaEngine { fn load_module(&self, decl: &ModuleDecl) -> Result<()> { self.set_current_module(Some(decl.name.clone())); - let result = if let Some(source) = decl.source.as_deref() { + 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) @@ -1296,16 +1358,31 @@ impl LuaEngine { 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 { - match err { - LuaError::RuntimeError(msg) if msg == MODULE_DECL_ABORT => {} - other => return Err(anyhow!(other.to_string())), + if !err.to_string().contains(MODULE_DECL_ABORT) { + return Err(anyhow!(err.to_string())); } } @@ -1559,6 +1636,91 @@ fn module_store_set(state_arc: &Arc>, module: &str, key: St }); } +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" }) 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/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 | From e39b1683988a3c30d0f5ee2c87667593069959ce Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 12 May 2026 00:20:45 +0800 Subject: [PATCH 24/76] feat: add bread-sync module for snapshot and restore functionality - Introduced `bread-sync` module with core functionalities for syncing system state via Git. - Implemented `MachineProfile` struct for managing machine profiles, including methods for reading and writing profiles. - Added package management support with snapshot capabilities for `pacman`, `pip`, `npm`, and `cargo`. - Created comprehensive tests for sync operations, package parsing, and machine profile management. - Enhanced `udev` adapter to include vendor and product IDs for scanned devices. - Updated state engine to handle module clearing commands. - Introduced Lua integration for accessing machine information and file system operations. - Improved packaging documentation for Arch Linux and systemd service setup. --- .gitignore | 6 +- Cargo.lock | 1103 ++++++++++++++++++++++++++++++- Cargo.toml | 8 +- README.md | 316 +++++++-- bread-cli/Cargo.toml | 12 + bread-cli/src/lib.rs | 2 + bread-cli/src/main.rs | 730 +++++++++++++++++++- bread-cli/src/modules_mgmt.rs | 175 +++++ bread-cli/tests/modules.rs | 133 ++++ bread-sync/Cargo.toml | 18 + bread-sync/README.md | 88 +++ bread-sync/src/config.rs | 135 ++++ bread-sync/src/delegates.rs | 109 +++ bread-sync/src/git.rs | 366 ++++++++++ bread-sync/src/lib.rs | 9 + bread-sync/src/machine.rs | 79 +++ bread-sync/src/packages.rs | 144 ++++ bread-sync/tests/sync.rs | 257 +++++++ breadd/src/adapters/udev.rs | 29 + breadd/src/core/state_engine.rs | 16 + breadd/src/core/types.rs | 4 + breadd/src/ipc/mod.rs | 33 + breadd/src/lua/mod.rs | 170 ++++- packaging/README.md | 50 +- packaging/arch/README.md | 30 +- 25 files changed, 3930 insertions(+), 92 deletions(-) create mode 100644 bread-cli/src/lib.rs create mode 100644 bread-cli/src/modules_mgmt.rs create mode 100644 bread-cli/tests/modules.rs create mode 100644 bread-sync/Cargo.toml create mode 100644 bread-sync/README.md create mode 100644 bread-sync/src/config.rs create mode 100644 bread-sync/src/delegates.rs create mode 100644 bread-sync/src/git.rs create mode 100644 bread-sync/src/lib.rs create mode 100644 bread-sync/src/machine.rs create mode 100644 bread-sync/src/packages.rs create mode 100644 bread-sync/tests/sync.rs diff --git a/.gitignore b/.gitignore index 0c56659..a253843 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,8 @@ target/ Overview.md DAEMON.md +LUA_RUNTIME.md +CLAUDE_SPEC.md .claude CLAUDE.md -<<<<<<< HEAD .github -======= -.github/ ->>>>>>> parent of e561156 (Begin Implementing V2 features) diff --git a/Cargo.lock b/Cargo.lock index 313315f..c04bd41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,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" @@ -248,6 +263,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "1.3.2" @@ -288,12 +309,20 @@ version = "0.1.0" dependencies = [ "anyhow", "bread-shared", + "bread-sync", + "chrono", "clap", + "dirs", + "flate2", "libc", "notify", + "reqwest", "serde", "serde_json", + "tar", + "tempfile", "tokio", + "toml", ] [[package]] @@ -304,6 +333,22 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bread-sync" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "dirs", + "git2", + "glob", + "libc", + "serde", + "serde_json", + "tempfile", + "toml", +] + [[package]] name = "breadd" version = "0.1.0" @@ -339,6 +384,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byteorder" version = "1.5.0" @@ -358,6 +409,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -367,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" @@ -422,6 +489,32 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -431,6 +524,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -477,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" @@ -606,12 +749,52 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +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" @@ -758,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" @@ -766,11 +961,51 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "git2" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -810,12 +1045,210 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[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]] name = "id-arena" 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" @@ -868,6 +1301,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -880,6 +1319,28 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -918,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" @@ -928,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" @@ -946,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" @@ -1022,6 +1538,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "0.8.11" @@ -1075,6 +1607,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1214,6 +1763,61 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "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" @@ -1268,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" @@ -1321,6 +1931,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1368,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" @@ -1413,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" @@ -1442,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" @@ -1503,6 +2179,27 @@ 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" @@ -1512,12 +2209,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1597,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" @@ -1633,6 +2374,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -1665,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" @@ -1699,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" @@ -1741,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" @@ -1770,6 +2582,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1822,6 +2657,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1883,6 +2724,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typeid" version = "1.0.3" @@ -1930,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" @@ -1942,6 +2807,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1964,6 +2835,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1988,6 +2868,61 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2022,6 +2957,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "which" version = "7.0.3" @@ -2065,12 +3010,65 @@ 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" @@ -2237,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" @@ -2337,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" @@ -2347,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" @@ -2434,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/README.md b/README.md index 73512df..adbff67 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,6 +45,7 @@ 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 ``` @@ -80,7 +86,7 @@ Run the install script — it builds, installs to `/usr/bin`, sets up the system bash scripts/install.sh ``` -Or do it step by step: +Or step by step: ```bash cargo build --release @@ -141,19 +147,16 @@ default_urgency = "normal" notify_send_path = "notify-send" [modules] -builtin = true # load built-in modules (monitors, devices, etc.) -disable = [] # list of built-in module names to disable +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) ``` @@ -165,19 +168,144 @@ end) All commands communicate with the running daemon over a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. ```bash +# 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 --filter bread.device.* # Stream filtered events bread events --since 60 # Replay events from the last 60 seconds -bread modules # List loaded modules and status +bread emit # Manually fire an event (for testing) + +# Profiles bread profile-list # List defined profiles bread profile-activate # Activate a named profile -bread emit --data '{}' # Manually fire an event (for testing) -bread ping # Check daemon connectivity -bread health # Daemon version, uptime, PID -bread doctor # Diagnose daemon and module health + +# 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 +bread sync push # Snapshot and push current state to remote +bread sync pull # Pull and apply latest state from remote +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 diff --remote # Show diff vs remote +bread sync machines # List known machines from sync repo +``` + +--- + +## 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, arbitrary dotfiles, and package lists — and stores it in a Git repository. Pull it on another machine to restore. + +```bash +# First-time setup +bread sync init --remote git@github.com:you/bread-config.git + +# Push current state +bread sync push + +# On another machine: pull and apply +bread sync pull + +# Check what's pending +bread sync status +``` + +Configure what gets synced 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"] +``` + +The sync repo stores: + +``` +sync-repo/ +├── bread/ ← ~/.config/bread/ snapshot +├── configs/ ← delegate paths (nvim, waybar, etc.) +├── machines/ ← per-machine profiles +└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.) ``` --- @@ -209,28 +337,48 @@ Events follow the namespace convention `bread...`. | `bread.network.connected` | Network interface came online | | `bread.network.disconnected` | Network interface went offline | | `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; returns a numeric ID +-- Subscribe to events; returns a subscription ID local id = bread.on("bread.monitor.connected", function(event) - print(event.data.name) + -- event.event → "bread.monitor.connected" + -- event.data → table of event-specific fields + -- event.source → adapter that produced it + bread.log(event.event) end) -- Unsubscribe by ID bread.off(id) --- Subscribe once, then auto-unsubscribe +-- 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 predicate filter +-- Subscribe with a filter predicate bread.filter("bread.device.connected", function(event) return event.data.class == "keyboard" end, function(event) @@ -241,18 +389,33 @@ end) 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 devices = bread.state.get("devices") +local online = bread.state.get("network.online") --- Watch a state key and fire on changes -bread.state.watch("active_workspace", function(new, old) - print("workspace changed from " .. tostring(old) .. " to " .. tostring(new)) +-- 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) ``` @@ -266,39 +429,99 @@ bread.profile.activate("default") ### Execution and notifications ```lua --- Fire-and-forget: returns immediately, process runs in background +-- Fire-and-forget shell command bread.exec("kitty") --- Desktop notification -bread.notify("Dock connected", { urgency = "normal", timeout = 3000 }) +-- 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) -bread.after(500, function() +local id = bread.after(500, function() bread.exec("some-delayed-command") end) --- Run on a repeating interval (ms); returns a timer ID +-- 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") -bread.warn("Unexpected state") -bread.error("Something failed") +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) +``` + +### 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" ``` --- @@ -317,9 +540,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`, `events.replay`, `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. --- @@ -327,7 +565,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 69a2c49..7d40088 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -7,12 +7,24 @@ edition = "2021" 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 0ca91df..eadd679 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,9 +1,16 @@ -use anyhow::Result; +mod modules_mgmt; + +use anyhow::{Context, Result}; +use bread_sync::{ + config::{bread_config_dir, SyncConfig}, + delegates, machine, packages, + SyncRepo, +}; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{json, Value}; use std::env; -use std::io; +use std::io::{self, Write as IoWrite}; use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -47,8 +54,16 @@ enum Commands { #[arg(long)] since: Option, }, - /// List loaded modules and status - Modules, + /// Manage installed Lua modules + Modules { + #[command(subcommand)] + subcommand: ModulesCommand, + }, + /// Manage sync (snapshot and restore system state) + Sync { + #[command(subcommand)] + subcommand: SyncCommand, + }, /// List available profiles ProfileList, /// Activate a profile @@ -71,14 +86,70 @@ enum Commands { }, } +#[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, +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); let socket = daemon_socket_path(); - match &cli.command { + match cli.command { Commands::Reload { watch } => { - if *watch { + if watch { watch_reload(&socket).await?; } else { let response = send_request(&socket, "modules.reload", json!({})).await?; @@ -86,19 +157,14 @@ async fn main() -> Result<()> { } } Commands::State { path, json } => { - if *json { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; + 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 { - let response = if let Some(path) = path { - send_request(&socket, "state.get", json!({ "key": path })).await? - } else { - send_request(&socket, "state.dump", json!({})).await? - }; print_state_formatted(path.as_deref(), &response); } } @@ -108,22 +174,25 @@ async fn main() -> Result<()> { fields, since, } => { - stream_events(&socket, filter.clone(), *json, fields.clone(), *since).await?; + stream_events(&socket, filter, 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", @@ -144,7 +213,7 @@ async fn main() -> Result<()> { print_json(&response)?; } Commands::Doctor { json } => { - if *json { + if json { let response = send_request(&socket, "health", json!({})).await?; print_json(&response)?; } else { @@ -156,6 +225,580 @@ async fn main() -> Result<()> { 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?, + } + 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 (git remote or path): "); + 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)?; + + // If it looks like a URL (not a local path), check if it exists + if !remote_url.starts_with('/') && !remote_url.starts_with('.') { + println!("remote does not exist yet — it will be created on first push"); + } + + println!(); + println!("sync initialized"); + println!(" machine: {}", machine_name); + println!(" remote: {}", remote_url); + 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(); + + // Clone or open the local sync repo + let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?; + + // Snapshot bread/ directory + let bread_dest = repo_path.join("bread"); + delegates::sync_dir( + cfg_dir, + &bread_dest, + &[ + // Don't recurse into the sync repo itself + ".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 {} failed: {}", manager, e); + } + } + } + + // Write machine profile + let machines_dir = repo_path.join("machines"); + let profile = + machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()); + profile.write(&machines_dir)?; + + // Set remote and commit + repo.set_remote("origin", &config.remote.url)?; + 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 push — already up to date"); + return Ok(()); + } + + repo.push("origin", &config.remote.branch)?; + + println!("pushed sync for {}", config.machine.name); + println!(" bread config: {}", cfg_dir.display()); + if !config.delegates.include.is_empty() { + println!(" delegates: {}", config.delegates.include.len()); + } + 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(); + + let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?; + repo.set_remote("origin", &config.remote.url)?; + + match repo.pull("origin", &config.remote.branch) { + Ok(()) => {} + Err(e) => { + eprintln!("{}", e); + 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 pushed"); + return Ok(()); + } + + let repo = SyncRepo::open(&repo_path)?; + repo.set_remote("origin", &config.remote.url)?; + + // Fetch remote refs without merging + let _ = repo.fetch("origin", &config.remote.branch); + + let last_push = 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!(" remote {}", config.remote.url); + println!(" last push {}", last_push); + + let local_changes = repo.local_changes()?; + println!(); + println!("local changes (not yet pushed):"); + if local_changes.is_empty() { + println!(" none"); + } else { + for (ch, path) in &local_changes { + println!(" {} {}", ch, path); + } + } + + let remote_changes = repo.remote_changes("origin", &config.remote.branch)?; + println!(); + println!("remote changes (not yet pulled):"); + if remote_changes.is_empty() { + println!(" none"); + } else { + for (ch, path) in &remote_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 = if vs_remote { + repo.set_remote("origin", &config.remote.url)?; + let _ = repo.fetch("origin", &config.remote.branch); + repo.remote_diff("origin", &config.remote.branch)? + } else { + 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(()) +} + +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.to_str().unwrap_or("")); + 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"); @@ -164,7 +807,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", @@ -195,7 +849,9 @@ async fn stream_events( since: Option, ) -> Result<()> { if let Some(seconds) = since { - let replay = send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })).await?; + 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 { @@ -303,9 +959,7 @@ fn format_timestamp(ms: u64) -> String { 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 + tm.tm_hour as u64 * 3600 + tm.tm_min as u64 * 60 + tm.tm_sec as u64 }; let h = (local_secs / 3600) % 24; @@ -346,8 +1000,6 @@ async fn watch_reload(socket: &Path) -> Result<()> { continue; } - // Debounce: drain any follow-up events that arrive within 150ms. - // A single file save typically generates 2-3 fs events in rapid succession. tokio::time::sleep(Duration::from_millis(150)).await; while rx.try_recv().is_ok() {} @@ -384,10 +1036,20 @@ 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 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!( + " daemon {} (pid {})", + if ok { "✓ running" } else { "✗ unreachable" }, + pid + ); println!(" version {version}"); println!(" uptime {}s", uptime_ms / 1000); println!(" socket {socket}"); diff --git a/bread-cli/src/modules_mgmt.rs b/bread-cli/src/modules_mgmt.rs new file mode 100644 index 0000000..17c0a7b --- /dev/null +++ b/bread-cli/src/modules_mgmt.rs @@ -0,0 +1,175 @@ +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..d05374c --- /dev/null +++ b/bread-cli/tests/modules.rs @@ -0,0 +1,133 @@ +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-sync/Cargo.toml b/bread-sync/Cargo.toml new file mode 100644 index 0000000..232b592 --- /dev/null +++ b/bread-sync/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bread-sync" +version = "0.1.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..55a8dd3 --- /dev/null +++ b/bread-sync/src/config.rs @@ -0,0 +1,135 @@ +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(), + "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) +} diff --git a/bread-sync/src/delegates.rs b/bread-sync/src/delegates.rs new file mode 100644 index 0000000..2c59792 --- /dev/null +++ b/bread-sync/src/delegates.rs @@ -0,0 +1,109 @@ +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() +} diff --git a/bread-sync/src/git.rs b/bread-sync/src/git.rs new file mode 100644 index 0000000..a3740f8 --- /dev/null +++ b/bread-sync/src/git.rs @@ -0,0 +1,366 @@ +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..4b89f1a --- /dev/null +++ b/bread-sync/src/lib.rs @@ -0,0 +1,9 @@ +/// Bread sync: snapshot and restore system state via a Git remote. +pub mod config; +pub mod delegates; +pub mod git; +pub mod machine; +pub mod packages; + +pub use config::SyncConfig; +pub use git::SyncRepo; diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs new file mode 100644 index 0000000..325ef5a --- /dev/null +++ b/bread-sync/src/machine.rs @@ -0,0 +1,79 @@ +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()) +} diff --git a/bread-sync/src/packages.rs b/bread-sync/src/packages.rs new file mode 100644 index 0000000..96ad7b3 --- /dev/null +++ b/bread-sync/src/packages.rs @@ -0,0 +1,144 @@ +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()?, + "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_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()), + } +} diff --git a/bread-sync/tests/sync.rs b/bread-sync/tests/sync.rs new file mode 100644 index 0000000..484120c --- /dev/null +++ b/bread-sync/tests/sync.rs @@ -0,0 +1,257 @@ +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 + ); +} diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs index ade7d2f..c3aba56 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -101,6 +101,8 @@ struct ScannedDevice { id: String, name: String, subsystem: String, + vendor_id: Option, + product_id: Option, } async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) -> Result<()> { @@ -148,6 +150,8 @@ async fn run_udev_monitor(subsystems: Vec, tx: mpsc::Sender) - "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(), }; @@ -183,11 +187,19 @@ 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(); + let vendor_id = dev + .property_value("ID_VENDOR_ID") + .map(|v| v.to_string_lossy().to_string()); + let product_id = dev + .property_value("ID_MODEL_ID") + .map(|v| v.to_string_lossy().to_string()); out.push(ScannedDevice { id, name, subsystem, + vendor_id, + product_id, }); } @@ -203,6 +215,8 @@ fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent { "id": dev.id, "name": dev.name, "subsystem": dev.subsystem, + "vendor_id": dev.vendor_id, + "product_id": dev.product_id, }), timestamp: now_unix_ms(), } @@ -226,6 +240,8 @@ fn scan_devices(subsystems: &[String]) -> Result> { id: format!("drm:{name}"), name, subsystem: "drm".to_string(), + vendor_id: None, + product_id: None, }); } } @@ -242,6 +258,8 @@ fn scan_devices(subsystems: &[String]) -> Result> { id: format!("input:{name}"), name, subsystem: "input".to_string(), + vendor_id: None, + product_id: None, }); } } @@ -257,6 +275,8 @@ fn scan_devices(subsystems: &[String]) -> Result> { id: format!("power_supply:{name}"), name, subsystem: "power_supply".to_string(), + vendor_id: None, + product_id: None, }); } } @@ -269,10 +289,19 @@ fn scan_devices(subsystems: &[String]) -> Result> { let entry = entry?; let name = entry.file_name().to_string_lossy().to_string(); if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) { + let syspath = entry.path(); + let vendor_id = fs::read_to_string(syspath.join("idVendor")) + .ok() + .map(|s| s.trim().to_string()); + let product_id = fs::read_to_string(syspath.join("idProduct")) + .ok() + .map(|s| s.trim().to_string()); out.push(ScannedDevice { id: format!("usb:{name}"), name, subsystem: "usb".to_string(), + vendor_id, + product_id, }); } } diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index caea8dc..6e69e6a 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -36,6 +36,7 @@ pub enum StateCommand { id: SubscriptionId, }, ClearSubscriptions, + ClearModules, SetModuleStatus { name: String, status: ModuleLoadState, @@ -112,6 +113,10 @@ impl StateHandle { let _ = self.command_tx.send(StateCommand::ClearSubscriptions); } + pub fn clear_modules(&self) { + let _ = self.command_tx.send(StateCommand::ClearModules); + } + pub fn set_module_status( &self, name: String, @@ -236,6 +241,9 @@ async fn handle_command( watches.clear(); subscription_count.store(0, Ordering::Relaxed); } + StateCommand::ClearModules => { + state.write().await.modules.clear(); + } StateCommand::SetModuleStatus { name, status, @@ -421,6 +429,14 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) .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); diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 45ccfa5..119b7af 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -57,6 +57,10 @@ pub struct Device { pub name: String, pub class: DeviceClass, 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)] diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index fff3368..25fe66c 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -267,6 +267,39 @@ impl Server { "recent_errors": recent_errors, })) } + "sync.status" => { + let cfg_home = 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 sync_path = cfg_home.join("bread").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); diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 228ac61..7caa9c6 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -9,6 +9,7 @@ use std::time::Duration; use anyhow::{anyhow, Result}; use bread_shared::{AdapterSource, BreadEvent}; +use libc; use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value}; use serde::Serialize; use serde_json::Value as JsonValue; @@ -250,6 +251,7 @@ impl LuaEngine { 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() @@ -837,6 +839,66 @@ impl LuaEngine { })?; 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)?; + globals.set("bread", bread)?; self.install_require_loader()?; self.install_wait_helper()?; @@ -927,7 +989,7 @@ impl LuaEngine { fn load_module(&self, decl: &ModuleDecl) -> Result<()> { self.set_current_module(Some(decl.name.clone())); - let result = if let Some(source) = decl.source.as_deref() { + 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) @@ -1296,16 +1358,31 @@ impl LuaEngine { 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 { - match err { - LuaError::RuntimeError(msg) if msg == MODULE_DECL_ABORT => {} - other => return Err(anyhow!(other.to_string())), + if !err.to_string().contains(MODULE_DECL_ABORT) { + return Err(anyhow!(err.to_string())); } } @@ -1559,6 +1636,91 @@ fn module_store_set(state_arc: &Arc>, module: &str, key: St }); } +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" }) 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/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 | From 762e6a6a59fac7a23c238666244f6ddd12966b6e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 16:25:35 +0000 Subject: [PATCH 25/76] docs: align daemon naming with package rename Agent-Logs-Url: https://github.com/Breadway/bread/sessions/1d380004-8d78-4a1f-9fbb-0c8a487b2e14 Co-authored-by: Breadway <108389940+Breadway@users.noreply.github.com> --- Documentation.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Documentation.md b/Documentation.md index c2ad50c..454f7a9 100644 --- a/Documentation.md +++ b/Documentation.md @@ -15,7 +15,7 @@ ## 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. +Bread is a reactive automation fabric for Linux desktops. The daemon (`bread`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation. - Daemon: long-running Rust process, source of truth for runtime state - Lua runtime: dedicated thread inside the daemon; automation logic lives here @@ -27,7 +27,7 @@ If you are new to Bread, start with the quick walkthrough below, then jump to th ### 1) Create a minimal config -- Daemon config: `~/.config/bread/breadd.toml` +- Daemon config: `~/.config/bread/bread.toml` - Lua entry point: `~/.config/bread/init.lua` - Lua modules: `~/.config/bread/modules/` @@ -465,7 +465,7 @@ Payload includes `online` and `interfaces`. ## Dictionary: IPC protocol -The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON. +The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/bread.sock`. Messages are newline-delimited JSON. Request: From d158fe21865f47e0da37fd74712ddf78604d93be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 16:25:35 +0000 Subject: [PATCH 26/76] docs: align daemon naming with package rename Agent-Logs-Url: https://github.com/Breadway/bread/sessions/1d380004-8d78-4a1f-9fbb-0c8a487b2e14 Co-authored-by: Breadway <108389940+Breadway@users.noreply.github.com> --- Documentation.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Documentation.md b/Documentation.md index c2ad50c..454f7a9 100644 --- a/Documentation.md +++ b/Documentation.md @@ -15,7 +15,7 @@ ## 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. +Bread is a reactive automation fabric for Linux desktops. The daemon (`bread`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation. - Daemon: long-running Rust process, source of truth for runtime state - Lua runtime: dedicated thread inside the daemon; automation logic lives here @@ -27,7 +27,7 @@ If you are new to Bread, start with the quick walkthrough below, then jump to th ### 1) Create a minimal config -- Daemon config: `~/.config/bread/breadd.toml` +- Daemon config: `~/.config/bread/bread.toml` - Lua entry point: `~/.config/bread/init.lua` - Lua modules: `~/.config/bread/modules/` @@ -465,7 +465,7 @@ Payload includes `online` and `interfaces`. ## Dictionary: IPC protocol -The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON. +The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/bread.sock`. Messages are newline-delimited JSON. Request: From 22f591e0e6ed2cc580ebc6e8e33e25e6786ca328 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 12 May 2026 00:35:09 +0800 Subject: [PATCH 27/76] revert --- Documentation.md | 644 +++++++++++++++++++++++++++++++---------------- 1 file changed, 430 insertions(+), 214 deletions(-) diff --git a/Documentation.md b/Documentation.md index 454f7a9..fe9ec60 100644 --- a/Documentation.md +++ b/Documentation.md @@ -6,6 +6,8 @@ - [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) - [Dictionary: Built-in modules](#dictionary-built-in-modules) @@ -15,11 +17,11 @@ ## Overview -Bread is a reactive automation fabric for Linux desktops. The daemon (`bread`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation. +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: long-running Rust process, source of truth for runtime state -- Lua runtime: dedicated thread inside the daemon; automation logic lives here -- CLI: talks to the daemon over a Unix socket +- **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 If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details. @@ -27,75 +29,189 @@ If you are new to Bread, start with the quick walkthrough below, then jump to th ### 1) Create a minimal config -- Daemon config: `~/.config/bread/bread.toml` +- 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 -require("modules.devices") -require("modules.workspaces") - -bread.on("bread.system.startup", function() +bread.on("bread.system.startup", function(event) bread.profile.activate("default") + bread.log("bread started on " .. bread.machine.name()) end) ``` -### 3) Add your first module +### 3) Start the daemon -Create a Lua file under your modules directory and load it from `init.lua`. +```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") + bread.log("hello from bread on " .. bread.machine.name()) bread.on("bread.device.*", function(event) - bread.log(event.event) + bread.log("device event: " .. event.event) end) end return M ``` -Why this shape? +Key rules: -- Every module must call `bread.module` once. -- `on_load` is a good place to register subscriptions. +- 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 -- Start the daemon, then use `bread reload` after editing Lua. -- `bread reload --watch` will keep reloading on changes. -- See [Examples.md](Examples.md) for real-world ports. +```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 Bread config, arbitrary dotfiles, and installed package lists into a Git repository. Pull it on another machine to restore state. + +```bash +# First-time setup +bread sync init --remote git@github.com:you/bread-config.git + +# Snapshot and push +bread sync push + +# On another machine: pull and apply +bread sync pull + +# Also reinstall packages from snapshot +bread sync pull --install-packages + +# See what has changed +bread sync status +bread sync diff +bread sync diff --remote + +# List known machines +bread sync machines +``` + +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"] +``` + +The sync repo stores: + +``` +~/.local/share/bread/sync-repo/ +├── bread/ ← ~/.config/bread/ snapshot +├── configs/ ← delegate paths (nvim, waybar, etc.) +├── machines/ ← per-machine profiles with tags and last-sync time +└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.) +``` ## Debugging tips -- Log event payloads with `bread.log(event.data.raw)` when matching devices. -- Use `bread.events` in the CLI to see live normalized events. -- Use `bread state` to see runtime state as JSON. +- 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. -## Lua module system +--- -### Entry point and module scanning +## Dictionary: Lua API -- `init.lua` is executed first. -- Modules are discovered by scanning `~/.config/bread/modules/` for `.lua` files. -- Every module must call `bread.module` exactly once at top-level. -- Modules are ordered by the `after` dependency list. +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", + name = "my.module", version = "0.1.0", - after = { "bread.devices" }, + after = { "bread.devices" }, -- optional: load after this module }) return M @@ -103,65 +219,25 @@ return M If a module does not call `bread.module`, it fails to load and is marked as a load error. -### Require loader - -`require("bread.")` resolves to a Lua file under the module path. For example: - -```lua -local utils = require("bread.lib.utils") -``` - -This loads `~/.config/bread/modules/lib/utils.lua` if it exists. Non-`bread.*` `require` calls fall back to standard Lua behavior. - -### Lifecycle hooks - -Modules may export any of the following hooks. All are optional. - -```lua -function M.on_load() - -- register subscriptions, initialize module state -end - -function M.on_reload() - -- called after a hot reload completes -end - -function M.on_unload() - -- called before the Lua instance is dropped -end - -function M.on_error(err) - -- called when a handler throws - -- return true to keep the subscription, false to cancel it - return true -end -``` - -### Module storage - -Each module has a scoped key-value store that survives reloads: - -```lua -M.store.set("last_profile", "docked") -local value = M.store.get("last_profile") -``` - -The store lives in the daemon runtime state and is not shared across modules. - -## Dictionary: Lua API - -Every API is exposed through the `bread` global table. - ### 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 filter. `opts` must contain `filter`: +Subscribe with a predicate. `opts` must contain a `filter` function: ```lua bread.filter("bread.device.*", function(event) @@ -174,13 +250,13 @@ end, { ``` #### `bread.off(id)` -Unsubscribe an event or state watch by ID. +Unsubscribe an event handler or state watch by ID. #### `bread.emit(event, data)` -Emit a custom event into the system pipeline. +Emit a custom event into the system pipeline. Useful for cross-module communication. #### `bread.wait(pattern, opts) -> event | nil` -Coroutine-only helper that waits for a matching event. +Coroutine-only helper that suspends until a matching event arrives. ```lua bread.spawn(function() @@ -192,30 +268,37 @@ end) ``` #### `bread.spawn(fn)` -Spawn a coroutine and surface errors if the coroutine fails. +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 (e.g. `"network.online"`). +Read a state subtree by dotted path. -#### Convenience helpers +```lua +local monitors = bread.state.get("monitors") +local online = bread.state.get("network.online") +``` -- `bread.state.monitors()` -- `bread.state.active_workspace()` -- `bread.state.active_window()` -- `bread.state.devices()` -- `bread.state.power()` -- `bread.state.network()` -- `bread.state.profile()` +#### 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. The callback receives `(new, old)`. +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.exec("notify-send 'AC connected'") + bread.notify("AC connected") end end) ``` @@ -223,66 +306,147 @@ end) ### Profiles #### `bread.profile.activate(name)` -Update the active profile. The CLI also emits `bread.profile.activated` over IPC; the Lua API does not emit this event on its own. +Activate a named profile. Emits `bread.profile.activated` over IPC. ### Execution #### `bread.exec(cmd)` -Runs `cmd` in a `sh -lc` shell. Fire-and-forget (async). +Run a shell command. Fire-and-forget (async, does not block Lua). ### Notifications #### `bread.notify(message, opts)` -Sends a desktop notification via `notify-send`. +Send a desktop notification via `notify-send`. Options: -- `title` (string, default: `"bread"`) -- `urgency` (string, default from config) -- `timeout` (ms, default from config) -- `icon` (string, optional) +| 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 delay. +Run once after a delay. #### `bread.every(interval_ms, fn) -> id` -Run repeatedly on an interval. +Run on a repeating interval. #### `bread.cancel(id)` -Cancel a timer created by `after` or `every`. +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 only fires after quiet time. +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)` -Log helpers that accept any Lua value. +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: +The `bread.hyprland` namespace provides compositor bindings. -- `bread.hyprland.dispatch(cmd, args)` -- `bread.hyprland.keyword(key, value)` -- `bread.hyprland.active_window()` -- `bread.hyprland.monitors()` -- `bread.hyprland.workspaces()` -- `bread.hyprland.clients()` -- `bread.hyprland.on_raw(kind, fn) -> id` +```lua +-- Dispatch a Hyprland command +bread.hyprland.dispatch("workspace", "2") +bread.hyprland.dispatch("exec", "kitty") -`bread.hyprland.on_raw` filters raw Hyprland events by `kind` and delivers the full event payload (including the original raw string). +-- 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) +``` + +### 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 enabled by default. Disable them via `[modules].disable` in the config. +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") @@ -291,41 +455,51 @@ monitors.layout("dock", function() end) monitors.on({ - when = "connected", + when = "connected", monitors = { "HDMI-A-1" }, - run = monitors.apply("dock"), + run = monitors.apply("dock"), }) ``` -- `monitors.on({ when, monitors, run })` -- `monitors.layout(name, fn)` -- `monitors.apply(name) -> fn` +| 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`. `run` may be a function or a shell command string. +`when` is one of `connected`, `disconnected`, `changed`. ### `bread.devices` +Device connection rules with class-based matching. + ```lua local devices = require("bread.devices") +-- Register a name pattern → class mapping +devices.register("CalDigit", "dock") devices.register("Keychron", "keyboard") devices.on({ - when = "connected", + when = "connected", class = "keyboard", - run = function(event) + run = function(event) bread.exec("xset r rate 200 40") end, }) ``` -- `devices.on({ when, class, name, run })` -- `devices.register(pattern, class)` +| Function | Description | +|----------|-------------| +| `M.on(opts)` | Register a device rule. `opts`: `when`, `class` (optional), `name` (optional pattern), `run` | +| `M.register(pattern, class)` | Map a device name pattern to a class string | -`class` may be `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. +`class` values: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. ### `bread.workspaces` +Workspace-to-monitor assignment and app pinning. + ```lua local workspaces = require("bread.workspaces") @@ -333,26 +507,34 @@ workspaces.assign("1", "HDMI-A-1") workspaces.pin({ app = "Firefox", workspace = "2" }) ``` -- `workspaces.assign(workspace, monitor)` -- `workspaces.pin({ app, workspace })` -- `workspaces.apply_assignments()` +| 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", + mods = { "SUPER" }, + key = "Return", dispatch = "exec", - args = "kitty", + args = "kitty", }) ``` -- `binds.add({ mods, key, dispatch, args })` -- `binds.remove(key)` -- `binds.replace(key, opts)` +| 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 @@ -369,103 +551,136 @@ Events are delivered as a `BreadEvent`: ### Pattern matching -Patterns match event names with glob-style syntax: - -- Exact match: `bread.device.dock.connected` -- `*` matches within a single segment (does not cross `.`) -- `**` matches across segments (recursive) -- `?` matches a single character within a segment - -Examples: - -```lua -bread.on("bread.device.*", handler) -bread.on("bread.device.**", handler) -bread.on("bread.monitor.?", handler) -``` +| 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 -- `bread.system.startup` (data: `{}`) +| Event | Data | +|-------|------| +| `bread.system.startup` | `{}` | #### Devices (udev) -- `bread.device.connected` -- `bread.device.disconnected` -- `bread.device.changed` -- `bread.device..connected` -- `bread.device..disconnected` -- `bread.device..changed` +| Event | Data | +|-------|------| +| `bread.device.connected` | `{ id, class, name, subsystem, vendor_id?, product_id? }` | +| `bread.device.disconnected` | `{ id, class, name, subsystem, vendor_id?, product_id? }` | +| `bread.device..connected` | same | +| `bread.device..disconnected` | same | -Payload notes: - -- Device events include `id` and `class`; the generic event also includes `raw`. -- `` is one of: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. +`class`: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. #### Hyprland -- `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` (raw payload for unhandled kinds) - -Raw Hyprland payloads contain `kind`, `raw`, and `data` fields. +| 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 -- `bread.power.ac.connected` -- `bread.power.ac.disconnected` -- `bread.power.battery.low` -- `bread.power.battery.very_low` -- `bread.power.battery.critical` -- `bread.power.battery.full` -- `bread.power.changed` (fallback) - -Payload includes `ac_connected` and `battery_percent`. +| 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 -- `bread.network.connected` -- `bread.network.disconnected` +| Event | Data | +|-------|------| +| `bread.network.connected` | `{ online, interfaces }` | +| `bread.network.disconnected` | `{ online, interfaces }` | -Payload includes `online` and `interfaces`. +#### System events -#### Other system events +| Event | Data | +|-------|------| +| `bread.profile.activated` | `{ name }` | +| `bread.notify.sent` | `{ title, message, urgency }` | +| `bread.state.changed.` | emitted by state watches | -- `bread.profile.activated` (emitted by IPC profile activation) -- `bread.notify.sent` (emitted by `bread.notify`) -- `bread.state.changed.` (emitted when a state watch fires) +--- ## Dictionary: Runtime state schema -`bread.state.get("")` returns the full `RuntimeState`: +`bread state` and `bread.state.get("")` return the full `RuntimeState`: ```json { - "monitors": [ { "name": "HDMI-A-1", "connected": true } ], - "workspaces": [ { "id": "1", "monitor": "HDMI-A-1" } ], + "monitors": [ + { "name": "HDMI-A-1", "connected": true, "resolution": null, "position": null } + ], + "workspaces": [ + { "id": "1", "monitor": "HDMI-A-1" } + ], "active_workspace": "1", - "active_window": "Firefox", - "devices": { "connected": [] }, - "network": { "interfaces": {}, "online": false }, - "power": { "ac_connected": false, "battery_percent": null, "battery_low": false }, - "profile": { "active": "default", "history": [], "profiles": {} }, - "modules": [ { "name": "bread.devices", "status": "loaded", "last_error": null, "builtin": true, "store": {} } ] + "active_window": "0x...", + "devices": { + "connected": [ + { + "id": "/sys/...", + "name": "CalDigit TS4", + "class": "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/bread.sock`. Messages are newline-delimited JSON. +The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON. Request: @@ -481,16 +696,17 @@ Response: Available methods: -- `ping` -- `health` -- `state.get` -- `state.dump` -- `modules.list` -- `modules.reload` -- `profile.list` -- `profile.activate` -- `events.subscribe` -- `events.replay` -- `emit` - -`events.subscribe` upgrades the socket to streaming mode and sends events as they occur. +| 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? }` | From acbf8e1b1ba2a71466f24945a572babb2af62a7d Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 12 May 2026 00:35:09 +0800 Subject: [PATCH 28/76] revert --- Documentation.md | 644 +++++++++++++++++++++++++++++++---------------- 1 file changed, 430 insertions(+), 214 deletions(-) diff --git a/Documentation.md b/Documentation.md index 454f7a9..fe9ec60 100644 --- a/Documentation.md +++ b/Documentation.md @@ -6,6 +6,8 @@ - [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) - [Dictionary: Built-in modules](#dictionary-built-in-modules) @@ -15,11 +17,11 @@ ## Overview -Bread is a reactive automation fabric for Linux desktops. The daemon (`bread`) normalizes external signals into semantic events, maintains runtime state, and dispatches events to Lua modules that implement automation. +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: long-running Rust process, source of truth for runtime state -- Lua runtime: dedicated thread inside the daemon; automation logic lives here -- CLI: talks to the daemon over a Unix socket +- **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 If you are new to Bread, start with the quick walkthrough below, then jump to the full dictionary when you need exact API details. @@ -27,75 +29,189 @@ If you are new to Bread, start with the quick walkthrough below, then jump to th ### 1) Create a minimal config -- Daemon config: `~/.config/bread/bread.toml` +- 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 -require("modules.devices") -require("modules.workspaces") - -bread.on("bread.system.startup", function() +bread.on("bread.system.startup", function(event) bread.profile.activate("default") + bread.log("bread started on " .. bread.machine.name()) end) ``` -### 3) Add your first module +### 3) Start the daemon -Create a Lua file under your modules directory and load it from `init.lua`. +```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") + bread.log("hello from bread on " .. bread.machine.name()) bread.on("bread.device.*", function(event) - bread.log(event.event) + bread.log("device event: " .. event.event) end) end return M ``` -Why this shape? +Key rules: -- Every module must call `bread.module` once. -- `on_load` is a good place to register subscriptions. +- 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 -- Start the daemon, then use `bread reload` after editing Lua. -- `bread reload --watch` will keep reloading on changes. -- See [Examples.md](Examples.md) for real-world ports. +```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 Bread config, arbitrary dotfiles, and installed package lists into a Git repository. Pull it on another machine to restore state. + +```bash +# First-time setup +bread sync init --remote git@github.com:you/bread-config.git + +# Snapshot and push +bread sync push + +# On another machine: pull and apply +bread sync pull + +# Also reinstall packages from snapshot +bread sync pull --install-packages + +# See what has changed +bread sync status +bread sync diff +bread sync diff --remote + +# List known machines +bread sync machines +``` + +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"] +``` + +The sync repo stores: + +``` +~/.local/share/bread/sync-repo/ +├── bread/ ← ~/.config/bread/ snapshot +├── configs/ ← delegate paths (nvim, waybar, etc.) +├── machines/ ← per-machine profiles with tags and last-sync time +└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.) +``` ## Debugging tips -- Log event payloads with `bread.log(event.data.raw)` when matching devices. -- Use `bread.events` in the CLI to see live normalized events. -- Use `bread state` to see runtime state as JSON. +- 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. -## Lua module system +--- -### Entry point and module scanning +## Dictionary: Lua API -- `init.lua` is executed first. -- Modules are discovered by scanning `~/.config/bread/modules/` for `.lua` files. -- Every module must call `bread.module` exactly once at top-level. -- Modules are ordered by the `after` dependency list. +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", + name = "my.module", version = "0.1.0", - after = { "bread.devices" }, + after = { "bread.devices" }, -- optional: load after this module }) return M @@ -103,65 +219,25 @@ return M If a module does not call `bread.module`, it fails to load and is marked as a load error. -### Require loader - -`require("bread.")` resolves to a Lua file under the module path. For example: - -```lua -local utils = require("bread.lib.utils") -``` - -This loads `~/.config/bread/modules/lib/utils.lua` if it exists. Non-`bread.*` `require` calls fall back to standard Lua behavior. - -### Lifecycle hooks - -Modules may export any of the following hooks. All are optional. - -```lua -function M.on_load() - -- register subscriptions, initialize module state -end - -function M.on_reload() - -- called after a hot reload completes -end - -function M.on_unload() - -- called before the Lua instance is dropped -end - -function M.on_error(err) - -- called when a handler throws - -- return true to keep the subscription, false to cancel it - return true -end -``` - -### Module storage - -Each module has a scoped key-value store that survives reloads: - -```lua -M.store.set("last_profile", "docked") -local value = M.store.get("last_profile") -``` - -The store lives in the daemon runtime state and is not shared across modules. - -## Dictionary: Lua API - -Every API is exposed through the `bread` global table. - ### 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 filter. `opts` must contain `filter`: +Subscribe with a predicate. `opts` must contain a `filter` function: ```lua bread.filter("bread.device.*", function(event) @@ -174,13 +250,13 @@ end, { ``` #### `bread.off(id)` -Unsubscribe an event or state watch by ID. +Unsubscribe an event handler or state watch by ID. #### `bread.emit(event, data)` -Emit a custom event into the system pipeline. +Emit a custom event into the system pipeline. Useful for cross-module communication. #### `bread.wait(pattern, opts) -> event | nil` -Coroutine-only helper that waits for a matching event. +Coroutine-only helper that suspends until a matching event arrives. ```lua bread.spawn(function() @@ -192,30 +268,37 @@ end) ``` #### `bread.spawn(fn)` -Spawn a coroutine and surface errors if the coroutine fails. +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 (e.g. `"network.online"`). +Read a state subtree by dotted path. -#### Convenience helpers +```lua +local monitors = bread.state.get("monitors") +local online = bread.state.get("network.online") +``` -- `bread.state.monitors()` -- `bread.state.active_workspace()` -- `bread.state.active_window()` -- `bread.state.devices()` -- `bread.state.power()` -- `bread.state.network()` -- `bread.state.profile()` +#### 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. The callback receives `(new, old)`. +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.exec("notify-send 'AC connected'") + bread.notify("AC connected") end end) ``` @@ -223,66 +306,147 @@ end) ### Profiles #### `bread.profile.activate(name)` -Update the active profile. The CLI also emits `bread.profile.activated` over IPC; the Lua API does not emit this event on its own. +Activate a named profile. Emits `bread.profile.activated` over IPC. ### Execution #### `bread.exec(cmd)` -Runs `cmd` in a `sh -lc` shell. Fire-and-forget (async). +Run a shell command. Fire-and-forget (async, does not block Lua). ### Notifications #### `bread.notify(message, opts)` -Sends a desktop notification via `notify-send`. +Send a desktop notification via `notify-send`. Options: -- `title` (string, default: `"bread"`) -- `urgency` (string, default from config) -- `timeout` (ms, default from config) -- `icon` (string, optional) +| 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 delay. +Run once after a delay. #### `bread.every(interval_ms, fn) -> id` -Run repeatedly on an interval. +Run on a repeating interval. #### `bread.cancel(id)` -Cancel a timer created by `after` or `every`. +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 only fires after quiet time. +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)` -Log helpers that accept any Lua value. +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: +The `bread.hyprland` namespace provides compositor bindings. -- `bread.hyprland.dispatch(cmd, args)` -- `bread.hyprland.keyword(key, value)` -- `bread.hyprland.active_window()` -- `bread.hyprland.monitors()` -- `bread.hyprland.workspaces()` -- `bread.hyprland.clients()` -- `bread.hyprland.on_raw(kind, fn) -> id` +```lua +-- Dispatch a Hyprland command +bread.hyprland.dispatch("workspace", "2") +bread.hyprland.dispatch("exec", "kitty") -`bread.hyprland.on_raw` filters raw Hyprland events by `kind` and delivers the full event payload (including the original raw string). +-- 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) +``` + +### 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 enabled by default. Disable them via `[modules].disable` in the config. +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") @@ -291,41 +455,51 @@ monitors.layout("dock", function() end) monitors.on({ - when = "connected", + when = "connected", monitors = { "HDMI-A-1" }, - run = monitors.apply("dock"), + run = monitors.apply("dock"), }) ``` -- `monitors.on({ when, monitors, run })` -- `monitors.layout(name, fn)` -- `monitors.apply(name) -> fn` +| 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`. `run` may be a function or a shell command string. +`when` is one of `connected`, `disconnected`, `changed`. ### `bread.devices` +Device connection rules with class-based matching. + ```lua local devices = require("bread.devices") +-- Register a name pattern → class mapping +devices.register("CalDigit", "dock") devices.register("Keychron", "keyboard") devices.on({ - when = "connected", + when = "connected", class = "keyboard", - run = function(event) + run = function(event) bread.exec("xset r rate 200 40") end, }) ``` -- `devices.on({ when, class, name, run })` -- `devices.register(pattern, class)` +| Function | Description | +|----------|-------------| +| `M.on(opts)` | Register a device rule. `opts`: `when`, `class` (optional), `name` (optional pattern), `run` | +| `M.register(pattern, class)` | Map a device name pattern to a class string | -`class` may be `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. +`class` values: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. ### `bread.workspaces` +Workspace-to-monitor assignment and app pinning. + ```lua local workspaces = require("bread.workspaces") @@ -333,26 +507,34 @@ workspaces.assign("1", "HDMI-A-1") workspaces.pin({ app = "Firefox", workspace = "2" }) ``` -- `workspaces.assign(workspace, monitor)` -- `workspaces.pin({ app, workspace })` -- `workspaces.apply_assignments()` +| 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", + mods = { "SUPER" }, + key = "Return", dispatch = "exec", - args = "kitty", + args = "kitty", }) ``` -- `binds.add({ mods, key, dispatch, args })` -- `binds.remove(key)` -- `binds.replace(key, opts)` +| 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 @@ -369,103 +551,136 @@ Events are delivered as a `BreadEvent`: ### Pattern matching -Patterns match event names with glob-style syntax: - -- Exact match: `bread.device.dock.connected` -- `*` matches within a single segment (does not cross `.`) -- `**` matches across segments (recursive) -- `?` matches a single character within a segment - -Examples: - -```lua -bread.on("bread.device.*", handler) -bread.on("bread.device.**", handler) -bread.on("bread.monitor.?", handler) -``` +| 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 -- `bread.system.startup` (data: `{}`) +| Event | Data | +|-------|------| +| `bread.system.startup` | `{}` | #### Devices (udev) -- `bread.device.connected` -- `bread.device.disconnected` -- `bread.device.changed` -- `bread.device..connected` -- `bread.device..disconnected` -- `bread.device..changed` +| Event | Data | +|-------|------| +| `bread.device.connected` | `{ id, class, name, subsystem, vendor_id?, product_id? }` | +| `bread.device.disconnected` | `{ id, class, name, subsystem, vendor_id?, product_id? }` | +| `bread.device..connected` | same | +| `bread.device..disconnected` | same | -Payload notes: - -- Device events include `id` and `class`; the generic event also includes `raw`. -- `` is one of: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. +`class`: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. #### Hyprland -- `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` (raw payload for unhandled kinds) - -Raw Hyprland payloads contain `kind`, `raw`, and `data` fields. +| 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 -- `bread.power.ac.connected` -- `bread.power.ac.disconnected` -- `bread.power.battery.low` -- `bread.power.battery.very_low` -- `bread.power.battery.critical` -- `bread.power.battery.full` -- `bread.power.changed` (fallback) - -Payload includes `ac_connected` and `battery_percent`. +| 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 -- `bread.network.connected` -- `bread.network.disconnected` +| Event | Data | +|-------|------| +| `bread.network.connected` | `{ online, interfaces }` | +| `bread.network.disconnected` | `{ online, interfaces }` | -Payload includes `online` and `interfaces`. +#### System events -#### Other system events +| Event | Data | +|-------|------| +| `bread.profile.activated` | `{ name }` | +| `bread.notify.sent` | `{ title, message, urgency }` | +| `bread.state.changed.` | emitted by state watches | -- `bread.profile.activated` (emitted by IPC profile activation) -- `bread.notify.sent` (emitted by `bread.notify`) -- `bread.state.changed.` (emitted when a state watch fires) +--- ## Dictionary: Runtime state schema -`bread.state.get("")` returns the full `RuntimeState`: +`bread state` and `bread.state.get("")` return the full `RuntimeState`: ```json { - "monitors": [ { "name": "HDMI-A-1", "connected": true } ], - "workspaces": [ { "id": "1", "monitor": "HDMI-A-1" } ], + "monitors": [ + { "name": "HDMI-A-1", "connected": true, "resolution": null, "position": null } + ], + "workspaces": [ + { "id": "1", "monitor": "HDMI-A-1" } + ], "active_workspace": "1", - "active_window": "Firefox", - "devices": { "connected": [] }, - "network": { "interfaces": {}, "online": false }, - "power": { "ac_connected": false, "battery_percent": null, "battery_low": false }, - "profile": { "active": "default", "history": [], "profiles": {} }, - "modules": [ { "name": "bread.devices", "status": "loaded", "last_error": null, "builtin": true, "store": {} } ] + "active_window": "0x...", + "devices": { + "connected": [ + { + "id": "/sys/...", + "name": "CalDigit TS4", + "class": "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/bread.sock`. Messages are newline-delimited JSON. +The daemon exposes a Unix socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. Messages are newline-delimited JSON. Request: @@ -481,16 +696,17 @@ Response: Available methods: -- `ping` -- `health` -- `state.get` -- `state.dump` -- `modules.list` -- `modules.reload` -- `profile.list` -- `profile.activate` -- `events.subscribe` -- `events.replay` -- `emit` - -`events.subscribe` upgrades the socket to streaming mode and sends events as they occur. +| 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? }` | From 434fe1721c2b70fff42c8b0aec100aa6f39eff2c Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 12 May 2026 21:27:07 +0800 Subject: [PATCH 29/76] feat: enhance device normalization and classification - Introduced a new mechanism in EventNormalizer to suppress duplicate events from child nodes of the same physical device. - Removed the device classification logic from the normalizer and replaced it with a rule-based system using Lua scripts. - Added support for user-defined device rules in Lua, allowing for flexible device naming based on various conditions. - Updated the state engine to handle device rules and resolve device names before dispatching events. - Modified the installation script to set up default configuration files for the daemon and Lua modules. - Improved the handling of systemd user services to dynamically set the ExecStart path based on the installation directory. --- .gitignore | 32 ++++ Documentation.md | 116 +++++++++++-- README.md | 9 +- bread-cli/src/main.rs | 11 +- breadd/Cargo.toml | 2 +- breadd/src/adapters/hyprland.rs | 35 +++- breadd/src/adapters/udev.rs | 294 +++++++++----------------------- breadd/src/core/normalizer.rs | 190 +++++++-------------- breadd/src/core/state_engine.rs | 160 ++++++++++++++++- breadd/src/core/types.rs | 37 ++-- breadd/src/lua/mod.rs | 217 ++++++++++++++++------- scripts/install.sh | 92 ++++++++-- 12 files changed, 719 insertions(+), 476 deletions(-) diff --git a/.gitignore b/.gitignore index a253843..a92804c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,36 @@ +# 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 LUA_RUNTIME.md diff --git a/Documentation.md b/Documentation.md index fe9ec60..f1a0aca 100644 --- a/Documentation.md +++ b/Documentation.md @@ -471,30 +471,110 @@ monitors.on({ ### `bread.devices` -Device connection rules with class-based matching. +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") --- Register a name pattern → class mapping -devices.register("CalDigit", "dock") -devices.register("Keychron", "keyboard") +devices.on({ + when = "connected", + device = "keyboard", + run = function(event) + bread.exec("xset r rate 200 40") + end, +}) devices.on({ - when = "connected", - class = "keyboard", - run = function(event) + 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, }) ``` -| Function | Description | -|----------|-------------| -| `M.on(opts)` | Register a device rule. `opts`: `when`, `class` (optional), `name` (optional pattern), `run` | -| `M.register(pattern, class)` | Map a device name pattern to a class string | +#### Example: Dock-specific setup -`class` values: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. +```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` @@ -570,12 +650,12 @@ Events are delivered as a `BreadEvent`: | Event | Data | |-------|------| -| `bread.device.connected` | `{ id, class, name, subsystem, vendor_id?, product_id? }` | -| `bread.device.disconnected` | `{ id, class, name, subsystem, vendor_id?, product_id? }` | -| `bread.device..connected` | same | -| `bread.device..disconnected` | same | +| `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 }` | -`class`: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. +`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`. #### Hyprland @@ -641,7 +721,7 @@ Events are delivered as a `BreadEvent`: { "id": "/sys/...", "name": "CalDigit TS4", - "class": "dock", + "device": "dock", "subsystem": "usb", "vendor_id": "0x35f5", "product_id": "0x0104" diff --git a/README.md b/README.md index adbff67..cc1e32c 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ 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 --filter bread.device.* # Stream filtered 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) @@ -319,9 +319,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 | @@ -380,7 +379,7 @@ end) -- Subscribe with a filter predicate bread.filter("bread.device.connected", function(event) - return event.data.class == "keyboard" + return event.data.device == "keyboard" end, function(event) bread.exec("xset r rate 200 40") end) diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index eadd679..ae100c3 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -42,8 +42,8 @@ enum Commands { }, /// Stream live normalized events Events { - #[arg(long)] - filter: Option, + /// Optional glob pattern to filter events (e.g. bread.device.*, bread.**) + pattern: Option, /// Output raw JSON #[arg(long)] json: bool, @@ -169,12 +169,12 @@ async fn main() -> Result<()> { } } Commands::Events { - filter, + pattern, json, fields, since, } => { - stream_events(&socket, filter, json, fields, since).await?; + stream_events(&socket, pattern, json, fields, since).await?; } Commands::Modules { subcommand } => { handle_modules_cmd(subcommand, &socket).await?; @@ -769,8 +769,7 @@ fn run_package_installs(packages_dir: &Path, managers: &[String]) -> Result<()> } "pip" => { let mut cmd = std::process::Command::new("pip"); - cmd.args(["install", "--user", "-r"]) - .arg(file.to_str().unwrap_or("")); + cmd.args(["install", "--user", "-r"]).arg(&file); let _ = cmd.status(); } "npm" => { diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index 4b949be..9b968d9 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -14,7 +14,7 @@ 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" diff --git a/breadd/src/adapters/hyprland.rs b/breadd/src/adapters/hyprland.rs index 2ef3731..c032612 100644 --- a/breadd/src/adapters/hyprland.rs +++ b/breadd/src/adapters/hyprland.rs @@ -48,13 +48,36 @@ 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/udev.rs b/breadd/src/adapters/udev.rs index c3aba56..5af66bc 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,122 +46,106 @@ impl Adapter for UdevAdapter { async fn run(&self, tx: mpsc::Sender) -> Result<()> { debug!("udev adapter started"); - match run_udev_monitor(self.subsystems.clone(), tx.clone()).await { - Ok(()) => return Ok(()), - Err(err) => { - tracing::warn!(error = %err, "udev netlink monitor unavailable, falling back to sysfs polling (add user to 'plugdev' group for real-time events)"); - } - } - - // Fallback: poll sysfs every 2 seconds for environments where the - // netlink socket is unavailable (missing plugdev membership, containers, etc). - let mut known: HashMap = scan_devices(&self.subsystems) - .unwrap_or_default() - .into_iter() - .map(|d| (d.id.clone(), d)) - .collect(); - - loop { - let current = scan_devices(&self.subsystems).unwrap_or_default(); - 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) { - if tx.send(raw_change_event("add", dev)).await.is_err() { - return Ok(()); - } - } - } - - for (id, dev) in &known { - if !current_map.contains_key(id) { - if tx.send(raw_change_event("remove", dev)).await.is_err() { - return Ok(()); - } - } - } - - 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, - vendor_id: Option, - product_id: Option, } +// 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, - "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(), + 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 { @@ -187,125 +165,7 @@ 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(); - let vendor_id = dev - .property_value("ID_VENDOR_ID") - .map(|v| v.to_string_lossy().to_string()); - let product_id = dev - .property_value("ID_MODEL_ID") - .map(|v| v.to_string_lossy().to_string()); - - out.push(ScannedDevice { - id, - name, - subsystem, - vendor_id, - product_id, - }); - } - - 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, - "vendor_id": dev.vendor_id, - "product_id": dev.product_id, - }), - timestamp: now_unix_ms(), - } -} - -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(), - vendor_id: None, - product_id: None, - }); - } - } - } - } - - 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(), - vendor_id: None, - product_id: None, - }); - } - } - } - - 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(), - vendor_id: None, - product_id: None, - }); - } - } - } - - 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()) { - let syspath = entry.path(); - let vendor_id = fs::read_to_string(syspath.join("idVendor")) - .ok() - .map(|s| s.trim().to_string()); - let product_id = fs::read_to_string(syspath.join("idProduct")) - .ok() - .map(|s| s.trim().to_string()); - out.push(ScannedDevice { - id: format!("usb:{name}"), - name, - subsystem: "usb".to_string(), - vendor_id, - product_id, - }); - } - } - } + out.push(ScannedDevice { id, name, subsystem }); } Ok(out) diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index 3eaef88..9c31d41 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()), } } @@ -42,40 +45,75 @@ 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('"', ""); + // "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 { @@ -109,13 +147,13 @@ impl EventNormalizer { event: "bread.monitor.connected".to_string(), timestamp: raw.timestamp, source: AdapterSource::Hyprland, - data: raw.payload.clone(), + data: json!({ "name": data }), }], "monitorremoved" => vec![BreadEvent { event: "bread.monitor.disconnected".to_string(), timestamp: raw.timestamp, source: AdapterSource::Hyprland, - data: raw.payload.clone(), + data: json!({ "name": data }), }], "activewindow" => vec![BreadEvent { event: "bread.window.focus.changed".to_string(), @@ -288,107 +326,3 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> { data.split(">>").collect() } -fn classify_device(payload: &Value) -> DeviceClass { - let subsystem = payload - .get("subsystem") - .and_then(Value::as_str) - .unwrap_or_default() - .to_lowercase(); - - // --- Property-based classification (reliable, hardware-agnostic) --- - - // udev sets ID_INPUT_KEYBOARD=1 for anything that presents as a keyboard HID device. - if payload.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) { - return DeviceClass::Keyboard; - } - - // ID_INPUT_MOUSE=1 covers mice and trackballs. - if payload.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) { - return DeviceClass::Mouse; - } - - // ID_INPUT_TABLET=1 covers drawing tablets (Wacom etc). - if payload.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) { - return DeviceClass::Tablet; - } - - // USB class 0x09 = Hub. Docks expose a hub interface; they also typically - // expose video (0x0e), audio (0x01), and ethernet (CDC 0x02) interfaces. - // We check for hub + at least one of those secondary interfaces. - if let Some(ifaces) = payload.get("id_usb_interfaces").and_then(Value::as_str) { - let ifaces_lc = ifaces.to_lowercase(); - let has_hub = ifaces_lc.contains(":0900") || ifaces_lc.contains(":0902"); - let has_secondary = ifaces_lc.contains(":0e") // video - || ifaces_lc.contains(":0200") // CDC ethernet - || ifaces_lc.contains(":0100") // audio - || ifaces_lc.contains(":0801"); // mass storage - if has_hub && has_secondary { - return DeviceClass::Dock; - } - } - - // USB class 0x01 = Audio. - if let Some(cls) = payload.get("id_usb_class").and_then(Value::as_str) { - if cls == "01" || cls.to_lowercase() == "0x01" { - return DeviceClass::Audio; - } - // USB class 0x08 = Mass Storage. - if cls == "08" || cls.to_lowercase() == "0x08" { - return DeviceClass::Storage; - } - } - - // DRM subsystem = display connector. - if subsystem == "drm" { - return DeviceClass::Display; - } - - // Block devices = storage. - if subsystem == "block" { - return DeviceClass::Storage; - } - - // Sound subsystem = audio. - if subsystem == "sound" { - return DeviceClass::Audio; - } - - // --- Name-based fallback (catches user-registered patterns and obvious names) --- - // This runs last so the property-based rules above always win. - - let name = payload - .get("name") - .and_then(Value::as_str) - .or_else(|| payload.get("id_model").and_then(Value::as_str)) - .unwrap_or_default() - .to_lowercase(); - - let vendor = payload - .get("id_vendor") - .and_then(Value::as_str) - .unwrap_or_default() - .to_lowercase(); - - let combined = format!("{name} {vendor}"); - - if combined.contains("dock") || combined.contains("hub") || combined.contains("thunderbolt") { - return DeviceClass::Dock; - } - if combined.contains("keyboard") || combined.contains("kbd") { - return DeviceClass::Keyboard; - } - if combined.contains("mouse") || combined.contains("trackball") || combined.contains("trackpoint") { - return DeviceClass::Mouse; - } - if combined.contains("tablet") || combined.contains("wacom") || combined.contains("stylus") { - return DeviceClass::Tablet; - } - if combined.contains("audio") || combined.contains("headset") || combined.contains("speaker") || combined.contains("dac") { - return DeviceClass::Audio; - } - if combined.contains("storage") || combined.contains("drive") || combined.contains("flash") || combined.contains("disk") { - return DeviceClass::Storage; - } - - DeviceClass::Unknown -} diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index 6e69e6a..784a0e9 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -9,7 +9,7 @@ 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)] @@ -46,6 +46,7 @@ pub enum StateCommand { SetProfile { name: String, }, + SetDeviceRules(Vec), } impl StateHandle { @@ -136,6 +137,10 @@ impl StateHandle { 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 fn subscription_count(&self) -> Arc { self.subscription_count.clone() } @@ -152,6 +157,7 @@ pub async fn run_state_engine( ) { let mut subscriptions = SubscriptionTable::default(); let mut watches: HashMap = HashMap::new(); + let mut device_rules: Vec = Vec::new(); loop { tokio::select! { @@ -164,13 +170,51 @@ pub async fn run_state_engine( let Some(cmd) = maybe_cmd else { break; }; - handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).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; }; + // 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(); + + // 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) + }; + + 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 { @@ -188,6 +232,13 @@ pub async fn run_state_engine( 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); @@ -273,6 +324,9 @@ async fn handle_command( guard.profile.active = name; } } + StateCommand::SetDeviceRules(_) => { + // Handled directly in run_state_engine before this function is called. + } } } @@ -399,6 +453,95 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { } } +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") @@ -411,10 +554,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, @@ -423,7 +567,7 @@ 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) diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 119b7af..38c7f81 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -55,7 +55,7 @@ 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, @@ -63,17 +63,30 @@ pub struct Device { 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)] diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 7caa9c6..7744fdf 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -21,7 +21,7 @@ use tracing::{error, info, warn}; use crate::core::config::{Config, ModulesConfig, NotificationsConfig}; use crate::core::state_engine::StateHandle; use crate::core::subscriptions::SubscriptionId; -use crate::core::types::{ModuleLoadState, RuntimeState}; +use crate::core::types::{DeviceRule, MatchCondition, ModuleLoadState, RuntimeState}; use bread_shared::now_unix_ms; pub enum LuaMessage { @@ -275,6 +275,8 @@ impl LuaEngine { .clear(); self.install_api()?; + self.load_device_rules()?; + self.load_profiles()?; self.load_init_and_modules()?; self.run_on_reload(); info!("lua runtime reloaded"); @@ -515,8 +517,14 @@ impl LuaEngine { 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)?; @@ -700,6 +708,13 @@ impl LuaEngine { })?; 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()))?; @@ -835,6 +850,11 @@ impl LuaEngine { 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)?; @@ -907,6 +927,98 @@ impl LuaEngine { 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", false)?; @@ -1796,24 +1908,18 @@ const BUILTIN_DEVICES: &str = r#" local M = bread.module({ name = "bread.devices", version = "1.0.0" }) local rules = {} -local user_patterns = {} -- { { pattern = "...", class = "..." }, ... } local function matches_rule(rule, event) - local class = rule.class local when = rule.when local data = event.data or {} - if when == "connected" and event.event ~= "bread.device.connected" then - if not event.event:match("%.connected$") then - return false - end - elseif when == "disconnected" and event.event ~= "bread.device.disconnected" then - if not event.event:match("%.disconnected$") then - return false - end + 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 class and data.class ~= class then + if rule.device and data.device ~= rule.device then return false end @@ -1832,55 +1938,15 @@ local function run_rule(rule, event) end end --- Reclassify an event's data.class based on user-registered name patterns. --- Called before rule matching so that user-registered patterns take effect --- even for devices that the daemon classified as Unknown. -local function apply_user_patterns(event) - if not event.data then return event end - local name = tostring(event.data.name or ""):lower() - local vendor = tostring(event.data.vendor or ""):lower() - local combined = name .. " " .. vendor - for _, p in ipairs(user_patterns) do - if combined:find(p.pattern, 1, true) then - -- Return a shallow copy with the class overridden so we don't - -- mutate the original event that other handlers may receive. - local patched = {} - for k, v in pairs(event) do patched[k] = v end - patched.data = {} - for k, v in pairs(event.data) do patched.data[k] = v end - patched.data.class = p.class - return patched - end - end - return event -end - function M.on(opts) table.insert(rules, opts) end --- Register a user-defined device pattern so the daemon can correctly classify --- hardware that the automatic classifier doesn't recognise. --- --- Usage: --- local devices = require("bread.devices") --- devices.register("CalDigit", "dock") --- devices.register("Keychron", "keyboard") --- devices.register("MX Master", "mouse") --- --- The pattern is matched case-insensitively against the device name and vendor --- combined. The class must be one of: dock, keyboard, mouse, tablet, display, --- storage, audio, unknown. -function M.register(pattern, class) - table.insert(user_patterns, { pattern = pattern:lower(), class = class }) -end - function M.on_load() bread.on("bread.device.**", function(event) - local patched = apply_user_patterns(event) for _, rule in ipairs(rules) do - if matches_rule(rule, patched) then - run_rule(rule, patched) + if matches_rule(rule, event) then + run_rule(rule, event) end end end) @@ -2018,13 +2084,28 @@ fn builtin_module_decls(disabled: &HashSet) -> Vec { } fn hyprland_request_socket() -> Result { - let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") - .map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?; let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); - Ok(PathBuf::from(runtime) - .join("hypr") - .join(instance) - .join(".socket.sock")) + + 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 { @@ -2039,6 +2120,22 @@ fn hyprland_request(request: &str) -> Result { 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> { let mut out = Vec::new(); if !root.exists() { diff --git a/scripts/install.sh b/scripts/install.sh index 961f792..5440530 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,35 +2,97 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/bin}" +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 "" -# ── install binaries ─────────────────────────────────────────────────────────── -echo "installing binaries to $INSTALL_PREFIX (requires sudo)..." -sudo install -Dm755 "$REPO_ROOT/target/release/breadd" "$INSTALL_PREFIX/breadd" -sudo install -Dm755 "$REPO_ROOT/target/release/bread" "$INSTALL_PREFIX/bread" -echo " installed $INSTALL_PREFIX/breadd" -echo " installed $INSTALL_PREFIX/bread" +# ── 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" -install -Dm644 "$REPO_ROOT/packaging/systemd/breadd.service" "$SERVICE_DIR/breadd.service" -echo " installed $SERVICE_DIR/breadd.service" +# 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 -systemctl --user enable --now breadd -echo " breadd enabled and started" + +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 ───────────────────────────────────────────────────────────────────── sleep 0.5 -if bread ping &>/dev/null; then - echo "" - bread doctor +if "$BIN_DIR/bread" ping &>/dev/null; then + "$BIN_DIR/bread" doctor else - echo "warning: daemon did not respond to ping — check: journalctl --user -u breadd -n 20" + echo "warning: daemon did not respond to ping" + echo " check: journalctl --user -u breadd -n 20" fi From d44ece3649d1d27972432f2f28b091022232b92b Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 12 May 2026 21:27:07 +0800 Subject: [PATCH 30/76] feat: enhance device normalization and classification - Introduced a new mechanism in EventNormalizer to suppress duplicate events from child nodes of the same physical device. - Removed the device classification logic from the normalizer and replaced it with a rule-based system using Lua scripts. - Added support for user-defined device rules in Lua, allowing for flexible device naming based on various conditions. - Updated the state engine to handle device rules and resolve device names before dispatching events. - Modified the installation script to set up default configuration files for the daemon and Lua modules. - Improved the handling of systemd user services to dynamically set the ExecStart path based on the installation directory. --- .gitignore | 32 ++++ Documentation.md | 116 +++++++++++-- README.md | 9 +- bread-cli/src/main.rs | 11 +- breadd/Cargo.toml | 2 +- breadd/src/adapters/hyprland.rs | 35 +++- breadd/src/adapters/udev.rs | 294 +++++++++----------------------- breadd/src/core/normalizer.rs | 190 +++++++-------------- breadd/src/core/state_engine.rs | 160 ++++++++++++++++- breadd/src/core/types.rs | 37 ++-- breadd/src/lua/mod.rs | 217 ++++++++++++++++------- scripts/install.sh | 92 ++++++++-- 12 files changed, 719 insertions(+), 476 deletions(-) diff --git a/.gitignore b/.gitignore index a253843..a92804c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,36 @@ +# 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 LUA_RUNTIME.md diff --git a/Documentation.md b/Documentation.md index fe9ec60..f1a0aca 100644 --- a/Documentation.md +++ b/Documentation.md @@ -471,30 +471,110 @@ monitors.on({ ### `bread.devices` -Device connection rules with class-based matching. +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") --- Register a name pattern → class mapping -devices.register("CalDigit", "dock") -devices.register("Keychron", "keyboard") +devices.on({ + when = "connected", + device = "keyboard", + run = function(event) + bread.exec("xset r rate 200 40") + end, +}) devices.on({ - when = "connected", - class = "keyboard", - run = function(event) + 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, }) ``` -| Function | Description | -|----------|-------------| -| `M.on(opts)` | Register a device rule. `opts`: `when`, `class` (optional), `name` (optional pattern), `run` | -| `M.register(pattern, class)` | Map a device name pattern to a class string | +#### Example: Dock-specific setup -`class` values: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. +```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` @@ -570,12 +650,12 @@ Events are delivered as a `BreadEvent`: | Event | Data | |-------|------| -| `bread.device.connected` | `{ id, class, name, subsystem, vendor_id?, product_id? }` | -| `bread.device.disconnected` | `{ id, class, name, subsystem, vendor_id?, product_id? }` | -| `bread.device..connected` | same | -| `bread.device..disconnected` | same | +| `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 }` | -`class`: `dock`, `keyboard`, `mouse`, `tablet`, `display`, `storage`, `audio`, `unknown`. +`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`. #### Hyprland @@ -641,7 +721,7 @@ Events are delivered as a `BreadEvent`: { "id": "/sys/...", "name": "CalDigit TS4", - "class": "dock", + "device": "dock", "subsystem": "usb", "vendor_id": "0x35f5", "product_id": "0x0104" diff --git a/README.md b/README.md index adbff67..cc1e32c 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ 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 --filter bread.device.* # Stream filtered 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) @@ -319,9 +319,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 | @@ -380,7 +379,7 @@ end) -- Subscribe with a filter predicate bread.filter("bread.device.connected", function(event) - return event.data.class == "keyboard" + return event.data.device == "keyboard" end, function(event) bread.exec("xset r rate 200 40") end) diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index eadd679..ae100c3 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -42,8 +42,8 @@ enum Commands { }, /// Stream live normalized events Events { - #[arg(long)] - filter: Option, + /// Optional glob pattern to filter events (e.g. bread.device.*, bread.**) + pattern: Option, /// Output raw JSON #[arg(long)] json: bool, @@ -169,12 +169,12 @@ async fn main() -> Result<()> { } } Commands::Events { - filter, + pattern, json, fields, since, } => { - stream_events(&socket, filter, json, fields, since).await?; + stream_events(&socket, pattern, json, fields, since).await?; } Commands::Modules { subcommand } => { handle_modules_cmd(subcommand, &socket).await?; @@ -769,8 +769,7 @@ fn run_package_installs(packages_dir: &Path, managers: &[String]) -> Result<()> } "pip" => { let mut cmd = std::process::Command::new("pip"); - cmd.args(["install", "--user", "-r"]) - .arg(file.to_str().unwrap_or("")); + cmd.args(["install", "--user", "-r"]).arg(&file); let _ = cmd.status(); } "npm" => { diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index 4b949be..9b968d9 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -14,7 +14,7 @@ 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" diff --git a/breadd/src/adapters/hyprland.rs b/breadd/src/adapters/hyprland.rs index 2ef3731..c032612 100644 --- a/breadd/src/adapters/hyprland.rs +++ b/breadd/src/adapters/hyprland.rs @@ -48,13 +48,36 @@ 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/udev.rs b/breadd/src/adapters/udev.rs index c3aba56..5af66bc 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,122 +46,106 @@ impl Adapter for UdevAdapter { async fn run(&self, tx: mpsc::Sender) -> Result<()> { debug!("udev adapter started"); - match run_udev_monitor(self.subsystems.clone(), tx.clone()).await { - Ok(()) => return Ok(()), - Err(err) => { - tracing::warn!(error = %err, "udev netlink monitor unavailable, falling back to sysfs polling (add user to 'plugdev' group for real-time events)"); - } - } - - // Fallback: poll sysfs every 2 seconds for environments where the - // netlink socket is unavailable (missing plugdev membership, containers, etc). - let mut known: HashMap = scan_devices(&self.subsystems) - .unwrap_or_default() - .into_iter() - .map(|d| (d.id.clone(), d)) - .collect(); - - loop { - let current = scan_devices(&self.subsystems).unwrap_or_default(); - 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) { - if tx.send(raw_change_event("add", dev)).await.is_err() { - return Ok(()); - } - } - } - - for (id, dev) in &known { - if !current_map.contains_key(id) { - if tx.send(raw_change_event("remove", dev)).await.is_err() { - return Ok(()); - } - } - } - - 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, - vendor_id: Option, - product_id: Option, } +// 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, - "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(), + 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 { @@ -187,125 +165,7 @@ 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(); - let vendor_id = dev - .property_value("ID_VENDOR_ID") - .map(|v| v.to_string_lossy().to_string()); - let product_id = dev - .property_value("ID_MODEL_ID") - .map(|v| v.to_string_lossy().to_string()); - - out.push(ScannedDevice { - id, - name, - subsystem, - vendor_id, - product_id, - }); - } - - 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, - "vendor_id": dev.vendor_id, - "product_id": dev.product_id, - }), - timestamp: now_unix_ms(), - } -} - -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(), - vendor_id: None, - product_id: None, - }); - } - } - } - } - - 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(), - vendor_id: None, - product_id: None, - }); - } - } - } - - 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(), - vendor_id: None, - product_id: None, - }); - } - } - } - - 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()) { - let syspath = entry.path(); - let vendor_id = fs::read_to_string(syspath.join("idVendor")) - .ok() - .map(|s| s.trim().to_string()); - let product_id = fs::read_to_string(syspath.join("idProduct")) - .ok() - .map(|s| s.trim().to_string()); - out.push(ScannedDevice { - id: format!("usb:{name}"), - name, - subsystem: "usb".to_string(), - vendor_id, - product_id, - }); - } - } - } + out.push(ScannedDevice { id, name, subsystem }); } Ok(out) diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index 3eaef88..9c31d41 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()), } } @@ -42,40 +45,75 @@ 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('"', ""); + // "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 { @@ -109,13 +147,13 @@ impl EventNormalizer { event: "bread.monitor.connected".to_string(), timestamp: raw.timestamp, source: AdapterSource::Hyprland, - data: raw.payload.clone(), + data: json!({ "name": data }), }], "monitorremoved" => vec![BreadEvent { event: "bread.monitor.disconnected".to_string(), timestamp: raw.timestamp, source: AdapterSource::Hyprland, - data: raw.payload.clone(), + data: json!({ "name": data }), }], "activewindow" => vec![BreadEvent { event: "bread.window.focus.changed".to_string(), @@ -288,107 +326,3 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> { data.split(">>").collect() } -fn classify_device(payload: &Value) -> DeviceClass { - let subsystem = payload - .get("subsystem") - .and_then(Value::as_str) - .unwrap_or_default() - .to_lowercase(); - - // --- Property-based classification (reliable, hardware-agnostic) --- - - // udev sets ID_INPUT_KEYBOARD=1 for anything that presents as a keyboard HID device. - if payload.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) { - return DeviceClass::Keyboard; - } - - // ID_INPUT_MOUSE=1 covers mice and trackballs. - if payload.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) { - return DeviceClass::Mouse; - } - - // ID_INPUT_TABLET=1 covers drawing tablets (Wacom etc). - if payload.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) { - return DeviceClass::Tablet; - } - - // USB class 0x09 = Hub. Docks expose a hub interface; they also typically - // expose video (0x0e), audio (0x01), and ethernet (CDC 0x02) interfaces. - // We check for hub + at least one of those secondary interfaces. - if let Some(ifaces) = payload.get("id_usb_interfaces").and_then(Value::as_str) { - let ifaces_lc = ifaces.to_lowercase(); - let has_hub = ifaces_lc.contains(":0900") || ifaces_lc.contains(":0902"); - let has_secondary = ifaces_lc.contains(":0e") // video - || ifaces_lc.contains(":0200") // CDC ethernet - || ifaces_lc.contains(":0100") // audio - || ifaces_lc.contains(":0801"); // mass storage - if has_hub && has_secondary { - return DeviceClass::Dock; - } - } - - // USB class 0x01 = Audio. - if let Some(cls) = payload.get("id_usb_class").and_then(Value::as_str) { - if cls == "01" || cls.to_lowercase() == "0x01" { - return DeviceClass::Audio; - } - // USB class 0x08 = Mass Storage. - if cls == "08" || cls.to_lowercase() == "0x08" { - return DeviceClass::Storage; - } - } - - // DRM subsystem = display connector. - if subsystem == "drm" { - return DeviceClass::Display; - } - - // Block devices = storage. - if subsystem == "block" { - return DeviceClass::Storage; - } - - // Sound subsystem = audio. - if subsystem == "sound" { - return DeviceClass::Audio; - } - - // --- Name-based fallback (catches user-registered patterns and obvious names) --- - // This runs last so the property-based rules above always win. - - let name = payload - .get("name") - .and_then(Value::as_str) - .or_else(|| payload.get("id_model").and_then(Value::as_str)) - .unwrap_or_default() - .to_lowercase(); - - let vendor = payload - .get("id_vendor") - .and_then(Value::as_str) - .unwrap_or_default() - .to_lowercase(); - - let combined = format!("{name} {vendor}"); - - if combined.contains("dock") || combined.contains("hub") || combined.contains("thunderbolt") { - return DeviceClass::Dock; - } - if combined.contains("keyboard") || combined.contains("kbd") { - return DeviceClass::Keyboard; - } - if combined.contains("mouse") || combined.contains("trackball") || combined.contains("trackpoint") { - return DeviceClass::Mouse; - } - if combined.contains("tablet") || combined.contains("wacom") || combined.contains("stylus") { - return DeviceClass::Tablet; - } - if combined.contains("audio") || combined.contains("headset") || combined.contains("speaker") || combined.contains("dac") { - return DeviceClass::Audio; - } - if combined.contains("storage") || combined.contains("drive") || combined.contains("flash") || combined.contains("disk") { - return DeviceClass::Storage; - } - - DeviceClass::Unknown -} diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index 6e69e6a..784a0e9 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -9,7 +9,7 @@ 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)] @@ -46,6 +46,7 @@ pub enum StateCommand { SetProfile { name: String, }, + SetDeviceRules(Vec), } impl StateHandle { @@ -136,6 +137,10 @@ impl StateHandle { 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 fn subscription_count(&self) -> Arc { self.subscription_count.clone() } @@ -152,6 +157,7 @@ pub async fn run_state_engine( ) { let mut subscriptions = SubscriptionTable::default(); let mut watches: HashMap = HashMap::new(); + let mut device_rules: Vec = Vec::new(); loop { tokio::select! { @@ -164,13 +170,51 @@ pub async fn run_state_engine( let Some(cmd) = maybe_cmd else { break; }; - handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).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; }; + // 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(); + + // 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) + }; + + 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 { @@ -188,6 +232,13 @@ pub async fn run_state_engine( 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); @@ -273,6 +324,9 @@ async fn handle_command( guard.profile.active = name; } } + StateCommand::SetDeviceRules(_) => { + // Handled directly in run_state_engine before this function is called. + } } } @@ -399,6 +453,95 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { } } +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") @@ -411,10 +554,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, @@ -423,7 +567,7 @@ 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) diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs index 119b7af..38c7f81 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -55,7 +55,7 @@ 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, @@ -63,17 +63,30 @@ pub struct Device { 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)] diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index 7caa9c6..7744fdf 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -21,7 +21,7 @@ use tracing::{error, info, warn}; use crate::core::config::{Config, ModulesConfig, NotificationsConfig}; use crate::core::state_engine::StateHandle; use crate::core::subscriptions::SubscriptionId; -use crate::core::types::{ModuleLoadState, RuntimeState}; +use crate::core::types::{DeviceRule, MatchCondition, ModuleLoadState, RuntimeState}; use bread_shared::now_unix_ms; pub enum LuaMessage { @@ -275,6 +275,8 @@ impl LuaEngine { .clear(); self.install_api()?; + self.load_device_rules()?; + self.load_profiles()?; self.load_init_and_modules()?; self.run_on_reload(); info!("lua runtime reloaded"); @@ -515,8 +517,14 @@ impl LuaEngine { 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)?; @@ -700,6 +708,13 @@ impl LuaEngine { })?; 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()))?; @@ -835,6 +850,11 @@ impl LuaEngine { 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)?; @@ -907,6 +927,98 @@ impl LuaEngine { 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", false)?; @@ -1796,24 +1908,18 @@ const BUILTIN_DEVICES: &str = r#" local M = bread.module({ name = "bread.devices", version = "1.0.0" }) local rules = {} -local user_patterns = {} -- { { pattern = "...", class = "..." }, ... } local function matches_rule(rule, event) - local class = rule.class local when = rule.when local data = event.data or {} - if when == "connected" and event.event ~= "bread.device.connected" then - if not event.event:match("%.connected$") then - return false - end - elseif when == "disconnected" and event.event ~= "bread.device.disconnected" then - if not event.event:match("%.disconnected$") then - return false - end + 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 class and data.class ~= class then + if rule.device and data.device ~= rule.device then return false end @@ -1832,55 +1938,15 @@ local function run_rule(rule, event) end end --- Reclassify an event's data.class based on user-registered name patterns. --- Called before rule matching so that user-registered patterns take effect --- even for devices that the daemon classified as Unknown. -local function apply_user_patterns(event) - if not event.data then return event end - local name = tostring(event.data.name or ""):lower() - local vendor = tostring(event.data.vendor or ""):lower() - local combined = name .. " " .. vendor - for _, p in ipairs(user_patterns) do - if combined:find(p.pattern, 1, true) then - -- Return a shallow copy with the class overridden so we don't - -- mutate the original event that other handlers may receive. - local patched = {} - for k, v in pairs(event) do patched[k] = v end - patched.data = {} - for k, v in pairs(event.data) do patched.data[k] = v end - patched.data.class = p.class - return patched - end - end - return event -end - function M.on(opts) table.insert(rules, opts) end --- Register a user-defined device pattern so the daemon can correctly classify --- hardware that the automatic classifier doesn't recognise. --- --- Usage: --- local devices = require("bread.devices") --- devices.register("CalDigit", "dock") --- devices.register("Keychron", "keyboard") --- devices.register("MX Master", "mouse") --- --- The pattern is matched case-insensitively against the device name and vendor --- combined. The class must be one of: dock, keyboard, mouse, tablet, display, --- storage, audio, unknown. -function M.register(pattern, class) - table.insert(user_patterns, { pattern = pattern:lower(), class = class }) -end - function M.on_load() bread.on("bread.device.**", function(event) - local patched = apply_user_patterns(event) for _, rule in ipairs(rules) do - if matches_rule(rule, patched) then - run_rule(rule, patched) + if matches_rule(rule, event) then + run_rule(rule, event) end end end) @@ -2018,13 +2084,28 @@ fn builtin_module_decls(disabled: &HashSet) -> Vec { } fn hyprland_request_socket() -> Result { - let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") - .map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE is not set"))?; let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); - Ok(PathBuf::from(runtime) - .join("hypr") - .join(instance) - .join(".socket.sock")) + + 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 { @@ -2039,6 +2120,22 @@ fn hyprland_request(request: &str) -> Result { 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> { let mut out = Vec::new(); if !root.exists() { diff --git a/scripts/install.sh b/scripts/install.sh index 961f792..5440530 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,35 +2,97 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/bin}" +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 "" -# ── install binaries ─────────────────────────────────────────────────────────── -echo "installing binaries to $INSTALL_PREFIX (requires sudo)..." -sudo install -Dm755 "$REPO_ROOT/target/release/breadd" "$INSTALL_PREFIX/breadd" -sudo install -Dm755 "$REPO_ROOT/target/release/bread" "$INSTALL_PREFIX/bread" -echo " installed $INSTALL_PREFIX/breadd" -echo " installed $INSTALL_PREFIX/bread" +# ── 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" -install -Dm644 "$REPO_ROOT/packaging/systemd/breadd.service" "$SERVICE_DIR/breadd.service" -echo " installed $SERVICE_DIR/breadd.service" +# 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 -systemctl --user enable --now breadd -echo " breadd enabled and started" + +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 ───────────────────────────────────────────────────────────────────── sleep 0.5 -if bread ping &>/dev/null; then - echo "" - bread doctor +if "$BIN_DIR/bread" ping &>/dev/null; then + "$BIN_DIR/bread" doctor else - echo "warning: daemon did not respond to ping — check: journalctl --user -u breadd -n 20" + echo "warning: daemon did not respond to ping" + echo " check: journalctl --user -u breadd -n 20" fi From 425b746780222c4fac61d2bf0fc3af4799818b46 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 13 May 2026 22:01:42 +0800 Subject: [PATCH 31/76] Final Release of Version 1.0 --- .gitignore | 1 - Cargo.lock | 10 +- README.md | 14 +- bread-cli/Cargo.toml | 2 +- bread-cli/src/main.rs | 66 ++- bread-cli/src/modules_mgmt.rs | 24 +- bread-cli/tests/modules.rs | 14 +- bread-shared/Cargo.toml | 2 +- bread-shared/src/lib.rs | 166 ++++++ bread-sync/Cargo.toml | 2 +- bread-sync/src/config.rs | 122 +++++ bread-sync/src/delegates.rs | 140 ++++- bread-sync/src/git.rs | 4 +- bread-sync/src/machine.rs | 88 +++ bread-sync/src/packages.rs | 127 ++++- bread-sync/tests/sync.rs | 235 +++++++- breadd/Cargo.toml | 4 +- breadd/src/adapters/hyprland.rs | 5 +- breadd/src/adapters/mod.rs | 12 +- breadd/src/adapters/network_rtnetlink.rs | 68 ++- breadd/src/adapters/power_upower.rs | 2 +- breadd/src/adapters/udev.rs | 6 +- breadd/src/core/config.rs | 266 ++++++++- breadd/src/core/normalizer.rs | 473 +++++++++++++++- breadd/src/core/state_engine.rs | 517 +++++++++++++++-- breadd/src/core/subscriptions.rs | 145 ++++- breadd/src/core/supervisor.rs | 9 +- breadd/src/core/types.rs | 30 +- breadd/src/ipc/mod.rs | 110 +++- breadd/src/lua/mod.rs | 671 ++++++++++++----------- breadd/src/main.rs | 10 +- breadd/tests/ipc_integration.rs | 308 +++++++++++ packaging/arch/PKGBUILD | 26 +- scripts/install.sh | 17 +- 34 files changed, 3129 insertions(+), 567 deletions(-) diff --git a/.gitignore b/.gitignore index a92804c..acf737f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,3 @@ LUA_RUNTIME.md CLAUDE_SPEC.md .claude CLAUDE.md -.github diff --git a/Cargo.lock b/Cargo.lock index c04bd41..52ab50b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,7 +305,7 @@ dependencies = [ [[package]] name = "bread-cli" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "bread-shared", @@ -327,7 +327,7 @@ dependencies = [ [[package]] name = "bread-shared" -version = "0.1.0" +version = "1.0.0" dependencies = [ "serde", "serde_json", @@ -335,7 +335,7 @@ dependencies = [ [[package]] name = "bread-sync" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "chrono", @@ -351,13 +351,13 @@ dependencies = [ [[package]] name = "breadd" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "async-trait", "bread-shared", + "bread-sync", "futures-util", - "hex", "libc", "mlua", "netlink-packet-core", diff --git a/README.md b/README.md index cc1e32c..c9b3d67 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,13 @@ git clone https://github.com/Breadway/bread.git cd bread ``` -Run the install script — it builds, installs to `/usr/bin`, sets up the systemd user service, and starts the daemon: +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 bash scripts/install.sh ``` -Or step by step: +Or step by step (system-wide install): ```bash cargo build --release @@ -377,12 +377,14 @@ bread.once("bread.system.startup", function(event) bread.profile.activate("default") end) --- Subscribe with a filter predicate +-- Subscribe with a filter predicate. The predicate goes in an opts table. bread.filter("bread.device.connected", function(event) - return event.data.device == "keyboard" -end, function(event) bread.exec("xset r rate 200 40") -end) +end, { + filter = function(event) + return event.data.device == "keyboard" + end, +}) -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 7d40088..1e4b667 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-cli" -version = "0.1.0" +version = "1.0.0" edition = "2021" [[bin]] diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index ae100c3..0e1b4a2 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -3,8 +3,7 @@ mod modules_mgmt; use anyhow::{Context, Result}; use bread_sync::{ config::{bread_config_dir, SyncConfig}, - delegates, machine, packages, - SyncRepo, + delegates, machine, packages, SyncRepo, }; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; @@ -18,7 +17,11 @@ 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, @@ -234,8 +237,7 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { match cmd { ModulesCommand::Install { source } => { - let manifest = - install_module(&source, &mods_dir).await?; + let manifest = install_module(&source, &mods_dir).await?; println!("installed {} v{}", manifest.name, manifest.version); try_daemon_reload(socket).await; } @@ -283,7 +285,10 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { .get(&m.name) .map(String::as_str) .unwrap_or("unknown"); - println!(" {:20} {:10} {:10} {}", m.name, m.version, status, m.source); + println!( + " {:20} {:10} {:10} {}", + m.name, m.version, status, m.source + ); } } @@ -298,12 +303,14 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { 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?; + 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); + println!( + "updated {} v{} → v{}", + manifest.name, old_ver, new_manifest.version + ); updated_any = true; } } else { @@ -352,9 +359,11 @@ async fn install_module( 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 - } + modules_mgmt::InstallSource::GitHub { + user, + repo, + git_ref, + } => install_from_github(&user, &repo, git_ref.as_deref(), source, mods_dir).await, } } @@ -388,8 +397,7 @@ async fn install_from_github( } }; - let tarball_url = - format!("https://api.github.com/repos/{user}/{repo}/tarball/{ref_to_use}"); + let tarball_url = format!("https://api.github.com/repos/{user}/{repo}/tarball/{ref_to_use}"); let bytes = client .get(&tarball_url) .send() @@ -400,8 +408,7 @@ async fn install_from_github( .context("failed to read module archive")?; let tmp = tempfile::tempdir()?; - let mut archive = - tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..])); + 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/") @@ -552,7 +559,10 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option) -> Result<()> { 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 {} failed: {}", manager, e); + eprintln!( + "bread: warning: package snapshot for {} failed: {}", + manager, e + ); } } } @@ -631,9 +641,11 @@ async fn cmd_sync_pull(cfg_dir: &Path, install_packages: bool, socket: &Path) -> 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() - }); + 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" @@ -848,9 +860,12 @@ async fn stream_events( since: Option, ) -> Result<()> { if let Some(seconds) = since { - let replay = - send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })) - .await?; + 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 { @@ -1039,10 +1054,7 @@ fn render_doctor(health: &Value) { .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 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 {})", diff --git a/bread-cli/src/modules_mgmt.rs b/bread-cli/src/modules_mgmt.rs index 17c0a7b..942ad29 100644 --- a/bread-cli/src/modules_mgmt.rs +++ b/bread-cli/src/modules_mgmt.rs @@ -60,13 +60,14 @@ pub fn parse_source(source: &str) -> Result { /// 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 { +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() - ); + bail!("bread: no bread.module.toml found in {}", src.display()); } let raw = fs::read_to_string(&manifest_path) @@ -136,8 +137,8 @@ pub fn read_module_manifest(name: &str, modules_dir: &Path) -> Result Result { - let raw = fs::read_to_string(path) - .with_context(|| format!("failed to read {}", path.display()))?; + 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") } @@ -167,8 +168,13 @@ fn copy_dir(src: &Path, dst: &Path) -> Result<()> { 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()))?; + 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 index d05374c..74022fe 100644 --- a/bread-cli/tests/modules.rs +++ b/bread-cli/tests/modules.rs @@ -28,8 +28,7 @@ fn install_from_local_succeeds_with_manifest() { 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()); + 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(); @@ -38,7 +37,11 @@ fn install_from_local_succeeds_with_manifest() { // 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("bread.module.toml") + .exists()); assert!(modules_tmp.path().join("mymod").join("init.lua").exists()); } @@ -79,7 +82,10 @@ fn remove_nonexistent_errors() { 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}"); + assert!( + msg.contains("ghost"), + "expected error mentioning module name, got: {msg}" + ); } #[test] 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..3f43385 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -1,32 +1,71 @@ +//! 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, } +/// 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 +76,136 @@ 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\"" + ); + } + + #[test] + fn adapter_source_round_trips_through_json() { + for source in [ + AdapterSource::Hyprland, + AdapterSource::Udev, + AdapterSource::Power, + AdapterSource::Network, + AdapterSource::System, + ] { + 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); + assert_eq!(set.len(), 2); + assert!(set.contains(&AdapterSource::Hyprland)); + } +} diff --git a/bread-sync/Cargo.toml b/bread-sync/Cargo.toml index 232b592..15bb845 100644 --- a/bread-sync/Cargo.toml +++ b/bread-sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-sync" -version = "0.1.0" +version = "1.0.0" edition = "2021" [dependencies] diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs index 55a8dd3..606a637 100644 --- a/bread-sync/src/config.rs +++ b/bread-sync/src/config.rs @@ -133,3 +133,125 @@ pub fn expand_path(path: &str) -> PathBuf { } 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(&"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 index 2c59792..815e87b 100644 --- a/bread-sync/src/delegates.rs +++ b/bread-sync/src/delegates.rs @@ -23,7 +23,11 @@ fn sync_dir_inner(src: &Path, dst: &Path, root: &Path, patterns: &[Pattern]) -> 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 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(); @@ -107,3 +111,137 @@ pub fn resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> { }) .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/git.rs b/bread-sync/src/git.rs index a3740f8..d8f04af 100644 --- a/bread-sync/src/git.rs +++ b/bread-sync/src/git.rs @@ -201,9 +201,7 @@ impl SyncRepo { 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) - { + 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) diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs index 325ef5a..6044d09 100644 --- a/bread-sync/src/machine.rs +++ b/bread-sync/src/machine.rs @@ -77,3 +77,91 @@ pub fn hostname() -> String { .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 index 96ad7b3..59f8e4c 100644 --- a/bread-sync/src/packages.rs +++ b/bread-sync/src/packages.rs @@ -19,10 +19,7 @@ pub fn snapshot(manager: &str, dest: &Path) -> Result { }; let Some(content) = content else { - eprintln!( - "bread: package manager '{}' not found, skipping", - manager - ); + eprintln!("bread: package manager '{}' not found, skipping", manager); return Ok(false); }; @@ -86,18 +83,15 @@ 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() - }) + .map(|l| l.split_whitespace().next().unwrap_or(l).to_string()) .collect() } 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(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()), @@ -127,7 +121,9 @@ fn run_npm() -> Result> { .args(["list", "-g", "--depth=0", "--parseable"]) .output() { - Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())), + 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()), @@ -136,9 +132,114 @@ fn run_npm() -> Result> { 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(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 index 484120c..0cc2dc9 100644 --- a/bread-sync/tests/sync.rs +++ b/bread-sync/tests/sync.rs @@ -84,7 +84,8 @@ fn sync_push_creates_correct_directory_structure() { // Init local sync repo let repo = SyncRepo::init(repo_tmp.path()).unwrap(); - repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) + .unwrap(); // Snapshot bread dir let bread_dest = repo_tmp.path().join("bread"); @@ -102,7 +103,11 @@ fn sync_push_creates_correct_directory_structure() { // 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()); + assert!(repo_tmp + .path() + .join("machines") + .join("testbox.toml") + .exists()); } #[test] @@ -123,7 +128,8 @@ fn sync_push_snapshots_bread_config() { .unwrap(); let repo = SyncRepo::init(repo_tmp.path()).unwrap(); - repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).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(); @@ -149,7 +155,8 @@ fn sync_pull_copies_files_from_repo() { // 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(); + 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(); @@ -160,7 +167,8 @@ fn sync_pull_copies_files_from_repo() { // 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(); + 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"); @@ -255,3 +263,220 @@ fn push_with_no_changes_returns_none() { 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 9b968d9..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 @@ -17,7 +18,6 @@ toml = "0.8" 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/hyprland.rs b/breadd/src/adapters/hyprland.rs index c032612..2c4a47b 100644 --- a/breadd/src/adapters/hyprland.rs +++ b/breadd/src/adapters/hyprland.rs @@ -71,7 +71,10 @@ fn hyprland_event_socket() -> Result { .collect(); match sockets.len() { - 0 => Err(anyhow!("no Hyprland instance found in {}", hypr_dir.display())), + 0 => Err(anyhow!( + "no Hyprland instance found in {}", + hypr_dir.display() + )), 1 => Ok(sockets.remove(0)), n => { warn!("found {n} Hyprland instances, using first"); diff --git a/breadd/src/adapters/mod.rs b/breadd/src/adapters/mod.rs index b33569b..c4915c1 100644 --- a/breadd/src/adapters/mod.rs +++ b/breadd/src/adapters/mod.rs @@ -1,21 +1,21 @@ use anyhow::Result; use async_trait::async_trait; use bread_shared::RawEvent; -use tokio::sync::{mpsc, watch, RwLock}; -use tracing::info; 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 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")] @@ -71,7 +71,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 { @@ -92,7 +92,7 @@ impl Manager { if let Ok(adapter) = rt { self.spawn_adapter(adapter); } else { - self.spawn_adapter(network::NetworkAdapter::default()); + self.spawn_adapter(network::NetworkAdapter); } } 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 5af66bc..8142980 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -165,7 +165,11 @@ 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, subsystem }); + out.push(ScannedDevice { + id, + name, + subsystem, + }); } Ok(out) diff --git a/breadd/src/core/config.rs b/breadd/src/core/config.rs index 1c756a9..4a78321 100644 --- a/breadd/src/core/config.rs +++ b/breadd/src/core/config.rs @@ -5,7 +5,7 @@ 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, @@ -45,7 +45,7 @@ pub struct ModulesConfig { pub disable: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] pub struct AdaptersConfig { #[serde(default)] pub hyprland: AdapterToggle, @@ -95,19 +95,6 @@ pub struct NotificationsConfig { pub notify_send_path: String, } -impl Default for Config { - fn default() -> Self { - Self { - daemon: DaemonConfig::default(), - lua: LuaConfig::default(), - modules: ModulesConfig::default(), - adapters: AdaptersConfig::default(), - notifications: NotificationsConfig::default(), - events: EventsConfig::default(), - } - } -} - impl Default for DaemonConfig { fn default() -> Self { Self { @@ -135,17 +122,6 @@ impl Default for ModulesConfig { } } -impl Default for AdaptersConfig { - fn default() -> Self { - Self { - hyprland: AdapterToggle::default(), - udev: UdevConfig::default(), - power: PowerConfig::default(), - network: AdapterToggle::default(), - } - } -} - impl Default for AdapterToggle { fn default() -> Self { Self { @@ -281,3 +257,241 @@ 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_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 + +[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_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 9c31d41..49e071d 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -44,7 +44,11 @@ impl EventNormalizer { } fn normalize_udev(&self, raw: &RawEvent) -> Vec { - let action = raw.payload.get("action").and_then(Value::as_str).unwrap_or("change"); + 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. @@ -52,11 +56,31 @@ impl EventNormalizer { 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(); + 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. @@ -74,7 +98,10 @@ impl EventNormalizer { _ => "changed", }; - if (verb == "connected" || verb == "disconnected") && !vendor_id.is_empty() && !product_id.is_empty() { + 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 = { @@ -89,13 +116,18 @@ impl EventNormalizer { 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)); + 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"); + 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. @@ -117,7 +149,11 @@ impl EventNormalizer { } fn normalize_hyprland(&self, raw: &RawEvent) -> Vec { - let kind = raw.payload.get("kind").and_then(Value::as_str).unwrap_or("unknown"); + let kind = raw + .payload + .get("kind") + .and_then(Value::as_str) + .unwrap_or("unknown"); let data = raw .payload .get("data") @@ -168,7 +204,7 @@ impl EventNormalizer { timestamp: raw.timestamp, source: AdapterSource::Hyprland, data: json!({ - "address": fields.get(0).unwrap_or(&"") + "address": fields.first().unwrap_or(&"") }), }] } @@ -179,7 +215,7 @@ impl EventNormalizer { timestamp: raw.timestamp, source: AdapterSource::Hyprland, data: json!({ - "address": fields.get(0).unwrap_or(&""), + "address": fields.first().unwrap_or(&""), "workspace": fields.get(1).unwrap_or(&""), "class": fields.get(2).unwrap_or(&""), "title": fields.get(3).unwrap_or(&""), @@ -192,7 +228,7 @@ impl EventNormalizer { event: "bread.window.closed".to_string(), timestamp: raw.timestamp, source: AdapterSource::Hyprland, - data: json!({ "address": fields.get(0).unwrap_or(&"") }), + data: json!({ "address": fields.first().unwrap_or(&"") }), }] } "movewindow" => { @@ -202,7 +238,7 @@ impl EventNormalizer { timestamp: raw.timestamp, source: AdapterSource::Hyprland, data: json!({ - "address": fields.get(0).unwrap_or(&""), + "address": fields.first().unwrap_or(&""), "workspace": fields.get(1).unwrap_or(&""), }), }] @@ -268,7 +304,11 @@ impl EventNormalizer { } 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 { @@ -310,7 +350,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); } @@ -326,3 +367,403 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> { 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")); + } + + // ─── 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"]); + } +} diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index 784a0e9..2ed7006 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; use anyhow::Result; use bread_shared::{AdapterSource, BreadEvent}; @@ -9,14 +9,15 @@ use tokio::sync::{broadcast, mpsc, watch, RwLock}; use tracing::warn; use crate::core::subscriptions::{SubscriptionId, SubscriptionTable}; -use crate::core::types::{Device, DeviceRule, InterfaceState, MatchCondition, ModuleLoadState, RuntimeState}; +use crate::core::types::{ + Device, DeviceRule, InterfaceState, MatchCondition, ModuleLoadState, RuntimeState, +}; use crate::lua::LuaMessage; #[derive(Clone)] pub struct StateHandle { state: Arc>, command_tx: mpsc::UnboundedSender, - subscription_count: Arc, } pub enum StateCommand { @@ -53,13 +54,8 @@ impl StateHandle { pub fn new( state: Arc>, command_tx: mpsc::UnboundedSender, - subscription_count: Arc, ) -> Self { - Self { - state, - command_tx, - subscription_count, - } + Self { state, command_tx } } pub fn state_arc(&self) -> Arc> { @@ -86,18 +82,21 @@ 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 }); + let _ = self + .command_tx + .send(StateCommand::RemoveSubscription { id }); } pub fn register_watch(&self, id: SubscriptionId, path: String) -> Result<()> { @@ -140,10 +139,6 @@ impl StateHandle { pub fn set_device_rules(&self, rules: Vec) { let _ = self.command_tx.send(StateCommand::SetDeviceRules(rules)); } - - pub fn subscription_count(&self) -> Arc { - self.subscription_count.clone() - } } pub async fn run_state_engine( @@ -376,8 +371,16 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { 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), }); } } @@ -403,7 +406,7 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { .data .get("window") .or_else(|| event.data.get("class")) - .or_else(|| event.data.get("address")) + .or_else(|| event.data.get("address")) .and_then(Value::as_str) .map(ToString::to_string); } @@ -421,7 +424,10 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { state.network.interfaces.clear(); for (name, meta) in ifaces { let up = meta.get("up").and_then(Value::as_bool).unwrap_or(false); - state.network.interfaces.insert(name.clone(), InterfaceState { up }); + state + .network + .interfaces + .insert(name.clone(), InterfaceState { up }); } } } @@ -455,7 +461,8 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { 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)) { + if !rule.conditions.is_empty() && rule.conditions.iter().all(|c| condition_matches(c, data)) + { return rule.device.clone(); } } @@ -476,37 +483,68 @@ fn condition_matches(cond: &MatchCondition, data: &Value) -> bool { } } if let Some(ref expected) = cond.name { - let actual = data.get("name").and_then(Value::as_str).unwrap_or("").to_lowercase(); + 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(); + 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 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 { + 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 { + 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 { + if data + .get("id_input_tablet") + .and_then(Value::as_bool) + .unwrap_or(false) + != expected + { return false; } } @@ -526,7 +564,10 @@ fn condition_matches(cond: &MatchCondition, data: &Value) -> bool { } } if let Some(ref expected) = cond.id_usb_class { - let actual = data.get("id_usb_class").and_then(Value::as_str).unwrap_or(""); + 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()) { @@ -534,7 +575,11 @@ fn condition_matches(cond: &MatchCondition, data: &Value) -> bool { } } if let Some(ref expected) = cond.subsystem { - let actual = data.get("subsystem").and_then(Value::as_str).unwrap_or("").to_lowercase(); + let actual = data + .get("subsystem") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); if actual != expected.to_lowercase() { return false; } @@ -586,3 +631,407 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) 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 9b218ff..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 }; @@ -129,24 +134,36 @@ fn matches_glob(pattern: &[u8], text: &[u8]) -> bool { #[cfg(test)] mod tests { - use super::matches_pattern; + 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")); + 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.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.device.**", + "bread.device.dock.connected" + )); assert!(matches_pattern("bread.**", "bread.device.dock.connected")); assert!(matches_pattern("bread.**", "bread")); } @@ -157,4 +174,120 @@ mod tests { 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 38c7f81..ad03fa4 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -3,7 +3,7 @@ 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, @@ -16,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, @@ -100,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, diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index 25fe66c..e9ef497 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -3,9 +3,9 @@ use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::process; -use std::time::Instant; +use std::sync::atomic::AtomicU64; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; use anyhow::{anyhow, Result}; use bread_shared::{now_unix_ms, AdapterSource, BreadEvent}; @@ -52,6 +52,9 @@ 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, @@ -161,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 })), @@ -208,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())); }; @@ -231,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!({})); @@ -253,7 +251,9 @@ impl Server { 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 subscription_count = self + .subscription_count + .load(std::sync::atomic::Ordering::Relaxed); let recent_errors = self.lua_runtime.recent_errors(); Ok(json!({ "ok": true, @@ -268,14 +268,7 @@ impl Server { })) } "sync.status" => { - let cfg_home = 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 sync_path = cfg_home.join("bread").join("sync.toml"); + 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()) @@ -301,7 +294,11 @@ impl Server { } } "events.replay" => { - let since_ms = req.params.get("since_ms").and_then(Value::as_u64).unwrap_or(0); + 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 @@ -412,3 +409,70 @@ fn matches_glob_filter(pattern: &[u8], text: &[u8]) -> bool { } } } + +#[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 7744fdf..caf49df 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -9,7 +9,6 @@ use std::time::Duration; use anyhow::{anyhow, Result}; use bread_shared::{AdapterSource, BreadEvent}; -use libc; use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value}; use serde::Serialize; use serde_json::Value as JsonValue; @@ -291,62 +290,66 @@ impl LuaEngine { 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_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) - })?; + 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 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) - })?; + 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 handlers = self.handlers.clone(); @@ -411,25 +414,27 @@ impl LuaEngine { 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(()) - })?; + 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| { - state_value_to_lua(lua, &state_arc, &path) - })?; + 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(); @@ -439,9 +444,9 @@ impl LuaEngine { 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"))?; + 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(); @@ -479,38 +484,40 @@ impl LuaEngine { 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) - })?; + 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)?; @@ -555,130 +562,134 @@ impl LuaEngine { 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 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 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, - }), - )); + let _ = emit_tx.send(BreadEvent::new( + "bread.notify.sent", + AdapterSource::System, + serde_json::json!({ + "title": title, + "message": message, + "urgency": urgency, + }), + )); - Ok(()) - })?; + 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 }); + 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() => {} } - } - _ = cancel_rx.changed() => {} - } - }); - Ok(id.0) - })?; + }); + 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; + 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) - })?; + }); + Ok(id.0) + })?; bread.set("every", every_fn)?; let timers = self.timers.clone(); @@ -694,18 +705,22 @@ impl LuaEngine { 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) - })?; + 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) - })?; + 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| { @@ -718,38 +733,38 @@ impl LuaEngine { 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()))?; + 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()))?; + 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()))?; + 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()))?; + 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())) })?; @@ -759,33 +774,33 @@ impl LuaEngine { 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) - })?; + 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)?; @@ -800,7 +815,9 @@ impl LuaEngine { .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")); + return Err(LuaError::external( + "module name does not match current load", + )); } let decl = module_decls @@ -834,7 +851,7 @@ impl LuaEngine { let set_fn = lua.create_function(move |lua, (key, value): (String, Value)| { let json = lua .from_value::(value) - .unwrap_or_else(|_| JsonValue::Null); + .unwrap_or(JsonValue::Null); module_store_set(&state_arc_set, &module_name, key, json); Ok(()) })?; @@ -845,10 +862,7 @@ impl LuaEngine { modules .lock() .map_err(|_| LuaError::external("module registry lock poisoned"))? - .insert( - decl.name.clone(), - ModuleInfo { table_key: key }, - ); + .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")?; @@ -862,9 +876,9 @@ impl LuaEngine { // 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()) - })?; + 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, ()| { @@ -877,9 +891,9 @@ impl LuaEngine { })?; machine_tbl.set("tags", tags_fn)?; - let has_tag_fn = self.lua.create_function(|_lua, tag: String| { - Ok(lua_machine_tags().contains(&tag)) - })?; + 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)?; @@ -887,15 +901,16 @@ impl LuaEngine { // 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())) - })?; + 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| { @@ -907,9 +922,9 @@ impl LuaEngine { })?; fs_tbl.set("read", read_fn)?; - let exists_fn = self.lua.create_function(|_lua, path: String| { - Ok(lua_expand_path(&path).exists()) - })?; + 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| { @@ -1025,18 +1040,16 @@ impl LuaEngine { let mut files = list_lua_files(&self.module_path)?; files.sort(); - let disabled: HashSet = self - .modules_config - .disable - .iter() - .cloned() - .collect(); + 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)) { + 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) => { @@ -1130,7 +1143,10 @@ impl LuaEngine { } 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(()) } @@ -1371,7 +1387,9 @@ impl LuaEngine { // // 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#" + self.lua + .load( + r#" local _bread = bread local function stringify(v) @@ -1392,7 +1410,9 @@ impl LuaEngine { function _bread.error(msg) _bread.__log_error(stringify(msg)) end - "#).exec()?; + "#, + ) + .exec()?; // Register the raw Rust-backed log functions that the Lua wrappers call. let globals = self.lua.globals(); @@ -1429,7 +1449,9 @@ impl LuaEngine { // // Because the Lua runtime is single-threaded, we implement this in // pure Lua using bread.cancel / bread.after. - self.lua.load(r#" + self.lua + .load( + r#" function bread.debounce(delay_ms, fn) local timer_id = nil return function(...) @@ -1444,7 +1466,9 @@ impl LuaEngine { end) end end - "#).exec()?; + "#, + ) + .exec()?; Ok(()) } @@ -1476,7 +1500,8 @@ impl LuaEngine { let bread = lua.create_table()?; bread.set("module", module_fn)?; lua.globals().set("bread", bread)?; - lua.load(r#" + 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) @@ -1486,10 +1511,15 @@ impl LuaEngine { return _noop_tbl end }) - "#).exec()?; + "#, + ) + .exec()?; let src = fs::read_to_string(path)?; - let result = lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec(); + 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 { @@ -1515,8 +1545,7 @@ impl LuaEngine { return Ok(Value::Nil); } - let src = fs::read_to_string(&path) - .map_err(|e| LuaError::external(e.to_string()))?; + 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()) @@ -1529,8 +1558,9 @@ impl LuaEngine { let bread: Table = globals.get("bread")?; bread.set("__require_loader", loader)?; - self.lua.load( - r#" + self.lua + .load( + r#" local searchers = package.searchers or package.loaders if searchers then table.insert(searchers, 1, function(name) @@ -1538,8 +1568,8 @@ impl LuaEngine { end) end "#, - ) - .exec()?; + ) + .exec()?; Ok(()) } @@ -1664,10 +1694,7 @@ fn order_module_decls(decls: Vec) -> (Vec, Vec<(String, 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('/', "."); + let mut name = rel.with_extension("").to_string_lossy().replace('/', "."); if name.starts_with('.') { name.remove(0); } @@ -1697,8 +1724,8 @@ fn state_value_to_lua<'lua>( } std::hint::spin_loop(); }; - let mut value = serde_json::to_value(&*snapshot) - .map_err(|e| LuaError::external(e.to_string()))?; + 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) @@ -1714,7 +1741,11 @@ fn state_value_to_lua<'lua>( .map_err(|e| LuaError::external(e.to_string())) } -fn module_store_get(state_arc: &Arc>, module: &str, key: &str) -> Option { +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; @@ -1725,7 +1756,12 @@ fn module_store_get(state_arc: &Arc>, module: &str, key: &s entry.store.get(key).cloned() } -fn module_store_set(state_arc: &Arc>, module: &str, key: String, value: JsonValue) { +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; @@ -1824,9 +1860,7 @@ fn lua_machine_tags() -> 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")) - }) + .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)?; @@ -2102,7 +2136,10 @@ fn hyprland_request_socket() -> Result { .collect(); match sockets.len() { - 0 => Err(anyhow!("no Hyprland instance found in {}", hypr_dir.display())), + 0 => Err(anyhow!( + "no Hyprland instance found in {}", + hypr_dir.display() + )), 1 => Ok(sockets.remove(0)), _ => Ok(sockets.remove(0)), } diff --git a/breadd/src/main.rs b/breadd/src/main.rs index bcb4daa..809c879 100644 --- a/breadd/src/main.rs +++ b/breadd/src/main.rs @@ -4,8 +4,8 @@ mod ipc; mod lua; use std::collections::VecDeque; -use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::sync::Arc; use anyhow::Result; use bread_shared::{AdapterSource, BreadEvent, RawEvent}; @@ -36,9 +36,10 @@ async fn main() -> Result<()> { 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, subscription_count.clone()); + 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( @@ -144,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/arch/PKGBUILD b/packaging/arch/PKGBUILD index 66157a7..80214e1 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,14 +1,19 @@ -# Maintainer: Your Name +# Maintainer: Breadway pkgname=bread -pkgver=0.1.0 +pkgver=1.0.0 pkgrel=1 -pkgdesc="Bread - 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,8 +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/bread "${pkgdir}/usr/bin/bread" - install -Dm644 packaging/systemd/bread.service "${pkgdir}/usr/lib/systemd/user/bread.service" + install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd" + 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/scripts/install.sh b/scripts/install.sh index 5440530..7a16cd9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -89,10 +89,21 @@ fi echo "" # ── verify ───────────────────────────────────────────────────────────────────── -sleep 0.5 -if "$BIN_DIR/bread" ping &>/dev/null; then +# 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" + echo "warning: daemon did not respond to ping within 5s" echo " check: journalctl --user -u breadd -n 20" fi From 9a471f3158cd2a0a829595c8665fb6f7a42cb974 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 13 May 2026 22:01:42 +0800 Subject: [PATCH 32/76] Final Release of Version 1.0 --- .gitignore | 1 - Cargo.lock | 10 +- README.md | 14 +- bread-cli/Cargo.toml | 2 +- bread-cli/src/main.rs | 66 ++- bread-cli/src/modules_mgmt.rs | 24 +- bread-cli/tests/modules.rs | 14 +- bread-shared/Cargo.toml | 2 +- bread-shared/src/lib.rs | 166 ++++++ bread-sync/Cargo.toml | 2 +- bread-sync/src/config.rs | 122 +++++ bread-sync/src/delegates.rs | 140 ++++- bread-sync/src/git.rs | 4 +- bread-sync/src/machine.rs | 88 +++ bread-sync/src/packages.rs | 127 ++++- bread-sync/tests/sync.rs | 235 +++++++- breadd/Cargo.toml | 4 +- breadd/src/adapters/hyprland.rs | 5 +- breadd/src/adapters/mod.rs | 12 +- breadd/src/adapters/network_rtnetlink.rs | 68 ++- breadd/src/adapters/power_upower.rs | 2 +- breadd/src/adapters/udev.rs | 6 +- breadd/src/core/config.rs | 266 ++++++++- breadd/src/core/normalizer.rs | 473 +++++++++++++++- breadd/src/core/state_engine.rs | 517 +++++++++++++++-- breadd/src/core/subscriptions.rs | 145 ++++- breadd/src/core/supervisor.rs | 9 +- breadd/src/core/types.rs | 30 +- breadd/src/ipc/mod.rs | 110 +++- breadd/src/lua/mod.rs | 671 ++++++++++++----------- breadd/src/main.rs | 10 +- breadd/tests/ipc_integration.rs | 308 +++++++++++ packaging/arch/PKGBUILD | 26 +- scripts/install.sh | 17 +- 34 files changed, 3129 insertions(+), 567 deletions(-) diff --git a/.gitignore b/.gitignore index a92804c..acf737f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,3 @@ LUA_RUNTIME.md CLAUDE_SPEC.md .claude CLAUDE.md -.github diff --git a/Cargo.lock b/Cargo.lock index c04bd41..52ab50b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,7 +305,7 @@ dependencies = [ [[package]] name = "bread-cli" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "bread-shared", @@ -327,7 +327,7 @@ dependencies = [ [[package]] name = "bread-shared" -version = "0.1.0" +version = "1.0.0" dependencies = [ "serde", "serde_json", @@ -335,7 +335,7 @@ dependencies = [ [[package]] name = "bread-sync" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "chrono", @@ -351,13 +351,13 @@ dependencies = [ [[package]] name = "breadd" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "async-trait", "bread-shared", + "bread-sync", "futures-util", - "hex", "libc", "mlua", "netlink-packet-core", diff --git a/README.md b/README.md index cc1e32c..c9b3d67 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,13 @@ git clone https://github.com/Breadway/bread.git cd bread ``` -Run the install script — it builds, installs to `/usr/bin`, sets up the systemd user service, and starts the daemon: +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 bash scripts/install.sh ``` -Or step by step: +Or step by step (system-wide install): ```bash cargo build --release @@ -377,12 +377,14 @@ bread.once("bread.system.startup", function(event) bread.profile.activate("default") end) --- Subscribe with a filter predicate +-- Subscribe with a filter predicate. The predicate goes in an opts table. bread.filter("bread.device.connected", function(event) - return event.data.device == "keyboard" -end, function(event) bread.exec("xset r rate 200 40") -end) +end, { + filter = function(event) + return event.data.device == "keyboard" + end, +}) -- Emit a custom event (for cross-module communication) bread.emit("mymodule.something", { key = "value" }) diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 7d40088..1e4b667 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-cli" -version = "0.1.0" +version = "1.0.0" edition = "2021" [[bin]] diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index ae100c3..0e1b4a2 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -3,8 +3,7 @@ mod modules_mgmt; use anyhow::{Context, Result}; use bread_sync::{ config::{bread_config_dir, SyncConfig}, - delegates, machine, packages, - SyncRepo, + delegates, machine, packages, SyncRepo, }; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; @@ -18,7 +17,11 @@ 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, @@ -234,8 +237,7 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { match cmd { ModulesCommand::Install { source } => { - let manifest = - install_module(&source, &mods_dir).await?; + let manifest = install_module(&source, &mods_dir).await?; println!("installed {} v{}", manifest.name, manifest.version); try_daemon_reload(socket).await; } @@ -283,7 +285,10 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { .get(&m.name) .map(String::as_str) .unwrap_or("unknown"); - println!(" {:20} {:10} {:10} {}", m.name, m.version, status, m.source); + println!( + " {:20} {:10} {:10} {}", + m.name, m.version, status, m.source + ); } } @@ -298,12 +303,14 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { 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?; + 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); + println!( + "updated {} v{} → v{}", + manifest.name, old_ver, new_manifest.version + ); updated_any = true; } } else { @@ -352,9 +359,11 @@ async fn install_module( 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 - } + modules_mgmt::InstallSource::GitHub { + user, + repo, + git_ref, + } => install_from_github(&user, &repo, git_ref.as_deref(), source, mods_dir).await, } } @@ -388,8 +397,7 @@ async fn install_from_github( } }; - let tarball_url = - format!("https://api.github.com/repos/{user}/{repo}/tarball/{ref_to_use}"); + let tarball_url = format!("https://api.github.com/repos/{user}/{repo}/tarball/{ref_to_use}"); let bytes = client .get(&tarball_url) .send() @@ -400,8 +408,7 @@ async fn install_from_github( .context("failed to read module archive")?; let tmp = tempfile::tempdir()?; - let mut archive = - tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..])); + 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/") @@ -552,7 +559,10 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option) -> Result<()> { 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 {} failed: {}", manager, e); + eprintln!( + "bread: warning: package snapshot for {} failed: {}", + manager, e + ); } } } @@ -631,9 +641,11 @@ async fn cmd_sync_pull(cfg_dir: &Path, install_packages: bool, socket: &Path) -> 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() - }); + 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" @@ -848,9 +860,12 @@ async fn stream_events( since: Option, ) -> Result<()> { if let Some(seconds) = since { - let replay = - send_request(socket, "events.replay", json!({ "since_ms": seconds * 1000 })) - .await?; + 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 { @@ -1039,10 +1054,7 @@ fn render_doctor(health: &Value) { .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 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 {})", diff --git a/bread-cli/src/modules_mgmt.rs b/bread-cli/src/modules_mgmt.rs index 17c0a7b..942ad29 100644 --- a/bread-cli/src/modules_mgmt.rs +++ b/bread-cli/src/modules_mgmt.rs @@ -60,13 +60,14 @@ pub fn parse_source(source: &str) -> Result { /// 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 { +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() - ); + bail!("bread: no bread.module.toml found in {}", src.display()); } let raw = fs::read_to_string(&manifest_path) @@ -136,8 +137,8 @@ pub fn read_module_manifest(name: &str, modules_dir: &Path) -> Result Result { - let raw = fs::read_to_string(path) - .with_context(|| format!("failed to read {}", path.display()))?; + 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") } @@ -167,8 +168,13 @@ fn copy_dir(src: &Path, dst: &Path) -> Result<()> { 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()))?; + 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 index d05374c..74022fe 100644 --- a/bread-cli/tests/modules.rs +++ b/bread-cli/tests/modules.rs @@ -28,8 +28,7 @@ fn install_from_local_succeeds_with_manifest() { 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()); + 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(); @@ -38,7 +37,11 @@ fn install_from_local_succeeds_with_manifest() { // 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("bread.module.toml") + .exists()); assert!(modules_tmp.path().join("mymod").join("init.lua").exists()); } @@ -79,7 +82,10 @@ fn remove_nonexistent_errors() { 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}"); + assert!( + msg.contains("ghost"), + "expected error mentioning module name, got: {msg}" + ); } #[test] 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..3f43385 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -1,32 +1,71 @@ +//! 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, } +/// 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 +76,136 @@ 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\"" + ); + } + + #[test] + fn adapter_source_round_trips_through_json() { + for source in [ + AdapterSource::Hyprland, + AdapterSource::Udev, + AdapterSource::Power, + AdapterSource::Network, + AdapterSource::System, + ] { + 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); + assert_eq!(set.len(), 2); + assert!(set.contains(&AdapterSource::Hyprland)); + } +} diff --git a/bread-sync/Cargo.toml b/bread-sync/Cargo.toml index 232b592..15bb845 100644 --- a/bread-sync/Cargo.toml +++ b/bread-sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-sync" -version = "0.1.0" +version = "1.0.0" edition = "2021" [dependencies] diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs index 55a8dd3..606a637 100644 --- a/bread-sync/src/config.rs +++ b/bread-sync/src/config.rs @@ -133,3 +133,125 @@ pub fn expand_path(path: &str) -> PathBuf { } 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(&"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 index 2c59792..815e87b 100644 --- a/bread-sync/src/delegates.rs +++ b/bread-sync/src/delegates.rs @@ -23,7 +23,11 @@ fn sync_dir_inner(src: &Path, dst: &Path, root: &Path, patterns: &[Pattern]) -> 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 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(); @@ -107,3 +111,137 @@ pub fn resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> { }) .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/git.rs b/bread-sync/src/git.rs index a3740f8..d8f04af 100644 --- a/bread-sync/src/git.rs +++ b/bread-sync/src/git.rs @@ -201,9 +201,7 @@ impl SyncRepo { 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) - { + 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) diff --git a/bread-sync/src/machine.rs b/bread-sync/src/machine.rs index 325ef5a..6044d09 100644 --- a/bread-sync/src/machine.rs +++ b/bread-sync/src/machine.rs @@ -77,3 +77,91 @@ pub fn hostname() -> String { .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 index 96ad7b3..59f8e4c 100644 --- a/bread-sync/src/packages.rs +++ b/bread-sync/src/packages.rs @@ -19,10 +19,7 @@ pub fn snapshot(manager: &str, dest: &Path) -> Result { }; let Some(content) = content else { - eprintln!( - "bread: package manager '{}' not found, skipping", - manager - ); + eprintln!("bread: package manager '{}' not found, skipping", manager); return Ok(false); }; @@ -86,18 +83,15 @@ 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() - }) + .map(|l| l.split_whitespace().next().unwrap_or(l).to_string()) .collect() } 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(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()), @@ -127,7 +121,9 @@ fn run_npm() -> Result> { .args(["list", "-g", "--depth=0", "--parseable"]) .output() { - Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())), + 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()), @@ -136,9 +132,114 @@ fn run_npm() -> Result> { 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(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 index 484120c..0cc2dc9 100644 --- a/bread-sync/tests/sync.rs +++ b/bread-sync/tests/sync.rs @@ -84,7 +84,8 @@ fn sync_push_creates_correct_directory_structure() { // Init local sync repo let repo = SyncRepo::init(repo_tmp.path()).unwrap(); - repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap(); + repo.set_remote("origin", bare_tmp.path().to_str().unwrap()) + .unwrap(); // Snapshot bread dir let bread_dest = repo_tmp.path().join("bread"); @@ -102,7 +103,11 @@ fn sync_push_creates_correct_directory_structure() { // 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()); + assert!(repo_tmp + .path() + .join("machines") + .join("testbox.toml") + .exists()); } #[test] @@ -123,7 +128,8 @@ fn sync_push_snapshots_bread_config() { .unwrap(); let repo = SyncRepo::init(repo_tmp.path()).unwrap(); - repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).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(); @@ -149,7 +155,8 @@ fn sync_pull_copies_files_from_repo() { // 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(); + 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(); @@ -160,7 +167,8 @@ fn sync_pull_copies_files_from_repo() { // 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(); + 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"); @@ -255,3 +263,220 @@ fn push_with_no_changes_returns_none() { 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 9b968d9..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 @@ -17,7 +18,6 @@ toml = "0.8" 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/hyprland.rs b/breadd/src/adapters/hyprland.rs index c032612..2c4a47b 100644 --- a/breadd/src/adapters/hyprland.rs +++ b/breadd/src/adapters/hyprland.rs @@ -71,7 +71,10 @@ fn hyprland_event_socket() -> Result { .collect(); match sockets.len() { - 0 => Err(anyhow!("no Hyprland instance found in {}", hypr_dir.display())), + 0 => Err(anyhow!( + "no Hyprland instance found in {}", + hypr_dir.display() + )), 1 => Ok(sockets.remove(0)), n => { warn!("found {n} Hyprland instances, using first"); diff --git a/breadd/src/adapters/mod.rs b/breadd/src/adapters/mod.rs index b33569b..c4915c1 100644 --- a/breadd/src/adapters/mod.rs +++ b/breadd/src/adapters/mod.rs @@ -1,21 +1,21 @@ use anyhow::Result; use async_trait::async_trait; use bread_shared::RawEvent; -use tokio::sync::{mpsc, watch, RwLock}; -use tracing::info; 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 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")] @@ -71,7 +71,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 { @@ -92,7 +92,7 @@ impl Manager { if let Ok(adapter) = rt { self.spawn_adapter(adapter); } else { - self.spawn_adapter(network::NetworkAdapter::default()); + self.spawn_adapter(network::NetworkAdapter); } } 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 5af66bc..8142980 100644 --- a/breadd/src/adapters/udev.rs +++ b/breadd/src/adapters/udev.rs @@ -165,7 +165,11 @@ 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, subsystem }); + out.push(ScannedDevice { + id, + name, + subsystem, + }); } Ok(out) diff --git a/breadd/src/core/config.rs b/breadd/src/core/config.rs index 1c756a9..4a78321 100644 --- a/breadd/src/core/config.rs +++ b/breadd/src/core/config.rs @@ -5,7 +5,7 @@ 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, @@ -45,7 +45,7 @@ pub struct ModulesConfig { pub disable: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] pub struct AdaptersConfig { #[serde(default)] pub hyprland: AdapterToggle, @@ -95,19 +95,6 @@ pub struct NotificationsConfig { pub notify_send_path: String, } -impl Default for Config { - fn default() -> Self { - Self { - daemon: DaemonConfig::default(), - lua: LuaConfig::default(), - modules: ModulesConfig::default(), - adapters: AdaptersConfig::default(), - notifications: NotificationsConfig::default(), - events: EventsConfig::default(), - } - } -} - impl Default for DaemonConfig { fn default() -> Self { Self { @@ -135,17 +122,6 @@ impl Default for ModulesConfig { } } -impl Default for AdaptersConfig { - fn default() -> Self { - Self { - hyprland: AdapterToggle::default(), - udev: UdevConfig::default(), - power: PowerConfig::default(), - network: AdapterToggle::default(), - } - } -} - impl Default for AdapterToggle { fn default() -> Self { Self { @@ -281,3 +257,241 @@ 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_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 + +[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_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 9c31d41..49e071d 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -44,7 +44,11 @@ impl EventNormalizer { } fn normalize_udev(&self, raw: &RawEvent) -> Vec { - let action = raw.payload.get("action").and_then(Value::as_str).unwrap_or("change"); + 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. @@ -52,11 +56,31 @@ impl EventNormalizer { 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(); + 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. @@ -74,7 +98,10 @@ impl EventNormalizer { _ => "changed", }; - if (verb == "connected" || verb == "disconnected") && !vendor_id.is_empty() && !product_id.is_empty() { + 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 = { @@ -89,13 +116,18 @@ impl EventNormalizer { 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)); + 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"); + 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. @@ -117,7 +149,11 @@ impl EventNormalizer { } fn normalize_hyprland(&self, raw: &RawEvent) -> Vec { - let kind = raw.payload.get("kind").and_then(Value::as_str).unwrap_or("unknown"); + let kind = raw + .payload + .get("kind") + .and_then(Value::as_str) + .unwrap_or("unknown"); let data = raw .payload .get("data") @@ -168,7 +204,7 @@ impl EventNormalizer { timestamp: raw.timestamp, source: AdapterSource::Hyprland, data: json!({ - "address": fields.get(0).unwrap_or(&"") + "address": fields.first().unwrap_or(&"") }), }] } @@ -179,7 +215,7 @@ impl EventNormalizer { timestamp: raw.timestamp, source: AdapterSource::Hyprland, data: json!({ - "address": fields.get(0).unwrap_or(&""), + "address": fields.first().unwrap_or(&""), "workspace": fields.get(1).unwrap_or(&""), "class": fields.get(2).unwrap_or(&""), "title": fields.get(3).unwrap_or(&""), @@ -192,7 +228,7 @@ impl EventNormalizer { event: "bread.window.closed".to_string(), timestamp: raw.timestamp, source: AdapterSource::Hyprland, - data: json!({ "address": fields.get(0).unwrap_or(&"") }), + data: json!({ "address": fields.first().unwrap_or(&"") }), }] } "movewindow" => { @@ -202,7 +238,7 @@ impl EventNormalizer { timestamp: raw.timestamp, source: AdapterSource::Hyprland, data: json!({ - "address": fields.get(0).unwrap_or(&""), + "address": fields.first().unwrap_or(&""), "workspace": fields.get(1).unwrap_or(&""), }), }] @@ -268,7 +304,11 @@ impl EventNormalizer { } 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 { @@ -310,7 +350,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); } @@ -326,3 +367,403 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> { 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")); + } + + // ─── 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"]); + } +} diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs index 784a0e9..2ed7006 100644 --- a/breadd/src/core/state_engine.rs +++ b/breadd/src/core/state_engine.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; use anyhow::Result; use bread_shared::{AdapterSource, BreadEvent}; @@ -9,14 +9,15 @@ use tokio::sync::{broadcast, mpsc, watch, RwLock}; use tracing::warn; use crate::core::subscriptions::{SubscriptionId, SubscriptionTable}; -use crate::core::types::{Device, DeviceRule, InterfaceState, MatchCondition, ModuleLoadState, RuntimeState}; +use crate::core::types::{ + Device, DeviceRule, InterfaceState, MatchCondition, ModuleLoadState, RuntimeState, +}; use crate::lua::LuaMessage; #[derive(Clone)] pub struct StateHandle { state: Arc>, command_tx: mpsc::UnboundedSender, - subscription_count: Arc, } pub enum StateCommand { @@ -53,13 +54,8 @@ impl StateHandle { pub fn new( state: Arc>, command_tx: mpsc::UnboundedSender, - subscription_count: Arc, ) -> Self { - Self { - state, - command_tx, - subscription_count, - } + Self { state, command_tx } } pub fn state_arc(&self) -> Arc> { @@ -86,18 +82,21 @@ 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 }); + let _ = self + .command_tx + .send(StateCommand::RemoveSubscription { id }); } pub fn register_watch(&self, id: SubscriptionId, path: String) -> Result<()> { @@ -140,10 +139,6 @@ impl StateHandle { pub fn set_device_rules(&self, rules: Vec) { let _ = self.command_tx.send(StateCommand::SetDeviceRules(rules)); } - - pub fn subscription_count(&self) -> Arc { - self.subscription_count.clone() - } } pub async fn run_state_engine( @@ -376,8 +371,16 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { 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), }); } } @@ -403,7 +406,7 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { .data .get("window") .or_else(|| event.data.get("class")) - .or_else(|| event.data.get("address")) + .or_else(|| event.data.get("address")) .and_then(Value::as_str) .map(ToString::to_string); } @@ -421,7 +424,10 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { state.network.interfaces.clear(); for (name, meta) in ifaces { let up = meta.get("up").and_then(Value::as_bool).unwrap_or(false); - state.network.interfaces.insert(name.clone(), InterfaceState { up }); + state + .network + .interfaces + .insert(name.clone(), InterfaceState { up }); } } } @@ -455,7 +461,8 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) { 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)) { + if !rule.conditions.is_empty() && rule.conditions.iter().all(|c| condition_matches(c, data)) + { return rule.device.clone(); } } @@ -476,37 +483,68 @@ fn condition_matches(cond: &MatchCondition, data: &Value) -> bool { } } if let Some(ref expected) = cond.name { - let actual = data.get("name").and_then(Value::as_str).unwrap_or("").to_lowercase(); + 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(); + 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 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 { + 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 { + 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 { + if data + .get("id_input_tablet") + .and_then(Value::as_bool) + .unwrap_or(false) + != expected + { return false; } } @@ -526,7 +564,10 @@ fn condition_matches(cond: &MatchCondition, data: &Value) -> bool { } } if let Some(ref expected) = cond.id_usb_class { - let actual = data.get("id_usb_class").and_then(Value::as_str).unwrap_or(""); + 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()) { @@ -534,7 +575,11 @@ fn condition_matches(cond: &MatchCondition, data: &Value) -> bool { } } if let Some(ref expected) = cond.subsystem { - let actual = data.get("subsystem").and_then(Value::as_str).unwrap_or("").to_lowercase(); + let actual = data + .get("subsystem") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); if actual != expected.to_lowercase() { return false; } @@ -586,3 +631,407 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) 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 9b218ff..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 }; @@ -129,24 +134,36 @@ fn matches_glob(pattern: &[u8], text: &[u8]) -> bool { #[cfg(test)] mod tests { - use super::matches_pattern; + 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")); + 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.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.device.**", + "bread.device.dock.connected" + )); assert!(matches_pattern("bread.**", "bread.device.dock.connected")); assert!(matches_pattern("bread.**", "bread")); } @@ -157,4 +174,120 @@ mod tests { 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 38c7f81..ad03fa4 100644 --- a/breadd/src/core/types.rs +++ b/breadd/src/core/types.rs @@ -3,7 +3,7 @@ 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, @@ -16,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, @@ -100,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, diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index 25fe66c..e9ef497 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -3,9 +3,9 @@ use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::process; -use std::time::Instant; +use std::sync::atomic::AtomicU64; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; use anyhow::{anyhow, Result}; use bread_shared::{now_unix_ms, AdapterSource, BreadEvent}; @@ -52,6 +52,9 @@ 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, @@ -161,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 })), @@ -208,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())); }; @@ -231,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!({})); @@ -253,7 +251,9 @@ impl Server { 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 subscription_count = self + .subscription_count + .load(std::sync::atomic::Ordering::Relaxed); let recent_errors = self.lua_runtime.recent_errors(); Ok(json!({ "ok": true, @@ -268,14 +268,7 @@ impl Server { })) } "sync.status" => { - let cfg_home = 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 sync_path = cfg_home.join("bread").join("sync.toml"); + 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()) @@ -301,7 +294,11 @@ impl Server { } } "events.replay" => { - let since_ms = req.params.get("since_ms").and_then(Value::as_u64).unwrap_or(0); + 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 @@ -412,3 +409,70 @@ fn matches_glob_filter(pattern: &[u8], text: &[u8]) -> bool { } } } + +#[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 7744fdf..caf49df 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -9,7 +9,6 @@ use std::time::Duration; use anyhow::{anyhow, Result}; use bread_shared::{AdapterSource, BreadEvent}; -use libc; use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value}; use serde::Serialize; use serde_json::Value as JsonValue; @@ -291,62 +290,66 @@ impl LuaEngine { 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_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) - })?; + 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 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) - })?; + 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 handlers = self.handlers.clone(); @@ -411,25 +414,27 @@ impl LuaEngine { 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(()) - })?; + 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| { - state_value_to_lua(lua, &state_arc, &path) - })?; + 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(); @@ -439,9 +444,9 @@ impl LuaEngine { 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"))?; + 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(); @@ -479,38 +484,40 @@ impl LuaEngine { 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) - })?; + 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)?; @@ -555,130 +562,134 @@ impl LuaEngine { 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 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 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, - }), - )); + let _ = emit_tx.send(BreadEvent::new( + "bread.notify.sent", + AdapterSource::System, + serde_json::json!({ + "title": title, + "message": message, + "urgency": urgency, + }), + )); - Ok(()) - })?; + 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 }); + 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() => {} } - } - _ = cancel_rx.changed() => {} - } - }); - Ok(id.0) - })?; + }); + 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; + 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) - })?; + }); + Ok(id.0) + })?; bread.set("every", every_fn)?; let timers = self.timers.clone(); @@ -694,18 +705,22 @@ impl LuaEngine { 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) - })?; + 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) - })?; + 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| { @@ -718,38 +733,38 @@ impl LuaEngine { 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()))?; + 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()))?; + 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()))?; + 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()))?; + 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())) })?; @@ -759,33 +774,33 @@ impl LuaEngine { 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) - })?; + 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)?; @@ -800,7 +815,9 @@ impl LuaEngine { .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")); + return Err(LuaError::external( + "module name does not match current load", + )); } let decl = module_decls @@ -834,7 +851,7 @@ impl LuaEngine { let set_fn = lua.create_function(move |lua, (key, value): (String, Value)| { let json = lua .from_value::(value) - .unwrap_or_else(|_| JsonValue::Null); + .unwrap_or(JsonValue::Null); module_store_set(&state_arc_set, &module_name, key, json); Ok(()) })?; @@ -845,10 +862,7 @@ impl LuaEngine { modules .lock() .map_err(|_| LuaError::external("module registry lock poisoned"))? - .insert( - decl.name.clone(), - ModuleInfo { table_key: key }, - ); + .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")?; @@ -862,9 +876,9 @@ impl LuaEngine { // 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()) - })?; + 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, ()| { @@ -877,9 +891,9 @@ impl LuaEngine { })?; machine_tbl.set("tags", tags_fn)?; - let has_tag_fn = self.lua.create_function(|_lua, tag: String| { - Ok(lua_machine_tags().contains(&tag)) - })?; + 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)?; @@ -887,15 +901,16 @@ impl LuaEngine { // 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())) - })?; + 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| { @@ -907,9 +922,9 @@ impl LuaEngine { })?; fs_tbl.set("read", read_fn)?; - let exists_fn = self.lua.create_function(|_lua, path: String| { - Ok(lua_expand_path(&path).exists()) - })?; + 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| { @@ -1025,18 +1040,16 @@ impl LuaEngine { let mut files = list_lua_files(&self.module_path)?; files.sort(); - let disabled: HashSet = self - .modules_config - .disable - .iter() - .cloned() - .collect(); + 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)) { + 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) => { @@ -1130,7 +1143,10 @@ impl LuaEngine { } 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(()) } @@ -1371,7 +1387,9 @@ impl LuaEngine { // // 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#" + self.lua + .load( + r#" local _bread = bread local function stringify(v) @@ -1392,7 +1410,9 @@ impl LuaEngine { function _bread.error(msg) _bread.__log_error(stringify(msg)) end - "#).exec()?; + "#, + ) + .exec()?; // Register the raw Rust-backed log functions that the Lua wrappers call. let globals = self.lua.globals(); @@ -1429,7 +1449,9 @@ impl LuaEngine { // // Because the Lua runtime is single-threaded, we implement this in // pure Lua using bread.cancel / bread.after. - self.lua.load(r#" + self.lua + .load( + r#" function bread.debounce(delay_ms, fn) local timer_id = nil return function(...) @@ -1444,7 +1466,9 @@ impl LuaEngine { end) end end - "#).exec()?; + "#, + ) + .exec()?; Ok(()) } @@ -1476,7 +1500,8 @@ impl LuaEngine { let bread = lua.create_table()?; bread.set("module", module_fn)?; lua.globals().set("bread", bread)?; - lua.load(r#" + 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) @@ -1486,10 +1511,15 @@ impl LuaEngine { return _noop_tbl end }) - "#).exec()?; + "#, + ) + .exec()?; let src = fs::read_to_string(path)?; - let result = lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec(); + 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 { @@ -1515,8 +1545,7 @@ impl LuaEngine { return Ok(Value::Nil); } - let src = fs::read_to_string(&path) - .map_err(|e| LuaError::external(e.to_string()))?; + 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()) @@ -1529,8 +1558,9 @@ impl LuaEngine { let bread: Table = globals.get("bread")?; bread.set("__require_loader", loader)?; - self.lua.load( - r#" + self.lua + .load( + r#" local searchers = package.searchers or package.loaders if searchers then table.insert(searchers, 1, function(name) @@ -1538,8 +1568,8 @@ impl LuaEngine { end) end "#, - ) - .exec()?; + ) + .exec()?; Ok(()) } @@ -1664,10 +1694,7 @@ fn order_module_decls(decls: Vec) -> (Vec, Vec<(String, 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('/', "."); + let mut name = rel.with_extension("").to_string_lossy().replace('/', "."); if name.starts_with('.') { name.remove(0); } @@ -1697,8 +1724,8 @@ fn state_value_to_lua<'lua>( } std::hint::spin_loop(); }; - let mut value = serde_json::to_value(&*snapshot) - .map_err(|e| LuaError::external(e.to_string()))?; + 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) @@ -1714,7 +1741,11 @@ fn state_value_to_lua<'lua>( .map_err(|e| LuaError::external(e.to_string())) } -fn module_store_get(state_arc: &Arc>, module: &str, key: &str) -> Option { +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; @@ -1725,7 +1756,12 @@ fn module_store_get(state_arc: &Arc>, module: &str, key: &s entry.store.get(key).cloned() } -fn module_store_set(state_arc: &Arc>, module: &str, key: String, value: JsonValue) { +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; @@ -1824,9 +1860,7 @@ fn lua_machine_tags() -> 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")) - }) + .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)?; @@ -2102,7 +2136,10 @@ fn hyprland_request_socket() -> Result { .collect(); match sockets.len() { - 0 => Err(anyhow!("no Hyprland instance found in {}", hypr_dir.display())), + 0 => Err(anyhow!( + "no Hyprland instance found in {}", + hypr_dir.display() + )), 1 => Ok(sockets.remove(0)), _ => Ok(sockets.remove(0)), } diff --git a/breadd/src/main.rs b/breadd/src/main.rs index bcb4daa..809c879 100644 --- a/breadd/src/main.rs +++ b/breadd/src/main.rs @@ -4,8 +4,8 @@ mod ipc; mod lua; use std::collections::VecDeque; -use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::sync::Arc; use anyhow::Result; use bread_shared::{AdapterSource, BreadEvent, RawEvent}; @@ -36,9 +36,10 @@ async fn main() -> Result<()> { 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, subscription_count.clone()); + 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( @@ -144,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/arch/PKGBUILD b/packaging/arch/PKGBUILD index 66157a7..80214e1 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,14 +1,19 @@ -# Maintainer: Your Name +# Maintainer: Breadway pkgname=bread -pkgver=0.1.0 +pkgver=1.0.0 pkgrel=1 -pkgdesc="Bread - 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,8 +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/bread "${pkgdir}/usr/bin/bread" - install -Dm644 packaging/systemd/bread.service "${pkgdir}/usr/lib/systemd/user/bread.service" + install -Dm755 target/release/breadd "${pkgdir}/usr/bin/breadd" + 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/scripts/install.sh b/scripts/install.sh index 5440530..7a16cd9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -89,10 +89,21 @@ fi echo "" # ── verify ───────────────────────────────────────────────────────────────────── -sleep 0.5 -if "$BIN_DIR/bread" ping &>/dev/null; then +# 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" + echo "warning: daemon did not respond to ping within 5s" echo " check: journalctl --user -u breadd -n 20" fi From 4072f64fcb5196a50b0593e19e44ecf5933e22ea Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 16 May 2026 19:44:19 +0800 Subject: [PATCH 33/76] Commiting for bread sync --- Documentation.md | 102 +++- README.md | 46 +- bread-cli/src/main.rs | 367 ++++++++++--- bread-shared/src/lib.rs | 10 +- bread-sync/src/config.rs | 2 + bread-sync/src/export.rs | 850 +++++++++++++++++++++++++++++++ bread-sync/src/lib.rs | 2 + bread-sync/src/packages.rs | 12 + breadd/src/adapters/bluetooth.rs | 255 ++++++++++ breadd/src/adapters/mod.rs | 7 + breadd/src/core/config.rs | 7 + breadd/src/core/normalizer.rs | 195 +++++++ breadd/src/lua/mod.rs | 264 ++++++++++ 13 files changed, 2040 insertions(+), 79 deletions(-) create mode 100644 bread-sync/src/export.rs create mode 100644 breadd/src/adapters/bluetooth.rs diff --git a/Documentation.md b/Documentation.md index f1a0aca..60f9b7c 100644 --- a/Documentation.md +++ b/Documentation.md @@ -10,6 +10,7 @@ - [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) @@ -23,6 +24,8 @@ Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`) - **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 @@ -402,6 +405,83 @@ bread.hyprland.on_raw("activewindow", function(raw) 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. @@ -646,7 +726,7 @@ Events are delivered as a `BreadEvent`: |-------|------| | `bread.system.startup` | `{}` | -#### Devices (udev) +#### Devices (udev / Bluetooth) | Event | Data | |-------|------| @@ -657,6 +737,26 @@ Events are delivered as a `BreadEvent`: `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 | diff --git a/README.md b/README.md index c9b3d67..554084e 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ 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 @@ -68,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) --- @@ -138,6 +139,9 @@ poll_interval_secs = 30 [adapters.network] enabled = true +[adapters.bluetooth] +enabled = true + [events] dedup_window_ms = 100 @@ -335,6 +339,8 @@ 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 | @@ -516,6 +522,44 @@ bread.hyprland.on_raw("activewindow", function(raw) 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. diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 0e1b4a2..924c7b3 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -3,7 +3,7 @@ mod modules_mgmt; use anyhow::{Context, Result}; use bread_sync::{ config::{bread_config_dir, SyncConfig}, - delegates, machine, packages, SyncRepo, + delegates, machine, packages, apply_import, stage_export, SyncRepo, }; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; @@ -143,6 +143,26 @@ enum SyncCommand { }, /// 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] @@ -447,6 +467,10 @@ async fn handle_sync_cmd(cmd: SyncCommand, socket: &Path) -> Result<()> { 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(()) } @@ -464,7 +488,7 @@ async fn cmd_sync_init(cfg_dir: &Path, remote: Option) -> Result<()> { let remote_url = match remote { Some(u) => u, None => { - print!("Sync remote URL (git remote or path): "); + 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)?; @@ -512,15 +536,17 @@ async fn cmd_sync_init(cfg_dir: &Path, remote: Option) -> Result<()> { }; config.save(cfg_dir)?; - // If it looks like a URL (not a local path), check if it exists - if !remote_url.starts_with('/') && !remote_url.starts_with('.') { - println!("remote does not exist yet — it will be created on first push"); - } - println!(); println!("sync initialized"); println!(" machine: {}", machine_name); - println!(" remote: {}", remote_url); + 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(()) } @@ -529,19 +555,15 @@ 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(); - // Clone or open the local sync repo - let repo = SyncRepo::open_or_clone(&config.remote.url, &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, - &[ - // Don't recurse into the sync repo itself - ".git".to_string(), - ], - )?; + delegates::sync_dir(cfg_dir, &bread_dest, &[".git".to_string()])?; // Snapshot delegate configs let configs_dir = repo_path.join("configs"); @@ -559,22 +581,16 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option) -> Result<()> { 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 {} failed: {}", - manager, e - ); + eprintln!("bread: warning: package snapshot for {manager} failed: {e}"); } } } // Write machine profile let machines_dir = repo_path.join("machines"); - let profile = - machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()); - profile.write(&machines_dir)?; + machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()) + .write(&machines_dir)?; - // Set remote and commit - repo.set_remote("origin", &config.remote.url)?; let commit_msg = message.unwrap_or_else(|| { format!( "sync: {} {}", @@ -584,19 +600,15 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option) -> Result<()> { }); if repo.commit(&commit_msg)?.is_none() { - println!("nothing to push — already up to date"); + println!("nothing to commit — already up to date"); return Ok(()); } - repo.push("origin", &config.remote.branch)?; - - println!("pushed sync for {}", config.machine.name); - println!(" bread config: {}", cfg_dir.display()); - if !config.delegates.include.is_empty() { - println!(" delegates: {}", config.delegates.include.len()); - } + 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(", ")); + println!(" packages: {}", config.packages.managers.join(", ")); } Ok(()) } @@ -605,15 +617,9 @@ async fn cmd_sync_pull(cfg_dir: &Path, install_packages: bool, socket: &Path) -> let config = load_sync_config(cfg_dir)?; let repo_path = SyncConfig::local_repo_path(); - let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?; - repo.set_remote("origin", &config.remote.url)?; - - match repo.pull("origin", &config.remote.branch) { - Ok(()) => {} - Err(e) => { - eprintln!("{}", e); - std::process::exit(1); - } + if !repo_path.exists() { + eprintln!("bread: no local snapshot found. Run 'bread sync push' first."); + std::process::exit(1); } // Apply bread/ → ~/.config/bread/ @@ -667,29 +673,25 @@ async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> { if !repo_path.exists() { println!("bread sync status"); - println!(" not yet pushed"); + println!(" not yet committed — run 'bread sync push'"); return Ok(()); } let repo = SyncRepo::open(&repo_path)?; - repo.set_remote("origin", &config.remote.url)?; - // Fetch remote refs without merging - let _ = repo.fetch("origin", &config.remote.branch); - - let last_push = repo + 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!(" remote {}", config.remote.url); - println!(" last push {}", last_push); + println!(" snapshot {}", repo_path.display()); + println!(" last commit {}", last_commit); let local_changes = repo.local_changes()?; println!(); - println!("local changes (not yet pushed):"); + println!("uncommitted changes:"); if local_changes.is_empty() { println!(" none"); } else { @@ -698,22 +700,11 @@ async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> { } } - let remote_changes = repo.remote_changes("origin", &config.remote.branch)?; - println!(); - println!("remote changes (not yet pulled):"); - if remote_changes.is_empty() { - println!(" none"); - } else { - for (ch, path) in &remote_changes { - println!(" {} {}", ch, path); - } - } - Ok(()) } -async fn cmd_sync_diff(cfg_dir: &Path, vs_remote: bool) -> Result<()> { - let config = load_sync_config(cfg_dir)?; +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() { @@ -722,15 +713,7 @@ async fn cmd_sync_diff(cfg_dir: &Path, vs_remote: bool) -> Result<()> { } let repo = SyncRepo::open(&repo_path)?; - - let diff = if vs_remote { - repo.set_remote("origin", &config.remote.url)?; - let _ = repo.fetch("origin", &config.remote.branch); - repo.remote_diff("origin", &config.remote.branch)? - } else { - repo.working_diff()? - }; - + let diff = repo.working_diff()?; print!("{}", diff); Ok(()) } @@ -752,6 +735,238 @@ async fn cmd_sync_machines(cfg_dir: &Path) -> Result<()> { 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), diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs index 3f43385..25bdac7 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -26,6 +26,8 @@ pub enum AdapterSource { /// 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. @@ -114,6 +116,10 @@ mod tests { serde_json::to_string(&AdapterSource::System).unwrap(), "\"system\"" ); + assert_eq!( + serde_json::to_string(&AdapterSource::Bluetooth).unwrap(), + "\"bluetooth\"" + ); } #[test] @@ -124,6 +130,7 @@ mod tests { 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(); @@ -205,7 +212,8 @@ mod tests { set.insert(AdapterSource::Hyprland); set.insert(AdapterSource::Hyprland); set.insert(AdapterSource::Udev); - assert_eq!(set.len(), 2); + set.insert(AdapterSource::Bluetooth); + assert_eq!(set.len(), 3); assert!(set.contains(&AdapterSource::Hyprland)); } } diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs index 606a637..9760449 100644 --- a/bread-sync/src/config.rs +++ b/bread-sync/src/config.rs @@ -50,6 +50,7 @@ impl Default for PackagesConfig { enabled: true, managers: vec![ "pacman".to_string(), + "aur".to_string(), "pip".to_string(), "npm".to_string(), "cargo".to_string(), @@ -194,6 +195,7 @@ mod tests { 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())); 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/lib.rs b/bread-sync/src/lib.rs index 4b89f1a..e508750 100644 --- a/bread-sync/src/lib.rs +++ b/bread-sync/src/lib.rs @@ -1,9 +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/packages.rs b/bread-sync/src/packages.rs index 59f8e4c..b1548ae 100644 --- a/bread-sync/src/packages.rs +++ b/bread-sync/src/packages.rs @@ -9,6 +9,7 @@ use std::process::Command; 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()?, @@ -87,6 +88,17 @@ pub fn parse_cargo(content: &str) -> Vec { .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() => { 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/mod.rs b/breadd/src/adapters/mod.rs index c4915c1..dcd7870 100644 --- a/breadd/src/adapters/mod.rs +++ b/breadd/src/adapters/mod.rs @@ -10,6 +10,7 @@ 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 network_rtnetlink; @@ -86,6 +87,12 @@ 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(); diff --git a/breadd/src/core/config.rs b/breadd/src/core/config.rs index 4a78321..b1be12c 100644 --- a/breadd/src/core/config.rs +++ b/breadd/src/core/config.rs @@ -55,6 +55,8 @@ pub struct AdaptersConfig { pub power: PowerConfig, #[serde(default)] pub network: AdapterToggle, + #[serde(default)] + pub bluetooth: AdapterToggle, } #[derive(Debug, Clone, Deserialize)] @@ -306,6 +308,7 @@ mod tests { 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); @@ -359,6 +362,9 @@ poll_interval_secs = 5 [adapters.network] enabled = false +[adapters.bluetooth] +enabled = false + [events] dedup_window_ms = 250 @@ -380,6 +386,7 @@ notify_send_path = "/usr/local/bin/notify-send" 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"); diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index 49e071d..963838d 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -31,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, @@ -303,6 +304,83 @@ 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 @@ -661,6 +739,123 @@ mod tests { 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] diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index caf49df..b7a7453 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -934,6 +934,74 @@ impl LuaEngine { 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()?; @@ -2193,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) +} From fc27916a5d5364eeea9a6c0a52a1b9b973bb05ed Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 16 May 2026 19:44:19 +0800 Subject: [PATCH 34/76] Commiting for bread sync --- Documentation.md | 102 +++- README.md | 46 +- bread-cli/src/main.rs | 367 ++++++++++--- bread-shared/src/lib.rs | 10 +- bread-sync/src/config.rs | 2 + bread-sync/src/export.rs | 850 +++++++++++++++++++++++++++++++ bread-sync/src/lib.rs | 2 + bread-sync/src/packages.rs | 12 + breadd/src/adapters/bluetooth.rs | 255 ++++++++++ breadd/src/adapters/mod.rs | 7 + breadd/src/core/config.rs | 7 + breadd/src/core/normalizer.rs | 195 +++++++ breadd/src/lua/mod.rs | 264 ++++++++++ 13 files changed, 2040 insertions(+), 79 deletions(-) create mode 100644 bread-sync/src/export.rs create mode 100644 breadd/src/adapters/bluetooth.rs diff --git a/Documentation.md b/Documentation.md index f1a0aca..60f9b7c 100644 --- a/Documentation.md +++ b/Documentation.md @@ -10,6 +10,7 @@ - [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) @@ -23,6 +24,8 @@ Bread is a reactive automation fabric for Linux desktops. The daemon (`breadd`) - **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 @@ -402,6 +405,83 @@ bread.hyprland.on_raw("activewindow", function(raw) 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. @@ -646,7 +726,7 @@ Events are delivered as a `BreadEvent`: |-------|------| | `bread.system.startup` | `{}` | -#### Devices (udev) +#### Devices (udev / Bluetooth) | Event | Data | |-------|------| @@ -657,6 +737,26 @@ Events are delivered as a `BreadEvent`: `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 | diff --git a/README.md b/README.md index c9b3d67..554084e 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ 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 @@ -68,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) --- @@ -138,6 +139,9 @@ poll_interval_secs = 30 [adapters.network] enabled = true +[adapters.bluetooth] +enabled = true + [events] dedup_window_ms = 100 @@ -335,6 +339,8 @@ 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 | @@ -516,6 +522,44 @@ bread.hyprland.on_raw("activewindow", function(raw) 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. diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 0e1b4a2..924c7b3 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -3,7 +3,7 @@ mod modules_mgmt; use anyhow::{Context, Result}; use bread_sync::{ config::{bread_config_dir, SyncConfig}, - delegates, machine, packages, SyncRepo, + delegates, machine, packages, apply_import, stage_export, SyncRepo, }; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; @@ -143,6 +143,26 @@ enum SyncCommand { }, /// 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] @@ -447,6 +467,10 @@ async fn handle_sync_cmd(cmd: SyncCommand, socket: &Path) -> Result<()> { 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(()) } @@ -464,7 +488,7 @@ async fn cmd_sync_init(cfg_dir: &Path, remote: Option) -> Result<()> { let remote_url = match remote { Some(u) => u, None => { - print!("Sync remote URL (git remote or path): "); + 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)?; @@ -512,15 +536,17 @@ async fn cmd_sync_init(cfg_dir: &Path, remote: Option) -> Result<()> { }; config.save(cfg_dir)?; - // If it looks like a URL (not a local path), check if it exists - if !remote_url.starts_with('/') && !remote_url.starts_with('.') { - println!("remote does not exist yet — it will be created on first push"); - } - println!(); println!("sync initialized"); println!(" machine: {}", machine_name); - println!(" remote: {}", remote_url); + 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(()) } @@ -529,19 +555,15 @@ 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(); - // Clone or open the local sync repo - let repo = SyncRepo::open_or_clone(&config.remote.url, &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, - &[ - // Don't recurse into the sync repo itself - ".git".to_string(), - ], - )?; + delegates::sync_dir(cfg_dir, &bread_dest, &[".git".to_string()])?; // Snapshot delegate configs let configs_dir = repo_path.join("configs"); @@ -559,22 +581,16 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option) -> Result<()> { 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 {} failed: {}", - manager, e - ); + eprintln!("bread: warning: package snapshot for {manager} failed: {e}"); } } } // Write machine profile let machines_dir = repo_path.join("machines"); - let profile = - machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()); - profile.write(&machines_dir)?; + machine::MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()) + .write(&machines_dir)?; - // Set remote and commit - repo.set_remote("origin", &config.remote.url)?; let commit_msg = message.unwrap_or_else(|| { format!( "sync: {} {}", @@ -584,19 +600,15 @@ async fn cmd_sync_push(cfg_dir: &Path, message: Option) -> Result<()> { }); if repo.commit(&commit_msg)?.is_none() { - println!("nothing to push — already up to date"); + println!("nothing to commit — already up to date"); return Ok(()); } - repo.push("origin", &config.remote.branch)?; - - println!("pushed sync for {}", config.machine.name); - println!(" bread config: {}", cfg_dir.display()); - if !config.delegates.include.is_empty() { - println!(" delegates: {}", config.delegates.include.len()); - } + 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(", ")); + println!(" packages: {}", config.packages.managers.join(", ")); } Ok(()) } @@ -605,15 +617,9 @@ async fn cmd_sync_pull(cfg_dir: &Path, install_packages: bool, socket: &Path) -> let config = load_sync_config(cfg_dir)?; let repo_path = SyncConfig::local_repo_path(); - let repo = SyncRepo::open_or_clone(&config.remote.url, &repo_path)?; - repo.set_remote("origin", &config.remote.url)?; - - match repo.pull("origin", &config.remote.branch) { - Ok(()) => {} - Err(e) => { - eprintln!("{}", e); - std::process::exit(1); - } + if !repo_path.exists() { + eprintln!("bread: no local snapshot found. Run 'bread sync push' first."); + std::process::exit(1); } // Apply bread/ → ~/.config/bread/ @@ -667,29 +673,25 @@ async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> { if !repo_path.exists() { println!("bread sync status"); - println!(" not yet pushed"); + println!(" not yet committed — run 'bread sync push'"); return Ok(()); } let repo = SyncRepo::open(&repo_path)?; - repo.set_remote("origin", &config.remote.url)?; - // Fetch remote refs without merging - let _ = repo.fetch("origin", &config.remote.branch); - - let last_push = repo + 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!(" remote {}", config.remote.url); - println!(" last push {}", last_push); + println!(" snapshot {}", repo_path.display()); + println!(" last commit {}", last_commit); let local_changes = repo.local_changes()?; println!(); - println!("local changes (not yet pushed):"); + println!("uncommitted changes:"); if local_changes.is_empty() { println!(" none"); } else { @@ -698,22 +700,11 @@ async fn cmd_sync_status(cfg_dir: &Path) -> Result<()> { } } - let remote_changes = repo.remote_changes("origin", &config.remote.branch)?; - println!(); - println!("remote changes (not yet pulled):"); - if remote_changes.is_empty() { - println!(" none"); - } else { - for (ch, path) in &remote_changes { - println!(" {} {}", ch, path); - } - } - Ok(()) } -async fn cmd_sync_diff(cfg_dir: &Path, vs_remote: bool) -> Result<()> { - let config = load_sync_config(cfg_dir)?; +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() { @@ -722,15 +713,7 @@ async fn cmd_sync_diff(cfg_dir: &Path, vs_remote: bool) -> Result<()> { } let repo = SyncRepo::open(&repo_path)?; - - let diff = if vs_remote { - repo.set_remote("origin", &config.remote.url)?; - let _ = repo.fetch("origin", &config.remote.branch); - repo.remote_diff("origin", &config.remote.branch)? - } else { - repo.working_diff()? - }; - + let diff = repo.working_diff()?; print!("{}", diff); Ok(()) } @@ -752,6 +735,238 @@ async fn cmd_sync_machines(cfg_dir: &Path) -> Result<()> { 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), diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs index 3f43385..25bdac7 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -26,6 +26,8 @@ pub enum AdapterSource { /// 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. @@ -114,6 +116,10 @@ mod tests { serde_json::to_string(&AdapterSource::System).unwrap(), "\"system\"" ); + assert_eq!( + serde_json::to_string(&AdapterSource::Bluetooth).unwrap(), + "\"bluetooth\"" + ); } #[test] @@ -124,6 +130,7 @@ mod tests { 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(); @@ -205,7 +212,8 @@ mod tests { set.insert(AdapterSource::Hyprland); set.insert(AdapterSource::Hyprland); set.insert(AdapterSource::Udev); - assert_eq!(set.len(), 2); + set.insert(AdapterSource::Bluetooth); + assert_eq!(set.len(), 3); assert!(set.contains(&AdapterSource::Hyprland)); } } diff --git a/bread-sync/src/config.rs b/bread-sync/src/config.rs index 606a637..9760449 100644 --- a/bread-sync/src/config.rs +++ b/bread-sync/src/config.rs @@ -50,6 +50,7 @@ impl Default for PackagesConfig { enabled: true, managers: vec![ "pacman".to_string(), + "aur".to_string(), "pip".to_string(), "npm".to_string(), "cargo".to_string(), @@ -194,6 +195,7 @@ mod tests { 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())); 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/lib.rs b/bread-sync/src/lib.rs index 4b89f1a..e508750 100644 --- a/bread-sync/src/lib.rs +++ b/bread-sync/src/lib.rs @@ -1,9 +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/packages.rs b/bread-sync/src/packages.rs index 59f8e4c..b1548ae 100644 --- a/bread-sync/src/packages.rs +++ b/bread-sync/src/packages.rs @@ -9,6 +9,7 @@ use std::process::Command; 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()?, @@ -87,6 +88,17 @@ pub fn parse_cargo(content: &str) -> Vec { .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() => { 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/mod.rs b/breadd/src/adapters/mod.rs index c4915c1..dcd7870 100644 --- a/breadd/src/adapters/mod.rs +++ b/breadd/src/adapters/mod.rs @@ -10,6 +10,7 @@ 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 network_rtnetlink; @@ -86,6 +87,12 @@ 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(); diff --git a/breadd/src/core/config.rs b/breadd/src/core/config.rs index 4a78321..b1be12c 100644 --- a/breadd/src/core/config.rs +++ b/breadd/src/core/config.rs @@ -55,6 +55,8 @@ pub struct AdaptersConfig { pub power: PowerConfig, #[serde(default)] pub network: AdapterToggle, + #[serde(default)] + pub bluetooth: AdapterToggle, } #[derive(Debug, Clone, Deserialize)] @@ -306,6 +308,7 @@ mod tests { 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); @@ -359,6 +362,9 @@ poll_interval_secs = 5 [adapters.network] enabled = false +[adapters.bluetooth] +enabled = false + [events] dedup_window_ms = 250 @@ -380,6 +386,7 @@ notify_send_path = "/usr/local/bin/notify-send" 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"); diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs index 49e071d..963838d 100644 --- a/breadd/src/core/normalizer.rs +++ b/breadd/src/core/normalizer.rs @@ -31,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, @@ -303,6 +304,83 @@ 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 @@ -661,6 +739,123 @@ mod tests { 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] diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index caf49df..b7a7453 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -934,6 +934,74 @@ impl LuaEngine { 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()?; @@ -2193,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) +} From 23bb4f89776bae4452208223efc3db02a388666e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 16 May 2026 22:17:26 +0800 Subject: [PATCH 35/76] docs: document sync export/import and updated snapshot layout Co-Authored-By: Claude Sonnet 4.6 --- Documentation.md | 65 +++++++++++++++++++++++++++++++++++++----------- README.md | 50 ++++++++++++++++++++++++------------- 2 files changed, 83 insertions(+), 32 deletions(-) diff --git a/Documentation.md b/Documentation.md index 60f9b7c..36c6d73 100644 --- a/Documentation.md +++ b/Documentation.md @@ -138,16 +138,18 @@ installed_at = "2026-01-01T00:00:00Z" ## Sync: snapshot and restore -Bread sync snapshots your Bread config, arbitrary dotfiles, and installed package lists into a Git repository. Pull it on another machine to restore state. +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 +# First-time setup (remote optional) +bread sync init bread sync init --remote git@github.com:you/bread-config.git -# Snapshot and push +# Commit local snapshot bread sync push +bread sync push --message "before reinstall" -# On another machine: pull and apply +# Apply snapshot to this machine bread sync pull # Also reinstall packages from snapshot @@ -156,12 +158,55 @@ bread sync pull --install-packages # See what has changed bread sync status bread sync diff -bread sync diff --remote # 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 @@ -182,16 +227,6 @@ include = ["~/.config/nvim", "~/.config/waybar"] exclude = ["**/.git", "**/*.cache"] ``` -The sync repo stores: - -``` -~/.local/share/bread/sync-repo/ -├── bread/ ← ~/.config/bread/ snapshot -├── configs/ ← delegate paths (nvim, waybar, etc.) -├── machines/ ← per-machine profiles with tags and last-sync time -└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.) -``` - ## Debugging tips - Run `bread events` to see live normalized events. diff --git a/README.md b/README.md index 554084e..18ad5ba 100644 --- a/README.md +++ b/README.md @@ -201,14 +201,19 @@ bread modules update [name] # Re-install one or all GitHub-sourced mod bread modules info # Show full manifest and daemon status # Sync -bread sync init # Initialize sync for this machine -bread sync push # Snapshot and push current state to remote -bread sync pull # Pull and apply latest state from remote +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 diff --remote # Show diff vs remote 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 ``` --- @@ -266,27 +271,31 @@ return M ## Sync system -Bread sync snapshots your entire setup — Bread config, arbitrary dotfiles, and package lists — and stores it in a Git repository. Pull it on another machine to restore. +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 +# First-time setup (remote is optional) +bread sync init bread sync init --remote git@github.com:you/bread-config.git -# Push current state +# Commit a local snapshot bread sync push -# On another machine: pull and apply -bread sync pull +# Create a portable .tar.gz (no git auth required) +bread sync export -# Check what's pending -bread sync status +# 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" +url = "git@github.com:you/bread-config.git" # optional branch = "main" [machine] @@ -302,14 +311,21 @@ include = ["~/.config/nvim", "~/.config/waybar"] exclude = ["**/.git", "**/*.cache"] ``` -The sync repo stores: +A portable export snapshot contains: ``` -sync-repo/ -├── bread/ ← ~/.config/bread/ snapshot -├── configs/ ← delegate paths (nvim, waybar, etc.) +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 -└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.) +├── manifest.toml ← path map for exact restore +└── restore.sh ← shell script for manual restore ``` --- From 3be8eec0655729cba78a64cdfe9e6d451407614e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 16 May 2026 22:17:26 +0800 Subject: [PATCH 36/76] docs: document sync export/import and updated snapshot layout --- Documentation.md | 65 +++++++++++++++++++++++++++++++++++++----------- README.md | 50 ++++++++++++++++++++++++------------- 2 files changed, 83 insertions(+), 32 deletions(-) diff --git a/Documentation.md b/Documentation.md index 60f9b7c..36c6d73 100644 --- a/Documentation.md +++ b/Documentation.md @@ -138,16 +138,18 @@ installed_at = "2026-01-01T00:00:00Z" ## Sync: snapshot and restore -Bread sync snapshots your Bread config, arbitrary dotfiles, and installed package lists into a Git repository. Pull it on another machine to restore state. +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 +# First-time setup (remote optional) +bread sync init bread sync init --remote git@github.com:you/bread-config.git -# Snapshot and push +# Commit local snapshot bread sync push +bread sync push --message "before reinstall" -# On another machine: pull and apply +# Apply snapshot to this machine bread sync pull # Also reinstall packages from snapshot @@ -156,12 +158,55 @@ bread sync pull --install-packages # See what has changed bread sync status bread sync diff -bread sync diff --remote # 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 @@ -182,16 +227,6 @@ include = ["~/.config/nvim", "~/.config/waybar"] exclude = ["**/.git", "**/*.cache"] ``` -The sync repo stores: - -``` -~/.local/share/bread/sync-repo/ -├── bread/ ← ~/.config/bread/ snapshot -├── configs/ ← delegate paths (nvim, waybar, etc.) -├── machines/ ← per-machine profiles with tags and last-sync time -└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.) -``` - ## Debugging tips - Run `bread events` to see live normalized events. diff --git a/README.md b/README.md index 554084e..18ad5ba 100644 --- a/README.md +++ b/README.md @@ -201,14 +201,19 @@ bread modules update [name] # Re-install one or all GitHub-sourced mod bread modules info # Show full manifest and daemon status # Sync -bread sync init # Initialize sync for this machine -bread sync push # Snapshot and push current state to remote -bread sync pull # Pull and apply latest state from remote +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 diff --remote # Show diff vs remote 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 ``` --- @@ -266,27 +271,31 @@ return M ## Sync system -Bread sync snapshots your entire setup — Bread config, arbitrary dotfiles, and package lists — and stores it in a Git repository. Pull it on another machine to restore. +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 +# First-time setup (remote is optional) +bread sync init bread sync init --remote git@github.com:you/bread-config.git -# Push current state +# Commit a local snapshot bread sync push -# On another machine: pull and apply -bread sync pull +# Create a portable .tar.gz (no git auth required) +bread sync export -# Check what's pending -bread sync status +# 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" +url = "git@github.com:you/bread-config.git" # optional branch = "main" [machine] @@ -302,14 +311,21 @@ include = ["~/.config/nvim", "~/.config/waybar"] exclude = ["**/.git", "**/*.cache"] ``` -The sync repo stores: +A portable export snapshot contains: ``` -sync-repo/ -├── bread/ ← ~/.config/bread/ snapshot -├── configs/ ← delegate paths (nvim, waybar, etc.) +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 -└── packages/ ← package snapshots (pacman.txt, pip.txt, etc.) +├── manifest.toml ← path map for exact restore +└── restore.sh ← shell script for manual restore ``` --- From 3a46f0ac7c8a253075177c193cca990cecc8facb Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 17 May 2026 00:22:21 +0800 Subject: [PATCH 37/76] refactor: remove remote module install, extract bread-sync, make CI real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Remove `bread modules install github:…`. Remote fetch pulled unreviewed third-party Lua and ran it with full bread.exec() privileges in an unsandboxed runtime. Module install is now local-only; parse_source rejects github:/git: with an explicit message. bread-sync extracted from the workspace (parked for its own project): - Removed from workspace members (now excluded); see bread-sync/EXTRACTION.md - Removed the entire `bread sync` CLI surface and now-unused deps (bread-sync, reqwest, tar, flate2; tempfile demoted to dev-dependency) - Removed the sync.status IPC method from breadd plus its integration tests - Moved the generic `expand_path` helper into bread-shared (with unit tests) CI now actually runs and gates quality: - Trigger on master/dev (was `main` — CI had never run, not once) - Added `cargo fmt --check` and `clippy -D warnings`; fixed 4 clippy warnings - Dropped the macOS matrix entry (breadd is Linux-only: udev/rtnetlink); added the libudev-dev system dependency the Linux build needs Hardening / honesty: - New ipc test: daemon survives repeated reloads and the event pipeline resumes (the prior suite only had a single happy-path reload check) - Docs scrubbed of sync across README/Documentation/Overview/DAEMON - "production-ready" and "compositor-agnostic" claims reworded to match reality rather than aspiration Note: bread-sync/src/export.rs held pre-existing local WIP authored outside this change set and is intentionally excluded from this commit. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 24 +- Cargo.lock | 878 +------------------------------- Cargo.toml | 4 + Documentation.md | 122 +---- README.md | 104 +--- bread-cli/Cargo.toml | 6 +- bread-cli/src/main.rs | 747 +-------------------------- bread-cli/src/modules_mgmt.rs | 51 +- bread-shared/src/lib.rs | 42 ++ bread-sync/EXTRACTION.md | 36 ++ breadd/Cargo.toml | 1 - breadd/src/ipc/mod.rs | 26 - breadd/src/lua/mod.rs | 22 +- breadd/tests/ipc_integration.rs | 85 ++-- 14 files changed, 202 insertions(+), 1946 deletions(-) create mode 100644 bread-sync/EXTRACTION.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7409b04..ce7614f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,29 +2,31 @@ name: CI on: push: - branches: [ main ] + branches: [ master, dev ] pull_request: - branches: [ main ] + branches: [ master, dev ] jobs: build-and-test: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - rust: [stable] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Rust uses: dtolnay/rust-toolchain@v1 with: - toolchain: ${{ matrix.rust }} + toolchain: stable + components: clippy, rustfmt + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config - name: Cargo cache uses: Swatinem/rust-cache@v2 with: workspaces: | . -> target + - name: Format check + run: cargo fmt --all --check + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings - name: Build run: cargo build --workspace --verbose - name: Run tests @@ -34,9 +36,9 @@ jobs: - name: Package artifacts run: | mkdir -p dist - tar -czf dist/bread-${{ matrix.os }}.tgz target/release/breadd target/release/bread-cli + tar -czf dist/bread-ubuntu-latest.tgz target/release/breadd target/release/bread-cli - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: bread-${{ matrix.os }} + name: bread-ubuntu-latest path: dist/*.tgz diff --git a/Cargo.lock b/Cargo.lock index 52ab50b..155c062 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -263,12 +257,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "bitflags" version = "1.3.2" @@ -309,17 +297,13 @@ version = "1.0.0" dependencies = [ "anyhow", "bread-shared", - "bread-sync", "chrono", "clap", "dirs", - "flate2", "libc", "notify", - "reqwest", "serde", "serde_json", - "tar", "tempfile", "tokio", "toml", @@ -333,22 +317,6 @@ dependencies = [ "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 = "1.0.0" @@ -356,7 +324,6 @@ dependencies = [ "anyhow", "async-trait", "bread-shared", - "bread-sync", "futures-util", "libc", "mlua", @@ -409,8 +376,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -489,26 +454,6 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -524,15 +469,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -600,32 +536,12 @@ dependencies = [ "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" @@ -749,52 +665,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -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" @@ -941,18 +817,6 @@ 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" @@ -961,51 +825,11 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", "wasip3", ] -[[package]] -name = "git2" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -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 = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -1045,77 +869,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.4.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper", - "native-tls", - "tokio", - "tokio-native-tls", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1140,115 +893,12 @@ 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]] name = "id-arena" 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" @@ -1301,12 +951,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1319,16 +963,6 @@ 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" @@ -1379,20 +1013,6 @@ 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" @@ -1402,20 +1022,6 @@ 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" @@ -1426,18 +1032,6 @@ 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" @@ -1456,12 +1050,6 @@ 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" @@ -1538,22 +1126,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "0.8.11" @@ -1607,23 +1179,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.2.1", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1763,55 +1318,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -1872,12 +1378,6 @@ 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" @@ -1931,15 +1431,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1987,12 +1478,6 @@ 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" @@ -2078,46 +1563,6 @@ 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" @@ -2179,27 +1624,12 @@ 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" @@ -2209,44 +1639,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -2326,18 +1724,6 @@ 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" @@ -2374,12 +1760,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - [[package]] name = "slab" version = "0.4.12" @@ -2412,12 +1792,6 @@ 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" @@ -2452,55 +1826,6 @@ 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" @@ -2543,16 +1868,6 @@ 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" @@ -2582,29 +1897,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.8.23" @@ -2657,12 +1949,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - [[package]] name = "tracing" version = "0.1.44" @@ -2724,12 +2010,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typeid" version = "1.0.3" @@ -2777,24 +2057,6 @@ 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" @@ -2807,12 +2069,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -2835,15 +2091,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2881,16 +2128,6 @@ 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" @@ -2957,16 +2194,6 @@ dependencies = [ "semver", ] -[[package]] -name = "web-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "which" version = "7.0.3" @@ -3235,16 +2462,6 @@ 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" @@ -3345,22 +2562,6 @@ 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" @@ -3371,29 +2572,6 @@ 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" @@ -3481,60 +2659,6 @@ 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 8216be1..7d0c7a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ members = [ "bread-shared", "breadd", "bread-cli", +] +# bread-sync is being extracted into its own project (see bread-sync/EXTRACTION.md). +# Excluded so it no longer builds, tests, or gates CI as part of bread. +exclude = [ "bread-sync", ] resolver = "2" diff --git a/Documentation.md b/Documentation.md index 36c6d73..a06699f 100644 --- a/Documentation.md +++ b/Documentation.md @@ -7,7 +7,6 @@ - [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) @@ -101,15 +100,16 @@ If any module fails to load, `bread reload` prints the error with a full Lua sta Modules are Lua packages installed to `~/.config/bread/modules/`. The CLI manages the install lifecycle. +Modules install from a **local directory only**. They run with full +`bread.exec()` privileges and are not sandboxed; remote installation was +removed so that reviewing third-party code stays an explicit, manual step. To +use a module published on a git host, clone it yourself, review it, then +install from the checkout. + ```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 +# Clone and review, then install from the local checkout +git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi +bread modules install ~/src/bread-wifi # List installed modules and their daemon status bread modules list @@ -117,9 +117,6 @@ 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 @@ -132,101 +129,10 @@ name = "wifi" version = "1.0.0" description = "WiFi management for Bread" author = "someuser" -source = "github:someuser/bread-wifi" +source = "/home/you/src/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. @@ -396,10 +302,13 @@ 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. +Returns the system hostname. If an external tool has written a +`~/.config/bread/sync.toml` with a `[machine].name`, that value takes +precedence (bread reads the file if present but does not create it). #### `bread.machine.tags() -> string[]` -Returns the tags array from `sync.toml`, or `{}` if sync is not initialized. +Returns `[machine].tags` from `~/.config/bread/sync.toml` if that file +exists, otherwise `{}`. #### `bread.machine.has_tag(tag) -> bool` Returns true if the machine has the given tag. @@ -924,4 +833,3 @@ Available methods: | `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/README.md b/README.md index 18ad5ba..0fab7e2 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ return M 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 ``` @@ -194,26 +193,9 @@ 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 install /local/path # Install from a local module 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 ``` --- @@ -224,15 +206,15 @@ Modules are Lua files (or directories) installed to `~/.config/bread/modules/`. ### Installing modules +Modules install from a local directory only. Modules run with full +`bread.exec()` privileges and are **not** sandboxed, so to use a module +published on a git host, clone it yourself and review the Lua before +installing from the local checkout: + ```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 +git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi +# review ~/src/bread-wifi, then: +bread modules install ~/src/bread-wifi ``` ### Writing a module @@ -252,7 +234,7 @@ name = "wifi" version = "1.0.0" description = "WiFi management for Bread" author = "someuser" -source = "github:someuser/bread-wifi" +source = "/home/you/src/bread-wifi" installed_at = "2026-01-01T00:00:00Z" ``` @@ -269,67 +251,6 @@ 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 -``` - ---- - ## Event reference Events follow the namespace convention `bread...`. @@ -496,7 +417,7 @@ end ### Machine and filesystem ```lua --- Machine identity (from sync.toml, falls back to hostname) +-- Machine identity (system hostname) local name = bread.machine.name() local tags = bread.machine.tags() -- array of strings local ok = bread.machine.has_tag("laptop") @@ -616,7 +537,6 @@ Available methods: | `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. @@ -626,7 +546,7 @@ Available methods: Bread is early-stage software. Contributions, issues, and feedback are welcome. -The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API, module system, and sync subsystem. +The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API and module system. --- diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 1e4b667..6688aea 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -13,7 +13,6 @@ 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 @@ -24,7 +23,6 @@ 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" + +[dev-dependencies] tempfile.workspace = true diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 924c7b3..64d44a0 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,10 +1,6 @@ mod modules_mgmt; -use anyhow::{Context, Result}; -use bread_sync::{ - config::{bread_config_dir, SyncConfig}, - delegates, machine, packages, apply_import, stage_export, SyncRepo, -}; +use anyhow::Result; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{json, Value}; @@ -62,11 +58,6 @@ enum Commands { #[command(subcommand)] subcommand: ModulesCommand, }, - /// Manage sync (snapshot and restore system state) - Sync { - #[command(subcommand)] - subcommand: SyncCommand, - }, /// List available profiles ProfileList, /// Activate a profile @@ -91,9 +82,9 @@ enum Commands { #[derive(Subcommand, Debug)] enum ModulesCommand { - /// Install a module from a source + /// Install a module from a local directory Install { - /// Source: github:user/repo[@ref] or /path/to/dir + /// Path to a local module directory source: String, }, /// Remove an installed module @@ -105,66 +96,10 @@ enum ModulesCommand { }, /// 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] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -202,9 +137,6 @@ async fn main() -> Result<()> { 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)?; @@ -257,7 +189,7 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { match cmd { ModulesCommand::Install { source } => { - let manifest = install_module(&source, &mods_dir).await?; + let manifest = install_module(&source, &mods_dir)?; println!("installed {} v{}", manifest.name, manifest.version); try_daemon_reload(socket).await; } @@ -312,39 +244,6 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { } } - 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 { @@ -371,74 +270,12 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { Ok(()) } -async fn install_module( +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) + let path = modules_mgmt::parse_source(source)?; + modules_mgmt::install_from_local(&path, source, mods_dir) } /// Notify the daemon to reload modules. Prints a warning if the daemon is unreachable. @@ -451,576 +288,6 @@ async fn try_daemon_reload(socket: &Path) { } } -// --------------------------------------------------------------------------- -// 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) // --------------------------------------------------------------------------- diff --git a/bread-cli/src/modules_mgmt.rs b/bread-cli/src/modules_mgmt.rs index 942ad29..f39a829 100644 --- a/bread-cli/src/modules_mgmt.rs +++ b/bread-cli/src/modules_mgmt.rs @@ -15,44 +15,31 @@ pub struct ModuleManifest { 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('/') +/// Resolve a module source string to a local directory path. +/// +/// Only local paths are accepted. Remote fetching (`github:user/repo`) was +/// removed: it pulled arbitrary, unsandboxed Lua that the daemon then runs with +/// full `bread.exec()` privileges as the user. Installing a remote module now +/// requires cloning it yourself, so the review step stays in the user's hands. +pub fn parse_source(source: &str) -> Result { + if source.starts_with("github:") || source.starts_with("git:") { + bail!( + "bread: remote module installation has been removed for security \ + (it ran unreviewed third-party Lua with full exec privileges). \ + Clone the repository yourself, review it, then run \ + 'bread modules install /path/to/checkout'" + ); + } + 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)) + Ok(bread_shared::expand_path(source)) } else { bail!( - "bread: invalid module source '{}'. Use 'github:user/repo' or an absolute/relative path", + "bread: invalid module source '{}'. Provide an absolute or relative \ + path to a local module directory", source ) } diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs index 25bdac7..bfbd481 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -89,11 +89,53 @@ pub fn now_unix_ms() -> u64 { .as_millis() as u64 } +/// Expand a leading `~` or `~/` in a path string to the user's home directory. +/// +/// Falls back to returning the path unchanged if `$HOME` is unset, which keeps +/// callers infallible. Shared by the daemon and CLI for resolving +/// user-supplied paths (config entries, module install sources). +pub fn expand_path(path: &str) -> std::path::PathBuf { + use std::path::PathBuf; + let home = std::env::var("HOME").ok(); + if path == "~" { + if let Some(home) = home { + return PathBuf::from(home); + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = home { + return PathBuf::from(home).join(rest); + } + } + PathBuf::from(path) +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + #[test] + fn expand_path_leaves_non_tilde_paths_unchanged() { + use std::path::PathBuf; + assert_eq!(expand_path("/abs/path"), PathBuf::from("/abs/path")); + assert_eq!(expand_path("relative/x"), PathBuf::from("relative/x")); + assert_eq!(expand_path("./x"), PathBuf::from("./x")); + // A `~` not in leading position is not special. + assert_eq!(expand_path("/etc/~weird"), PathBuf::from("/etc/~weird")); + } + + #[test] + fn expand_path_expands_leading_tilde() { + // Read-only env access; safe under parallel test execution. + if let Ok(home) = std::env::var("HOME") { + assert_eq!(expand_path("~"), std::path::PathBuf::from(&home)); + assert_eq!( + expand_path("~/.config/bread"), + std::path::PathBuf::from(&home).join(".config/bread") + ); + } + } + #[test] fn adapter_source_serializes_as_snake_case() { assert_eq!( diff --git a/bread-sync/EXTRACTION.md b/bread-sync/EXTRACTION.md new file mode 100644 index 0000000..6dce450 --- /dev/null +++ b/bread-sync/EXTRACTION.md @@ -0,0 +1,36 @@ +# bread-sync — slated for extraction + +This crate is **no longer part of the `bread` workspace**. It is parked here +pending extraction into its own standalone project. + +## Why + +`bread`'s architecture deliberately scopes itself to a reactive automation +fabric — see the Non-Goals in `Overview.md`. State/dotfile synchronization +across machines is explicitly *out* of that scope. `bread-sync` grew into a +git-backed snapshot/restore + package + delegate-path manager, which is a +genuinely useful tool but a different product with a different lifecycle. It +was the one component pulling `bread`'s scope discipline out of shape, so it +is being spun out rather than removed (the code is good; it just doesn't +belong in this repo). + +## Status + +- Removed from the root `Cargo.toml` workspace (`members` → `exclude`). +- The `bread sync …` CLI subcommands have been removed from `bread-cli`. +- The `sync.status` IPC method and its integration tests have been removed + from `breadd`. +- No code in `bread`/`breadd`/`bread-cli` depends on this crate anymore. + +## For whoever extracts it (name polls are open) + +1. Move this directory into the new repository. +2. It inherited workspace dependencies (`serde`, `git2`, `dirs`, `chrono`, + `tempfile`, `glob`, …). Pin concrete versions in its own `Cargo.toml`; + `*.workspace = true` will not resolve outside this workspace. +3. The only helper that had to leave this crate is `config::expand_path`, + which moved to `bread-shared::expand_path` because non-sync code (the + module installer) needed it. Reintroduce a local copy in the new project + so it no longer depends on `bread-shared`. +4. Re-add the `bread sync` UX as a standalone binary, or as a `breadd` IPC + client, in the new project — not here. diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index 03609ca..7d8620f 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -5,7 +5,6 @@ edition = "2021" [dependencies] bread-shared = { path = "../bread-shared" } -bread-sync = { path = "../bread-sync" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index e9ef497..587f1d0 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -267,32 +267,6 @@ impl Server { "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 diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index b7a7453..484a0c6 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -873,7 +873,8 @@ impl LuaEngine { })?; bread.set("module", module_fn)?; - // bread.machine — machine name and tags from sync.toml + // bread.machine — hostname/tags; reads an optional, externally-managed + // ~/.config/bread/sync.toml if present (bread does not create it) let machine_tbl = self.lua.create_table()?; let name_fn = self @@ -947,9 +948,9 @@ impl LuaEngine { })?; bluetooth_tbl.set("power", power_fn)?; - let powered_fn = self.lua.create_function(move |_lua, ()| { - Ok(bluetooth_query(|| bluetooth_get_powered()).ok()) - })?; + 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| { @@ -983,7 +984,7 @@ impl LuaEngine { bluetooth_tbl.set("scan", scan_fn)?; let devices_fn = self.lua.create_function(move |lua, ()| { - let devs = match bluetooth_query(|| bluetooth_list_devices()) { + let devs = match bluetooth_query(bluetooth_list_devices) { Ok(d) => d, Err(_) => return Ok(Value::Nil), }; @@ -2298,7 +2299,8 @@ where .block_on(factory()); let _ = tx.send(result); }); - rx.recv().map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))? + rx.recv() + .map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))? } async fn bluetooth_find_adapter(conn: &zbus::Connection) -> anyhow::Result { @@ -2392,7 +2394,11 @@ async fn bluetooth_disconnect(address: String) -> anyhow::Result<()> { 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" }; + let method = if enabled { + "StartDiscovery" + } else { + "StopDiscovery" + }; conn.call_method( Some("org.bluez"), adapter.as_str(), @@ -2429,7 +2435,7 @@ async fn bluetooth_list_devices() -> anyhow::Result> { > = msg.body()?; let mut devices = Vec::new(); - for (_, interfaces) in &objects { + for interfaces in objects.values() { 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 { diff --git a/breadd/tests/ipc_integration.rs b/breadd/tests/ipc_integration.rs index a12e504..4af40a9 100644 --- a/breadd/tests/ipc_integration.rs +++ b/breadd/tests/ipc_integration.rs @@ -161,37 +161,49 @@ async fn modules_reload_succeeds() -> Result<()> { } #[tokio::test] -async fn sync_status_uninitialized_when_no_config() -> Result<()> { +async fn daemon_survives_repeated_reloads_and_pipeline_resumes() -> 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) - ); + // Event emitted before any reload. + harness + .send_request("emit", json!({"event": "bread.reload.before", "data": {}})) + .await?; - harness.shutdown(); - Ok(()) -} + // Hammer reload: each cycle drops and rebuilds the Lua VM, cancels timers, + // and re-registers subscriptions. A wedge here (lost Lua thread, deadlocked + // dispatch, paused-and-never-resumed pipeline) is the regression this guards + // — the previous suite only checked a single happy-path reload. + for _ in 0..3 { + let r = harness.send_request("modules.reload", json!({})).await?; + assert_eq!(r.get("ok").and_then(Value::as_bool), Some(true)); + } -#[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?; + // Daemon must still answer control requests after the reload storm. + let ping = harness.send_request("ping", json!({})).await?; + assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true)); + let health = harness.send_request("health", json!({})).await?; + assert_eq!(health.get("ok").and_then(Value::as_bool), Some(true)); - 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") + // The pipeline must have resumed: an event emitted *after* the reloads + // still flows through normalization into the replay buffer. + harness + .send_request("emit", json!({"event": "bread.reload.after", "data": {}})) + .await?; + sleep(Duration::from_millis(100)).await; + + let replay = harness + .send_request("events.replay", json!({"since_ms": 30_000})) + .await?; + let names: Vec<&str> = replay + .as_array() + .expect("replay result should be array") + .iter() + .filter_map(|e| e.get("event").and_then(Value::as_str)) + .collect(); + assert!( + names.contains(&"bread.reload.after"), + "event pipeline did not resume after reload; got {names:?}" ); harness.shutdown(); @@ -385,14 +397,6 @@ 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"); @@ -433,21 +437,6 @@ 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) From cc456b78fe658aec76f65981b91c91aa9125c633 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 17 May 2026 00:22:21 +0800 Subject: [PATCH 38/76] refactor: remove remote module install, extract bread-sync, make CI real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Remove `bread modules install github:…`. Remote fetch pulled unreviewed third-party Lua and ran it with full bread.exec() privileges in an unsandboxed runtime. Module install is now local-only; parse_source rejects github:/git: with an explicit message. bread-sync extracted from the workspace (parked for its own project): - Removed from workspace members (now excluded); see bread-sync/EXTRACTION.md - Removed the entire `bread sync` CLI surface and now-unused deps (bread-sync, reqwest, tar, flate2; tempfile demoted to dev-dependency) - Removed the sync.status IPC method from breadd plus its integration tests - Moved the generic `expand_path` helper into bread-shared (with unit tests) CI now actually runs and gates quality: - Trigger on master/dev (was `main` — CI had never run, not once) - Added `cargo fmt --check` and `clippy -D warnings`; fixed 4 clippy warnings - Dropped the macOS matrix entry (breadd is Linux-only: udev/rtnetlink); added the libudev-dev system dependency the Linux build needs Hardening / honesty: - New ipc test: daemon survives repeated reloads and the event pipeline resumes (the prior suite only had a single happy-path reload check) - Docs scrubbed of sync across README/Documentation/Overview/DAEMON - "production-ready" and "compositor-agnostic" claims reworded to match reality rather than aspiration Note: bread-sync/src/export.rs held pre-existing local WIP authored outside this change set and is intentionally excluded from this commit. --- .github/workflows/ci.yml | 24 +- Cargo.lock | 878 +------------------------------- Cargo.toml | 4 + Documentation.md | 122 +---- README.md | 104 +--- bread-cli/Cargo.toml | 6 +- bread-cli/src/main.rs | 747 +-------------------------- bread-cli/src/modules_mgmt.rs | 51 +- bread-shared/src/lib.rs | 42 ++ bread-sync/EXTRACTION.md | 36 ++ breadd/Cargo.toml | 1 - breadd/src/ipc/mod.rs | 26 - breadd/src/lua/mod.rs | 22 +- breadd/tests/ipc_integration.rs | 85 ++-- 14 files changed, 202 insertions(+), 1946 deletions(-) create mode 100644 bread-sync/EXTRACTION.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7409b04..ce7614f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,29 +2,31 @@ name: CI on: push: - branches: [ main ] + branches: [ master, dev ] pull_request: - branches: [ main ] + branches: [ master, dev ] jobs: build-and-test: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - rust: [stable] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Rust uses: dtolnay/rust-toolchain@v1 with: - toolchain: ${{ matrix.rust }} + toolchain: stable + components: clippy, rustfmt + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config - name: Cargo cache uses: Swatinem/rust-cache@v2 with: workspaces: | . -> target + - name: Format check + run: cargo fmt --all --check + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings - name: Build run: cargo build --workspace --verbose - name: Run tests @@ -34,9 +36,9 @@ jobs: - name: Package artifacts run: | mkdir -p dist - tar -czf dist/bread-${{ matrix.os }}.tgz target/release/breadd target/release/bread-cli + tar -czf dist/bread-ubuntu-latest.tgz target/release/breadd target/release/bread-cli - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: bread-${{ matrix.os }} + name: bread-ubuntu-latest path: dist/*.tgz diff --git a/Cargo.lock b/Cargo.lock index 52ab50b..155c062 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -263,12 +257,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "bitflags" version = "1.3.2" @@ -309,17 +297,13 @@ version = "1.0.0" dependencies = [ "anyhow", "bread-shared", - "bread-sync", "chrono", "clap", "dirs", - "flate2", "libc", "notify", - "reqwest", "serde", "serde_json", - "tar", "tempfile", "tokio", "toml", @@ -333,22 +317,6 @@ dependencies = [ "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 = "1.0.0" @@ -356,7 +324,6 @@ dependencies = [ "anyhow", "async-trait", "bread-shared", - "bread-sync", "futures-util", "libc", "mlua", @@ -409,8 +376,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -489,26 +454,6 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -524,15 +469,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -600,32 +536,12 @@ dependencies = [ "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" @@ -749,52 +665,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -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" @@ -941,18 +817,6 @@ 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" @@ -961,51 +825,11 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", "wasip3", ] -[[package]] -name = "git2" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -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 = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -1045,77 +869,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.4.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper", - "native-tls", - "tokio", - "tokio-native-tls", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1140,115 +893,12 @@ 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]] name = "id-arena" 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" @@ -1301,12 +951,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1319,16 +963,6 @@ 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" @@ -1379,20 +1013,6 @@ 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" @@ -1402,20 +1022,6 @@ 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" @@ -1426,18 +1032,6 @@ 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" @@ -1456,12 +1050,6 @@ 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" @@ -1538,22 +1126,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "0.8.11" @@ -1607,23 +1179,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.2.1", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "netlink-packet-core" version = "0.4.2" @@ -1763,55 +1318,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -1872,12 +1378,6 @@ 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" @@ -1931,15 +1431,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1987,12 +1478,6 @@ 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" @@ -2078,46 +1563,6 @@ 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" @@ -2179,27 +1624,12 @@ 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" @@ -2209,44 +1639,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -2326,18 +1724,6 @@ 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" @@ -2374,12 +1760,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - [[package]] name = "slab" version = "0.4.12" @@ -2412,12 +1792,6 @@ 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" @@ -2452,55 +1826,6 @@ 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" @@ -2543,16 +1868,6 @@ 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" @@ -2582,29 +1897,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.8.23" @@ -2657,12 +1949,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - [[package]] name = "tracing" version = "0.1.44" @@ -2724,12 +2010,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typeid" version = "1.0.3" @@ -2777,24 +2057,6 @@ 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" @@ -2807,12 +2069,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -2835,15 +2091,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2881,16 +2128,6 @@ 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" @@ -2957,16 +2194,6 @@ dependencies = [ "semver", ] -[[package]] -name = "web-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "which" version = "7.0.3" @@ -3235,16 +2462,6 @@ 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" @@ -3345,22 +2562,6 @@ 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" @@ -3371,29 +2572,6 @@ 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" @@ -3481,60 +2659,6 @@ 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 8216be1..7d0c7a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ members = [ "bread-shared", "breadd", "bread-cli", +] +# bread-sync is being extracted into its own project (see bread-sync/EXTRACTION.md). +# Excluded so it no longer builds, tests, or gates CI as part of bread. +exclude = [ "bread-sync", ] resolver = "2" diff --git a/Documentation.md b/Documentation.md index 36c6d73..a06699f 100644 --- a/Documentation.md +++ b/Documentation.md @@ -7,7 +7,6 @@ - [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) @@ -101,15 +100,16 @@ If any module fails to load, `bread reload` prints the error with a full Lua sta Modules are Lua packages installed to `~/.config/bread/modules/`. The CLI manages the install lifecycle. +Modules install from a **local directory only**. They run with full +`bread.exec()` privileges and are not sandboxed; remote installation was +removed so that reviewing third-party code stays an explicit, manual step. To +use a module published on a git host, clone it yourself, review it, then +install from the checkout. + ```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 +# Clone and review, then install from the local checkout +git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi +bread modules install ~/src/bread-wifi # List installed modules and their daemon status bread modules list @@ -117,9 +117,6 @@ 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 @@ -132,101 +129,10 @@ name = "wifi" version = "1.0.0" description = "WiFi management for Bread" author = "someuser" -source = "github:someuser/bread-wifi" +source = "/home/you/src/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. @@ -396,10 +302,13 @@ 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. +Returns the system hostname. If an external tool has written a +`~/.config/bread/sync.toml` with a `[machine].name`, that value takes +precedence (bread reads the file if present but does not create it). #### `bread.machine.tags() -> string[]` -Returns the tags array from `sync.toml`, or `{}` if sync is not initialized. +Returns `[machine].tags` from `~/.config/bread/sync.toml` if that file +exists, otherwise `{}`. #### `bread.machine.has_tag(tag) -> bool` Returns true if the machine has the given tag. @@ -924,4 +833,3 @@ Available methods: | `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/README.md b/README.md index 18ad5ba..0fab7e2 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ return M 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 ``` @@ -194,26 +193,9 @@ 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 install /local/path # Install from a local module 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 ``` --- @@ -224,15 +206,15 @@ Modules are Lua files (or directories) installed to `~/.config/bread/modules/`. ### Installing modules +Modules install from a local directory only. Modules run with full +`bread.exec()` privileges and are **not** sandboxed, so to use a module +published on a git host, clone it yourself and review the Lua before +installing from the local checkout: + ```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 +git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi +# review ~/src/bread-wifi, then: +bread modules install ~/src/bread-wifi ``` ### Writing a module @@ -252,7 +234,7 @@ name = "wifi" version = "1.0.0" description = "WiFi management for Bread" author = "someuser" -source = "github:someuser/bread-wifi" +source = "/home/you/src/bread-wifi" installed_at = "2026-01-01T00:00:00Z" ``` @@ -269,67 +251,6 @@ 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 -``` - ---- - ## Event reference Events follow the namespace convention `bread...`. @@ -496,7 +417,7 @@ end ### Machine and filesystem ```lua --- Machine identity (from sync.toml, falls back to hostname) +-- Machine identity (system hostname) local name = bread.machine.name() local tags = bread.machine.tags() -- array of strings local ok = bread.machine.has_tag("laptop") @@ -616,7 +537,6 @@ Available methods: | `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. @@ -626,7 +546,7 @@ Available methods: Bread is early-stage software. Contributions, issues, and feedback are welcome. -The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API, module system, and sync subsystem. +The daemon (`breadd`) is the most stable part of the codebase. Active development is happening across the Lua API and module system. --- diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 1e4b667..6688aea 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -13,7 +13,6 @@ 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 @@ -24,7 +23,6 @@ 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" + +[dev-dependencies] tempfile.workspace = true diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs index 924c7b3..64d44a0 100644 --- a/bread-cli/src/main.rs +++ b/bread-cli/src/main.rs @@ -1,10 +1,6 @@ mod modules_mgmt; -use anyhow::{Context, Result}; -use bread_sync::{ - config::{bread_config_dir, SyncConfig}, - delegates, machine, packages, apply_import, stage_export, SyncRepo, -}; +use anyhow::Result; use clap::{Parser, Subcommand}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use serde_json::{json, Value}; @@ -62,11 +58,6 @@ enum Commands { #[command(subcommand)] subcommand: ModulesCommand, }, - /// Manage sync (snapshot and restore system state) - Sync { - #[command(subcommand)] - subcommand: SyncCommand, - }, /// List available profiles ProfileList, /// Activate a profile @@ -91,9 +82,9 @@ enum Commands { #[derive(Subcommand, Debug)] enum ModulesCommand { - /// Install a module from a source + /// Install a module from a local directory Install { - /// Source: github:user/repo[@ref] or /path/to/dir + /// Path to a local module directory source: String, }, /// Remove an installed module @@ -105,66 +96,10 @@ enum ModulesCommand { }, /// 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] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -202,9 +137,6 @@ async fn main() -> Result<()> { 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)?; @@ -257,7 +189,7 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { match cmd { ModulesCommand::Install { source } => { - let manifest = install_module(&source, &mods_dir).await?; + let manifest = install_module(&source, &mods_dir)?; println!("installed {} v{}", manifest.name, manifest.version); try_daemon_reload(socket).await; } @@ -312,39 +244,6 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { } } - 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 { @@ -371,74 +270,12 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> { Ok(()) } -async fn install_module( +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) + let path = modules_mgmt::parse_source(source)?; + modules_mgmt::install_from_local(&path, source, mods_dir) } /// Notify the daemon to reload modules. Prints a warning if the daemon is unreachable. @@ -451,576 +288,6 @@ async fn try_daemon_reload(socket: &Path) { } } -// --------------------------------------------------------------------------- -// 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) // --------------------------------------------------------------------------- diff --git a/bread-cli/src/modules_mgmt.rs b/bread-cli/src/modules_mgmt.rs index 942ad29..f39a829 100644 --- a/bread-cli/src/modules_mgmt.rs +++ b/bread-cli/src/modules_mgmt.rs @@ -15,44 +15,31 @@ pub struct ModuleManifest { 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('/') +/// Resolve a module source string to a local directory path. +/// +/// Only local paths are accepted. Remote fetching (`github:user/repo`) was +/// removed: it pulled arbitrary, unsandboxed Lua that the daemon then runs with +/// full `bread.exec()` privileges as the user. Installing a remote module now +/// requires cloning it yourself, so the review step stays in the user's hands. +pub fn parse_source(source: &str) -> Result { + if source.starts_with("github:") || source.starts_with("git:") { + bail!( + "bread: remote module installation has been removed for security \ + (it ran unreviewed third-party Lua with full exec privileges). \ + Clone the repository yourself, review it, then run \ + 'bread modules install /path/to/checkout'" + ); + } + 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)) + Ok(bread_shared::expand_path(source)) } else { bail!( - "bread: invalid module source '{}'. Use 'github:user/repo' or an absolute/relative path", + "bread: invalid module source '{}'. Provide an absolute or relative \ + path to a local module directory", source ) } diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs index 25bdac7..bfbd481 100644 --- a/bread-shared/src/lib.rs +++ b/bread-shared/src/lib.rs @@ -89,11 +89,53 @@ pub fn now_unix_ms() -> u64 { .as_millis() as u64 } +/// Expand a leading `~` or `~/` in a path string to the user's home directory. +/// +/// Falls back to returning the path unchanged if `$HOME` is unset, which keeps +/// callers infallible. Shared by the daemon and CLI for resolving +/// user-supplied paths (config entries, module install sources). +pub fn expand_path(path: &str) -> std::path::PathBuf { + use std::path::PathBuf; + let home = std::env::var("HOME").ok(); + if path == "~" { + if let Some(home) = home { + return PathBuf::from(home); + } + } else if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = home { + return PathBuf::from(home).join(rest); + } + } + PathBuf::from(path) +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + #[test] + fn expand_path_leaves_non_tilde_paths_unchanged() { + use std::path::PathBuf; + assert_eq!(expand_path("/abs/path"), PathBuf::from("/abs/path")); + assert_eq!(expand_path("relative/x"), PathBuf::from("relative/x")); + assert_eq!(expand_path("./x"), PathBuf::from("./x")); + // A `~` not in leading position is not special. + assert_eq!(expand_path("/etc/~weird"), PathBuf::from("/etc/~weird")); + } + + #[test] + fn expand_path_expands_leading_tilde() { + // Read-only env access; safe under parallel test execution. + if let Ok(home) = std::env::var("HOME") { + assert_eq!(expand_path("~"), std::path::PathBuf::from(&home)); + assert_eq!( + expand_path("~/.config/bread"), + std::path::PathBuf::from(&home).join(".config/bread") + ); + } + } + #[test] fn adapter_source_serializes_as_snake_case() { assert_eq!( diff --git a/bread-sync/EXTRACTION.md b/bread-sync/EXTRACTION.md new file mode 100644 index 0000000..6dce450 --- /dev/null +++ b/bread-sync/EXTRACTION.md @@ -0,0 +1,36 @@ +# bread-sync — slated for extraction + +This crate is **no longer part of the `bread` workspace**. It is parked here +pending extraction into its own standalone project. + +## Why + +`bread`'s architecture deliberately scopes itself to a reactive automation +fabric — see the Non-Goals in `Overview.md`. State/dotfile synchronization +across machines is explicitly *out* of that scope. `bread-sync` grew into a +git-backed snapshot/restore + package + delegate-path manager, which is a +genuinely useful tool but a different product with a different lifecycle. It +was the one component pulling `bread`'s scope discipline out of shape, so it +is being spun out rather than removed (the code is good; it just doesn't +belong in this repo). + +## Status + +- Removed from the root `Cargo.toml` workspace (`members` → `exclude`). +- The `bread sync …` CLI subcommands have been removed from `bread-cli`. +- The `sync.status` IPC method and its integration tests have been removed + from `breadd`. +- No code in `bread`/`breadd`/`bread-cli` depends on this crate anymore. + +## For whoever extracts it (name polls are open) + +1. Move this directory into the new repository. +2. It inherited workspace dependencies (`serde`, `git2`, `dirs`, `chrono`, + `tempfile`, `glob`, …). Pin concrete versions in its own `Cargo.toml`; + `*.workspace = true` will not resolve outside this workspace. +3. The only helper that had to leave this crate is `config::expand_path`, + which moved to `bread-shared::expand_path` because non-sync code (the + module installer) needed it. Reintroduce a local copy in the new project + so it no longer depends on `bread-shared`. +4. Re-add the `bread sync` UX as a standalone binary, or as a `breadd` IPC + client, in the new project — not here. diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index 03609ca..7d8620f 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -5,7 +5,6 @@ edition = "2021" [dependencies] bread-shared = { path = "../bread-shared" } -bread-sync = { path = "../bread-sync" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs index e9ef497..587f1d0 100644 --- a/breadd/src/ipc/mod.rs +++ b/breadd/src/ipc/mod.rs @@ -267,32 +267,6 @@ impl Server { "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 diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs index b7a7453..484a0c6 100644 --- a/breadd/src/lua/mod.rs +++ b/breadd/src/lua/mod.rs @@ -873,7 +873,8 @@ impl LuaEngine { })?; bread.set("module", module_fn)?; - // bread.machine — machine name and tags from sync.toml + // bread.machine — hostname/tags; reads an optional, externally-managed + // ~/.config/bread/sync.toml if present (bread does not create it) let machine_tbl = self.lua.create_table()?; let name_fn = self @@ -947,9 +948,9 @@ impl LuaEngine { })?; bluetooth_tbl.set("power", power_fn)?; - let powered_fn = self.lua.create_function(move |_lua, ()| { - Ok(bluetooth_query(|| bluetooth_get_powered()).ok()) - })?; + 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| { @@ -983,7 +984,7 @@ impl LuaEngine { bluetooth_tbl.set("scan", scan_fn)?; let devices_fn = self.lua.create_function(move |lua, ()| { - let devs = match bluetooth_query(|| bluetooth_list_devices()) { + let devs = match bluetooth_query(bluetooth_list_devices) { Ok(d) => d, Err(_) => return Ok(Value::Nil), }; @@ -2298,7 +2299,8 @@ where .block_on(factory()); let _ = tx.send(result); }); - rx.recv().map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))? + rx.recv() + .map_err(|_| anyhow::anyhow!("bluetooth query thread failed"))? } async fn bluetooth_find_adapter(conn: &zbus::Connection) -> anyhow::Result { @@ -2392,7 +2394,11 @@ async fn bluetooth_disconnect(address: String) -> anyhow::Result<()> { 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" }; + let method = if enabled { + "StartDiscovery" + } else { + "StopDiscovery" + }; conn.call_method( Some("org.bluez"), adapter.as_str(), @@ -2429,7 +2435,7 @@ async fn bluetooth_list_devices() -> anyhow::Result> { > = msg.body()?; let mut devices = Vec::new(); - for (_, interfaces) in &objects { + for interfaces in objects.values() { 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 { diff --git a/breadd/tests/ipc_integration.rs b/breadd/tests/ipc_integration.rs index a12e504..4af40a9 100644 --- a/breadd/tests/ipc_integration.rs +++ b/breadd/tests/ipc_integration.rs @@ -161,37 +161,49 @@ async fn modules_reload_succeeds() -> Result<()> { } #[tokio::test] -async fn sync_status_uninitialized_when_no_config() -> Result<()> { +async fn daemon_survives_repeated_reloads_and_pipeline_resumes() -> 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) - ); + // Event emitted before any reload. + harness + .send_request("emit", json!({"event": "bread.reload.before", "data": {}})) + .await?; - harness.shutdown(); - Ok(()) -} + // Hammer reload: each cycle drops and rebuilds the Lua VM, cancels timers, + // and re-registers subscriptions. A wedge here (lost Lua thread, deadlocked + // dispatch, paused-and-never-resumed pipeline) is the regression this guards + // — the previous suite only checked a single happy-path reload. + for _ in 0..3 { + let r = harness.send_request("modules.reload", json!({})).await?; + assert_eq!(r.get("ok").and_then(Value::as_bool), Some(true)); + } -#[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?; + // Daemon must still answer control requests after the reload storm. + let ping = harness.send_request("ping", json!({})).await?; + assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true)); + let health = harness.send_request("health", json!({})).await?; + assert_eq!(health.get("ok").and_then(Value::as_bool), Some(true)); - 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") + // The pipeline must have resumed: an event emitted *after* the reloads + // still flows through normalization into the replay buffer. + harness + .send_request("emit", json!({"event": "bread.reload.after", "data": {}})) + .await?; + sleep(Duration::from_millis(100)).await; + + let replay = harness + .send_request("events.replay", json!({"since_ms": 30_000})) + .await?; + let names: Vec<&str> = replay + .as_array() + .expect("replay result should be array") + .iter() + .filter_map(|e| e.get("event").and_then(Value::as_str)) + .collect(); + assert!( + names.contains(&"bread.reload.after"), + "event pipeline did not resume after reload; got {names:?}" ); harness.shutdown(); @@ -385,14 +397,6 @@ 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"); @@ -433,21 +437,6 @@ 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) From 918045695d01caff575e73dff7ea1b050eec0ad6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 17 May 2026 08:33:00 +0800 Subject: [PATCH 39/76] Revert to v0.6 --- Cargo.lock | 6 +- bread-cli/Cargo.toml | 2 +- bread-shared/Cargo.toml | 2 +- bread-sync/src/export.rs | 143 +++++++++++++++++++++++---------------- breadd/Cargo.toml | 2 +- packaging/arch/PKGBUILD | 2 +- 6 files changed, 93 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 155c062..01f9fde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,7 +293,7 @@ dependencies = [ [[package]] name = "bread-cli" -version = "1.0.0" +version = "0.6.0" dependencies = [ "anyhow", "bread-shared", @@ -311,7 +311,7 @@ dependencies = [ [[package]] name = "bread-shared" -version = "1.0.0" +version = "0.6.0" dependencies = [ "serde", "serde_json", @@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "breadd" -version = "1.0.0" +version = "0.6.0" dependencies = [ "anyhow", "async-trait", diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 6688aea..c43d83d 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-cli" -version = "1.0.0" +version = "0.6.0" edition = "2021" [[bin]] diff --git a/bread-shared/Cargo.toml b/bread-shared/Cargo.toml index 475e94c..66c3118 100644 --- a/bread-shared/Cargo.toml +++ b/bread-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-shared" -version = "1.0.0" +version = "0.6.0" edition = "2021" [dependencies] diff --git a/bread-sync/src/export.rs b/bread-sync/src/export.rs index 9397f4b..ae75bb4 100644 --- a/bread-sync/src/export.rs +++ b/bread-sync/src/export.rs @@ -64,48 +64,48 @@ pub struct ExportManifest { /// 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"), + ("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"), + ("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"), + (".gitconfig", "~/.gitconfig"), ("user-dirs.dirs", "~/.config/user-dirs.dirs"), - ("mimeapps.list", "~/.config/mimeapps.list"), - ("ssh_config", "~/.ssh/config"), - (".zshrc", "~/.zshrc"), - (".zprofile", "~/.zprofile"), - (".zshenv", "~/.zshenv"), + ("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"), + ("udev", "/etc/udev/rules.d"), + ("modprobe", "/etc/modprobe.d"), + ("sysctl", "/etc/sysctl.d"), ("networkmanager", "/etc/NetworkManager/system-connections"), - ("bluetooth", "/var/lib/bluetooth"), + ("bluetooth", "/var/lib/bluetooth"), ]; /// Directories excluded from every recursive copy. @@ -120,18 +120,22 @@ static DEFAULT_EXCLUDES: &[&str] = &[ /// Directories skipped when searching for git repos. static GIT_SKIP_DIRS: &[&str] = &[ - ".local", "Nextcloud", "target", "node_modules", "__pycache__", - ".cache", "snap", "flatpak", "@girs", "Steam", + ".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 { +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(); @@ -238,8 +242,7 @@ pub fn stage_export( 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")?; + 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(), @@ -292,9 +295,7 @@ pub fn stage_export( 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}" - ), + Err(e) => eprintln!("bread: warning: package snapshot for {manager} failed: {e}"), } } } @@ -307,10 +308,18 @@ pub fn stage_export( // 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())) + 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(", ")); + eprintln!( + "bread: skipping Nextcloud-tracked folders: {}", + labels.join(", ") + ); } let repos = find_git_repos(&home); commit_and_push_repos(&repos, &home); @@ -565,10 +574,7 @@ fn commit_and_push_repos(repos: &[GitRepoRecord], home: &Path) { .output(); match push { Ok(o) if o.status.success() => eprintln!("ok"), - Ok(o) => eprintln!( - "failed: {}", - String::from_utf8_lossy(&o.stderr).trim() - ), + Ok(o) => eprintln!("failed: {}", String::from_utf8_lossy(&o.stderr).trim()), Err(e) => eprintln!("failed: {}", e), } } @@ -611,7 +617,15 @@ fn find_git_repos(home: &Path) -> Vec { 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"] { + 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); @@ -630,7 +644,14 @@ fn find_git_repos(home: &Path) -> Vec { repos } -fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut Vec, nc_dirs: &[PathBuf]) { +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; @@ -655,7 +676,11 @@ fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut V .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| dir.to_string_lossy().to_string()); - repos.push(GitRepoRecord { path: rel, remote, branch }); + repos.push(GitRepoRecord { + path: rel, + remote, + branch, + }); } } return; // don't recurse into git repos (skip submodules) @@ -700,7 +725,9 @@ fn install_packages_from(packages_dir: &Path) -> Result<()> { 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 _ = std::process::Command::new("cargo") + .args(["install", &pkg]) + .status(); } } let pip_file = packages_dir.join("pip.txt"); @@ -713,7 +740,9 @@ fn install_packages_from(packages_dir: &Path) -> Result<()> { 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(); + let _ = std::process::Command::new("npm") + .args(["install", "-g", &pkg]) + .status(); } } Ok(()) @@ -787,7 +816,9 @@ fn generate_restore_sh(manifest: &ExportManifest) -> 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"); + 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"); @@ -832,9 +863,7 @@ fn generate_restore_sh(manifest: &ExportManifest) -> String { 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!("if [ ! -d \"{dest}/.git\" ]; then\n")); s.push_str(&format!( " git clone --branch {branch} {remote} \"{dest}\" && echo \"[OK] ~/{}\"\n", repo.path diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index 7d8620f..d0c6485 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadd" -version = "1.0.0" +version = "0.6.0" edition = "2021" [dependencies] diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 80214e1..d2bb31e 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Breadway pkgname=bread -pkgver=1.0.0 +pkgver=0.6.0 pkgrel=1 pkgdesc="A reactive automation fabric for Linux desktops" arch=('x86_64') From 114c9e2bccd6619c91ceb31497b9eeea92a73b1b Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 17 May 2026 08:33:00 +0800 Subject: [PATCH 40/76] Revert to v0.6 --- Cargo.lock | 6 +- bread-cli/Cargo.toml | 2 +- bread-shared/Cargo.toml | 2 +- bread-sync/src/export.rs | 143 +++++++++++++++++++++++---------------- breadd/Cargo.toml | 2 +- packaging/arch/PKGBUILD | 2 +- 6 files changed, 93 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 155c062..01f9fde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,7 +293,7 @@ dependencies = [ [[package]] name = "bread-cli" -version = "1.0.0" +version = "0.6.0" dependencies = [ "anyhow", "bread-shared", @@ -311,7 +311,7 @@ dependencies = [ [[package]] name = "bread-shared" -version = "1.0.0" +version = "0.6.0" dependencies = [ "serde", "serde_json", @@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "breadd" -version = "1.0.0" +version = "0.6.0" dependencies = [ "anyhow", "async-trait", diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index 6688aea..c43d83d 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-cli" -version = "1.0.0" +version = "0.6.0" edition = "2021" [[bin]] diff --git a/bread-shared/Cargo.toml b/bread-shared/Cargo.toml index 475e94c..66c3118 100644 --- a/bread-shared/Cargo.toml +++ b/bread-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-shared" -version = "1.0.0" +version = "0.6.0" edition = "2021" [dependencies] diff --git a/bread-sync/src/export.rs b/bread-sync/src/export.rs index 9397f4b..ae75bb4 100644 --- a/bread-sync/src/export.rs +++ b/bread-sync/src/export.rs @@ -64,48 +64,48 @@ pub struct ExportManifest { /// 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"), + ("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"), + ("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"), + (".gitconfig", "~/.gitconfig"), ("user-dirs.dirs", "~/.config/user-dirs.dirs"), - ("mimeapps.list", "~/.config/mimeapps.list"), - ("ssh_config", "~/.ssh/config"), - (".zshrc", "~/.zshrc"), - (".zprofile", "~/.zprofile"), - (".zshenv", "~/.zshenv"), + ("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"), + ("udev", "/etc/udev/rules.d"), + ("modprobe", "/etc/modprobe.d"), + ("sysctl", "/etc/sysctl.d"), ("networkmanager", "/etc/NetworkManager/system-connections"), - ("bluetooth", "/var/lib/bluetooth"), + ("bluetooth", "/var/lib/bluetooth"), ]; /// Directories excluded from every recursive copy. @@ -120,18 +120,22 @@ static DEFAULT_EXCLUDES: &[&str] = &[ /// Directories skipped when searching for git repos. static GIT_SKIP_DIRS: &[&str] = &[ - ".local", "Nextcloud", "target", "node_modules", "__pycache__", - ".cache", "snap", "flatpak", "@girs", "Steam", + ".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 { +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(); @@ -238,8 +242,7 @@ pub fn stage_export( 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")?; + 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(), @@ -292,9 +295,7 @@ pub fn stage_export( 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}" - ), + Err(e) => eprintln!("bread: warning: package snapshot for {manager} failed: {e}"), } } } @@ -307,10 +308,18 @@ pub fn stage_export( // 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())) + 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(", ")); + eprintln!( + "bread: skipping Nextcloud-tracked folders: {}", + labels.join(", ") + ); } let repos = find_git_repos(&home); commit_and_push_repos(&repos, &home); @@ -565,10 +574,7 @@ fn commit_and_push_repos(repos: &[GitRepoRecord], home: &Path) { .output(); match push { Ok(o) if o.status.success() => eprintln!("ok"), - Ok(o) => eprintln!( - "failed: {}", - String::from_utf8_lossy(&o.stderr).trim() - ), + Ok(o) => eprintln!("failed: {}", String::from_utf8_lossy(&o.stderr).trim()), Err(e) => eprintln!("failed: {}", e), } } @@ -611,7 +617,15 @@ fn find_git_repos(home: &Path) -> Vec { 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"] { + 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); @@ -630,7 +644,14 @@ fn find_git_repos(home: &Path) -> Vec { repos } -fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut Vec, nc_dirs: &[PathBuf]) { +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; @@ -655,7 +676,11 @@ fn walk_repos(dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut V .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| dir.to_string_lossy().to_string()); - repos.push(GitRepoRecord { path: rel, remote, branch }); + repos.push(GitRepoRecord { + path: rel, + remote, + branch, + }); } } return; // don't recurse into git repos (skip submodules) @@ -700,7 +725,9 @@ fn install_packages_from(packages_dir: &Path) -> Result<()> { 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 _ = std::process::Command::new("cargo") + .args(["install", &pkg]) + .status(); } } let pip_file = packages_dir.join("pip.txt"); @@ -713,7 +740,9 @@ fn install_packages_from(packages_dir: &Path) -> Result<()> { 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(); + let _ = std::process::Command::new("npm") + .args(["install", "-g", &pkg]) + .status(); } } Ok(()) @@ -787,7 +816,9 @@ fn generate_restore_sh(manifest: &ExportManifest) -> 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"); + 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"); @@ -832,9 +863,7 @@ fn generate_restore_sh(manifest: &ExportManifest) -> String { 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!("if [ ! -d \"{dest}/.git\" ]; then\n")); s.push_str(&format!( " git clone --branch {branch} {remote} \"{dest}\" && echo \"[OK] ~/{}\"\n", repo.path diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index 7d8620f..d0c6485 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadd" -version = "1.0.0" +version = "0.6.0" edition = "2021" [dependencies] diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 80214e1..d2bb31e 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Breadway pkgname=bread -pkgver=1.0.0 +pkgver=0.6.0 pkgrel=1 pkgdesc="A reactive automation fabric for Linux desktops" arch=('x86_64') From 8730214a6e5d281dc01cb3a161682827c964be8e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 17 May 2026 08:40:13 +0800 Subject: [PATCH 41/76] Fix CI tar path --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce7614f..bda65bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - name: Package artifacts run: | mkdir -p dist - tar -czf dist/bread-ubuntu-latest.tgz target/release/breadd target/release/bread-cli + tar -czf dist/bread-ubuntu-latest.tgz target/release/breadd target/release/bread - name: Upload artifacts uses: actions/upload-artifact@v4 with: From e57f085e37303a178bb67bf80c77e2a5bc875a55 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 17 May 2026 08:40:13 +0800 Subject: [PATCH 42/76] Fix CI tar path --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce7614f..bda65bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - name: Package artifacts run: | mkdir -p dist - tar -czf dist/bread-ubuntu-latest.tgz target/release/breadd target/release/bread-cli + tar -czf dist/bread-ubuntu-latest.tgz target/release/breadd target/release/bread - name: Upload artifacts uses: actions/upload-artifact@v4 with: From d2efb9913bb790976c4a0424ba47295e8d475599 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:31:01 +0800 Subject: [PATCH 43/76] Add bakery.toml and release workflow Wires bread into the bakery ecosystem: prebuilt binaries are published to dl.breadway.dev and GitHub Releases on every v* tag via the self-hosted hestia runner. bakery install bread downloads, verifies, and wires the systemd unit automatically. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 61 +++++++++++++++++++++++++++++++++++ bakery.toml | 18 +++++++++++ 2 files changed, 79 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 bakery.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..615aa10 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: release + +on: + push: + tags: ["v*"] + +env: + DL_DIR: /srv/breadway-dl + ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + + - name: build + run: cargo build --release --locked + + - name: test + run: cargo test --release --locked --workspace + + - name: prepare artifacts + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/bread/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadd bread; do + cp "target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp packaging/systemd/breadd.service "${PKG_DIR}/" + cp bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${PKG_DIR}" "${DL_DIR}/bread/latest" + + - name: ensure bread-ecosystem + run: | + if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then + git -C "${ECOSYSTEM_DIR}" pull --ff-only + else + mkdir -p "$(dirname "${ECOSYSTEM_DIR}")" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + fi + + - name: regenerate index.json + run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" + + - name: upload to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/bread/${VERSION}" + gh release upload "${GITHUB_REF_NAME}" \ + "${PKG_DIR}/breadd-x86_64" \ + "${PKG_DIR}/bread-x86_64" \ + "${PKG_DIR}/breadd-x86_64.sha256" \ + "${PKG_DIR}/bread-x86_64.sha256" \ + --clobber diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..52dff3c --- /dev/null +++ b/bakery.toml @@ -0,0 +1,18 @@ +name = "bread" +description = "Reactive automation daemon and CLI for Linux desktops" +binaries = ["breadd", "bread"] +system_deps = [] +bread_deps = [] + +[[service]] +unit = "breadd.service" +enable = true + +[config] +dir = "~/.config/bread" +example = "breadd.toml" + +[install] +post_install = [ + "systemctl --user is-active --quiet breadd || systemctl --user start breadd", +] From 76e503b837e35a90a00f2ed37f1198adbf8195c6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:31:01 +0800 Subject: [PATCH 44/76] Add bakery.toml and release workflow Wires bread into the bakery ecosystem: prebuilt binaries are published to dl.breadway.dev and GitHub Releases on every v* tag via the self-hosted hestia runner. bakery install bread downloads, verifies, and wires the systemd unit automatically. --- .github/workflows/release.yml | 61 +++++++++++++++++++++++++++++++++++ bakery.toml | 18 +++++++++++ 2 files changed, 79 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 bakery.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..615aa10 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: release + +on: + push: + tags: ["v*"] + +env: + DL_DIR: /srv/breadway-dl + ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + + - name: build + run: cargo build --release --locked + + - name: test + run: cargo test --release --locked --workspace + + - name: prepare artifacts + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/bread/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadd bread; do + cp "target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp packaging/systemd/breadd.service "${PKG_DIR}/" + cp bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${PKG_DIR}" "${DL_DIR}/bread/latest" + + - name: ensure bread-ecosystem + run: | + if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then + git -C "${ECOSYSTEM_DIR}" pull --ff-only + else + mkdir -p "$(dirname "${ECOSYSTEM_DIR}")" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + fi + + - name: regenerate index.json + run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" + + - name: upload to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/bread/${VERSION}" + gh release upload "${GITHUB_REF_NAME}" \ + "${PKG_DIR}/breadd-x86_64" \ + "${PKG_DIR}/bread-x86_64" \ + "${PKG_DIR}/breadd-x86_64.sha256" \ + "${PKG_DIR}/bread-x86_64.sha256" \ + --clobber diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..52dff3c --- /dev/null +++ b/bakery.toml @@ -0,0 +1,18 @@ +name = "bread" +description = "Reactive automation daemon and CLI for Linux desktops" +binaries = ["breadd", "bread"] +system_deps = [] +bread_deps = [] + +[[service]] +unit = "breadd.service" +enable = true + +[config] +dir = "~/.config/bread" +example = "breadd.toml" + +[install] +post_install = [ + "systemctl --user is-active --quiet breadd || systemctl --user start breadd", +] From 39710f1a6bf068db5061081a0777c92a53b67db3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:19:48 +0800 Subject: [PATCH 45/76] fix: add missing build deps for hestia (Ubuntu) runner --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 615aa10..c03d0fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: install build deps + run: sudo apt-get install -y libudev-dev libdbus-1-dev pkg-config 2>/dev/null || true + - name: build run: cargo build --release --locked From 0f430e873db9f4e8a2b384079839bba489420159 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:19:48 +0800 Subject: [PATCH 46/76] fix: add missing build deps for hestia (Ubuntu) runner --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 615aa10..c03d0fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: install build deps + run: sudo apt-get install -y libudev-dev libdbus-1-dev pkg-config 2>/dev/null || true + - name: build run: cargo build --release --locked From 81e8155e098ad336c4576d0946647d0feb0af282 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:43:08 +0800 Subject: [PATCH 47/76] fix: skip integration tests in CI (require live daemon) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c03d0fc..4da722c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: run: cargo build --release --locked - name: test - run: cargo test --release --locked --workspace + run: cargo test --release --locked --workspace --lib - name: prepare artifacts run: | From 109b11c77f19f80f0dfa92b351f3ab9d51bfb253 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:43:08 +0800 Subject: [PATCH 48/76] fix: skip integration tests in CI (require live daemon) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c03d0fc..4da722c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: run: cargo build --release --locked - name: test - run: cargo test --release --locked --workspace + run: cargo test --release --locked --workspace --lib - name: prepare artifacts run: | From e967dca1df8386dbffeb7720ebfadfe13d6f466a Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:52:34 +0800 Subject: [PATCH 49/76] fix: create GitHub Release before uploading artifacts --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4da722c..51f93ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,8 @@ jobs: run: | VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="${DL_DIR}/bread/${VERSION}" + gh release create "${GITHUB_REF_NAME}" \ + --title "bread v${VERSION}" --generate-notes 2>/dev/null || true gh release upload "${GITHUB_REF_NAME}" \ "${PKG_DIR}/breadd-x86_64" \ "${PKG_DIR}/bread-x86_64" \ From a9b199259836aac1a474e52bb11f84e053482e6a Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:52:34 +0800 Subject: [PATCH 50/76] fix: create GitHub Release before uploading artifacts --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4da722c..51f93ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,8 @@ jobs: run: | VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="${DL_DIR}/bread/${VERSION}" + gh release create "${GITHUB_REF_NAME}" \ + --title "bread v${VERSION}" --generate-notes 2>/dev/null || true gh release upload "${GITHUB_REF_NAME}" \ "${PKG_DIR}/breadd-x86_64" \ "${PKG_DIR}/bread-x86_64" \ From 13abad22c246a2451e1937dc7e7045ea0de20472 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 00:00:45 +0800 Subject: [PATCH 51/76] fix: add contents: write permission for GitHub Release creation Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51f93ee..acde6e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: tags: ["v*"] +permissions: + contents: write + env: DL_DIR: /srv/breadway-dl ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem From 3025c485d17dbbfbcc3c67606ed789540e2e7004 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 00:00:45 +0800 Subject: [PATCH 52/76] fix: add contents: write permission for GitHub Release creation --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51f93ee..acde6e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: tags: ["v*"] +permissions: + contents: write + env: DL_DIR: /srv/breadway-dl ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem From 2022ffc7de37b5537d14f15e087aaff68e366686 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 09:00:23 +0800 Subject: [PATCH 53/76] fix: use relative symlink for latest to work inside Docker containers Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acde6e3..cacfb7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: done cp packaging/systemd/breadd.service "${PKG_DIR}/" cp bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${PKG_DIR}" "${DL_DIR}/bread/latest" + ln -sfn "${VERSION}" "${DL_DIR}/bread/latest" - name: ensure bread-ecosystem run: | From db4d82f219dede14ddf6081d2bbeb466ce323b76 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 09:00:23 +0800 Subject: [PATCH 54/76] fix: use relative symlink for latest to work inside Docker containers --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acde6e3..cacfb7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: done cp packaging/systemd/breadd.service "${PKG_DIR}/" cp bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${PKG_DIR}" "${DL_DIR}/bread/latest" + ln -sfn "${VERSION}" "${DL_DIR}/bread/latest" - name: ensure bread-ecosystem run: | From 895f2e04a7a1f00b730b006c8fe1c8f9a8d0d7a7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 13:37:30 +0800 Subject: [PATCH 55/76] fix: update system_deps to accurate Arch package names Required: systemd-libs (libudev.so.1), openssl, zlib (bread CLI via git2) Optional: bluez (Bluetooth, graceful degradation), hyprland (IPC features) Removes empty system_deps placeholder. Co-Authored-By: Claude Sonnet 4.6 --- bakery.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bakery.toml b/bakery.toml index 52dff3c..ab782a0 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,7 +1,8 @@ name = "bread" description = "Reactive automation daemon and CLI for Linux desktops" binaries = ["breadd", "bread"] -system_deps = [] +system_deps = ["systemd-libs", "openssl", "zlib"] +optional_system_deps = ["bluez", "hyprland"] bread_deps = [] [[service]] From 9bbadc522192c9c4794a488b747a1ce163d55855 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 13:37:30 +0800 Subject: [PATCH 56/76] fix: update system_deps to accurate Arch package names Required: systemd-libs (libudev.so.1), openssl, zlib (bread CLI via git2) Optional: bluez (Bluetooth, graceful degradation), hyprland (IPC features) Removes empty system_deps placeholder. --- bakery.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bakery.toml b/bakery.toml index 52dff3c..ab782a0 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,7 +1,8 @@ name = "bread" description = "Reactive automation daemon and CLI for Linux desktops" binaries = ["breadd", "bread"] -system_deps = [] +system_deps = ["systemd-libs", "openssl", "zlib"] +optional_system_deps = ["bluez", "hyprland"] bread_deps = [] [[service]] From 981a08e89f62b4129fee906b8f3a8f9db7e8ac64 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:21:13 +0800 Subject: [PATCH 57/76] chore: bump version to 0.6.1 --- bread-cli/Cargo.toml | 2 +- bread-shared/Cargo.toml | 2 +- breadd/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index c43d83d..5bdcb14 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-cli" -version = "0.6.0" +version = "0.6.1" edition = "2021" [[bin]] diff --git a/bread-shared/Cargo.toml b/bread-shared/Cargo.toml index 66c3118..aa4fe61 100644 --- a/bread-shared/Cargo.toml +++ b/bread-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-shared" -version = "0.6.0" +version = "0.6.1" edition = "2021" [dependencies] diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index d0c6485..19a3a67 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadd" -version = "0.6.0" +version = "0.6.1" edition = "2021" [dependencies] From 32982b96de4ce585160b940a3ba9240a3dabadcb Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:21:13 +0800 Subject: [PATCH 58/76] chore: bump version to 0.6.1 --- bread-cli/Cargo.toml | 2 +- bread-shared/Cargo.toml | 2 +- breadd/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml index c43d83d..5bdcb14 100644 --- a/bread-cli/Cargo.toml +++ b/bread-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-cli" -version = "0.6.0" +version = "0.6.1" edition = "2021" [[bin]] diff --git a/bread-shared/Cargo.toml b/bread-shared/Cargo.toml index 66c3118..aa4fe61 100644 --- a/bread-shared/Cargo.toml +++ b/bread-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bread-shared" -version = "0.6.0" +version = "0.6.1" edition = "2021" [dependencies] diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml index d0c6485..19a3a67 100644 --- a/breadd/Cargo.toml +++ b/breadd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadd" -version = "0.6.0" +version = "0.6.1" edition = "2021" [dependencies] From 48bf09ad5bacfabb9122998e1093b5ad9d9dc691 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:27:53 +0800 Subject: [PATCH 59/76] chore: update Cargo.lock for v0.6.1 --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01f9fde..3f631e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,7 +293,7 @@ dependencies = [ [[package]] name = "bread-cli" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "bread-shared", @@ -311,7 +311,7 @@ dependencies = [ [[package]] name = "bread-shared" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde", "serde_json", @@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "breadd" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "async-trait", From 3ccb041778363e6f4d6a76376f3b4b5ae4542ab2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:27:53 +0800 Subject: [PATCH 60/76] chore: update Cargo.lock for v0.6.1 --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01f9fde..3f631e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,7 +293,7 @@ dependencies = [ [[package]] name = "bread-cli" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "bread-shared", @@ -311,7 +311,7 @@ dependencies = [ [[package]] name = "bread-shared" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde", "serde_json", @@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "breadd" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "async-trait", From 289317394485c72006e564b5be24a4eec4b52f7c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 11:42:06 +0800 Subject: [PATCH 61/76] Add Forgejo Actions workflows for mirroring and package publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .forgejo/workflows/mirror.yml: mirrors every push/tag to GitHub - .forgejo/workflows/package.yml: builds PKGBUILD on tag and publishes the bread package to the Forgejo Arch registry (distrib=breadway) Requires two Forgejo secrets: GITHUB_MIRROR_TOKEN — GitHub PAT with repo push scope FORGEJO_TOKEN — Forgejo token with package:write scope Co-Authored-By: Claude Sonnet 4.6 --- .forgejo/workflows/mirror.yml | 20 +++++++++++++++ .forgejo/workflows/package.yml | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .forgejo/workflows/mirror.yml create mode 100644 .forgejo/workflows/package.yml diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..7f4005d --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,20 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Push to GitHub + run: | + git remote add github \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/bread.git" + git push github --mirror diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..919377f --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -0,0 +1,46 @@ +name: Build and publish package + +on: + push: + tags: ['v*'] + +jobs: + package: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + options: --privileged + + steps: + - uses: actions/checkout@v4 + + - name: Set version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV + + - name: Install build dependencies + run: pacman -Syu --noconfirm base-devel git rust cargo libgit2 openssl + + - name: Create builder user + run: useradd -m builder + + - name: Prepare source + run: | + git archive --format=tar.gz \ + --prefix=bread-${VERSION}/ \ + HEAD > packaging/arch/bread-${VERSION}.tar.gz + SHA=$(sha256sum packaging/arch/bread-${VERSION}.tar.gz | awk '{print $1}') + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD + cp -r . /home/builder/src + chown -R builder:builder /home/builder/src + + - name: Build package + run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" + + - name: Publish to Forgejo registry + run: | + PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ + --upload-file "${PKG}" \ + "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" From 4446b5e98b5472d40f1885b9bf460d76a90ee759 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 11:42:06 +0800 Subject: [PATCH 62/76] Add Forgejo Actions workflows for mirroring and package publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .forgejo/workflows/mirror.yml: mirrors every push/tag to GitHub - .forgejo/workflows/package.yml: builds PKGBUILD on tag and publishes the bread package to the Forgejo Arch registry (distrib=breadway) Requires two Forgejo secrets: GITHUB_MIRROR_TOKEN — GitHub PAT with repo push scope FORGEJO_TOKEN — Forgejo token with package:write scope --- .forgejo/workflows/mirror.yml | 20 +++++++++++++++ .forgejo/workflows/package.yml | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .forgejo/workflows/mirror.yml create mode 100644 .forgejo/workflows/package.yml diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..7f4005d --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,20 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Push to GitHub + run: | + git remote add github \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/bread.git" + git push github --mirror diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..919377f --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -0,0 +1,46 @@ +name: Build and publish package + +on: + push: + tags: ['v*'] + +jobs: + package: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + options: --privileged + + steps: + - uses: actions/checkout@v4 + + - name: Set version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV + + - name: Install build dependencies + run: pacman -Syu --noconfirm base-devel git rust cargo libgit2 openssl + + - name: Create builder user + run: useradd -m builder + + - name: Prepare source + run: | + git archive --format=tar.gz \ + --prefix=bread-${VERSION}/ \ + HEAD > packaging/arch/bread-${VERSION}.tar.gz + SHA=$(sha256sum packaging/arch/bread-${VERSION}.tar.gz | awk '{print $1}') + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD + cp -r . /home/builder/src + chown -R builder:builder /home/builder/src + + - name: Build package + run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" + + - name: Publish to Forgejo registry + run: | + PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ + --upload-file "${PKG}" \ + "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" From c21474148a2d8ad24534650ed7b068912326a8e2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:01:58 +0800 Subject: [PATCH 63/76] Fix Forgejo workflows for the actual server capabilities - package.yml: correct Arch registry upload (octet-stream + binary body), drop --privileged, manual shell clone (archlinux image has no Node), built-in Actions token, --nocheck - mirror.yml: clone --mirror + explicit refs push with --prune Requires only the GITHUB_MIRROR_TOKEN secret for the mirror job. Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/mirror.yml | 17 ++++++------ .forgejo/workflows/package.yml | 48 +++++++++++++++------------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 7f4005d..6b6e480 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -9,12 +9,13 @@ jobs: mirror: runs-on: [self-hosted, hestia] steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Push to GitHub + - name: Mirror to GitHub run: | - git remote add github \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/bread.git" - git push github --mirror + set -euo pipefail + git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); + # --prune deletes GitHub refs that no longer exist on Forgejo. + git push --prune \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/bread.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 919377f..2a64145 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -9,38 +9,32 @@ jobs: runs-on: [self-hosted, hestia] container: image: archlinux:latest - options: --privileged - steps: - - uses: actions/checkout@v4 - - - name: Set version - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV - - - name: Install build dependencies - run: pacman -Syu --noconfirm base-devel git rust cargo libgit2 openssl - - - name: Create builder user - run: useradd -m builder - - - name: Prepare source + # Note: no actions/checkout — the archlinux image has no Node, which JS + # actions require. Everything runs as shell steps and clones manually. + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - git archive --format=tar.gz \ - --prefix=bread-${VERSION}/ \ - HEAD > packaging/arch/bread-${VERSION}.tar.gz + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + pacman -Syu --noconfirm base-devel git rust cargo libgit2 openssl + useradd -m builder + git config --global --add safe.directory '*' + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + cd /home/builder/src + git archive --format=tar.gz --prefix="bread-${VERSION}/" HEAD \ + > packaging/arch/bread-${VERSION}.tar.gz SHA=$(sha256sum packaging/arch/bread-${VERSION}.tar.gz | awk '{print $1}') sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - cp -r . /home/builder/src chown -R builder:builder /home/builder/src - - - name: Build package - run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" - - - name: Publish to Forgejo registry - run: | + # --nocheck: packaging builds the artifact; tests belong in a CI job. + su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) curl -fsS -X PUT \ - -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ - --upload-file "${PKG}" \ - "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" From c70c9a7278badb7b3c1ec6c90d7c35d68f4db0cb Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:01:58 +0800 Subject: [PATCH 64/76] Fix Forgejo workflows for the actual server capabilities - package.yml: correct Arch registry upload (octet-stream + binary body), drop --privileged, manual shell clone (archlinux image has no Node), built-in Actions token, --nocheck - mirror.yml: clone --mirror + explicit refs push with --prune Requires only the GITHUB_MIRROR_TOKEN secret for the mirror job. --- .forgejo/workflows/mirror.yml | 17 ++++++------ .forgejo/workflows/package.yml | 48 +++++++++++++++------------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 7f4005d..6b6e480 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -9,12 +9,13 @@ jobs: mirror: runs-on: [self-hosted, hestia] steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Push to GitHub + - name: Mirror to GitHub run: | - git remote add github \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/bread.git" - git push github --mirror + set -euo pipefail + git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); + # --prune deletes GitHub refs that no longer exist on Forgejo. + git push --prune \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/bread.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 919377f..2a64145 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -9,38 +9,32 @@ jobs: runs-on: [self-hosted, hestia] container: image: archlinux:latest - options: --privileged - steps: - - uses: actions/checkout@v4 - - - name: Set version - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV - - - name: Install build dependencies - run: pacman -Syu --noconfirm base-devel git rust cargo libgit2 openssl - - - name: Create builder user - run: useradd -m builder - - - name: Prepare source + # Note: no actions/checkout — the archlinux image has no Node, which JS + # actions require. Everything runs as shell steps and clones manually. + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - git archive --format=tar.gz \ - --prefix=bread-${VERSION}/ \ - HEAD > packaging/arch/bread-${VERSION}.tar.gz + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + pacman -Syu --noconfirm base-devel git rust cargo libgit2 openssl + useradd -m builder + git config --global --add safe.directory '*' + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + cd /home/builder/src + git archive --format=tar.gz --prefix="bread-${VERSION}/" HEAD \ + > packaging/arch/bread-${VERSION}.tar.gz SHA=$(sha256sum packaging/arch/bread-${VERSION}.tar.gz | awk '{print $1}') sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - cp -r . /home/builder/src chown -R builder:builder /home/builder/src - - - name: Build package - run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" - - - name: Publish to Forgejo registry - run: | + # --nocheck: packaging builds the artifact; tests belong in a CI job. + su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) curl -fsS -X PUT \ - -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ - --upload-file "${PKG}" \ - "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" From 6b14032d3eb2603c0806449cc8b29b3cf1c991aa Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:10:49 +0800 Subject: [PATCH 65/76] Rename mirror secret to MIRROR_TOKEN (GITHUB_ prefix is reserved) Forgejo/gitea rejects user secret names starting with GITHUB_. Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/mirror.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 6b6e480..45d36e3 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -17,5 +17,5 @@ jobs: # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. git push --prune \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/bread.git" \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bread.git" \ '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From 73f01e97b4f586ad77d2c0eded6a2ef3a8c8009d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:10:49 +0800 Subject: [PATCH 66/76] Rename mirror secret to MIRROR_TOKEN (GITHUB_ prefix is reserved) Forgejo/gitea rejects user secret names starting with GITHUB_. --- .forgejo/workflows/mirror.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 6b6e480..45d36e3 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -17,5 +17,5 @@ jobs: # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. git push --prune \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/bread.git" \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bread.git" \ '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From 746e03a5a89dfff54474250048da6808e3e1f4be Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:14:13 +0800 Subject: [PATCH 67/76] Clone from public URL, not GITHUB_SERVER_URL (resolves to localhost in runner) The Forgejo runner injects GITHUB_SERVER_URL as http://localhost:3002, which is unreachable from inside the job container. Use the public URL instead. Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/mirror.yml | 2 +- .forgejo/workflows/package.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 45d36e3..5cee7d9 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -12,7 +12,7 @@ jobs: - name: Mirror to GitHub run: | set -euo pipefail - git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git cd repo.git # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 2a64145..86a3a38 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -22,7 +22,7 @@ jobs: useradd -m builder git config --global --add safe.directory '*' git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src cd /home/builder/src git archive --format=tar.gz --prefix="bread-${VERSION}/" HEAD \ > packaging/arch/bread-${VERSION}.tar.gz From d454e832d9a4e0fdb748b98833622cb89479576d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:14:13 +0800 Subject: [PATCH 68/76] Clone from public URL, not GITHUB_SERVER_URL (resolves to localhost in runner) The Forgejo runner injects GITHUB_SERVER_URL as http://localhost:3002, which is unreachable from inside the job container. Use the public URL instead. --- .forgejo/workflows/mirror.yml | 2 +- .forgejo/workflows/package.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 45d36e3..5cee7d9 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -12,7 +12,7 @@ jobs: - name: Mirror to GitHub run: | set -euo pipefail - git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git cd repo.git # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 2a64145..86a3a38 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -22,7 +22,7 @@ jobs: useradd -m builder git config --global --add safe.directory '*' git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src cd /home/builder/src git archive --format=tar.gz --prefix="bread-${VERSION}/" HEAD \ > packaging/arch/bread-${VERSION}.tar.gz From 01a95ecbbff1448e62219ba865dc771a0a19e3e2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:52:54 +0800 Subject: [PATCH 69/76] Disable LTO in PKGBUILD to fix vendored Lua static link makepkg's default -flto=auto made mlua's vendored liblua5.4.a contain GCC LTO bitcode that the Rust (lld) link couldn't read, leaving all lua_* symbols undefined. options=(!lto) produces a clean static link. Verified building in a clean archlinux container. Co-Authored-By: Claude Opus 4.8 --- packaging/arch/PKGBUILD | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index d2bb31e..55649a7 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -7,6 +7,10 @@ pkgdesc="A reactive automation fabric for Linux desktops" arch=('x86_64') url="https://github.com/Breadway/bread" license=('MIT') +# mlua builds Lua from vendored C source. makepkg's default -flto=auto would +# emit GCC LTO bitcode into liblua5.4.a, which the Rust (lld) link can't read, +# leaving all lua_* symbols undefined. Disable LTO for a clean static link. +options=(!lto) depends=('glibc' 'libgit2') optdepends=( 'libnotify: desktop notifications via bread.notify()' From 64e756f6ebe9963b3d412d8804d64854a7d9a766 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:52:54 +0800 Subject: [PATCH 70/76] Disable LTO in PKGBUILD to fix vendored Lua static link makepkg's default -flto=auto made mlua's vendored liblua5.4.a contain GCC LTO bitcode that the Rust (lld) link couldn't read, leaving all lua_* symbols undefined. options=(!lto) produces a clean static link. Verified building in a clean archlinux container. --- packaging/arch/PKGBUILD | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index d2bb31e..55649a7 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -7,6 +7,10 @@ pkgdesc="A reactive automation fabric for Linux desktops" arch=('x86_64') url="https://github.com/Breadway/bread" license=('MIT') +# mlua builds Lua from vendored C source. makepkg's default -flto=auto would +# emit GCC LTO bitcode into liblua5.4.a, which the Rust (lld) link can't read, +# leaving all lua_* symbols undefined. Disable LTO for a clean static link. +options=(!lto) depends=('glibc' 'libgit2') optdepends=( 'libnotify: desktop notifications via bread.notify()' From abf748b8148af8d032ba859bd7c801c0273eca52 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 22:55:39 +0800 Subject: [PATCH 71/76] Use REGISTRY_TOKEN (scoped write:package) for registry publish Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 86a3a38..f48f9d8 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -14,7 +14,7 @@ jobs: # actions require. Everything runs as shell steps and clones manually. - name: Build and publish env: - PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" From 623560cea661cc35c5a9f3b35a93c8aa572981a6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 22:55:39 +0800 Subject: [PATCH 72/76] Use REGISTRY_TOKEN (scoped write:package) for registry publish --- .forgejo/workflows/package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 86a3a38..f48f9d8 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -14,7 +14,7 @@ jobs: # actions require. Everything runs as shell steps and clones manually. - name: Build and publish env: - PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" From 13f54ad4ca826d2bab8034cff130290ee213e165 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 23:00:46 +0800 Subject: [PATCH 73/76] Disable debug package so the main package publishes correctly makepkg's debug split produced a -debug pkg; the upload's head -1 could grab it instead of the main package. !debug yields a single package. Co-Authored-By: Claude Opus 4.8 --- packaging/arch/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 55649a7..520280e 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -10,7 +10,7 @@ license=('MIT') # mlua builds Lua from vendored C source. makepkg's default -flto=auto would # emit GCC LTO bitcode into liblua5.4.a, which the Rust (lld) link can't read, # leaving all lua_* symbols undefined. Disable LTO for a clean static link. -options=(!lto) +options=(!lto !debug) depends=('glibc' 'libgit2') optdepends=( 'libnotify: desktop notifications via bread.notify()' From 152915198bca9751c835eb49e9b6d4db21da9501 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 23:00:46 +0800 Subject: [PATCH 74/76] Disable debug package so the main package publishes correctly makepkg's debug split produced a -debug pkg; the upload's head -1 could grab it instead of the main package. !debug yields a single package. --- packaging/arch/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 55649a7..520280e 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -10,7 +10,7 @@ license=('MIT') # mlua builds Lua from vendored C source. makepkg's default -flto=auto would # emit GCC LTO bitcode into liblua5.4.a, which the Rust (lld) link can't read, # leaving all lua_* symbols undefined. Disable LTO for a clean static link. -options=(!lto) +options=(!lto !debug) depends=('glibc' 'libgit2') optdepends=( 'libnotify: desktop notifications via bread.notify()' From c47d50d16e792490360e10e43481f2eb11a2d1bc Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 17:06:44 +0800 Subject: [PATCH 75/76] Add ready-to-use example modules examples/modules/ ships complete, drop-in bread modules for common desktop automations (low-battery warning, pause-media-on-headphone-unplug, dock-monitors) plus a README on installing them. Complements Examples.md, which teaches the porting patterns. Co-Authored-By: Claude Opus 4.8 --- examples/modules/README.md | 28 ++++++++++++++++++ examples/modules/dock-monitors.lua | 26 +++++++++++++++++ examples/modules/low-battery-warning.lua | 29 +++++++++++++++++++ .../pause-media-on-headphone-unplug.lua | 25 ++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 examples/modules/README.md create mode 100644 examples/modules/dock-monitors.lua create mode 100644 examples/modules/low-battery-warning.lua create mode 100644 examples/modules/pause-media-on-headphone-unplug.lua diff --git a/examples/modules/README.md b/examples/modules/README.md new file mode 100644 index 0000000..e9636ec --- /dev/null +++ b/examples/modules/README.md @@ -0,0 +1,28 @@ +# Example bread modules + +Ready-to-use modules for common desktop automations. Unlike the snippets in +[`../../Examples.md`](../../Examples.md) (which teach the porting patterns), +these are complete files you can drop in as-is. + +## Installing + +Modules in `~/.config/bread/modules/` are **auto-discovered** — copy a file in +and reload; no `init.lua` edit needed: + +```sh +cp low-battery-warning.lua ~/.config/bread/modules/ +bread reload +``` + +## Modules + +| File | What it does | Config needed | +|------|--------------|---------------| +| `low-battery-warning.lua` | Critical notification once when the battery runs low; resets on AC. | none | +| `pause-media-on-headphone-unplug.lua` | Runs `playerctl pause` when a headphone/earbud device disconnects. | none (needs `playerctl`) | +| `dock-monitors.lua` | Applies a multi-monitor layout when an external display connects, reverts when removed. | edit output names/resolutions | + +Each module is the standard skeleton — `bread.module{...}`, an `on_load` that +registers subscriptions, `return M` — so they double as references for writing +your own. See [`../../Documentation.md`](../../Documentation.md) for the full +event list and Lua API. diff --git a/examples/modules/dock-monitors.lua b/examples/modules/dock-monitors.lua new file mode 100644 index 0000000..1fd92a6 --- /dev/null +++ b/examples/modules/dock-monitors.lua @@ -0,0 +1,26 @@ +-- dock-monitors — apply a monitor layout when an external display is plugged +-- in (a "dock") and revert to the laptop panel when it's removed. +-- +-- Drop-in: copy into ~/.config/bread/modules/ and edit the output names / +-- resolutions for your machine (see `hyprctl monitors`). + +local monitors = require("bread.monitors") +local M = bread.module({ name = "dock-monitors", version = "1.0.0" }) + +-- Named layouts ---------------------------------------------------------------- +monitors.layout("docked", function() + bread.hyprland.keyword("monitor", "eDP-1, 1920x1200@60, 0x0, 1") + bread.hyprland.keyword("monitor", "HDMI-A-1, preferred, 1920x0, 1") +end) + +monitors.layout("solo", function() + bread.hyprland.keyword("monitor", "eDP-1, preferred, 0x0, 1") +end) + +-- React to the external display ------------------------------------------------ +function M.on_load() + monitors.on({ when = "connected", monitors = { "HDMI-A-1" }, run = monitors.apply("docked") }) + monitors.on({ when = "disconnected", monitors = { "HDMI-A-1" }, run = monitors.apply("solo") }) +end + +return M diff --git a/examples/modules/low-battery-warning.lua b/examples/modules/low-battery-warning.lua new file mode 100644 index 0000000..eb68839 --- /dev/null +++ b/examples/modules/low-battery-warning.lua @@ -0,0 +1,29 @@ +-- low-battery-warning — notify once when the battery runs low. +-- +-- Drop-in: copy into ~/.config/bread/modules/ (auto-discovered; no init.lua +-- edit needed). Zero configuration. + +local M = bread.module({ name = "low-battery-warning", version = "1.0.0" }) + +-- Latch so we warn once per low-battery episode, not on every poll. +local warned = false + +function M.on_load() + bread.on("bread.power.battery.low", function(event) + if warned then return end + warned = true + local pct = event.data.battery_percent or "?" + bread.notify("Battery low (" .. pct .. "%). Plug in soon.", { + urgency = "critical", + title = "Battery", + timeout = 10000, + }) + end) + + -- Reset once back on AC so the next low episode warns again. + bread.on("bread.power.ac.connected", function() + warned = false + end) +end + +return M diff --git a/examples/modules/pause-media-on-headphone-unplug.lua b/examples/modules/pause-media-on-headphone-unplug.lua new file mode 100644 index 0000000..6b6d6e7 --- /dev/null +++ b/examples/modules/pause-media-on-headphone-unplug.lua @@ -0,0 +1,25 @@ +-- pause-media-on-headphone-unplug — pause playback when headphones disconnect, +-- so sound doesn't suddenly blast out of the speakers. +-- +-- Drop-in: copy into ~/.config/bread/modules/. Requires `playerctl`. + +local M = bread.module({ name = "pause-media-on-headphone-unplug", version = "1.0.0" }) + +local function looks_like_headphones(name) + if not name then return false end + name = name:lower() + return name:find("head") ~= nil + or name:find("earbud") ~= nil + or name:find("airpod") ~= nil + or name:find("buds") ~= nil +end + +function M.on_load() + bread.on("bread.device.disconnected", function(event) + if looks_like_headphones(event.data.name) then + bread.exec("playerctl pause") + end + end) +end + +return M From 954b7f381e3337838e175ca7c86b47ad2addfccd Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 17:06:44 +0800 Subject: [PATCH 76/76] Add ready-to-use example modules examples/modules/ ships complete, drop-in bread modules for common desktop automations (low-battery warning, pause-media-on-headphone-unplug, dock-monitors) plus a README on installing them. Complements Examples.md, which teaches the porting patterns. --- examples/modules/README.md | 28 ++++++++++++++++++ examples/modules/dock-monitors.lua | 26 +++++++++++++++++ examples/modules/low-battery-warning.lua | 29 +++++++++++++++++++ .../pause-media-on-headphone-unplug.lua | 25 ++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 examples/modules/README.md create mode 100644 examples/modules/dock-monitors.lua create mode 100644 examples/modules/low-battery-warning.lua create mode 100644 examples/modules/pause-media-on-headphone-unplug.lua diff --git a/examples/modules/README.md b/examples/modules/README.md new file mode 100644 index 0000000..e9636ec --- /dev/null +++ b/examples/modules/README.md @@ -0,0 +1,28 @@ +# Example bread modules + +Ready-to-use modules for common desktop automations. Unlike the snippets in +[`../../Examples.md`](../../Examples.md) (which teach the porting patterns), +these are complete files you can drop in as-is. + +## Installing + +Modules in `~/.config/bread/modules/` are **auto-discovered** — copy a file in +and reload; no `init.lua` edit needed: + +```sh +cp low-battery-warning.lua ~/.config/bread/modules/ +bread reload +``` + +## Modules + +| File | What it does | Config needed | +|------|--------------|---------------| +| `low-battery-warning.lua` | Critical notification once when the battery runs low; resets on AC. | none | +| `pause-media-on-headphone-unplug.lua` | Runs `playerctl pause` when a headphone/earbud device disconnects. | none (needs `playerctl`) | +| `dock-monitors.lua` | Applies a multi-monitor layout when an external display connects, reverts when removed. | edit output names/resolutions | + +Each module is the standard skeleton — `bread.module{...}`, an `on_load` that +registers subscriptions, `return M` — so they double as references for writing +your own. See [`../../Documentation.md`](../../Documentation.md) for the full +event list and Lua API. diff --git a/examples/modules/dock-monitors.lua b/examples/modules/dock-monitors.lua new file mode 100644 index 0000000..1fd92a6 --- /dev/null +++ b/examples/modules/dock-monitors.lua @@ -0,0 +1,26 @@ +-- dock-monitors — apply a monitor layout when an external display is plugged +-- in (a "dock") and revert to the laptop panel when it's removed. +-- +-- Drop-in: copy into ~/.config/bread/modules/ and edit the output names / +-- resolutions for your machine (see `hyprctl monitors`). + +local monitors = require("bread.monitors") +local M = bread.module({ name = "dock-monitors", version = "1.0.0" }) + +-- Named layouts ---------------------------------------------------------------- +monitors.layout("docked", function() + bread.hyprland.keyword("monitor", "eDP-1, 1920x1200@60, 0x0, 1") + bread.hyprland.keyword("monitor", "HDMI-A-1, preferred, 1920x0, 1") +end) + +monitors.layout("solo", function() + bread.hyprland.keyword("monitor", "eDP-1, preferred, 0x0, 1") +end) + +-- React to the external display ------------------------------------------------ +function M.on_load() + monitors.on({ when = "connected", monitors = { "HDMI-A-1" }, run = monitors.apply("docked") }) + monitors.on({ when = "disconnected", monitors = { "HDMI-A-1" }, run = monitors.apply("solo") }) +end + +return M diff --git a/examples/modules/low-battery-warning.lua b/examples/modules/low-battery-warning.lua new file mode 100644 index 0000000..eb68839 --- /dev/null +++ b/examples/modules/low-battery-warning.lua @@ -0,0 +1,29 @@ +-- low-battery-warning — notify once when the battery runs low. +-- +-- Drop-in: copy into ~/.config/bread/modules/ (auto-discovered; no init.lua +-- edit needed). Zero configuration. + +local M = bread.module({ name = "low-battery-warning", version = "1.0.0" }) + +-- Latch so we warn once per low-battery episode, not on every poll. +local warned = false + +function M.on_load() + bread.on("bread.power.battery.low", function(event) + if warned then return end + warned = true + local pct = event.data.battery_percent or "?" + bread.notify("Battery low (" .. pct .. "%). Plug in soon.", { + urgency = "critical", + title = "Battery", + timeout = 10000, + }) + end) + + -- Reset once back on AC so the next low episode warns again. + bread.on("bread.power.ac.connected", function() + warned = false + end) +end + +return M diff --git a/examples/modules/pause-media-on-headphone-unplug.lua b/examples/modules/pause-media-on-headphone-unplug.lua new file mode 100644 index 0000000..6b6d6e7 --- /dev/null +++ b/examples/modules/pause-media-on-headphone-unplug.lua @@ -0,0 +1,25 @@ +-- pause-media-on-headphone-unplug — pause playback when headphones disconnect, +-- so sound doesn't suddenly blast out of the speakers. +-- +-- Drop-in: copy into ~/.config/bread/modules/. Requires `playerctl`. + +local M = bread.module({ name = "pause-media-on-headphone-unplug", version = "1.0.0" }) + +local function looks_like_headphones(name) + if not name then return false end + name = name:lower() + return name:find("head") ~= nil + or name:find("earbud") ~= nil + or name:find("airpod") ~= nil + or name:find("buds") ~= nil +end + +function M.on_load() + bread.on("bread.device.disconnected", function(event) + if looks_like_headphones(event.data.name) then + bread.exec("playerctl pause") + end + end) +end + +return M