From 730a8b61d77cf10eec2b3c5be496f57d223b184b Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 11 May 2026 11:56:03 +0800 Subject: [PATCH] Release 1.0 --- .github/workflows/ci.yml | 42 + .gitignore | 1 + Cargo.lock | 2876 ++++++++++++++++++++++ Cargo.toml | 15 + DAEMON.md | 503 ++++ bread-cli/Cargo.toml | 12 + bread-cli/src/main.rs | 158 ++ bread-shared/Cargo.toml | 8 + bread-shared/src/lib.rs | 45 + breadd/Cargo.toml | 27 + breadd/src/adapters/hyprland.rs | 66 + breadd/src/adapters/mod.rs | 109 + breadd/src/adapters/network.rs | 93 + breadd/src/adapters/network_rtnetlink.rs | 151 ++ breadd/src/adapters/power.rs | 92 + breadd/src/adapters/power_upower.rs | 147 ++ breadd/src/adapters/udev.rs | 265 ++ breadd/src/core/config.rs | 228 ++ breadd/src/core/mod.rs | 6 + breadd/src/core/normalizer.rs | 213 ++ breadd/src/core/state_engine.rs | 304 +++ breadd/src/core/subscriptions.rs | 63 + breadd/src/core/supervisor.rs | 65 + breadd/src/core/types.rs | 132 + breadd/src/ipc/mod.rs | 272 ++ breadd/src/lua/mod.rs | 340 +++ breadd/src/main.rs | 128 + breadd/tests/ipc_integration.rs | 210 ++ packaging/README.md | 5 + packaging/arch/PKGBUILD | 25 + packaging/arch/README.md | 9 + packaging/systemd/breadd.service | 19 + 32 files changed, 6629 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 DAEMON.md create mode 100644 bread-cli/Cargo.toml create mode 100644 bread-cli/src/main.rs create mode 100644 bread-shared/Cargo.toml create mode 100644 bread-shared/src/lib.rs create mode 100644 breadd/Cargo.toml create mode 100644 breadd/src/adapters/hyprland.rs create mode 100644 breadd/src/adapters/mod.rs create mode 100644 breadd/src/adapters/network.rs create mode 100644 breadd/src/adapters/network_rtnetlink.rs create mode 100644 breadd/src/adapters/power.rs create mode 100644 breadd/src/adapters/power_upower.rs create mode 100644 breadd/src/adapters/udev.rs create mode 100644 breadd/src/core/config.rs create mode 100644 breadd/src/core/mod.rs create mode 100644 breadd/src/core/normalizer.rs create mode 100644 breadd/src/core/state_engine.rs create mode 100644 breadd/src/core/subscriptions.rs create mode 100644 breadd/src/core/supervisor.rs create mode 100644 breadd/src/core/types.rs create mode 100644 breadd/src/ipc/mod.rs create mode 100644 breadd/src/lua/mod.rs create mode 100644 breadd/src/main.rs create mode 100644 breadd/tests/ipc_integration.rs create mode 100644 packaging/README.md create mode 100644 packaging/arch/PKGBUILD create mode 100644 packaging/arch/README.md create mode 100644 packaging/systemd/breadd.service 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 new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a36c9da --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2876 @@ +# This file is automatically @generated by Cargo. +# 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-broadcast" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" +dependencies = [ + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.4.1", + "futures-lite 2.6.1", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "blocking", + "futures-lite 1.13.0", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite 1.13.0", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.28", + "slab", + "socket2 0.4.10", + "waker-fn", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite 2.6.1", + "parking", + "polling 3.11.0", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88" +dependencies = [ + "async-io 1.13.0", + "async-lock 2.8.0", + "async-signal", + "blocking", + "cfg-if", + "event-listener 3.1.0", + "futures-lite 1.13.0", + "rustix 0.38.44", + "windows-sys 0.48.0", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io 2.6.0", + "async-lock 3.4.2", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite 2.6.1", + "piper", +] + +[[package]] +name = "bread-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "bread-shared", + "clap", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "bread-shared" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "breadd" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bread-shared", + "futures-util", + "hex", + "libc", + "metrics 0.23.1", + "metrics-exporter-prometheus", + "mlua", + "netlink-packet-core", + "netlink-packet-route", + "rtnetlink", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "udev", + "zbus", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93877bcde0eb80ca09131a08d23f0a5c18a620b01db137dba666d18cd9b30c2" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.4.1", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lua-src" +version = "547.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edaf29e3517b49b8b746701e5648ccb5785cde1c119062cbabbc5d5cd115e42" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.5.12+a4f56a4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a8e7962a5368d5f264d045a5a255e90f9aa3fc1941ae15a8d2940d42cac671" +dependencies = [ + "cc", + "which", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mlua" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d111deb18a9c9bd33e1541309f4742523bfab01d276bfa9a27519f6de9c11dc7" +dependencies = [ + "bstr", + "erased-serde", + "futures-util", + "mlua-sys", + "num-traits", + "once_cell", + "rustc-hash", + "serde", + "serde-value", +] + +[[package]] +name = "mlua-sys" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380c1f7e2099cafcf40e51d3a9f20a346977587aa4d012eae1f043149a728a93" +dependencies = [ + "cc", + "cfg-if", + "lua-src", + "luajit-src", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "345b8ab5bd4e71a2986663e88c56856699d060e78e152e6e9d7966fcd5491297" +dependencies = [ + "anyhow", + "byteorder", + "libc", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-route" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "733ea73609acfd7fa7ddadfb7bf709b0471668c456ad9513685af543a06342b2" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "byteorder", + "libc", + "netlink-packet-core", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-utils" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34" +dependencies = [ + "anyhow", + "byteorder", + "paste", + "thiserror", +] + +[[package]] +name = "netlink-proto" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8785b8141e8432aa45fceb922a7e876d7da3fad37fa7e7ec702ace3aa0826b" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "tokio", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "nix" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4916f159ed8e5de0082076562152a76b7a1f64a01fd9d1e0fea002c37624faf" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand 2.4.1", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.4", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rtnetlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f54290e54521dac3de4149d83ddf9f62a359b3cc93bcb494a794a41e6f4744b" +dependencies = [ + "futures", + "log", + "netlink-packet-route", + "netlink-proto", + "nix 0.22.3", + "thiserror", + "tokio", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "0.37.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand 2.4.1", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_write" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "udev" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "waker-fn" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix 1.1.4", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "zbus" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io 1.13.0", + "async-lock 2.8.0", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "byteorder", + "derivative", + "enumflags2", + "event-listener 2.5.3", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.26.4", + "once_cell", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "winapi", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eef2be88ba09b358d3b58aca6e41cd853631d44787f319a1383ca83424fb2db" +dependencies = [ + "byteorder", + "enumflags2", + "libc", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ab4e899 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +members = [ + "bread-shared", + "breadd", + "bread-cli" +] +resolver = "2" + +[workspace.dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.40", features = ["full"] } +anyhow = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/DAEMON.md b/DAEMON.md new file mode 100644 index 0000000..a05b169 --- /dev/null +++ b/DAEMON.md @@ -0,0 +1,503 @@ +# breadd — Daemon Architecture +### The Bread Runtime Daemon + +--- + +## Overview + +`breadd` is the long-running Rust daemon at the center of Bread. It is the canonical source of truth for all desktop runtime state: what hardware is connected, what the compositor is doing, what profile is active, and what events have occurred. + +Everything else in Bread — Lua modules, the CLI, profile logic, automation behavior — exists as a consumer of what `breadd` tracks and exposes. The daemon is the foundation. + +`breadd` does not implement automation. It makes automation possible. + +--- + +## Responsibilities + +At a high level, `breadd` is responsible for six things: + +1. **Adapter management** — spawn and supervise connections to external systems (Hyprland IPC, udev, power, network) +2. **Event ingestion** — receive raw signals from adapters and push them into the pipeline +3. **Event normalization** — transform raw signals into stable, semantic Bread events +4. **State maintenance** — keep a live, structured model of the desktop +5. **Subscription dispatch** — deliver normalized events to Lua module subscribers +6. **IPC** — expose runtime state and control to the CLI and external consumers + +The daemon does not decide what to do when events occur. That is Lua's job. The daemon decides what is true about the system, and tells Lua about it. + +--- + +## Process Model + +`breadd` is a single long-running process started at login (via systemd user service or similar). It runs for the duration of the session. + +``` +breadd (main process) +├── Adapter threads +│ ├── HyprlandAdapter (async task — IPC socket reader) +│ ├── UdevAdapter (async task — netlink listener) +│ ├── PowerAdapter (async task — sysfs / UPower watcher) +│ └── NetworkAdapter (async task — netlink / D-Bus watcher) +├── State Engine (async task — central coordinator) +├── Lua Runtime (dedicated thread — Lua is not Send) +├── IPC Server (async task — Unix socket listener) +└── Watcher (async task — config file watcher, optional) +``` + +The daemon uses Tokio as its async runtime. Most work is non-blocking and event-driven. The Lua runtime runs on a dedicated OS thread because Lua's C bindings are not `Send`-safe; it communicates with the async side through a bounded channel. + +--- + +## Internal Event Pipeline + +Every signal that enters `breadd` flows through the same pipeline before it reaches a Lua module: + +``` +External System + │ + â–¼ + Adapter + (raw ingestion) + │ + │ RawEvent + â–¼ + Normalizer + (semantic interpretation) + │ + │ BreadEvent + â–¼ + State Engine + (state update + fan-out) + │ + ├──► State Store (updated) + │ + └──► Subscription Dispatcher + │ + │ BreadEvent (per subscriber) + â–¼ + Lua Runtime + (module handlers) +``` + +No step is skipped. Raw events never reach Lua directly. Lua never reads from sysfs or a compositor socket directly. The pipeline enforces clean separation between "what the system said" and "what it means." + +--- + +## Core Data Structures + +### RawEvent + +A `RawEvent` is what an adapter produces. It is uninterpreted — it contains only what the external system reported. + +```rust +pub struct RawEvent { + pub source: AdapterSource, // Hyprland | Udev | Power | Network + pub kind: String, // raw event type string from the source + pub payload: serde_json::Value, // raw data, source-specific shape + pub timestamp: u64, // unix milliseconds +} +``` + +### BreadEvent + +A `BreadEvent` is what the normalizer produces. It is stable, versioned, and typed. + +```rust +pub struct BreadEvent { + pub event: String, // "bread.device.dock.connected" + pub timestamp: u64, + pub source: AdapterSource, + pub data: serde_json::Value, // normalized, structured payload +} +``` + +The `event` field follows the namespace convention `bread...`. This string is stable across Bread versions; modules can rely on it without breaking. + +### RuntimeState + +The `RuntimeState` is the daemon's live model of the desktop. It is updated atomically as events arrive. + +```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, +} +``` + +State is stored behind an `Arc>`. Readers (IPC, Lua state queries) take a read lock briefly. The state engine holds the write lock only during update. Contention is minimal because updates are infrequent relative to query frequency. + +--- + +## Adapters + +Each adapter is an independent async task. It owns its connection to an external system and is responsible for reconnection if that connection is lost. + +### Adapter Trait + +```rust +#[async_trait] +pub trait Adapter: Send + Sync { + fn name(&self) -> &str; + async fn run(&self, tx: Sender) -> Result<()>; + async fn on_connect(&self) {} + async fn on_disconnect(&self) {} +} +``` + +Each adapter runs its `run` loop indefinitely, pushing `RawEvent`s into the shared channel. Failures inside `run` trigger a reconnect cycle with exponential backoff — the adapter never terminates the daemon. + +### HyprlandAdapter + +Connects to Hyprland's event socket (`$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock`). Reads newline-delimited event strings and forwards them as `RawEvent`s. + +Handles: +- `monitoradded` / `monitorremoved` +- `workspace` / `workspacev2` +- `activewindow` / `activewindowv2` +- `openwindow` / `closewindow` +- `focusedmon` + +Reconnects if the socket disappears (compositor restart). Buffers events during reconnect to avoid losing the first few signals after the compositor comes back. + +### UdevAdapter + +Uses `tokio-udev` to listen on a netlink socket for kernel device events. Monitors all subsystems relevant to desktop hardware: + +- `usb` — docks, peripherals, hubs +- `input` — keyboards, mice, tablets +- `drm` — display connectors +- `power_supply` — batteries, chargers + +Unlike most adapters, `UdevAdapter` also performs an initial enumeration on startup so the state engine has a full picture of currently-connected hardware before any hotplug events arrive. + +### PowerAdapter + +Reads battery and AC state from `/sys/class/power_supply/`. Polls on a configurable interval (default: 30s) and emits events on meaningful state transitions: + +- AC plugged / unplugged +- Battery level crossing thresholds (20%, 10%, 5%) +- Battery full + +Also subscribes to UPower over D-Bus when available for faster event delivery. + +### NetworkAdapter + +Monitors network interface state via netlink. Emits events when interfaces transition between up and down states, and when the system gains or loses default-route connectivity. + +--- + +## Normalizer + +The normalizer sits between the adapter channel and the state engine. It is a pure function: given a `RawEvent`, produce zero or more `BreadEvent`s. + +```rust +pub trait Normalizer: Send + Sync { + fn normalize(&self, raw: &RawEvent) -> Vec; +} +``` + +Each adapter source has its own normalizer implementation. Normalization is where domain knowledge lives: knowing that a udev `add` event on a `usb` device with certain vendor/product IDs means "dock connected" rather than "generic USB device." + +### Device Classification + +The `UdevNormalizer` maintains a device classifier that maps hardware identifiers to semantic device types: + +```rust +pub enum DeviceClass { + Dock, + Keyboard, + Mouse, + Tablet, + Display, + Storage, + Audio, + Unknown, +} +``` + +Classification is based on udev properties (`ID_INPUT_KEYBOARD`, `ID_USB_CLASS`, subsystem, driver name). Unknown devices are classified as `Unknown` and still emit a generic `bread.device.connected` event — they are never silently dropped. + +### Event Deduplication + +The normalizer tracks recent events and suppresses duplicates within a configurable window (default: 100ms). This prevents rapid-fire hardware oscillation (e.g., a dock that briefly disconnects and reconnects during power negotiation) from flooding the event bus with spurious events. + +--- + +## State Engine + +The state engine is the coordinator. It receives `BreadEvent`s from the normalizer, updates `RuntimeState`, and dispatches events to subscribers. + +```rust +pub struct StateEngine { + state: Arc>, + subscriptions: Arc>, + lua_tx: Sender, +} +``` + +On each event: + +1. Acquire write lock on `RuntimeState` +2. Apply the state update corresponding to the event +3. Release write lock +4. Look up matching subscriptions in `SubscriptionTable` +5. For each match, send a `LuaMessage::Event` to the Lua runtime channel + +State updates are synchronous and must be fast. No I/O, no blocking, no external calls inside the update path. + +### Subscription Table + +The `SubscriptionTable` maps event patterns to subscriber IDs. Patterns support exact matches and wildcard suffix matching (`bread.device.*`). + +```rust +pub struct SubscriptionTable { + entries: Vec, +} + +pub struct Subscription { + pub id: SubscriptionId, + pub pattern: EventPattern, + pub once: bool, +} +``` + +Matching is O(n) over the subscription list. For typical module counts (tens of subscriptions), this is negligible. If subscription counts grow into the thousands, an index structure would be warranted — but that is not a V1 concern. + +--- + +## Lua Runtime + +The Lua runtime runs on a dedicated OS thread. It owns the `mlua` `Lua` instance and processes messages from the async side through a `tokio::sync::mpsc` channel. + +```rust +pub enum LuaMessage { + Event(BreadEvent), + Reload, + Exec(String), + StateQuery { key: String, reply: oneshot::Sender }, + Shutdown, +} +``` + +### Module Loading + +On startup (and on reload), the Lua runtime: + +1. Creates a fresh `Lua` instance (on reload, the old one is dropped) +2. Registers all built-in `bread.*` API functions +3. Evaluates `~/.config/bread/init.lua` +4. Resolves module dependency order +5. Loads each module in order, calling `on_load` if defined +6. Registers all `bread.on` subscriptions with the state engine + +### Error Isolation + +Lua errors during event handler execution are caught with `pcall`. The error message and stack trace are logged. The handler is removed from the subscription table if it is a `once` subscription; otherwise it remains registered and will be called again on the next matching event. + +Errors during module load are fatal to that module but not to the daemon. The failed module is marked as `LoadError` in module state; remaining modules continue loading. + +### Lua ↔ Rust Boundary + +All calls across the Lua/Rust 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 functions are caught by mlua and converted to Lua errors — they do not unwind into the Lua thread. + +--- + +## IPC + +`breadd` exposes a Unix domain socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The protocol is newline-delimited JSON. + +### Request / Response + +```json +{ "id": "1", "method": "state.get", "params": { "key": "monitors" } } +``` + +```json +{ "id": "1", "result": [ { "name": "HDMI-A-1", "resolution": "2560x1440" } ] } +``` + +### Methods + +| Method | Description | +|--------|-------------| +| `state.get` | Read a value from RuntimeState by key path | +| `state.dump` | Return full RuntimeState as JSON | +| `events.subscribe` | Subscribe to a stream of BreadEvents (persistent connection) | +| `modules.list` | List loaded modules and their status | +| `modules.reload` | Trigger a hot reload | +| `profile.list` | List defined profiles | +| `profile.activate` | Activate a named profile | +| `emit` | Inject a synthetic BreadEvent | +| `ping` | Health check | + +### Event Streaming + +`events.subscribe` upgrades the connection to a streaming mode. The daemon pushes `BreadEvent` JSON objects line-by-line as they occur. The CLI's `bread events` command uses this to implement its live event stream. The connection remains open until the client disconnects. + +### IPC Security + +The socket is created with `0600` permissions, owned by the user. No authentication is performed — any process running as the same user can connect. This is intentional for V1 and consistent with how tools like Hyprland and sway handle their IPC sockets. + +--- + +## Hot Reload + +Hot reload is a first-class feature of `breadd`. The daemon persists; the Lua layer restarts. + +Reload sequence: + +``` +bread reload (CLI) + │ + â–¼ +IPC: modules.reload + │ + â–¼ +StateEngine: pause event dispatch to Lua + │ + â–¼ +LuaRuntime: receive Reload message + │ + ├── call on_unload() on each module (reverse dependency order) + ├── cancel all active timers and intervals + ├── send subscription cancellations to SubscriptionTable + ├── drop Lua instance (all state cleared) + ├── create new Lua instance + ├── re-register built-in API + ├── re-load 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. The previous Lua instance cannot be restored (it was dropped), so the daemon enters a degraded state: no Lua handlers active, but the daemon itself remains running and IPC-accessible. The CLI reports the error and the user can fix the Lua and reload again. + +This tradeoff (no rollback on failed reload) is intentional for V1. Rollback would require snapshotting the previous Lua state before initiating reload, which adds complexity. The user experience is acceptable: a syntax error in a module gives a clear error message via `bread reload`, and the daemon stays alive. + +--- + +## Startup Sequence + +``` +1. Parse config (breadd.toml or default) +2. Initialize logging (tracing subscriber) +3. Create RuntimeState (empty) +4. Create SubscriptionTable (empty) +5. Bind IPC socket +6. Spawn adapter tasks: + a. UdevAdapter (enumerate existing devices → populate initial state) + b. HyprlandAdapter (connect to compositor socket) + c. PowerAdapter (read initial battery state) + d. NetworkAdapter (read initial interface state) +7. Spawn StateEngine task +8. Spawn Lua runtime thread +9. Send Lua runtime: load init.lua +10. Lua loads modules, registers subscriptions +11. StateEngine fires bread.system.startup event +12. Daemon enters steady-state event loop +``` + +Step 6a (UdevAdapter enumeration) is synchronous before other adapters start. This ensures that when Lua modules first run, `bread.state.get("devices")` returns an accurate picture of what's already connected rather than an empty list. + +--- + +## Configuration + +`breadd` reads from `~/.config/bread/breadd.toml`. All values have defaults; the file is optional. + +```toml +[daemon] +log_level = "info" # trace | debug | info | warn | error +socket_path = "" # default: $XDG_RUNTIME_DIR/bread/breadd.sock + +[lua] +entry_point = "~/.config/bread/init.lua" +module_path = "~/.config/bread/modules" + +[adapters.hyprland] +enabled = true +reconnect_delay_ms = 500 +reconnect_max_attempts = 10 + +[adapters.udev] +enabled = true +subsystems = ["usb", "input", "drm", "power_supply"] + +[adapters.power] +enabled = true +poll_interval_secs = 30 + +[adapters.network] +enabled = true + +[events] +dedup_window_ms = 100 # suppress duplicate events within this window +``` + +--- + +## Observability + +### Logging + +`breadd` uses `tracing` for structured logging. Log level is configurable. At `debug` level, every `RawEvent` and `BreadEvent` is logged with full payloads. At `info` level, only significant lifecycle events and errors are logged. + +### `bread doctor` + +The `bread doctor` command queries the daemon over IPC and produces a diagnostic report: + +- Daemon version and uptime +- IPC socket status +- Adapter connection status (connected / disconnected / reconnecting) +- Module load status (loaded / error / not found) +- Active subscriptions count +- Recent errors (last 10 Lua errors with stack traces) +- RuntimeState summary + +### `bread events` + +Streams the live `BreadEvent` log to the terminal. Supports optional pattern filtering: + +```bash +bread events # all events +bread events --filter "bread.device.*" # device events only +bread events --filter "bread.monitor.*" # monitor events only +``` + +--- + +## Failure Modes & Recovery + +| Failure | Behavior | +|---------|----------| +| Hyprland socket unavailable | HyprlandAdapter retries with backoff; other adapters unaffected | +| Compositor restart | HyprlandAdapter detects disconnect, reconnects when socket reappears | +| Lua syntax error on reload | Reload aborts; daemon enters degraded mode; reports error via IPC | +| Lua runtime error in handler | Error caught and logged; handler remains registered; daemon continues | +| IPC client disconnect | Connection cleaned up; no effect on daemon | +| udev socket error | UdevAdapter logs error and retries; events may be missed during outage | +| Panic in Rust async task | Task restarts via supervisor; logged as critical error | + +The daemon is designed to never require a full restart due to a recoverable failure. The only cases that warrant a daemon restart are: daemon binary update, unrecoverable OS-level error, or explicit user action. + +--- + +## Summary + +`breadd` is a narrow, focused daemon. It does not automate. It does not configure the compositor. It does not manage packages or provision machines. + +It does one thing well: maintain a live, coherent model of the desktop runtime and deliver that model — as structured state and semantic events — to the Lua automation layer that acts on it. + +Everything complex lives in Lua. Everything reliable lives in `breadd`. diff --git a/bread-cli/Cargo.toml b/bread-cli/Cargo.toml new file mode 100644 index 0000000..0550c57 --- /dev/null +++ b/bread-cli/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "bread-cli" +version = "0.1.0" +edition = "2021" + +[dependencies] +bread-shared = { path = "../bread-shared" } +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +anyhow.workspace = true +clap = { version = "4.5", features = ["derive"] } diff --git a/bread-cli/src/main.rs b/bread-cli/src/main.rs new file mode 100644 index 0000000..e4af194 --- /dev/null +++ b/bread-cli/src/main.rs @@ -0,0 +1,158 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; +use serde_json::{json, Value}; +use std::env; +use std::path::{Path, PathBuf}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; + +#[derive(Parser, Debug)] +#[command(author, version, about = "Bread CLI - the reactive desktop automation fabric")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Hot-reload all Lua modules + Reload, + /// Dump current runtime state + State, + /// Stream live normalized events + Events { + #[arg(long)] + filter: Option, + }, + /// List loaded modules and status + Modules, + /// List available profiles + ProfileList, + /// Activate a profile + ProfileActivate { name: String }, + /// Manually emit an event + Emit { + event: String, + #[arg(short, long, default_value = "{}")] + data: String, + }, + /// Health check daemon connectivity + Ping, + /// Fetch daemon health details + Health, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + let socket = daemon_socket_path(); + + match &cli.command { + Commands::Reload => { + let response = send_request(&socket, "modules.reload", json!({})).await?; + print_json(&response)?; + } + Commands::State => { + let response = send_request(&socket, "state.dump", json!({})).await?; + print_json(&response)?; + } + Commands::Events { filter } => { + stream_events(&socket, filter.clone()).await?; + } + Commands::Modules => { + let response = send_request(&socket, "modules.list", json!({})).await?; + print_json(&response)?; + } + 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?; + print_json(&response)?; + } + Commands::Emit { event, data } => { + let parsed = serde_json::from_str::(data).unwrap_or_else(|_| json!({})); + let response = send_request( + &socket, + "emit", + json!({ + "event": event, + "data": parsed, + }), + ) + .await?; + print_json(&response)?; + } + Commands::Ping => { + let response = send_request(&socket, "ping", json!({})).await?; + print_json(&response)?; + } + Commands::Health => { + let response = send_request(&socket, "health", json!({})).await?; + print_json(&response)?; + } + } + + Ok(()) +} + +fn daemon_socket_path() -> PathBuf { + if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") { + return Path::new(&runtime).join("bread").join("breadd.sock"); + } + PathBuf::from("/tmp/bread/breadd.sock") +} + +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(); + 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!({}))) +} + +async fn stream_events(socket: &Path, filter: Option) -> Result<()> { + let stream = UnixStream::connect(socket).await?; + let (read_half, mut write_half) = stream.into_split(); + let request = json!({ + "id": "1", + "method": "events.subscribe", + "params": { + "filter": filter, + }, + }); + + write_half + .write_all(format!("{}\n", serde_json::to_string(&request)?).as_bytes()) + .await?; + + 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)?); + } + + Ok(()) +} + +fn print_json(value: &Value) -> Result<()> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} diff --git a/bread-shared/Cargo.toml b/bread-shared/Cargo.toml new file mode 100644 index 0000000..0e8c503 --- /dev/null +++ b/bread-shared/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "bread-shared" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde.workspace = true +serde_json.workspace = true diff --git a/bread-shared/src/lib.rs b/bread-shared/src/lib.rs new file mode 100644 index 0000000..9566bc3 --- /dev/null +++ b/bread-shared/src/lib.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum AdapterSource { + Hyprland, + Udev, + Power, + Network, + System, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawEvent { + pub source: AdapterSource, + pub kind: String, + pub payload: serde_json::Value, + pub timestamp: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BreadEvent { + pub event: String, + pub timestamp: u64, + pub source: AdapterSource, + pub data: serde_json::Value, +} + +impl BreadEvent { + pub fn new(event: impl Into, source: AdapterSource, data: serde_json::Value) -> Self { + Self { + event: event.into(), + timestamp: now_unix_ms(), + source, + data, + } + } +} + +pub fn now_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} diff --git a/breadd/Cargo.toml b/breadd/Cargo.toml new file mode 100644 index 0000000..4b949be --- /dev/null +++ b/breadd/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "breadd" +version = "0.1.0" +edition = "2021" + +[dependencies] +bread-shared = { path = "../bread-shared" } +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +anyhow.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +mlua = { version = "0.9", features = ["lua54", "vendored", "async", "serialize"] } +async-trait = "0.1" +toml = "0.8" +udev = "0.9" +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" +libc = "0.2" + +[dev-dependencies] +tempfile = "3.13" diff --git a/breadd/src/adapters/hyprland.rs b/breadd/src/adapters/hyprland.rs new file mode 100644 index 0000000..2ef3731 --- /dev/null +++ b/breadd/src/adapters/hyprland.rs @@ -0,0 +1,66 @@ +use std::env; +use std::path::PathBuf; + +use anyhow::{anyhow, Result}; +use bread_shared::{now_unix_ms, AdapterSource, RawEvent}; +use serde_json::json; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::net::UnixStream; +use tokio::sync::mpsc; +use tracing::{debug, warn}; + +use crate::adapters::Adapter; + +#[derive(Clone, Default)] +pub struct HyprlandAdapter; + +#[async_trait::async_trait] +impl Adapter for HyprlandAdapter { + fn name(&self) -> &'static str { + "hyprland" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + debug!("hyprland adapter started"); + let socket = hyprland_event_socket()?; + let stream = UnixStream::connect(&socket).await?; + let reader = BufReader::new(stream); + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await? { + let (kind, data) = parse_hyprland_line(&line); + tx.send(RawEvent { + source: AdapterSource::Hyprland, + kind: "hyprland.event".to_string(), + payload: json!({ + "kind": kind, + "raw": line, + "data": data, + }), + timestamp: now_unix_ms(), + }) + .await?; + } + + warn!("hyprland socket closed"); + Err(anyhow!("hyprland socket closed")) + } +} + +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")) +} + +fn parse_hyprland_line(line: &str) -> (String, String) { + if let Some((kind, data)) = line.split_once(">>") { + return (kind.to_string(), data.to_string()); + } + + ("unknown".to_string(), line.to_string()) +} diff --git a/breadd/src/adapters/mod.rs b/breadd/src/adapters/mod.rs new file mode 100644 index 0000000..d3a8a3f --- /dev/null +++ b/breadd/src/adapters/mod.rs @@ -0,0 +1,109 @@ +use anyhow::Result; +use async_trait::async_trait; +use bread_shared::RawEvent; +use tokio::sync::{mpsc, watch}; +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_upower; + +#[async_trait] +pub trait Adapter: Send + Sync { + fn name(&self) -> &'static str; + async fn run(&self, tx: mpsc::Sender) -> Result<()>; + async fn on_connect(&self) -> Result<()> { + Ok(()) + } + async fn on_disconnect(&self) -> Result<()> { + Ok(()) + } +} + +pub struct Manager { + raw_tx: mpsc::Sender, + config: Config, + shutdown_rx: watch::Receiver, +} + +impl Manager { + pub fn new( + raw_tx: mpsc::Sender, + config: Config, + shutdown_rx: watch::Receiver, + ) -> Self { + Self { + raw_tx, + config, + shutdown_rx, + } + } + + pub async fn start_all(&self) -> Result<()> { + info!("starting adapters"); + + if self.config.adapters.udev.enabled { + let adapter = udev::UdevAdapter::new(self.config.adapters.udev.subsystems.clone()); + adapter.enumerate_existing(&self.raw_tx).await?; + self.spawn_adapter(adapter); + } + + if self.config.adapters.hyprland.enabled { + self.spawn_adapter(hyprland::HyprlandAdapter::default()); + } + + if self.config.adapters.power.enabled { + // Prefer UPower DBus adapter; fall back to sysfs poller + let upower = power_upower::UPowerAdapter::new(); + if let Ok(adapter) = upower { + self.spawn_adapter(adapter); + } else { + self.spawn_adapter(power::PowerAdapter::new( + self.config.adapters.power.poll_interval_secs, + )); + } + } + + if self.config.adapters.network.enabled { + // Prefer rtnetlink-based adapter; fall back to existing sysfs-based adapter + let rt = network_rtnetlink::RtnetlinkAdapter::new(); + if let Ok(adapter) = rt { + self.spawn_adapter(adapter); + } else { + self.spawn_adapter(network::NetworkAdapter::default()); + } + } + + Ok(()) + } + + fn spawn_adapter(&self, adapter: A) + where + A: Adapter + Clone + 'static, + { + let name = adapter.name(); + let tx = self.raw_tx.clone(); + let shutdown_rx = self.shutdown_rx.clone(); + let shutdown_for_task = shutdown_rx.clone(); + spawn_supervised(name, shutdown_rx, move || { + let adapter = adapter.clone(); + let tx = tx.clone(); + let mut shutdown_rx = shutdown_for_task.clone(); + async move { + adapter.on_connect().await?; + let result = tokio::select! { + result = adapter.run(tx) => result, + _ = shutdown_rx.changed() => Ok(()), + }; + adapter.on_disconnect().await?; + result + } + }); + } +} diff --git a/breadd/src/adapters/network.rs b/breadd/src/adapters/network.rs new file mode 100644 index 0000000..c15b7a9 --- /dev/null +++ b/breadd/src/adapters/network.rs @@ -0,0 +1,93 @@ +use std::collections::BTreeMap; +use std::fs; + +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; + +#[derive(Clone, Default)] +pub struct NetworkAdapter; + +#[async_trait::async_trait] +impl Adapter for NetworkAdapter { + fn name(&self) -> &'static str { + "network" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + debug!("network adapter started"); + let mut last = read_network_state(); + tx.send(network_raw_event(&last)).await?; + + loop { + sleep(Duration::from_secs(5)).await; + let now = read_network_state(); + if now != last { + tx.send(network_raw_event(&now)).await?; + last = now; + } + } + } +} + +#[derive(Debug, Clone, PartialEq)] +struct NetworkSnapshot { + interfaces: BTreeMap, + online: bool, +} + +fn network_raw_event(snapshot: &NetworkSnapshot) -> RawEvent { + let interfaces = snapshot + .interfaces + .iter() + .map(|(name, up)| (name.clone(), json!({ "up": up }))) + .collect::>(); + + RawEvent { + source: AdapterSource::Network, + kind: "network.snapshot".to_string(), + payload: json!({ + "online": snapshot.online, + "interfaces": interfaces, + }), + timestamp: now_unix_ms(), + } +} + +fn read_network_state() -> NetworkSnapshot { + let mut interfaces = BTreeMap::new(); + + if let Ok(entries) = fs::read_dir("/sys/class/net") { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name == "lo" { + continue; + } + let oper = fs::read_to_string(entry.path().join("operstate")).unwrap_or_default(); + let up = oper.trim() == "up"; + interfaces.insert(name, up); + } + } + + let online = has_default_route(); + + NetworkSnapshot { interfaces, online } +} + +fn has_default_route() -> bool { + if let Ok(routes) = fs::read_to_string("/proc/net/route") { + for line in routes.lines().skip(1) { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() > 2 && cols[1] == "00000000" { + return true; + } + } + } + + false +} diff --git a/breadd/src/adapters/network_rtnetlink.rs b/breadd/src/adapters/network_rtnetlink.rs new file mode 100644 index 0000000..aaa9f46 --- /dev/null +++ b/breadd/src/adapters/network_rtnetlink.rs @@ -0,0 +1,151 @@ +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use bread_shared::{AdapterSource, RawEvent}; +use futures_util::StreamExt; +use netlink_packet_route::RtnlMessage; +use rtnetlink::new_connection; +use serde_json::json; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use tokio::sync::mpsc; +use tracing::{debug, info}; + +use super::Adapter; + +#[derive(Clone, Debug)] +pub struct RtnetlinkAdapter; + +impl RtnetlinkAdapter { + pub fn new() -> Result { + // Try to create a connection to validate presence of rtnetlink + let conn = new_connection(); + match conn { + Ok((connection, _handle, _messages)) => { + // Spawn and immediately drop the connection task; we just validated + tokio::spawn(connection); + Ok(Self) + } + Err(e) => Err(anyhow!(e)), + } + } +} + +#[async_trait] +impl Adapter for RtnetlinkAdapter { + fn name(&self) -> &'static str { + "rtnetlink-network" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + info!("rtnetlink adapter starting"); + let (connection, _handle, mut messages) = new_connection()?; + tokio::spawn(connection); + + while let Some((message, _addr)) = messages.next().await { + match message.payload { + netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewLink(link)) => { + let ifname = link.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::link::nlas::Nla::IfName(name) => Some(name.clone()), + _ => None, + }); + let mtu = link.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::link::nlas::Nla::Mtu(mtu) => Some(*mtu), + _ => None, + }); + let netns_id = link.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::link::nlas::Nla::NetnsId(id) => Some(*id), + _ => None, + }); + let netns_fd = link.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::link::nlas::Nla::NetNsFd(fd) => Some(*fd), + _ => None, + }); + + let up = link.header.flags & (libc::IFF_UP as u32) != 0; + if let Some(name) = ifname { + let kind = if up { "link.up" } else { "link.down" }; + let payload = json!({ + "ifname": name, + "index": link.header.index, + "mtu": mtu, + "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; + } + } + netlink_packet_core::NetlinkPayload::InnerMessage(RtnlMessage::NewRoute(route)) => { + // Heuristic: if destination is default (empty), treat as default-route change + let is_default = route.header.destination_prefix_length == 0; + if is_default { + let gateway = route.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::route::nlas::Nla::Gateway(gw) => Some(gw.clone()), + _ => None, + }); + let gateway_ip = gateway.as_deref().and_then(ip_from_bytes); + let payload = json!({ + "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; + } + } + 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()), + _ => None, + }); + let label = addr.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()), + _ => None, + }); + let ip = address.as_deref().and_then(ip_from_bytes); + let payload = json!({ + "ifindex": addr.header.index, + "prefix_len": addr.header.prefix_len, + "family": addr.header.family, + "address": ip, + "label": label + }); + 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)) => { + 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()), + _ => None, + }); + let label = addr.nlas.iter().find_map(|nla| match nla { + netlink_packet_route::address::nlas::Nla::Label(label) => Some(label.clone()), + _ => None, + }); + let ip = address.as_deref().and_then(ip_from_bytes); + let payload = json!({ + "ifindex": addr.header.index, + "prefix_len": addr.header.prefix_len, + "family": addr.header.family, + "address": ip, + "label": label + }); + let _ = tx.send(RawEvent { source: AdapterSource::Network, kind: "address.removed".to_string(), payload, timestamp: bread_shared::now_unix_ms() }).await; + } + _ => { + debug!("unhandled netlink message"); + } + } + } + + Ok(()) + } +} + +fn ip_from_bytes(bytes: &[u8]) -> Option { + match bytes.len() { + 4 => Some(IpAddr::V4(Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3])).to_string()), + 16 => { + let octets: [u8; 16] = bytes.try_into().ok()?; + Some(IpAddr::V6(Ipv6Addr::from(octets)).to_string()) + } + _ => None, + } +} diff --git a/breadd/src/adapters/power.rs b/breadd/src/adapters/power.rs new file mode 100644 index 0000000..b86f319 --- /dev/null +++ b/breadd/src/adapters/power.rs @@ -0,0 +1,92 @@ +use std::fs; +use std::path::Path; + +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; + +#[derive(Clone)] +pub struct PowerAdapter { + poll_interval_secs: u64, +} + +impl PowerAdapter { + pub fn new(poll_interval_secs: u64) -> Self { + Self { poll_interval_secs } + } +} + +#[async_trait::async_trait] +impl Adapter for PowerAdapter { + fn name(&self) -> &'static str { + "power" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + debug!("power adapter started"); + + let mut last = read_power_state(); + tx.send(power_raw_event(&last)).await?; + + loop { + sleep(Duration::from_secs(self.poll_interval_secs.max(5))).await; + let now = read_power_state(); + if now != last { + tx.send(power_raw_event(&now)).await?; + last = now; + } + } + } +} + +#[derive(Debug, Clone, PartialEq)] +struct PowerSnapshot { + ac_connected: bool, + battery_percent: Option, +} + +fn power_raw_event(snapshot: &PowerSnapshot) -> RawEvent { + RawEvent { + source: AdapterSource::Power, + kind: "power.snapshot".to_string(), + payload: json!({ + "ac_connected": snapshot.ac_connected, + "battery_percent": snapshot.battery_percent, + }), + timestamp: now_unix_ms(), + } +} + +fn read_power_state() -> PowerSnapshot { + let power_dir = Path::new("/sys/class/power_supply"); + let mut ac_connected = false; + let mut battery_percent = None; + + if let Ok(entries) = fs::read_dir(power_dir) { + for entry in entries.flatten() { + let path = entry.path(); + let typ = fs::read_to_string(path.join("type")).unwrap_or_default(); + if typ.trim().eq_ignore_ascii_case("Mains") || typ.trim().eq_ignore_ascii_case("USB") { + let online = fs::read_to_string(path.join("online")).unwrap_or_default(); + if online.trim() == "1" { + ac_connected = true; + } + } else if typ.trim().eq_ignore_ascii_case("Battery") { + let cap = fs::read_to_string(path.join("capacity")).unwrap_or_default(); + if let Ok(parsed) = cap.trim().parse::() { + battery_percent = Some(parsed.min(100)); + } + } + } + } + + PowerSnapshot { + ac_connected, + battery_percent, + } +} diff --git a/breadd/src/adapters/power_upower.rs b/breadd/src/adapters/power_upower.rs new file mode 100644 index 0000000..26bcacc --- /dev/null +++ b/breadd/src/adapters/power_upower.rs @@ -0,0 +1,147 @@ +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use bread_shared::{AdapterSource, RawEvent}; +use futures_util::StreamExt; +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 super::Adapter; + +#[derive(Clone, Debug)] +pub struct UPowerAdapter; + +impl UPowerAdapter { + pub fn new() -> Result { + // Attempt to connect to system bus to validate availability + // We don't actually open the connection here because zbus::Connection::system() is async. + Ok(Self) + } +} + +#[async_trait] +impl Adapter for UPowerAdapter { + fn name(&self) -> &'static str { + "upower" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + info!("UPower adapter starting (attempting DBus subscription)"); + + // Defer loading zbus until runtime to avoid build-time optional complexity + match zbus::Connection::system().await { + Ok(conn) => { + let payload = json!({"message": "upower:connected"}); + let _ = tx + .send(RawEvent { + source: AdapterSource::Power, + kind: "power.upower.connected".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }) + .await; + + let mut stream = MessageStream::from(&conn); + while let Some(result) = stream.next().await { + match result { + Ok(message) => match parse_upower_message(&message) { + Ok(event) => { + let _ = tx.send(event).await; + } + Err(err) => { + debug!("upower parse error: {err:?}"); + } + }, + Err(err) => { + debug!("upower stream error: {err:?}"); + } + } + } + + Ok(()) + } + Err(e) => { + // If DBus connection fails, fall back to periodic polling handled elsewhere + Err(anyhow!(e)) + } + } + } +} + +fn parse_upower_message(message: &Message) -> Result { + let header = message.header()?; + let interface = header.interface()?.map(|v| v.as_str()).unwrap_or(""); + let member = header.member()?.map(|v| v.as_str()).unwrap_or(""); + let path = header.path()?.map(|v| v.as_str()).unwrap_or(""); + + if interface == "org.freedesktop.UPower" { + match member { + "DeviceAdded" => { + let (device_path,): (OwnedObjectPath,) = message.body()?; + let payload = json!({"device_path": device_path.as_str()}); + return Ok(RawEvent { + source: AdapterSource::Power, + kind: "power.device.added".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }); + } + "DeviceRemoved" => { + let (device_path,): (OwnedObjectPath,) = message.body()?; + let payload = json!({"device_path": device_path.as_str()}); + return Ok(RawEvent { + source: AdapterSource::Power, + kind: "power.device.removed".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }); + } + _ => {} + } + } + + if interface == "org.freedesktop.DBus.Properties" && member == "PropertiesChanged" { + let (iface, changed, invalidated): (String, HashMap, Vec) = + message.body()?; + if iface == "org.freedesktop.UPower.Device" { + let changed_json = serde_json::to_value(&changed).unwrap_or_else(|_| json!({})); + let normalized = json!({ + "percentage": changed_json.get("Percentage").and_then(|v| v.as_f64()), + "state": changed_json.get("State").and_then(|v| v.as_u64()), + "time_to_empty": changed_json.get("TimeToEmpty").and_then(|v| v.as_i64()), + "time_to_full": changed_json.get("TimeToFull").and_then(|v| v.as_i64()), + "is_present": changed_json.get("IsPresent").and_then(|v| v.as_bool()), + "battery_type": changed_json.get("Type").and_then(|v| v.as_u64()), + "online": changed_json.get("Online").and_then(|v| v.as_bool()), + "native_path": changed_json.get("NativePath").and_then(|v| v.as_str()), + "model": changed_json.get("Model").and_then(|v| v.as_str()), + "vendor": changed_json.get("Vendor").and_then(|v| v.as_str()), + "serial": changed_json.get("Serial").and_then(|v| v.as_str()), + "update_time": changed_json.get("UpdateTime").and_then(|v| v.as_u64()), + }); + let payload = json!({ + "path": path, + "properties": changed_json, + "invalidated": invalidated, + "normalized": normalized + }); + + return Ok(RawEvent { + source: AdapterSource::Power, + kind: "power.device.changed".to_string(), + payload, + timestamp: bread_shared::now_unix_ms(), + }); + } + } + + Ok(RawEvent { + source: AdapterSource::Power, + kind: "power.upower.signal".to_string(), + payload: json!({"interface": interface, "member": member, "path": path}), + timestamp: bread_shared::now_unix_ms(), + }) +} diff --git a/breadd/src/adapters/udev.rs b/breadd/src/adapters/udev.rs new file mode 100644 index 0000000..ffe4d15 --- /dev/null +++ b/breadd/src/adapters/udev.rs @@ -0,0 +1,265 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +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; + +#[derive(Clone)] +pub struct UdevAdapter { + subsystems: Vec, +} + +impl UdevAdapter { + pub fn new(subsystems: Vec) -> Self { + Self { subsystems } + } + + 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() + }); + + for device in devices { + tx.send(RawEvent { + source: AdapterSource::Udev, + kind: "udev.enumerate".to_string(), + payload: json!({ + "action": "add", + "id": device.id, + "name": device.name, + "subsystem": device.subsystem, + }), + timestamp: now_unix_ms(), + }) + .await?; + } + Ok(()) + } +} + +#[async_trait::async_trait] +impl Adapter for UdevAdapter { + fn name(&self) -> &'static str { + "udev" + } + + async fn run(&self, tx: mpsc::Sender) -> Result<()> { + debug!("udev adapter started"); + if let Ok(()) = run_udev_monitor(self.subsystems.clone(), tx.clone()).await { + return Ok(()); + } + + // Fallback for environments where monitor sockets are unavailable. + let mut known: HashMap = scan_devices(&self.subsystems)? + .into_iter() + .map(|d| (d.id.clone(), d)) + .collect(); + + loop { + let current = scan_devices(&self.subsystems)?; + let current_map: HashMap = current + .into_iter() + .map(|d| (d.id.clone(), d)) + .collect(); + + for (id, dev) in ¤t_map { + if !known.contains_key(id) { + tx.send(raw_change_event("add", dev)).await?; + } + } + + for (id, dev) in &known { + if !current_map.contains_key(id) { + tx.send(raw_change_event("remove", dev)).await?; + } + } + + known = current_map; + sleep(Duration::from_secs(2)).await; + } + } +} + +#[derive(Clone, Debug)] +struct ScannedDevice { + id: String, + name: String, + 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, + }), + 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 { + enumerator.match_subsystem(subsystem)?; + } + + let mut out = Vec::new(); + for dev in enumerator.scan_devices()? { + let subsystem = dev + .subsystem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let name = dev + .property_value("ID_MODEL") + .or_else(|| dev.property_value("NAME")) + .map(|v| v.to_string_lossy().to_string()) + .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, + }); + } + + Ok(out) +} + +fn raw_change_event(action: &str, dev: &ScannedDevice) -> RawEvent { + RawEvent { + source: AdapterSource::Udev, + kind: "udev.change".to_string(), + payload: json!({ + "action": action, + "id": dev.id, + "name": dev.name, + "subsystem": dev.subsystem, + }), + timestamp: now_unix_ms(), + } +} + +fn scan_devices(subsystems: &[String]) -> Result> { + let mut out = Vec::new(); + + if subsystems.iter().any(|s| s == "drm") { + let drm_dir = Path::new("/sys/class/drm"); + if drm_dir.exists() { + for entry in fs::read_dir(drm_dir)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + if !name.contains('-') { + continue; + } + let status = fs::read_to_string(entry.path().join("status")).unwrap_or_default(); + if status.trim() == "connected" { + out.push(ScannedDevice { + id: format!("drm:{name}"), + name, + subsystem: "drm".to_string(), + }); + } + } + } + } + + if subsystems.iter().any(|s| s == "input") { + let input_dir = Path::new("/dev/input/by-id"); + if input_dir.exists() { + for entry in fs::read_dir(input_dir)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + out.push(ScannedDevice { + id: format!("input:{name}"), + name, + subsystem: "input".to_string(), + }); + } + } + } + + if subsystems.iter().any(|s| s == "power_supply") { + let pwr_dir = Path::new("/sys/class/power_supply"); + if pwr_dir.exists() { + for entry in fs::read_dir(pwr_dir)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + out.push(ScannedDevice { + id: format!("power_supply:{name}"), + name, + subsystem: "power_supply".to_string(), + }); + } + } + } + + if subsystems.iter().any(|s| s == "usb") { + let usb_dir = Path::new("/sys/bus/usb/devices"); + if usb_dir.exists() { + for entry in fs::read_dir(usb_dir)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + if !name.contains(':') && name.chars().any(|c| c.is_ascii_digit()) { + out.push(ScannedDevice { + id: format!("usb:{name}"), + name, + subsystem: "usb".to_string(), + }); + } + } + } + } + + Ok(out) +} diff --git a/breadd/src/core/config.rs b/breadd/src/core/config.rs new file mode 100644 index 0000000..dedd0d4 --- /dev/null +++ b/breadd/src/core/config.rs @@ -0,0 +1,228 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + #[serde(default)] + pub daemon: DaemonConfig, + #[serde(default)] + pub lua: LuaConfig, + #[serde(default)] + pub adapters: AdaptersConfig, + #[serde(default)] + pub events: EventsConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DaemonConfig { + #[serde(default = "default_log_level")] + pub log_level: String, + #[serde(default)] + pub socket_path: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LuaConfig { + #[serde(default = "default_lua_entry")] + pub entry_point: String, + #[serde(default = "default_lua_modules")] + pub module_path: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AdaptersConfig { + #[serde(default)] + pub hyprland: AdapterToggle, + #[serde(default)] + pub udev: UdevConfig, + #[serde(default)] + pub power: PowerConfig, + #[serde(default)] + pub network: AdapterToggle, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AdapterToggle { + #[serde(default = "default_true")] + pub enabled: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UdevConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_udev_subsystems")] + pub subsystems: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PowerConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_poll_interval")] + pub poll_interval_secs: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct EventsConfig { + #[serde(default = "default_dedup_window")] + pub dedup_window_ms: u64, +} + +impl Default for Config { + fn default() -> Self { + Self { + daemon: DaemonConfig::default(), + lua: LuaConfig::default(), + adapters: AdaptersConfig::default(), + events: EventsConfig::default(), + } + } +} + +impl Default for DaemonConfig { + fn default() -> Self { + Self { + log_level: default_log_level(), + socket_path: String::new(), + } + } +} + +impl Default for LuaConfig { + fn default() -> Self { + Self { + entry_point: default_lua_entry(), + module_path: default_lua_modules(), + } + } +} + +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 { + enabled: default_true(), + } + } +} + +impl Default for UdevConfig { + fn default() -> Self { + Self { + enabled: default_true(), + subsystems: default_udev_subsystems(), + } + } +} + +impl Default for PowerConfig { + fn default() -> Self { + Self { + enabled: default_true(), + poll_interval_secs: default_poll_interval(), + } + } +} + +impl Default for EventsConfig { + fn default() -> Self { + Self { + dedup_window_ms: default_dedup_window(), + } + } +} + +impl Config { + pub fn load() -> Result { + let path = config_path(); + if !path.exists() { + return Ok(Self::default()); + } + + let raw = fs::read_to_string(&path)?; + let cfg: Config = toml::from_str(&raw)?; + Ok(cfg) + } + + pub fn socket_path(&self) -> PathBuf { + if !self.daemon.socket_path.is_empty() { + return expand_home(&self.daemon.socket_path); + } + + let runtime_dir = env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + Path::new(&runtime_dir).join("bread").join("breadd.sock") + } + + pub fn lua_entry_point(&self) -> PathBuf { + expand_home(&self.lua.entry_point) + } + + pub fn lua_module_path(&self) -> PathBuf { + expand_home(&self.lua.module_path) + } +} + +fn config_path() -> PathBuf { + if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { + return Path::new(&xdg).join("bread").join("breadd.toml"); + } + + expand_home("~/.config/bread/breadd.toml") +} + +fn expand_home(input: &str) -> PathBuf { + if let Some(stripped) = input.strip_prefix("~/") { + if let Ok(home) = env::var("HOME") { + return Path::new(&home).join(stripped); + } + } + PathBuf::from(input) +} + +fn default_log_level() -> String { + "info".to_string() +} + +fn default_lua_entry() -> String { + "~/.config/bread/init.lua".to_string() +} + +fn default_lua_modules() -> String { + "~/.config/bread/modules".to_string() +} + +fn default_true() -> bool { + true +} + +fn default_poll_interval() -> u64 { + 30 +} + +fn default_dedup_window() -> u64 { + 100 +} + +fn default_udev_subsystems() -> Vec { + vec![ + "usb".to_string(), + "input".to_string(), + "drm".to_string(), + "power_supply".to_string(), + ] +} diff --git a/breadd/src/core/mod.rs b/breadd/src/core/mod.rs new file mode 100644 index 0000000..bdb2e19 --- /dev/null +++ b/breadd/src/core/mod.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod normalizer; +pub mod state_engine; +pub mod subscriptions; +pub mod supervisor; +pub mod types; diff --git a/breadd/src/core/normalizer.rs b/breadd/src/core/normalizer.rs new file mode 100644 index 0000000..587af0f --- /dev/null +++ b/breadd/src/core/normalizer.rs @@ -0,0 +1,213 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use bread_shared::{AdapterSource, BreadEvent, RawEvent}; +use serde_json::{json, Value}; + +use crate::core::types::DeviceClass; + +pub struct EventNormalizer { + dedup_window_ms: u64, + recent: Mutex>, +} + +impl EventNormalizer { + pub fn new(dedup_window_ms: u64) -> Self { + Self { + dedup_window_ms, + recent: Mutex::new(HashMap::new()), + } + } + + pub fn normalize(&self, raw: &RawEvent) -> Vec { + let mut out = match raw.source { + AdapterSource::Udev => self.normalize_udev(raw), + AdapterSource::Hyprland => self.normalize_hyprland(raw), + AdapterSource::Power => self.normalize_power(raw), + AdapterSource::Network => self.normalize_network(raw), + AdapterSource::System => vec![BreadEvent { + event: raw.kind.clone(), + timestamp: raw.timestamp, + source: raw.source, + data: raw.payload.clone(), + }], + }; + + out.retain(|ev| self.accept(ev)); + out + } + + fn normalize_udev(&self, raw: &RawEvent) -> Vec { + let action = raw.payload.get("action").and_then(Value::as_str).unwrap_or("change"); + let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown"); + let class = classify_device(&raw.payload); + let class_str = serde_json::to_string(&class) + .unwrap_or_else(|_| "\"unknown\"".to_string()) + .replace('"', ""); + + let verb = match action { + "add" => "connected", + "remove" => "disconnected", + _ => "changed", + }; + + let mut events = vec![BreadEvent { + event: format!("bread.device.{}", verb), + timestamp: raw.timestamp, + source: AdapterSource::Udev, + data: json!({ + "id": id, + "class": class, + "raw": raw.payload, + }), + }]; + + events.push(BreadEvent { + event: format!("bread.device.{}.{}", class_str, verb), + timestamp: raw.timestamp, + source: AdapterSource::Udev, + data: json!({ + "id": id, + "class": class, + }), + }); + + events + } + + fn normalize_hyprland(&self, raw: &RawEvent) -> Vec { + let kind = raw.payload.get("kind").and_then(Value::as_str).unwrap_or("unknown"); + let mapped = match kind { + "workspace" | "workspacev2" => "bread.workspace.changed", + "monitoradded" => "bread.monitor.connected", + "monitorremoved" => "bread.monitor.disconnected", + "activewindow" | "activewindowv2" => "bread.window.focus.changed", + "openwindow" => "bread.window.opened", + "closewindow" => "bread.window.closed", + _ => "bread.hyprland.event", + }; + + vec![BreadEvent { + event: mapped.to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Hyprland, + data: raw.payload.clone(), + }] + } + + fn normalize_power(&self, raw: &RawEvent) -> Vec { + let mut events = Vec::new(); + + if let Some(ac) = raw.payload.get("ac_connected").and_then(Value::as_bool) { + events.push(BreadEvent { + event: if ac { + "bread.power.ac.connected".to_string() + } else { + "bread.power.ac.disconnected".to_string() + }, + timestamp: raw.timestamp, + source: AdapterSource::Power, + data: raw.payload.clone(), + }); + } + + if let Some(level) = raw.payload.get("battery_percent").and_then(Value::as_u64) { + let battery_event = if level <= 5 { + Some("bread.power.battery.critical") + } else if level <= 10 { + Some("bread.power.battery.very_low") + } else if level <= 20 { + Some("bread.power.battery.low") + } else if level >= 100 { + Some("bread.power.battery.full") + } else { + None + }; + + if let Some(event) = battery_event { + events.push(BreadEvent { + event: event.to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Power, + data: raw.payload.clone(), + }); + } + } + + if events.is_empty() { + events.push(BreadEvent { + event: "bread.power.changed".to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Power, + data: raw.payload.clone(), + }); + } + + events + } + + fn normalize_network(&self, raw: &RawEvent) -> Vec { + let online = raw.payload.get("online").and_then(Value::as_bool).unwrap_or(false); + let name = if online { + "bread.network.connected" + } else { + "bread.network.disconnected" + }; + + vec![BreadEvent { + event: name.to_string(), + timestamp: raw.timestamp, + source: AdapterSource::Network, + data: raw.payload.clone(), + }] + } + + fn accept(&self, event: &BreadEvent) -> bool { + let key = format!("{}:{}", event.event, event.data); + let mut recent = self.recent.lock().expect("normalizer dedup mutex poisoned"); + let now = event.timestamp; + + if let Some(last) = recent.get(&key) { + if now.saturating_sub(*last) < self.dedup_window_ms { + return false; + } + } + + recent.insert(key, now); + true + } +} + +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") { + return DeviceClass::Keyboard; + } + if subsystem == "input" && name.contains("mouse") { + return DeviceClass::Mouse; + } + if subsystem == "drm" { + return DeviceClass::Display; + } + if subsystem == "sound" || name.contains("audio") { + return DeviceClass::Audio; + } + if subsystem == "block" || name.contains("storage") { + return DeviceClass::Storage; + } + + DeviceClass::Unknown +} diff --git a/breadd/src/core/state_engine.rs b/breadd/src/core/state_engine.rs new file mode 100644 index 0000000..d824fd0 --- /dev/null +++ b/breadd/src/core/state_engine.rs @@ -0,0 +1,304 @@ +use std::sync::Arc; + +use anyhow::Result; +use bread_shared::BreadEvent; +use serde_json::Value; +use tokio::sync::{broadcast, mpsc, watch, RwLock}; +use tracing::warn; + +use crate::core::subscriptions::{SubscriptionId, SubscriptionTable}; +use crate::core::types::{Device, DeviceClass, InterfaceState, ModuleLoadState, RuntimeState}; +use crate::lua::LuaMessage; + +#[derive(Clone)] +pub struct StateHandle { + state: Arc>, + command_tx: mpsc::UnboundedSender, +} + +pub enum StateCommand { + RegisterSubscription { + id: SubscriptionId, + pattern: String, + once: bool, + }, + ClearSubscriptions, + SetModuleStatus { + name: String, + status: ModuleLoadState, + last_error: Option, + }, + SetProfile { + name: String, + }, +} + +impl StateHandle { + pub fn new(state: Arc>, command_tx: mpsc::UnboundedSender) -> Self { + Self { state, command_tx } + } + + pub fn state_arc(&self) -> Arc> { + self.state.clone() + } + + pub async fn state_get(&self, path: &str) -> Option { + let state = self.state.read().await; + let full = serde_json::to_value(&*state).ok()?; + + if path.is_empty() { + return Some(full); + } + + let mut current = &full; + for part in path.split('.') { + current = current.get(part)?; + } + Some(current.clone()) + } + + pub async fn state_dump(&self) -> Value { + let state = self.state.read().await; + serde_json::to_value(&*state).unwrap_or_else(|_| serde_json::json!({})) + } + + pub fn register_subscription(&self, id: SubscriptionId, pattern: String, once: bool) -> Result<()> { + self.command_tx + .send(StateCommand::RegisterSubscription { + id, + pattern, + once, + }) + .map_err(|_| anyhow::anyhow!("state engine command channel closed")) + } + + pub fn clear_subscriptions(&self) { + let _ = self.command_tx.send(StateCommand::ClearSubscriptions); + } + + pub fn set_module_status(&self, name: String, status: ModuleLoadState, last_error: Option) { + let _ = self.command_tx.send(StateCommand::SetModuleStatus { + name, + status, + last_error, + }); + } + + pub fn set_profile(&self, name: String) { + let _ = self.command_tx.send(StateCommand::SetProfile { name }); + } +} + +pub async fn run_state_engine( + mut event_rx: mpsc::UnboundedReceiver, + mut command_rx: mpsc::UnboundedReceiver, + state: Arc>, + lua_tx: mpsc::UnboundedSender, + event_stream_tx: broadcast::Sender, + mut shutdown_rx: watch::Receiver, +) { + let mut subscriptions = SubscriptionTable::default(); + + loop { + tokio::select! { + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break; + } + } + maybe_cmd = command_rx.recv() => { + let Some(cmd) = maybe_cmd else { + break; + }; + handle_command(cmd, &state, &mut subscriptions).await; + } + maybe_event = event_rx.recv() => { + let Some(event) = maybe_event else { + break; + }; + + apply_event_to_state(&state, &event).await; + + 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 }); + } + } + } + } + + warn!("state engine loop exited"); +} + +async fn handle_command( + cmd: StateCommand, + state: &Arc>, + subscriptions: &mut SubscriptionTable, +) { + match cmd { + StateCommand::RegisterSubscription { id, pattern, once } => { + subscriptions.add_with_id(id, pattern, once); + } + StateCommand::ClearSubscriptions => { + subscriptions.clear(); + } + StateCommand::SetModuleStatus { + name, + status, + last_error, + } => { + 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; + } else { + guard.modules.push(crate::core::types::ModuleStatus { + name, + status, + last_error, + }); + } + } + StateCommand::SetProfile { name } => { + let mut guard = state.write().await; + if guard.profile.active != name { + let previous = guard.profile.active.clone(); + guard.profile.history.push(previous); + guard.profile.active = name; + } + } + } +} + +async fn apply_event_to_state(state: &Arc>, event: &BreadEvent) { + let mut guard = state.write().await; + 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) { + m.connected = true; + } else { + guard.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), + }); + } + } + } + "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) { + m.connected = false; + } + } + } + "bread.workspace.changed" => { + let ws = event + .data + .get("workspace") + .or_else(|| event.data.get("id")) + .and_then(Value::as_str) + .map(ToString::to_string); + guard.active_workspace = ws; + } + "bread.window.focus.changed" => { + guard.active_window = event + .data + .get("window") + .or_else(|| event.data.get("class")) + .and_then(Value::as_str) + .map(ToString::to_string); + } + "bread.device.connected" => { + apply_device_change(&mut guard, &event.data, true); + } + "bread.device.disconnected" => { + apply_device_change(&mut guard, &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; + } + if let Some(ifaces) = event.data.get("interfaces").and_then(Value::as_object) { + guard.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 }); + } + } + } + "bread.power.changed" + | "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" => { + if let Some(ac) = event.data.get("ac_connected").and_then(Value::as_bool) { + guard.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; + } + } + "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(); + } + } + } + _ => {} + } +} + +fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) { + let id = data + .get("id") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + + if connected { + if state.devices.connected.iter().any(|d| d.id == id) { + return; + } + + let class = data + .get("class") + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .unwrap_or(DeviceClass::Unknown); + + state.devices.connected.push(Device { + id, + name: data + .get("name") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(), + class, + subsystem: data + .get("subsystem") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(), + }); + } else { + state.devices.connected.retain(|d| d.id != id); + } +} diff --git a/breadd/src/core/subscriptions.rs b/breadd/src/core/subscriptions.rs new file mode 100644 index 0000000..d355b45 --- /dev/null +++ b/breadd/src/core/subscriptions.rs @@ -0,0 +1,63 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub struct SubscriptionId(pub u64); + +#[derive(Debug, Clone)] +pub struct Subscription { + pub id: SubscriptionId, + pub pattern: String, + pub once: bool, +} + +#[derive(Default, Debug)] +pub struct SubscriptionTable { + entries: Vec, + by_id: HashMap, + next_id: u64, +} + +impl SubscriptionTable { + 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 }; + self.entries.push(sub); + self.by_id.insert(id, self.entries.len() - 1); + id + } + + pub fn remove(&mut self, id: SubscriptionId) -> bool { + let Some(idx) = self.by_id.remove(&id) else { + return false; + }; + + self.entries.swap_remove(idx); + if let Some(swapped) = self.entries.get(idx) { + self.by_id.insert(swapped.id, idx); + } + true + } + + pub fn clear(&mut self) { + self.entries.clear(); + self.by_id.clear(); + } + + pub fn match_event(&self, event_name: &str) -> Vec { + self.entries + .iter() + .filter(|sub| matches_pattern(&sub.pattern, event_name)) + .cloned() + .collect() + } +} + +fn matches_pattern(pattern: &str, event_name: &str) -> bool { + if pattern.ends_with(".*") { + let prefix = &pattern[..pattern.len() - 1]; + return event_name.starts_with(prefix); + } + + pattern == event_name +} diff --git a/breadd/src/core/supervisor.rs b/breadd/src/core/supervisor.rs new file mode 100644 index 0000000..424ba6f --- /dev/null +++ b/breadd/src/core/supervisor.rs @@ -0,0 +1,65 @@ +use std::future::Future; + +use tokio::sync::watch; +use tokio::time::{sleep, Duration}; +use tracing::{error, info, warn}; + +pub fn spawn_supervised( + name: &'static str, + mut shutdown_rx: watch::Receiver, + mut task_factory: F, +) +where + F: FnMut() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + tokio::spawn(async move { + let mut attempt: u32 = 0; + + loop { + if *shutdown_rx.borrow() { + info!(adapter = name, "shutdown requested"); + break; + } + + let result = tokio::select! { + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + info!(adapter = name, "shutdown requested"); + break; + } + continue; + } + result = task_factory() => result, + }; + + match result { + Ok(()) => { + info!(adapter = name, "adapter task exited cleanly"); + attempt = 0; + } + Err(err) => { + error!(adapter = name, error = %err, "adapter task failed"); + attempt = attempt.saturating_add(1); + } + } + + if *shutdown_rx.borrow() { + info!(adapter = name, "shutdown requested"); + break; + } + + let wait_ms = 500u64.saturating_mul(2u64.saturating_pow(attempt.min(6))); + warn!(adapter = name, delay_ms = wait_ms, "restarting adapter after failure"); + tokio::select! { + _ = sleep(Duration::from_millis(wait_ms)) => {}, + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + info!(adapter = name, "shutdown requested"); + break; + } + } + } + } + }); +} diff --git a/breadd/src/core/types.rs b/breadd/src/core/types.rs new file mode 100644 index 0000000..02886c9 --- /dev/null +++ b/breadd/src/core/types.rs @@ -0,0 +1,132 @@ +use std::collections::{BTreeMap, HashMap}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +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, +} + +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, + pub connected: bool, + pub resolution: Option, + pub position: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workspace { + pub id: String, + pub monitor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DeviceTopology { + pub connected: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Device { + pub id: String, + pub name: String, + pub class: DeviceClass, + pub subsystem: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum DeviceClass { + Dock, + Keyboard, + Mouse, + Tablet, + Display, + Storage, + Audio, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NetworkState { + pub interfaces: HashMap, + pub online: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InterfaceState { + pub up: bool, +} + +#[derive(Debug, Clone, 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, + pub history: Vec, + pub profiles: BTreeMap, +} + +impl Default for ProfileState { + fn default() -> Self { + Self { + active: "default".to_string(), + history: Vec::new(), + profiles: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleStatus { + pub name: String, + pub status: ModuleLoadState, + pub last_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ModuleLoadState { + Loaded, + LoadError, + NotFound, +} diff --git a/breadd/src/ipc/mod.rs b/breadd/src/ipc/mod.rs new file mode 100644 index 0000000..1ac245b --- /dev/null +++ b/breadd/src/ipc/mod.rs @@ -0,0 +1,272 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process; +use std::time::Instant; + +use anyhow::{anyhow, Result}; +use bread_shared::{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 tracing::{error, info, warn}; + +use crate::core::state_engine::StateHandle; +use crate::lua::RuntimeHandle; + +#[derive(Clone)] +pub struct Server { + socket_path: PathBuf, + state_handle: StateHandle, + event_tx: broadcast::Sender, + lua_runtime: RuntimeHandle, + emit_tx: mpsc::UnboundedSender, + started_at: Instant, + pid: u32, +} + +#[derive(Debug, Deserialize)] +struct IpcRequest { + id: String, + method: String, + #[serde(default)] + params: Value, +} + +#[derive(Debug, Serialize)] +struct IpcResponse { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl Server { + pub fn new( + socket_path: PathBuf, + state_handle: StateHandle, + event_tx: broadcast::Sender, + lua_runtime: RuntimeHandle, + emit_tx: mpsc::UnboundedSender, + ) -> Self { + Self { + socket_path, + state_handle, + event_tx, + lua_runtime, + emit_tx, + started_at: Instant::now(), + pid: process::id(), + } + } + + pub async fn serve(&self, mut shutdown_rx: watch::Receiver) -> Result<()> { + if let Some(parent) = self.socket_path.parent() { + fs::create_dir_all(parent)?; + } + + if self.socket_path.exists() { + fs::remove_file(&self.socket_path)?; + } + + let listener = UnixListener::bind(&self.socket_path)?; + fs::set_permissions(&self.socket_path, fs::Permissions::from_mode(0o600))?; + + info!(socket = %self.socket_path.display(), "ipc server listening"); + + loop { + tokio::select! { + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break; + } + } + accept = listener.accept() => { + let (stream, _) = accept?; + let server = self.clone(); + tokio::spawn(async move { + if let Err(err) = server.handle_connection(stream).await { + warn!(error = %err, "ipc connection failed"); + } + }); + } + } + } + + Ok(()) + } + + async fn handle_connection(&self, stream: UnixStream) -> Result<()> { + let (read_half, mut write_half) = stream.into_split(); + let mut lines = BufReader::new(read_half).lines(); + + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + + let req: IpcRequest = serde_json::from_str(&line)?; + if req.method == "events.subscribe" { + let filter = req + .params + .get("filter") + .and_then(Value::as_str) + .map(ToString::to_string); + let ok = IpcResponse { + id: req.id, + result: Some(json!({ "subscribed": true })), + error: None, + }; + write_half + .write_all(format!("{}\n", serde_json::to_string(&ok)?).as_bytes()) + .await?; + self.stream_events(&mut write_half, filter).await?; + return Ok(()); + } + + let response = match self.handle_request(req).await { + Ok(res) => IpcResponse { + id: res.0, + result: Some(res.1), + error: None, + }, + Err((id, err)) => IpcResponse { + id, + result: None, + error: Some(err), + }, + }; + + write_half + .write_all(format!("{}\n", serde_json::to_string(&response)?).as_bytes()) + .await?; + } + + Ok(()) + } + + 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 })), + "state.get" => { + let key = req.params.get("key").and_then(Value::as_str).unwrap_or(""); + let value = self + .state_handle + .state_get(key) + .await + .ok_or_else(|| anyhow!("state path not found")); + value.map_err(|e| e.to_string()) + } + "state.dump" => Ok(self.state_handle.state_dump().await), + "modules.list" => { + 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()), + "profile.list" => { + let full = self.state_handle.state_dump().await; + let profiles = full + .get("profile") + .and_then(|v| v.get("profiles")) + .cloned() + .unwrap_or_else(|| json!({})); + Ok(profiles) + } + "profile.activate" => { + let Some(name) = req + .params + .get("name") + .and_then(Value::as_str) + else { + return Err((id, "missing profile name".to_string())); + }; + + self.state_handle.set_profile(name.to_string()); + if self + .emit_tx + .send(BreadEvent::new( + "bread.profile.activated", + AdapterSource::System, + json!({ "name": name }), + )) + .is_err() + { + return Err((id, "emit channel closed".to_string())); + } + Ok(json!({ "active": name })) + } + "emit" => { + 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!({})); + if self + .emit_tx + .send(BreadEvent::new(event, AdapterSource::System, data)) + .is_err() + { + return Err((id, "emit channel closed".to_string())); + } + Ok(json!({ "emitted": true })) + } + "health" => { + let uptime_ms = self.started_at.elapsed().as_millis(); + Ok(json!({ + "ok": true, + "pid": self.pid, + "version": env!("CARGO_PKG_VERSION"), + "uptime_ms": uptime_ms, + })) + } + _ => Err("unknown method".to_string()), + }; + + match result { + Ok(v) => Ok((id, v)), + Err(err) => Err((id, err)), + } + } + + async fn stream_events( + &self, + writer: &mut tokio::net::unix::OwnedWriteHalf, + filter: Option, + ) -> Result<()> { + let mut rx = self.event_tx.subscribe(); + loop { + let evt = rx.recv().await?; + if let Some(filter) = filter.as_deref() { + if !matches_filter(&evt.event, filter) { + continue; + } + } + + let line = format!("{}\n", serde_json::to_string(&evt)?); + if let Err(err) = writer.write_all(line.as_bytes()).await { + error!(error = %err, "failed to write event stream line"); + return Ok(()); + } + } + } +} + +fn matches_filter(event_name: &str, pattern: &str) -> bool { + if pattern.ends_with(".*") { + let prefix = &pattern[..pattern.len() - 1]; + return event_name.starts_with(prefix); + } + event_name == pattern +} diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs new file mode 100644 index 0000000..e9f100a --- /dev/null +++ b/breadd/src/lua/mod.rs @@ -0,0 +1,340 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use anyhow::{anyhow, Result}; +use bread_shared::{AdapterSource, BreadEvent}; +use mlua::{Function, Lua, LuaSerdeExt, RegistryKey, Value}; +use tokio::sync::{mpsc, oneshot}; +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; + +pub enum LuaMessage { + Event { + subscription_id: SubscriptionId, + event: BreadEvent, + }, + SubscriptionCancelled { + id: SubscriptionId, + }, + Reload { + reply: oneshot::Sender>, + }, + Shutdown, +} + +#[derive(Clone)] +pub struct RuntimeHandle { + tx: mpsc::UnboundedSender, +} + +impl RuntimeHandle { + pub fn sender(&self) -> mpsc::UnboundedSender { + self.tx.clone() + } + + pub async fn reload(&self) -> Result<()> { + let (tx, rx) = oneshot::channel(); + self.tx + .send(LuaMessage::Reload { reply: tx }) + .map_err(|_| anyhow!("lua runtime channel closed"))?; + match rx.await { + Ok(Ok(())) => Ok(()), + Ok(Err(err)) => Err(anyhow!(err)), + Err(_) => Err(anyhow!("lua runtime dropped reload response")), + } + } + + pub fn shutdown(&self) { + let _ = self.tx.send(LuaMessage::Shutdown); + } +} + +pub fn spawn_runtime( + config: Config, + state_handle: StateHandle, + emit_tx: mpsc::UnboundedSender, +) -> Result { + let (tx, mut rx) = mpsc::unbounded_channel(); + let handle = RuntimeHandle { tx }; + let thread_tx = handle.tx.clone(); + + 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 = match LuaEngine::new(config, state_handle, emit_tx) { + Ok(engine) => engine, + Err(err) => { + error!(error = %err, "failed to initialize lua engine"); + return; + } + }; + + if let Err(err) = engine.reload_internal() { + error!(error = %err, "initial lua load failed"); + } + + while let Some(msg) = rx.recv().await { + match msg { + LuaMessage::Event { + subscription_id, + event, + } => { + if let Err(err) = engine.handle_event(subscription_id, event) { + error!(error = %err, "lua event handler failed"); + } + } + LuaMessage::SubscriptionCancelled { id } => { + engine.remove_handler(id); + } + LuaMessage::Reload { reply } => { + let result = engine.reload_internal().map_err(|e| e.to_string()); + let _ = reply.send(result); + } + LuaMessage::Shutdown => { + break; + } + } + } + + info!("lua runtime thread exiting"); + }); + })?; + + let _ = thread_tx; + Ok(handle) +} + +struct LuaEngine { + lua: Lua, + handlers: Arc>>, + next_sub_id: Arc, + state_handle: StateHandle, + emit_tx: mpsc::UnboundedSender, + entry_point: PathBuf, + module_path: PathBuf, +} + +impl LuaEngine { + fn new(config: Config, state_handle: StateHandle, emit_tx: mpsc::UnboundedSender) -> Result { + Ok(Self { + lua: Lua::new(), + handlers: Arc::new(Mutex::new(HashMap::new())), + next_sub_id: Arc::new(AtomicU64::new(1)), + state_handle, + emit_tx, + entry_point: config.lua_entry_point(), + module_path: config.lua_module_path(), + }) + } + + fn reload_internal(&mut self) -> Result<()> { + self.state_handle.clear_subscriptions(); + self.lua = Lua::new(); + self.handlers + .lock() + .expect("lua handlers mutex poisoned") + .clear(); + + self.install_api()?; + self.load_init_and_modules()?; + info!("lua runtime reloaded"); + Ok(()) + } + + fn install_api(&self) -> Result<()> { + let globals = self.lua.globals(); + let bread = self.lua.create_table()?; + + let handlers = self.handlers.clone(); + let next_sub_id = self.next_sub_id.clone(); + let state_handle = self.state_handle.clone(); + let on_fn = self.lua.create_function(move |lua, (pattern, callback): (String, Function)| { + let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + handlers + .lock() + .map_err(|_| mlua::Error::external("handler lock poisoned"))? + .insert(id, key); + state_handle + .register_subscription(id, pattern, false) + .map_err(mlua::Error::external)?; + Ok(id.0) + })?; + bread.set("on", on_fn)?; + + let handlers = self.handlers.clone(); + let next_sub_id = self.next_sub_id.clone(); + let state_handle = self.state_handle.clone(); + let once_fn = self.lua.create_function(move |lua, (pattern, callback): (String, Function)| { + let id = SubscriptionId(next_sub_id.fetch_add(1, Ordering::Relaxed)); + let key = lua.create_registry_value(callback)?; + handlers + .lock() + .map_err(|_| mlua::Error::external("handler lock poisoned"))? + .insert(id, key); + state_handle + .register_subscription(id, pattern, true) + .map_err(mlua::Error::external)?; + Ok(id.0) + })?; + bread.set("once", once_fn)?; + + let emit_tx = self.emit_tx.clone(); + let emit_fn = self.lua.create_function(move |lua, (event_name, payload): (String, Value)| { + let data = match payload { + Value::Nil => serde_json::json!({}), + other => lua + .from_value::(other) + .unwrap_or_else(|_| serde_json::json!({})), + }; + emit_tx + .send(BreadEvent::new(event_name, AdapterSource::System, data)) + .map_err(|_| mlua::Error::external("event channel closed"))?; + Ok(()) + })?; + bread.set("emit", emit_fn)?; + + let state_arc = self.state_handle.state_arc(); + let state_tbl = self.lua.create_table()?; + let get_fn = self.lua.create_function(move |lua, path: String| { + let snapshot = state_arc.blocking_read(); + let mut value = serde_json::to_value(&*snapshot) + .map_err(|e| mlua::Error::external(e.to_string()))?; + if path.is_empty() { + return lua + .to_value(&value) + .map_err(|e| mlua::Error::external(e.to_string())); + } + for part in path.split('.') { + value = value + .get(part) + .cloned() + .ok_or_else(|| mlua::Error::external("state path not found"))?; + } + lua.to_value(&value) + .map_err(|e| mlua::Error::external(e.to_string())) + })?; + state_tbl.set("get", get_fn)?; + bread.set("state", state_tbl)?; + + let profile_tbl = self.lua.create_table()?; + let state_handle = self.state_handle.clone(); + let activate_fn = self.lua.create_function(move |_lua, name: String| { + state_handle.set_profile(name.clone()); + Ok(()) + })?; + profile_tbl.set("activate", activate_fn)?; + bread.set("profile", profile_tbl)?; + + let exec_fn = self.lua.create_function(move |_lua, cmd: String| { + let status = std::process::Command::new("sh") + .arg("-lc") + .arg(&cmd) + .status() + .map_err(mlua::Error::external)?; + Ok(status.code().unwrap_or_default()) + })?; + bread.set("exec", exec_fn)?; + + globals.set("bread", bread)?; + Ok(()) + } + + fn load_init_and_modules(&self) -> Result<()> { + self.load_lua_file(&self.entry_point, "init")?; + + 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); + } + Err(err) => { + self.state_handle.set_module_status( + module_name, + ModuleLoadState::LoadError, + Some(err.to_string()), + ); + } + } + } + + Ok(()) + } + + fn load_lua_file(&self, path: &Path, module_name: &str) -> 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, + ); + return Ok(()); + } + + let src = fs::read_to_string(path)?; + self.lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec()?; + Ok(()) + } + + 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: 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(()) + } + + fn remove_handler(&self, id: SubscriptionId) { + if let Ok(mut map) = self.handlers.lock() { + map.remove(&id); + } + } +} + +fn list_lua_files(root: &Path) -> Result> { + let mut out = Vec::new(); + if !root.exists() { + return Ok(out); + } + + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().and_then(|e| e.to_str()) == Some("lua") { + out.push(path); + } + } + } + Ok(out) +} diff --git a/breadd/src/main.rs b/breadd/src/main.rs new file mode 100644 index 0000000..c57fabd --- /dev/null +++ b/breadd/src/main.rs @@ -0,0 +1,128 @@ +mod adapters; +mod core; +mod ipc; +mod lua; + +use std::sync::Arc; + +use anyhow::Result; +use bread_shared::{AdapterSource, BreadEvent, RawEvent}; +use tokio::sync::{broadcast, mpsc, watch, RwLock}; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; + +use crate::core::config::Config; +use crate::core::normalizer::EventNormalizer; +use crate::core::state_engine::{run_state_engine, StateHandle}; +use crate::core::types::RuntimeState; + +#[tokio::main] +async fn main() -> Result<()> { + let config = Config::load()?; + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new(config.daemon.log_level.clone())) + .init(); + + info!("starting breadd"); + + let state = Arc::new(RwLock::new(RuntimeState::default())); + + let (raw_tx, mut raw_rx) = mpsc::channel::(2048); + let (normalized_tx, normalized_rx) = mpsc::unbounded_channel::(); + let (state_cmd_tx, state_cmd_rx) = mpsc::unbounded_channel(); + 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 lua_runtime = lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?; + let lua_tx = lua_runtime.sender(); + + tokio::spawn(run_state_engine( + normalized_rx, + state_cmd_rx, + state.clone(), + lua_tx, + event_stream_tx.clone(), + shutdown_rx.clone(), + )); + + let normalizer = Arc::new(EventNormalizer::new(config.events.dedup_window_ms)); + { + let normalizer = normalizer.clone(); + let normalized_tx = normalized_tx.clone(); + let mut shutdown_rx = shutdown_rx.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + break; + } + } + maybe_raw = raw_rx.recv() => { + let Some(raw) = maybe_raw else { + break; + }; + for event in normalizer.normalize(&raw) { + if normalized_tx.send(event).is_err() { + break; + } + } + } + } + } + }); + } + + let adapter_manager = adapters::Manager::new(raw_tx, config.clone(), shutdown_rx.clone()); + adapter_manager.start_all().await?; + + let _ = normalized_tx.send(BreadEvent::new( + "bread.system.startup", + AdapterSource::System, + serde_json::json!({}), + )); + + let ipc_server = ipc::Server::new( + config.socket_path(), + state_handle, + event_stream_tx, + lua_runtime.clone(), + normalized_tx, + ); + + info!("breadd fully started"); + tokio::select! { + result = ipc_server.serve(shutdown_rx.clone()) => { + if let Err(err) = result { + error!(error = %err, "ipc server failed"); + } + } + _ = wait_for_shutdown() => { + info!("shutdown signal received"); + } + } + + let _ = shutdown_tx.send(true); + + lua_runtime.shutdown(); + Ok(()) +} + +async fn wait_for_shutdown() { + let ctrl_c = tokio::signal::ctrl_c(); + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); + tokio::select! { + _ = ctrl_c => {}, + _ = sigterm.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _ = ctrl_c.await; + } +} diff --git a/breadd/tests/ipc_integration.rs b/breadd/tests/ipc_integration.rs new file mode 100644 index 0000000..9f8e573 --- /dev/null +++ b/breadd/tests/ipc_integration.rs @@ -0,0 +1,210 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use tempfile::TempDir; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; +use tokio::time::sleep; + +#[tokio::test] +async fn ping_and_state_dump_work() -> Result<()> { + let harness = TestHarness::spawn()?; + harness.wait_until_ready().await?; + + 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)); + assert!(health.get("version").and_then(Value::as_str).is_some()); + assert!(health.get("uptime_ms").and_then(Value::as_u64).is_some()); + + let dump = harness.send_request("state.dump", json!({})).await?; + assert!(dump.get("devices").is_some()); + assert!(dump.get("profile").is_some()); + + harness.shutdown(); + Ok(()) +} + +#[tokio::test] +async fn events_stream_receives_emitted_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-1", + "method": "events.subscribe", + "params": { + "filter": "bread.system.*" + } + }); + write_half + .write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes()) + .await?; + + let mut reader = BufReader::new(read_half).lines(); + + let ack = reader + .next_line() + .await? + .ok_or_else(|| anyhow!("missing subscribe ack"))?; + let ack_json: Value = serde_json::from_str(&ack)?; + assert_eq!( + ack_json + .get("result") + .and_then(|v| v.get("subscribed")) + .and_then(Value::as_bool), + Some(true) + ); + + harness + .send_request( + "emit", + json!({ + "event": "bread.system.test", + "data": { "ok": true } + }), + ) + .await?; + + let deadline = Instant::now() + Duration::from_secs(5); + let mut got = false; + while Instant::now() < deadline { + let Some(line) = reader.next_line().await? else { + break; + }; + let event: Value = serde_json::from_str(&line)?; + if event.get("event").and_then(Value::as_str) == Some("bread.system.test") { + got = true; + break; + } + } + + assert!(got, "did not receive emitted event on stream"); + harness.shutdown(); + Ok(()) +} + +struct TestHarness { + _temp: TempDir, + child: Child, + socket_path: PathBuf, +} + +impl TestHarness { + fn spawn() -> Result { + let temp = tempfile::tempdir()?; + let runtime_dir = temp.path().join("runtime"); + let config_home = temp.path().join("config"); + let home = temp.path().join("home"); + fs::create_dir_all(&runtime_dir)?; + fs::create_dir_all(&config_home)?; + fs::create_dir_all(&home)?; + + let bread_cfg = config_home.join("bread"); + fs::create_dir_all(bread_cfg.join("modules"))?; + + fs::write( + bread_cfg.join("init.lua"), + "bread.on('bread.system.startup', function() end)\n", + )?; + + fs::write( + bread_cfg.join("breadd.toml"), + r#" +[daemon] +log_level = "error" + +[lua] +entry_point = "~/.config/bread/init.lua" +module_path = "~/.config/bread/modules" + +[adapters.hyprland] +enabled = false + +[adapters.udev] +enabled = false + +[adapters.power] +enabled = false + +[adapters.network] +enabled = false +"#, + )?; + + 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) + .env("XDG_CONFIG_HOME", &config_home) + .env("HOME", &home) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + + Ok(Self { + _temp: temp, + child, + socket_path, + }) + } + + fn socket_path(&self) -> &Path { + &self.socket_path + } + + async fn wait_until_ready(&self) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(8); + while Instant::now() < deadline { + if self.socket_path.exists() { + let ping = self.send_request("ping", json!({})).await; + if ping.is_ok() { + return Ok(()); + } + } + sleep(Duration::from_millis(100)).await; + } + + Err(anyhow!("daemon did not become ready in time")) + } + + async fn send_request(&self, method: &str, params: Value) -> Result { + let stream = UnixStream::connect(self.socket_path()).await?; + let (read_half, mut write_half) = stream.into_split(); + + let req = json!({ + "id": "1", + "method": method, + "params": 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!("missing ipc response"))?; + let parsed: Value = serde_json::from_str(&line)?; + + if let Some(err) = parsed.get("error").and_then(Value::as_str) { + return Err(anyhow!(err.to_string())); + } + + Ok(parsed.get("result").cloned().unwrap_or_else(|| json!({}))) + } + + fn shutdown(mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..3f829f8 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,5 @@ +Packaging notes +================ + +This repo targets Arch packaging. The Arch PKGBUILD skeleton lives under +`packaging/arch/`. diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..a49c7f7 --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,25 @@ +# Maintainer: Your Name + +pkgname=breadd +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Bread daemon - event normalizer and automation runtime" +arch=('x86_64') +url="https://example.com/bread" +license=('MIT') +depends=('glibc') +makedepends=('rust') +source=("${pkgname}-${pkgver}.tar.gz") +sha256sums=('SKIP') + +build() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo build --release --locked +} + +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 -Dm644 packaging/systemd/breadd.service "${pkgdir}/usr/lib/systemd/user/breadd.service" +} diff --git a/packaging/arch/README.md b/packaging/arch/README.md new file mode 100644 index 0000000..1873cd6 --- /dev/null +++ b/packaging/arch/README.md @@ -0,0 +1,9 @@ +Arch packaging +============== + +This is a minimal PKGBUILD skeleton. + +Steps to use: +- Update `pkgver`, `source`, `sha256sums`, and `url`. +- Set the correct license and dependencies. +- Ensure the release tarball includes `packaging/systemd/breadd.service`. diff --git a/packaging/systemd/breadd.service b/packaging/systemd/breadd.service new file mode 100644 index 0000000..9f36697 --- /dev/null +++ b/packaging/systemd/breadd.service @@ -0,0 +1,19 @@ +[Unit] +Description=Bread Runtime Daemon +After=graphical-session.target +Wants=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/.cargo/bin/breadd +Restart=on-failure +RestartSec=2 +UMask=0077 +RuntimeDirectory=bread +RuntimeDirectoryMode=0700 +KillSignal=SIGTERM +TimeoutStopSec=5 +Environment=RUST_LOG=info + +[Install] +WantedBy=default.target