diff --git a/Cargo.lock b/Cargo.lock index 8c287d2..88bc3af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,18 +73,181 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -94,6 +257,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bluer" version = "0.17.4" @@ -193,6 +369,40 @@ 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 = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[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 = "ctrlc" version = "3.5.2" @@ -294,13 +504,23 @@ dependencies = [ "tokio", ] +[[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 = "dispatch2" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -323,6 +543,33 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -339,6 +586,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -353,7 +621,7 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "fluxo-rs" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "bluer", @@ -363,6 +631,7 @@ dependencies = [ "libpulse-binding", "maestro", "nix 0.31.2", + "notify", "regex", "serde", "serde_json", @@ -374,6 +643,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "zbus", ] [[package]] @@ -388,6 +658,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -436,6 +715,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -476,6 +768,27 @@ dependencies = [ "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" @@ -510,6 +823,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -530,9 +849,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -540,6 +859,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -571,6 +910,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -604,7 +963,7 @@ version = "2.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", "libpulse-sys", "num-derive", @@ -691,6 +1050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -707,10 +1067,11 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -719,13 +1080,40 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", "memoffset", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -801,7 +1189,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -832,6 +1220,22 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[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 = "petgraph" version = "0.8.3" @@ -869,12 +1273,46 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "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" @@ -969,6 +1407,36 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +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 = "regex" version = "1.12.3" @@ -1004,7 +1472,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -1017,6 +1485,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "semver" version = "1.0.27" @@ -1066,6 +1543,17 @@ dependencies = [ "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", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1075,6 +1563,17 @@ dependencies = [ "serde_core", ] +[[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" @@ -1116,6 +1615,12 @@ dependencies = [ "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" @@ -1187,7 +1692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1276,9 +1781,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "994b95d9e7bae62b34bab0e2a4510b801fa466066a6a8b2b57361fa1eba068ee" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", @@ -1312,9 +1817,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] @@ -1386,6 +1891,23 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1410,7 +1932,7 @@ version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -1421,6 +1943,22 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1518,7 +2056,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -1540,6 +2078,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1647,13 +2194,31 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -1671,14 +2236,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -1696,48 +2278,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.1" @@ -1805,7 +2435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -1835,8 +2465,137 @@ dependencies = [ "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 = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +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", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index d2bc0c5..ecbd9f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sysinfo = "0.38.4" thiserror = "2.0" -toml = "1.1.1" +toml = "1.1.2" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } maestro = { git = "https://github.com/qzed/pbpctrl", package = "maestro" } @@ -22,6 +22,8 @@ tokio-util = { version = "0.7", features = ["codec", "time"] } futures = "0.3" libpulse-binding = "2.30" nix = { version = "0.31", features = ["net"] } +notify = "8.2.0" +zbus = "4" [dev-dependencies] diff --git a/example.config.toml b/example.config.toml index dd140fe..8fcc7a1 100644 --- a/example.config.toml +++ b/example.config.toml @@ -1,7 +1,7 @@ # Fluxo configuration example [general] -menu_command = "fuzzel --dmenu --prompt '{prompt}'" +menu_command = "fuzzel --dmenu --prompt \"$FLUXO_PROMPT\"" [network] format = "{interface} ({ip}):  {rx:>5.2} MB/s  {tx:>5.2} MB/s" diff --git a/src/config.rs b/src/config.rs index bff02f1..d1289a9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,8 @@ pub struct Config { #[serde(default)] pub general: GeneralConfig, #[serde(default)] + pub signals: SignalsConfig, + #[serde(default)] pub network: NetworkConfig, #[serde(default)] pub cpu: CpuConfig, @@ -31,6 +33,14 @@ pub struct Config { pub bt: BtConfig, #[serde(default)] pub game: GameConfig, + #[serde(default)] + pub mpris: MprisConfig, + #[serde(default)] + pub backlight: BacklightConfig, + #[serde(default)] + pub keyboard: KeyboardConfig, + #[serde(default)] + pub dnd: DndConfig, } #[derive(Deserialize, Clone)] @@ -41,11 +51,31 @@ pub struct GeneralConfig { impl Default for GeneralConfig { fn default() -> Self { Self { - menu_command: "fuzzel --dmenu --prompt '{prompt}'".to_string(), + menu_command: "fuzzel --dmenu --prompt \"$FLUXO_PROMPT\"".to_string(), } } } +#[allow(dead_code)] +#[derive(Deserialize, Default, Clone)] +pub struct SignalsConfig { + pub network: Option, + pub cpu: Option, + pub memory: Option, + pub gpu: Option, + pub sys: Option, + pub disk: Option, + pub pool: Option, + pub power: Option, + pub audio: Option, + pub bt: Option, + pub game: Option, + pub mpris: Option, + pub backlight: Option, + pub keyboard: Option, + pub dnd: Option, +} + #[derive(Deserialize, Clone)] pub struct NetworkConfig { pub format: String, @@ -209,6 +239,60 @@ impl Default for GameConfig { } } +#[derive(Deserialize, Clone)] +pub struct MprisConfig { + pub format: String, +} + +impl Default for MprisConfig { + fn default() -> Self { + Self { + format: "{status_icon} {artist} - {title}".to_string(), + } + } +} + +#[derive(Deserialize, Clone)] +pub struct BacklightConfig { + pub format: String, +} + +impl Default for BacklightConfig { + fn default() -> Self { + Self { + format: "{percentage:>3}% {icon}".to_string(), + } + } +} + +#[derive(Deserialize, Clone)] +pub struct KeyboardConfig { + pub format: String, +} + +impl Default for KeyboardConfig { + fn default() -> Self { + Self { + format: "{layout}".to_string(), + } + } +} + +#[derive(Deserialize, Clone)] +pub struct DndConfig { + pub format_dnd: String, + pub format_normal: String, +} + +impl Default for DndConfig { + fn default() -> Self { + Self { + format_dnd: "󰂛".to_string(), + format_normal: "󰂚".to_string(), + } + } +} + static TOKEN_RE: LazyLock = LazyLock::new(|| Regex::new(r"\{([a-zA-Z0-9_]+)(?::([<>\^])?(\d+)?(?:\.(\d+))?)?\}").unwrap()); @@ -284,6 +368,13 @@ impl Config { &self.bt.format_plugin, &["alias", "left", "right", "anc", "mac"], ); + validate_format( + "mpris", + &self.mpris.format, + &["artist", "title", "album", "status_icon"], + ); + validate_format("backlight", &self.backlight.format, &["percentage", "icon"]); + validate_format("keyboard", &self.keyboard.format, &["layout"]); } } @@ -330,7 +421,7 @@ mod tests { let config = Config::default(); assert_eq!( config.general.menu_command, - "fuzzel --dmenu --prompt '{prompt}'" + "fuzzel --dmenu --prompt \"$FLUXO_PROMPT\"" ); assert!(config.cpu.format.contains("usage")); assert!(config.cpu.format.contains("temp")); @@ -344,7 +435,7 @@ mod tests { // Should fallback to defaults without panicking assert_eq!( config.general.menu_command, - "fuzzel --dmenu --prompt '{prompt}'" + "fuzzel --dmenu --prompt \"$FLUXO_PROMPT\"" ); } @@ -371,7 +462,7 @@ mod tests { // Should fallback to defaults assert_eq!( config.general.menu_command, - "fuzzel --dmenu --prompt '{prompt}'" + "fuzzel --dmenu --prompt \"$FLUXO_PROMPT\"" ); } @@ -383,7 +474,7 @@ mod tests { let config = load_config(Some(tmpfile.path().to_path_buf())); assert_eq!( config.general.menu_command, - "fuzzel --dmenu --prompt '{prompt}'" + "fuzzel --dmenu --prompt \"$FLUXO_PROMPT\"" ); assert!(config.cpu.format.contains("usage")); } diff --git a/src/daemon.rs b/src/daemon.rs index dfde0e9..062a80c 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3,18 +3,24 @@ use crate::error::FluxoError; use crate::ipc::socket_path; use crate::modules::WaybarModule; use crate::modules::audio::AudioDaemon; +use crate::modules::backlight::BacklightDaemon; use crate::modules::bt::BtDaemon; +use crate::modules::dnd::DndDaemon; use crate::modules::hardware::HardwareDaemon; +use crate::modules::keyboard::KeyboardDaemon; +use crate::modules::mpris::MprisDaemon; use crate::modules::network::NetworkDaemon; +use crate::signaler::WaybarSignaler; use crate::state::AppReceivers; use anyhow::Result; +use notify::{Config as NotifyConfig, Event, RecommendedWatcher, RecursiveMode, Watcher}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::sync::Arc; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixListener; -use tokio::sync::{RwLock, watch}; +use tokio::sync::{RwLock, mpsc, watch}; use tokio::time::{Duration, Instant, sleep}; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; @@ -30,6 +36,18 @@ impl Drop for SocketGuard { } } +fn get_config_path(custom_path: Option) -> PathBuf { + custom_path.unwrap_or_else(|| { + let config_dir = std::env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| { + let home = std::env::var("HOME").unwrap_or_else(|_| String::from("/")); + PathBuf::from(home).join(".config") + }); + config_dir.join("fluxo/config.toml") + }) +} + pub async fn run_daemon(config_path: Option) -> Result<()> { let sock_path = socket_path(); @@ -46,7 +64,13 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { let (disks_tx, disks_rx) = watch::channel(Default::default()); let (bt_tx, bt_rx) = watch::channel(Default::default()); let (audio_tx, audio_rx) = watch::channel(Default::default()); + let (mpris_tx, mpris_rx) = watch::channel(Default::default()); + let (backlight_tx, backlight_rx) = watch::channel(Default::default()); + let (keyboard_tx, keyboard_rx) = watch::channel(Default::default()); + let (dnd_tx, dnd_rx) = watch::channel(Default::default()); let health = Arc::new(RwLock::new(HashMap::new())); + let (bt_force_tx, mut bt_force_rx) = mpsc::channel(1); + let (audio_cmd_tx, audio_cmd_rx) = mpsc::channel(8); let receivers = AppReceivers { network: net_rx, @@ -57,7 +81,13 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { disks: disks_rx, bluetooth: bt_rx, audio: audio_rx, + mpris: mpris_rx, + backlight: backlight_rx, + keyboard: keyboard_rx, + dnd: dnd_rx, health: Arc::clone(&health), + bt_force_poll: bt_force_tx, + audio_cmd_tx, }; let listener = UnixListener::bind(&sock_path)?; @@ -74,8 +104,56 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { token_clone.cancel(); }); - let config_path_clone = config_path.clone(); - let config = Arc::new(RwLock::new(crate::config::load_config(config_path))); + let resolved_config_path = get_config_path(config_path.clone()); + let config = Arc::new(RwLock::new(crate::config::load_config(config_path.clone()))); + + // 0. Config Watcher (Hot Reload) + let watcher_config = Arc::clone(&config); + let watcher_path = resolved_config_path.clone(); + tokio::spawn(async move { + let (ev_tx, mut ev_rx) = mpsc::channel(1); + let mut watcher = RecommendedWatcher::new( + move |res: notify::Result| { + if let Ok(event) = res + && (event.kind.is_modify() || event.kind.is_create()) + { + let _ = ev_tx.blocking_send(()); + } + }, + NotifyConfig::default(), + ) + .unwrap(); + + if let Some(parent) = watcher_path.parent() { + let _ = watcher.watch(parent, RecursiveMode::NonRecursive); + } + + info!("Config watcher started on {:?}", watcher_path); + + loop { + tokio::select! { + _ = ev_rx.recv() => { + // Debounce reloads + sleep(Duration::from_millis(100)).await; + while ev_rx.try_recv().is_ok() {} + reload_config(&watcher_config, Some(watcher_path.clone())).await; + } + } + } + }); + + // 0.1 SIGHUP Handler + let hup_config = Arc::clone(&config); + let hup_path = resolved_config_path.clone(); + tokio::spawn(async move { + use tokio::signal::unix::{SignalKind, signal}; + let mut stream = signal(SignalKind::hangup()).unwrap(); + loop { + stream.recv().await; + info!("Received SIGHUP, reloading config..."); + reload_config(&hup_config, Some(hup_path.clone())).await; + } + }); // 1. Network Task let token = cancel_token.clone(); @@ -84,14 +162,13 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { info!("Starting Network polling task"); let mut daemon = NetworkDaemon::new(); loop { + if !is_in_backoff("net", &net_health).await { + let res = daemon.poll(&net_tx).await; + handle_poll_result("net", res, &net_health).await; + } tokio::select! { _ = token.cancelled() => break, - _ = sleep(Duration::from_secs(1)) => { - if !is_in_backoff("net", &net_health).await { - let res = daemon.poll(&net_tx).await; - handle_poll_result("net", res, &net_health).await; - } - } + _ = sleep(Duration::from_secs(1)) => {} } } info!("Network task shut down."); @@ -104,13 +181,12 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { info!("Starting Fast Hardware polling task"); let mut daemon = HardwareDaemon::new(); loop { + if !is_in_backoff("cpu", &hw_health).await { + daemon.poll_fast(&cpu_tx, &mem_tx, &sys_tx).await; + } tokio::select! { _ = token.cancelled() => break, - _ = sleep(Duration::from_secs(1)) => { - if !is_in_backoff("cpu", &hw_health).await { - daemon.poll_fast(&cpu_tx, &mem_tx, &sys_tx).await; - } - } + _ = sleep(Duration::from_secs(1)) => {} } } info!("Fast Hardware task shut down."); @@ -123,13 +199,12 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { info!("Starting Slow Hardware polling task"); let mut daemon = HardwareDaemon::new(); loop { + if !is_in_backoff("gpu", &slow_health).await { + daemon.poll_slow(&gpu_tx, &disks_tx).await; + } tokio::select! { _ = token.cancelled() => break, - _ = sleep(Duration::from_secs(5)) => { - if !is_in_backoff("gpu", &slow_health).await { - daemon.poll_slow(&gpu_tx, &disks_tx).await; - } - } + _ = sleep(Duration::from_secs(5)) => {} } } info!("Slow Hardware task shut down."); @@ -144,14 +219,14 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { info!("Starting Bluetooth polling task"); let mut daemon = BtDaemon::new(); loop { + if !is_in_backoff("bt", &bt_health).await { + let config = poll_config.read().await; + daemon.poll(&bt_tx, &poll_receivers, &config).await; + } tokio::select! { _ = token.cancelled() => break, - _ = sleep(Duration::from_secs(2)) => { - if !is_in_backoff("bt", &bt_health).await { - let config = poll_config.read().await; - daemon.poll(&bt_tx, &poll_receivers, &config).await; - } - } + _ = bt_force_rx.recv() => {}, + _ = sleep(Duration::from_secs(2)) => {} } } info!("Bluetooth task shut down."); @@ -159,7 +234,32 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { // 5. Audio Thread (Event driven) let audio_daemon = AudioDaemon::new(); - audio_daemon.start(&audio_tx); + audio_daemon.start(&audio_tx, audio_cmd_rx); + + // 5.1 Backlight Thread (Event driven) + let backlight_daemon = BacklightDaemon::new(); + backlight_daemon.start(backlight_tx); + + // 5.2 Keyboard Thread (Event driven) + let keyboard_daemon = KeyboardDaemon::new(); + keyboard_daemon.start(keyboard_tx); + + // 5.3 DND Thread (Event driven) + let dnd_daemon = DndDaemon::new(); + dnd_daemon.start(dnd_tx); + + // 5.4 MPRIS Thread + let mpris_daemon = MprisDaemon::new(); + mpris_daemon.start(mpris_tx); + + // 6. Waybar Signaler Task + let signaler = WaybarSignaler::new(); + let sig_config = Arc::clone(&config); + let sig_receivers = receivers.clone(); + tokio::spawn(async move { + info!("Starting Waybar Signaler task"); + signaler.run(sig_config, sig_receivers).await; + }); info!("Fluxo daemon successfully bound to socket: {}", sock_path); @@ -173,7 +273,7 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { Ok((mut stream, _)) => { let state_clone = receivers.clone(); let config_clone = Arc::clone(&config); - let cp_clone = config_path_clone.clone(); + let cp_clone = config_path.clone(); tokio::spawn(async move { let (reader, mut writer) = stream.split(); let mut reader = BufReader::new(reader); @@ -191,12 +291,8 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { let parts: Vec<&str> = request.split_whitespace().collect(); if let Some(module_name) = parts.first() { if *module_name == "reload" { - info!("Reloading configuration..."); - let new_config = crate::config::load_config(cp_clone); - let mut config_lock = config_clone.write().await; - *config_lock = new_config; + reload_config(&config_clone, cp_clone).await; let _ = writer.write_all(b"{\"text\":\"ok\"}").await; - info!("Configuration reloaded successfully."); return; } @@ -281,6 +377,89 @@ async fn is_in_backoff( false } +pub async fn reload_config(config_lock: &Arc>, path: Option) { + info!("Reloading configuration..."); + let new_config = crate::config::load_config(path); + let mut lock = config_lock.write().await; + *lock = new_config; + info!("Configuration reloaded successfully."); +} + +pub async fn evaluate_module_for_signaler( + module_name: &str, + state: &AppReceivers, + config: &Config, +) -> Option { + let result = match module_name { + "net" | "network" => { + crate::modules::network::NetworkModule + .run(config, state, &[]) + .await + } + "cpu" => crate::modules::cpu::CpuModule.run(config, state, &[]).await, + "mem" | "memory" => { + crate::modules::memory::MemoryModule + .run(config, state, &[]) + .await + } + "disk" => { + crate::modules::disk::DiskModule + .run(config, state, &["/"]) + .await + } + "pool" | "btrfs" => { + crate::modules::btrfs::BtrfsModule + .run(config, state, &["btrfs"]) + .await + } + "vol" | "audio" => { + crate::modules::audio::AudioModule + .run(config, state, &["sink", "show"]) + .await + } + "mic" => { + crate::modules::audio::AudioModule + .run(config, state, &["source", "show"]) + .await + } + "gpu" => crate::modules::gpu::GpuModule.run(config, state, &[]).await, + "sys" => crate::modules::sys::SysModule.run(config, state, &[]).await, + "bt" | "bluetooth" => { + crate::modules::bt::BtModule + .run(config, state, &["show"]) + .await + } + "power" => { + crate::modules::power::PowerModule + .run(config, state, &[]) + .await + } + "game" => { + crate::modules::game::GameModule + .run(config, state, &[]) + .await + } + "backlight" => { + crate::modules::backlight::BacklightModule + .run(config, state, &[]) + .await + } + "kbd" | "keyboard" => { + crate::modules::keyboard::KeyboardModule + .run(config, state, &[]) + .await + } + "dnd" => crate::modules::dnd::DndModule.run(config, state, &[]).await, + "mpris" => { + crate::modules::mpris::MprisModule + .run(config, state, &[]) + .await + } + _ => return None, + }; + result.ok().and_then(|out| serde_json::to_string(&out).ok()) +} + async fn handle_request( module_name: &str, args: &[&str], @@ -288,20 +467,27 @@ async fn handle_request( config_lock: &Arc>, ) -> String { // 1. Check Circuit Breaker status - let is_in_backoff = { + let (is_in_backoff, cached_output) = { let lock = state.health.read().await; if let Some(health) = lock.get(module_name) { - if let Some(until) = health.backoff_until { + let in_backoff = if let Some(until) = health.backoff_until { Instant::now() < until } else { false - } + }; + (in_backoff, health.last_successful_output.clone()) } else { - false + (false, None) } }; if is_in_backoff { + if let Some(mut cached) = cached_output { + // Add a "warning" class to indicate stale data + let class = cached.class.unwrap_or_default(); + cached.class = Some(format!("{} warning", class).trim().to_string()); + return serde_json::to_string(&cached).unwrap_or_else(|_| "{}".to_string()); + } return format!( "{{\"text\":\"\u{200B}Cooling down ({})\u{200B}\",\"class\":\"error\"}}", module_name @@ -336,14 +522,14 @@ async fn handle_request( .run(&config, state, args) .await } - "vol" => { + "vol" | "audio" => { crate::modules::audio::AudioModule - .run(&config, state, &["sink", args.first().unwrap_or(&"show")]) + .run(&config, state, args) .await } "mic" => { crate::modules::audio::AudioModule - .run(&config, state, &["source", args.first().unwrap_or(&"show")]) + .run(&config, state, args) .await } "gpu" => { @@ -367,20 +553,41 @@ async fn handle_request( .run(&config, state, args) .await } + "backlight" => { + crate::modules::backlight::BacklightModule + .run(&config, state, args) + .await + } + "kbd" | "keyboard" => { + crate::modules::keyboard::KeyboardModule + .run(&config, state, args) + .await + } + "dnd" => { + crate::modules::dnd::DndModule + .run(&config, state, args) + .await + } + "mpris" => { + crate::modules::mpris::MprisModule + .run(&config, state, args) + .await + } _ => { warn!("Received request for unknown module: '{}'", module_name); Err(FluxoError::Ipc(format!("Unknown module: {}", module_name))) } }; - // 2. Update Health based on result + // 2. Update Health and Cache based on result { let mut lock = state.health.write().await; let health = lock.entry(module_name.to_string()).or_default(); match &result { - Ok(_) => { + Ok(output) => { health.consecutive_failures = 0; health.backoff_until = None; + health.last_successful_output = Some(output.clone()); } Err(e) => { health.consecutive_failures += 1; @@ -397,6 +604,13 @@ async fn handle_request( match result { Ok(output) => serde_json::to_string(&output).unwrap_or_else(|_| "{}".to_string()), Err(e) => { + // If we have a cached output, return it as fallback even on immediate error + if let Some(mut cached) = cached_output { + let class = cached.class.unwrap_or_default(); + cached.class = Some(format!("{} warning", class).trim().to_string()); + return serde_json::to_string(&cached).unwrap_or_else(|_| "{}".to_string()); + } + let error_msg = e.to_string(); error!(module = module_name, error = %error_msg, "Module execution failed"); let err_out = crate::output::WaybarOutput { diff --git a/src/main.rs b/src/main.rs index c1f5dd2..c3de821 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod error; mod ipc; mod modules; mod output; +mod signaler; mod state; mod utils; @@ -18,7 +19,14 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*}; #[command(about = "A high-performance daemon/client for Waybar custom modules", long_about = None)] struct Cli { #[command(subcommand)] - command: Commands, + command: Option, + + /// Module name to query or interact with + module: Option, + + /// Arguments to pass to the module + #[arg(trailing_var_arg = true)] + args: Vec, } #[derive(Subcommand)] @@ -31,51 +39,6 @@ enum Commands { }, /// Reload the daemon configuration Reload, - /// Network speed module - #[command(alias = "network")] - Net, - /// CPU usage and temp module - Cpu, - /// Memory usage module - #[command(alias = "memory")] - Mem, - /// Disk usage module (path defaults to /) - Disk { - #[arg(default_value = "/")] - path: String, - }, - /// Storage pool aggregate module (e.g., btrfs) - #[command(alias = "btrfs")] - Pool { - #[arg(default_value = "btrfs")] - kind: String, - }, - /// Audio volume (sink) control - Vol { - /// Cycle to the next available output device - #[arg(short, long)] - cycle: bool, - }, - /// Microphone (source) control - Mic { - /// Cycle to the next available input device - #[arg(short, long)] - cycle: bool, - }, - /// GPU usage, VRAM, and temp module - Gpu, - /// System load average and uptime - Sys, - /// Bluetooth audio device status - #[command(alias = "bluetooth")] - Bt { - #[arg(default_value = "show")] - action: String, - }, - /// System power and battery status - Power, - /// Hyprland gamemode status - Game, } fn main() { @@ -86,114 +49,108 @@ fn main() { let cli = Cli::parse(); - match &cli.command { - Commands::Daemon { config } => { - info!("Starting Fluxo daemon..."); - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .unwrap(); + if let Some(command) = &cli.command { + match command { + Commands::Daemon { config } => { + info!("Starting Fluxo daemon..."); + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); - if let Err(e) = rt.block_on(daemon::run_daemon(config.clone())) { - error!("Daemon failed: {}", e); - process::exit(1); + if let Err(e) = rt.block_on(daemon::run_daemon(config.clone())) { + error!("Daemon failed: {}", e); + process::exit(1); + } } + Commands::Reload => match ipc::request_data("reload", &[]) { + Ok(_) => info!("Reload signal sent to daemon"), + Err(e) => { + error!("Failed to send reload signal: {}", e); + process::exit(1); + } + }, } - Commands::Reload => match ipc::request_data("reload", &[]) { - Ok(_) => info!("Reload signal sent to daemon"), - Err(e) => { - error!("Failed to send reload signal: {}", e); - process::exit(1); + return; + } + + if let Some(module) = &cli.module { + // Special case for client-side Bluetooth menu which requires UI + if module == "bt" && cli.args.first().map(|s| s.as_str()) == Some("menu") { + let config = config::load_config(None); + let mut items = Vec::new(); + + if let Ok(json_str) = ipc::request_data("bt", &["get_modes"]) + && let Ok(val) = serde_json::from_str::(&json_str) + && let Some(modes_str) = val.get("text").and_then(|t| t.as_str()) + && !modes_str.is_empty() + { + for mode in modes_str.lines() { + items.push(format!("Mode: {}", mode)); + } } - }, - Commands::Net => handle_ipc_response(ipc::request_data("net", &[])), - Commands::Cpu => handle_ipc_response(ipc::request_data("cpu", &[])), - Commands::Mem => handle_ipc_response(ipc::request_data("mem", &[])), - Commands::Disk { path } => handle_ipc_response(ipc::request_data("disk", &[path])), - Commands::Pool { kind } => handle_ipc_response(ipc::request_data("pool", &[kind])), - Commands::Vol { cycle } => { - let action = if *cycle { "cycle" } else { "show" }; - handle_ipc_response(ipc::request_data("vol", &[action])); - } - Commands::Mic { cycle } => { - let action = if *cycle { "cycle" } else { "show" }; - handle_ipc_response(ipc::request_data("mic", &[action])); - } - Commands::Gpu => handle_ipc_response(ipc::request_data("gpu", &[])), - Commands::Sys => handle_ipc_response(ipc::request_data("sys", &[])), - Commands::Bt { action } => { - if action == "menu" { - // Client-side execution of the menu - let config = config::load_config(None); - let mut items = Vec::new(); + if !items.is_empty() { + items.push("Disconnect".to_string()); + } - // If connected, show plugin modes and disconnect - if let Ok(json_str) = ipc::request_data("bt", &["get_modes"]) - && let Ok(val) = serde_json::from_str::(&json_str) - && let Some(modes_str) = val.get("text").and_then(|t| t.as_str()) - && !modes_str.is_empty() + items.push("--- Connect Device ---".to_string()); + + if let Ok(json_str) = ipc::request_data("bt", &["menu_data"]) + && let Ok(val) = serde_json::from_str::(&json_str) + && let Some(devices_str) = val.get("text").and_then(|t| t.as_str()) + { + for line in devices_str.lines() { + if !line.is_empty() { + items.push(line.to_string()); + } + } + } + + if !items.is_empty() { + if let Ok(selected) = + utils::show_menu("BT Menu: ", &items, &config.general.menu_command) { - for mode in modes_str.lines() { - items.push(format!("Mode: {}", mode)); - } - } - - if !items.is_empty() { - items.push("Disconnect".to_string()); - } - - items.push("--- Connect Device ---".to_string()); - - let devices_out = match std::process::Command::new("bluetoothctl") - .args(["devices"]) - .output() - { - Ok(out) => out, - Err(e) => { - error!("bluetoothctl not found or failed: {}", e); - return; - } - }; - let stdout = String::from_utf8_lossy(&devices_out.stdout); - - for line in stdout.lines() { - if line.starts_with("Device ") { - let parts: Vec<&str> = line.splitn(3, ' ').collect(); - if parts.len() == 3 { - items.push(format!("{} ({})", parts[2], parts[1])); - } - } - } - - if !items.is_empty() { - if let Ok(selected) = - utils::show_menu("BT Menu: ", &items, &config.general.menu_command) + if let Some(mode) = selected.strip_prefix("Mode: ") { + handle_ipc_response(ipc::request_data("bt", &["set_mode", mode])); + } else if selected == "Disconnect" { + handle_ipc_response(ipc::request_data("bt", &["disconnect"])); + } else if selected == "--- Connect Device ---" { + // Do nothing + } else if let Some(mac_start) = selected.rfind('(') + && let Some(mac_end) = selected.rfind(')') { - if let Some(mode) = selected.strip_prefix("Mode: ") { - handle_ipc_response(ipc::request_data("bt", &["set_mode", mode])); - } else if selected == "Disconnect" { - handle_ipc_response(ipc::request_data("bt", &["disconnect"])); - } else if selected == "--- Connect Device ---" { - // Do nothing - } else if let Some(mac_start) = selected.rfind('(') - && let Some(mac_end) = selected.rfind(')') - { - let mac = &selected[mac_start + 1..mac_end]; - let _ = std::process::Command::new("bluetoothctl") - .args(["connect", mac]) - .status(); - } + let mac = &selected[mac_start + 1..mac_end]; + handle_ipc_response(ipc::request_data("bt", &["connect", mac])); } - } else { - info!("No Bluetooth options found."); } - return; + } else { + info!("No Bluetooth options found."); } - handle_ipc_response(ipc::request_data("bt", &[action])); + return; } - Commands::Power => handle_ipc_response(ipc::request_data("power", &[])), - Commands::Game => handle_ipc_response(ipc::request_data("game", &[])), + + // Generic module dispatch + // To handle module-specific default targets like "vol up" -> "vol sink up" or "mic up" -> "vol source up" + // Wait, `daemon.rs` `evaluate_module_for_signaler` defaults `vol` to `sink show`. + // If we map `mic` to `["vol", "source"]` in `main.rs` instead of keeping `mic` in daemon: + let (actual_module, actual_args) = if module == "vol" { + let mut new_args = vec!["sink".to_string()]; + new_args.extend(cli.args.clone()); + ("vol".to_string(), new_args) + } else if module == "mic" { + let mut new_args = vec!["source".to_string()]; + new_args.extend(cli.args.clone()); + ("vol".to_string(), new_args) + } else { + (module.clone(), cli.args.clone()) + }; + + let args_ref: Vec<&str> = actual_args.iter().map(|s| s.as_str()).collect(); + handle_ipc_response(ipc::request_data(&actual_module, &args_ref)); + } else { + println!("Please specify a module or command. See --help."); + process::exit(1); } } diff --git a/src/modules/audio.rs b/src/modules/audio.rs index 3194e46..b4a5d5b 100644 --- a/src/modules/audio.rs +++ b/src/modules/audio.rs @@ -9,9 +9,23 @@ use libpulse_binding::context::subscribe::{Facility, InterestMaskSet}; use libpulse_binding::context::{Context, FlagSet as ContextFlag}; use libpulse_binding::mainloop::threaded::Mainloop as ThreadedMainloop; use libpulse_binding::volume::Volume; -use tokio::sync::watch; +use tokio::sync::{mpsc, watch}; use tracing::error; +pub enum AudioCommand { + ChangeVolume { + is_sink: bool, + step_val: u32, + is_up: bool, + }, + ToggleMute { + is_sink: bool, + }, + CycleDevice { + is_sink: bool, + }, +} + pub struct AudioDaemon; impl AudioDaemon { @@ -19,7 +33,11 @@ impl AudioDaemon { Self } - pub fn start(&self, state_tx: &watch::Sender) { + pub fn start( + &self, + state_tx: &watch::Sender, + mut cmd_rx: mpsc::Receiver, + ) { let state_tx = state_tx.clone(); std::thread::spawn(move || { @@ -64,11 +82,12 @@ impl AudioDaemon { context.subscribe(interest, |_| {}); let (tx, rx) = std::sync::mpsc::channel(); + let tx_cb = tx.clone(); context.set_subscribe_callback(Some(Box::new(move |facility, _operation, _index| { match facility { Some(Facility::Sink) | Some(Facility::Source) | Some(Facility::Server) => { - let _ = tx.send(()); + let _ = tx_cb.send(()); } _ => {} } @@ -76,14 +95,110 @@ impl AudioDaemon { mainloop.unlock(); - // Background polling loop driven by events or a 2s fallback timeout loop { - let _ = rx.recv_timeout(Duration::from_secs(2)); - { + if let Ok(cmd) = cmd_rx.try_recv() { mainloop.lock(); - let _ = fetch_audio_data_sync(&mut context, &state_tx); + match cmd { + AudioCommand::ChangeVolume { + is_sink, + step_val, + is_up, + } => { + let current = state_tx.borrow().clone(); + let (name, mut vol, channels) = if is_sink { + ( + current.sink.name.clone(), + current.sink.volume, + current.sink.channels, + ) + } else { + ( + current.source.name.clone(), + current.source.volume, + current.source.channels, + ) + }; + + if is_up { + vol = vol.saturating_add(step_val as u8).min(150); + } else { + vol = vol.saturating_sub(step_val as u8); + } + + let pulse_vol = Volume( + (vol as f64 / 100.0 * Volume::NORMAL.0 as f64).round() as u32, + ); + let mut cvol = libpulse_binding::volume::ChannelVolumes::default(); + cvol.set(channels.max(1), pulse_vol); + + if is_sink { + context + .introspect() + .set_sink_volume_by_name(&name, &cvol, None); + } else { + context + .introspect() + .set_source_volume_by_name(&name, &cvol, None); + } + } + AudioCommand::ToggleMute { is_sink } => { + let current = state_tx.borrow().clone(); + let (name, muted) = if is_sink { + (current.sink.name.clone(), current.sink.muted) + } else { + (current.source.name.clone(), current.source.muted) + }; + + if is_sink { + context + .introspect() + .set_sink_mute_by_name(&name, !muted, None); + } else { + context + .introspect() + .set_source_mute_by_name(&name, !muted, None); + } + } + AudioCommand::CycleDevice { is_sink } => { + let current = state_tx.borrow().clone(); + let current_name = if is_sink { + current.sink.name.clone() + } else { + current.source.name.clone() + }; + + let devices = if is_sink { + ¤t.available_sinks + } else { + ¤t.available_sources + }; + if !devices.is_empty() { + let current_idx = + devices.iter().position(|d| d == ¤t_name).unwrap_or(0); + let next_idx = (current_idx + 1) % devices.len(); + let next_dev = &devices[next_idx]; + + if is_sink { + context.set_default_sink(next_dev, |_| {}); + } else { + context.set_default_source(next_dev, |_| {}); + } + } + } + } mainloop.unlock(); + let _ = tx.send(()); } + + let _ = rx.recv_timeout(Duration::from_millis(50)); + while rx.try_recv().is_ok() {} + + mainloop.lock(); + + // Fetch data and update available sinks/sources + let _ = fetch_audio_data_sync(&mut context, &state_tx); + + mainloop.unlock(); } }); } @@ -116,48 +231,84 @@ fn fetch_audio_data_sync( let tx_sink = state_tx.clone(); context.introspect().get_sink_info_list(move |res| { - if let ListResult::Item(item) = res { - let mut current = tx_sink.borrow().clone(); - // If this matches our default sink name, or if we don't have details for any yet - let is_default = item - .name - .as_ref() - .map(|s| s.as_ref() == current.sink.name) - .unwrap_or(false); - if is_default { - current.sink.description = item - .description + let mut current = tx_sink.borrow().clone(); + match res { + ListResult::Item(item) => { + if let Some(name) = item.name.as_ref() { + let name_str = name.to_string(); + if !current.available_sinks.contains(&name_str) { + current.available_sinks.push(name_str); + } + } + + let is_default = item + .name .as_ref() - .map(|s| s.to_string()) - .unwrap_or_default(); - current.sink.volume = - ((item.volume.avg().0 as f64 / Volume::NORMAL.0 as f64) * 100.0).round() as u8; - current.sink.muted = item.mute; + .map(|s| s.as_ref() == current.sink.name) + .unwrap_or(false); + if is_default { + current.sink.description = item + .description + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(); + current.sink.volume = ((item.volume.avg().0 as f64 / Volume::NORMAL.0 as f64) + * 100.0) + .round() as u8; + current.sink.muted = item.mute; + current.sink.channels = item.volume.len(); + } let _ = tx_sink.send(current); } + ListResult::End => { + // Clear the list on End so it rebuilds fresh next time + current.available_sinks.clear(); + let _ = tx_sink.send(current); + } + ListResult::Error => {} } }); let tx_source = state_tx.clone(); context.introspect().get_source_info_list(move |res| { - if let ListResult::Item(item) = res { - let mut current = tx_source.borrow().clone(); - let is_default = item - .name - .as_ref() - .map(|s| s.as_ref() == current.source.name) - .unwrap_or(false); - if is_default { - current.source.description = item - .description + let mut current = tx_source.borrow().clone(); + match res { + ListResult::Item(item) => { + if let Some(name) = item.name.as_ref() { + let name_str = name.to_string(); + // PulseAudio includes monitor sources, ignore them if we want to + if !name_str.contains(".monitor") + && !current.available_sources.contains(&name_str) + { + current.available_sources.push(name_str); + } + } + + let is_default = item + .name .as_ref() - .map(|s| s.to_string()) - .unwrap_or_default(); - current.source.volume = - ((item.volume.avg().0 as f64 / Volume::NORMAL.0 as f64) * 100.0).round() as u8; - current.source.muted = item.mute; + .map(|s| s.as_ref() == current.source.name) + .unwrap_or(false); + if is_default { + current.source.description = item + .description + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_default(); + current.source.volume = ((item.volume.avg().0 as f64 / Volume::NORMAL.0 as f64) + * 100.0) + .round() as u8; + current.source.muted = item.mute; + current.source.channels = item.volume.len(); + } let _ = tx_source.send(current); } + ListResult::End => { + // Clear the list on End so it rebuilds fresh next time + current.available_sources.clear(); + let _ = tx_source.send(current); + } + ListResult::Error => {} } }); @@ -175,10 +326,23 @@ impl WaybarModule for AudioModule { ) -> Result { let target_type = args.first().unwrap_or(&"sink"); let action = args.get(1).unwrap_or(&"show"); + let step = args.get(2).unwrap_or(&"5"); match *action { + "up" => { + self.change_volume(state, target_type, step, true).await?; + Ok(WaybarOutput::default()) + } + "down" => { + self.change_volume(state, target_type, step, false).await?; + Ok(WaybarOutput::default()) + } + "mute" => { + self.toggle_mute(state, target_type).await?; + Ok(WaybarOutput::default()) + } "cycle" => { - self.cycle_device(target_type).await?; + self.cycle_device(state, target_type).await?; Ok(WaybarOutput::default()) } "show" => self.get_status(config, state, target_type).await, @@ -280,76 +444,41 @@ impl AudioModule { }) } - async fn cycle_device(&self, target_type: &str) -> Result<()> { - // Keep using pactl for cycling for now as it's a rare action - // but we could also implement it natively later. - let set_cmd = if target_type == "sink" { - "set-default-sink" - } else { - "set-default-source" - }; - - // We need to find the "next" device. - // For simplicity, let's keep the CLI version for now or refactor later. - // The user asked for "step by step". - - let list_cmd = if target_type == "sink" { - "sinks" - } else { - "sources" - }; - let output = tokio::process::Command::new("pactl") - .args(["list", "short", list_cmd]) - .output() - .await?; - let stdout = String::from_utf8_lossy(&output.stdout); - - let devices: Vec = stdout - .lines() - .filter_map(|l| { - let parts: Vec<&str> = l.split_whitespace().collect(); - if parts.len() >= 2 { - let name = parts[1].to_string(); - if target_type == "source" && name.contains(".monitor") { - None - } else { - Some(name) - } - } else { - None - } + async fn change_volume( + &self, + state: &AppReceivers, + target_type: &str, + step: &str, + is_up: bool, + ) -> Result<()> { + let is_sink = target_type == "sink"; + let step_val: u32 = step.parse().unwrap_or(5); + let _ = state + .audio_cmd_tx + .send(AudioCommand::ChangeVolume { + is_sink, + step_val, + is_up, }) - .collect(); + .await; + Ok(()) + } - if devices.is_empty() { - return Ok(()); - } + async fn toggle_mute(&self, state: &AppReceivers, target_type: &str) -> Result<()> { + let is_sink = target_type == "sink"; + let _ = state + .audio_cmd_tx + .send(AudioCommand::ToggleMute { is_sink }) + .await; + Ok(()) + } - let info_output = tokio::process::Command::new("pactl") - .args(["info"]) - .output() - .await?; - let info_stdout = String::from_utf8_lossy(&info_output.stdout); - let search_key = if target_type == "sink" { - "Default Sink:" - } else { - "Default Source:" - }; - let current_dev = info_stdout - .lines() - .find(|l| l.contains(search_key)) - .and_then(|l| l.split(':').nth(1)) - .map(|s| s.trim()) - .unwrap_or(""); - - let current_index = devices.iter().position(|d| d == current_dev).unwrap_or(0); - let next_index = (current_index + 1) % devices.len(); - let next_dev = &devices[next_index]; - - tokio::process::Command::new("pactl") - .args([set_cmd, next_dev]) - .status() - .await?; + async fn cycle_device(&self, state: &AppReceivers, target_type: &str) -> Result<()> { + let is_sink = target_type == "sink"; + let _ = state + .audio_cmd_tx + .send(AudioCommand::CycleDevice { is_sink }) + .await; Ok(()) } } diff --git a/src/modules/backlight.rs b/src/modules/backlight.rs new file mode 100644 index 0000000..5c66ac6 --- /dev/null +++ b/src/modules/backlight.rs @@ -0,0 +1,154 @@ +use crate::config::Config; +use crate::error::Result; +use crate::modules::WaybarModule; +use crate::output::WaybarOutput; +use crate::state::{AppReceivers, BacklightState}; +use crate::utils::{TokenValue, format_template}; +use notify::{Config as NotifyConfig, Event, RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::PathBuf; +use std::sync::mpsc; +use std::time::Duration; +use tokio::sync::watch; +use tracing::{error, info}; + +pub struct BacklightModule; + +impl WaybarModule for BacklightModule { + async fn run( + &self, + config: &Config, + state: &AppReceivers, + _args: &[&str], + ) -> Result { + let percentage = state.backlight.borrow().percentage; + + let icon = if percentage < 30 { + "󰃞" + } else if percentage < 70 { + "󰃟" + } else { + "󰃠" + }; + + let text = format_template( + &config.backlight.format, + &[ + ("percentage", TokenValue::Int(percentage as i64)), + ("icon", TokenValue::String(icon.to_string())), + ], + ); + + Ok(WaybarOutput { + text, + tooltip: Some(format!("Brightness: {}%", percentage)), + class: Some("normal".to_string()), + percentage: Some(percentage), + }) + } +} + +pub struct BacklightDaemon; + +impl BacklightDaemon { + pub fn new() -> Self { + Self + } + + pub fn start(&self, tx: watch::Sender) { + std::thread::spawn(move || { + let base_dir = PathBuf::from("/sys/class/backlight"); + let mut device_dir = None; + + if let Ok(entries) = std::fs::read_dir(&base_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + device_dir = Some(path); + break; + } + } + } + + let Some(dir) = device_dir else { + error!("No backlight device found in /sys/class/backlight"); + return; + }; + + info!("Monitoring backlight device: {:?}", dir); + + let max_brightness_path = dir.join("max_brightness"); + let brightness_path = dir.join("actual_brightness"); + let brightness_path_fallback = dir.join("brightness"); + + let target_file = if brightness_path.exists() { + brightness_path + } else { + brightness_path_fallback + }; + + let get_percentage = || -> u8 { + let max: f64 = std::fs::read_to_string(&max_brightness_path) + .unwrap_or_default() + .trim() + .parse() + .unwrap_or(100.0); + let current: f64 = std::fs::read_to_string(&target_file) + .unwrap_or_default() + .trim() + .parse() + .unwrap_or(0.0); + + if max > 0.0 { + ((current / max) * 100.0).round() as u8 + } else { + 0 + } + }; + + // Initial poll + let _ = tx.send(BacklightState { + percentage: get_percentage(), + }); + + // Set up notify watcher + let (ev_tx, ev_rx) = mpsc::channel(); + let mut watcher = RecommendedWatcher::new( + move |res: notify::Result| { + if let Ok(event) = res + && event.kind.is_modify() + { + let _ = ev_tx.send(()); + } + }, + NotifyConfig::default(), + ) + .unwrap(); + + if let Err(e) = watcher.watch(&target_file, RecursiveMode::NonRecursive) { + error!("Failed to watch backlight file: {}", e); + return; + } + + loop { + // Block until an event occurs or a timeout to catch missed events + if ev_rx.recv_timeout(Duration::from_secs(5)).is_ok() { + // Debounce rapid events + std::thread::sleep(Duration::from_millis(50)); + while ev_rx.try_recv().is_ok() {} + + let _ = tx.send(BacklightState { + percentage: get_percentage(), + }); + } else { + // Timeout hit, poll just in case + let current = get_percentage(); + if tx.borrow().percentage != current { + let _ = tx.send(BacklightState { + percentage: current, + }); + } + } + } + }); + } +} diff --git a/src/modules/bt/mod.rs b/src/modules/bt/mod.rs index 55f93eb..382c5bd 100644 --- a/src/modules/bt/mod.rs +++ b/src/modules/bt/mod.rs @@ -109,6 +109,17 @@ impl BtDaemon { static PLUGINS: LazyLock>> = LazyLock::new(|| vec![Box::new(PixelBudsPlugin)]); +fn trigger_robust_poll(state: AppReceivers) { + tokio::spawn(async move { + // Poll immediately and then a few times over the next few seconds + // to catch slow state changes in bluez or plugins. + for delay in [200, 500, 1000, 2000, 3000] { + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; + let _ = state.bt_force_poll.try_send(()); + } + }); +} + pub struct BtModule; impl WaybarModule for BtModule { @@ -124,19 +135,57 @@ impl WaybarModule for BtModule { let bt_state = state.bluetooth.borrow().clone(); match action.as_str() { - "disconnect" if bt_state.connected => { - let _ = tokio::process::Command::new("bluetoothctl") - .args(["disconnect", &bt_state.device_address]) - .output() - .await; + "connect" => { + if let Some(mac) = args.get(1) { + if let Ok(session) = bluer::Session::new().await + && let Ok(adapter) = session.default_adapter().await + && let Ok(addr) = mac.parse::() + && let Ok(device) = adapter.device(addr) + { + let _ = device.connect().await; + } + trigger_robust_poll(state.clone()); + } return Ok(WaybarOutput::default()); } + "disconnect" if bt_state.connected => { + if let Ok(session) = bluer::Session::new().await + && let Ok(adapter) = session.default_adapter().await + && let Ok(addr) = bt_state.device_address.parse::() + && let Ok(device) = adapter.device(addr) + { + let _ = device.disconnect().await; + } + trigger_robust_poll(state.clone()); + return Ok(WaybarOutput::default()); + } + "menu_data" => { + let mut devs = Vec::new(); + if let Ok(session) = bluer::Session::new().await + && let Ok(adapter) = session.default_adapter().await + && let Ok(addresses) = adapter.device_addresses().await + { + for addr in addresses { + if let Ok(device) = adapter.device(addr) + && device.is_paired().await.unwrap_or(false) + { + let alias = device.alias().await.unwrap_or_else(|_| addr.to_string()); + devs.push(format!("{} ({})", alias, addr)); + } + } + } + return Ok(WaybarOutput { + text: devs.join("\n"), + ..Default::default() + }); + } "cycle_mode" if bt_state.connected => { let plugin = PLUGINS .iter() .find(|p| p.can_handle(&bt_state.device_alias, &bt_state.device_address)); if let Some(p) = plugin { p.cycle_mode(&bt_state.device_address, state).await?; + trigger_robust_poll(state.clone()); } return Ok(WaybarOutput::default()); } @@ -161,6 +210,7 @@ impl WaybarModule for BtModule { .find(|p| p.can_handle(&bt_state.device_alias, &bt_state.device_address)); if let Some(p) = plugin { p.set_mode(mode, &bt_state.device_address, state).await?; + trigger_robust_poll(state.clone()); } } return Ok(WaybarOutput::default()); diff --git a/src/modules/disk.rs b/src/modules/disk.rs index 26fc079..a674078 100644 --- a/src/modules/disk.rs +++ b/src/modules/disk.rs @@ -18,6 +18,13 @@ impl WaybarModule for DiskModule { let disks = state.disks.borrow().clone(); + if disks.is_empty() { + return Ok(WaybarOutput { + text: "Disk Loading...".to_string(), + ..Default::default() + }); + } + for disk in &disks { if disk.mount_point == *mountpoint { let total = disk.total_bytes as f64; diff --git a/src/modules/dnd.rs b/src/modules/dnd.rs new file mode 100644 index 0000000..fa25c48 --- /dev/null +++ b/src/modules/dnd.rs @@ -0,0 +1,104 @@ +use crate::config::Config; +use crate::error::Result; +use crate::modules::WaybarModule; +use crate::output::WaybarOutput; +use crate::state::{AppReceivers, DndState}; +use futures::StreamExt; +use tokio::sync::watch; +use tracing::{debug, error, info}; +use zbus::{Connection, fdo::PropertiesProxy, proxy}; + +pub struct DndModule; + +impl WaybarModule for DndModule { + async fn run( + &self, + config: &Config, + state: &AppReceivers, + _args: &[&str], + ) -> Result { + let is_dnd = state.dnd.borrow().is_dnd; + + if is_dnd { + Ok(WaybarOutput { + text: config.dnd.format_dnd.clone(), + tooltip: Some("Do Not Disturb: On".to_string()), + class: Some("dnd".to_string()), + percentage: None, + }) + } else { + Ok(WaybarOutput { + text: config.dnd.format_normal.clone(), + tooltip: Some("Do Not Disturb: Off".to_string()), + class: Some("normal".to_string()), + percentage: None, + }) + } + } +} + +pub struct DndDaemon; + +#[proxy( + interface = "org.erikreider.swaync.control", + default_service = "org.erikreider.swaync.control", + default_path = "/org/erikreider/swaync/control" +)] +trait SwayncControl { + #[zbus(property)] + fn dnd(&self) -> zbus::Result; +} + +impl DndDaemon { + pub fn new() -> Self { + Self + } + + pub fn start(&self, tx: watch::Sender) { + tokio::spawn(async move { + loop { + if let Err(e) = Self::listen_loop(&tx).await { + error!("DND listener error: {}", e); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + } + }); + } + + async fn listen_loop(tx: &watch::Sender) -> anyhow::Result<()> { + let connection = Connection::session().await?; + + info!("Connected to D-Bus for DND monitoring"); + + // Try SwayNC first + if let Ok(proxy) = SwayncControlProxy::new(&connection).await { + debug!("Found SwayNC, using it for DND state."); + + // Get initial state + if let Ok(is_dnd) = proxy.dnd().await { + let _ = tx.send(DndState { is_dnd }); + } + + // Monitor properties changed + if let Ok(props_proxy) = PropertiesProxy::builder(&connection) + .destination("org.erikreider.swaync.control")? + .path("/org/erikreider/swaync/control")? + .build() + .await + { + let mut stream = props_proxy.receive_properties_changed().await?; + while let Some(signal) = stream.next().await { + let args = signal.args()?; + if args.interface_name == "org.erikreider.swaync.control" + && let Some(val) = args.changed_properties.get("dnd") + && let Ok(is_dnd) = bool::try_from(val) + { + let _ = tx.send(DndState { is_dnd }); + } + } + } + } + + Err(anyhow::anyhow!("DND stream ended or daemon not found")) + } +} diff --git a/src/modules/game.rs b/src/modules/game.rs index e128e13..a8e1fd0 100644 --- a/src/modules/game.rs +++ b/src/modules/game.rs @@ -3,8 +3,6 @@ use crate::error::Result; use crate::modules::WaybarModule; use crate::output::WaybarOutput; use crate::state::AppReceivers; -use anyhow::anyhow; -use std::env; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::UnixStream; @@ -41,9 +39,7 @@ impl WaybarModule for GameModule { } async fn hyprland_ipc(cmd: &str) -> Result { - let signature = env::var("HYPRLAND_INSTANCE_SIGNATURE") - .map_err(|_| anyhow!("HYPRLAND_INSTANCE_SIGNATURE not set"))?; - let path = format!("/tmp/hypr/{}/.socket.sock", signature); + let path = crate::utils::get_hyprland_socket(".socket.sock")?; let mut stream = UnixStream::connect(path).await?; stream.write_all(cmd.as_bytes()).await?; diff --git a/src/modules/keyboard.rs b/src/modules/keyboard.rs new file mode 100644 index 0000000..d25c4eb --- /dev/null +++ b/src/modules/keyboard.rs @@ -0,0 +1,104 @@ +use crate::config::Config; +use crate::error::Result; +use crate::modules::WaybarModule; +use crate::output::WaybarOutput; +use crate::state::{AppReceivers, KeyboardState}; +use crate::utils::{TokenValue, format_template}; +use anyhow::anyhow; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::net::UnixStream; +use tokio::sync::watch; +use tracing::{error, info}; + +pub struct KeyboardModule; + +impl WaybarModule for KeyboardModule { + async fn run( + &self, + config: &Config, + state: &AppReceivers, + _args: &[&str], + ) -> Result { + let layout = state.keyboard.borrow().layout.clone(); + + if layout.is_empty() { + return Ok(WaybarOutput { + text: "Layout Loading...".to_string(), + tooltip: None, + class: Some("loading".to_string()), + percentage: None, + }); + } + + let text = format_template( + &config.keyboard.format, + &[("layout", TokenValue::String(layout.clone()))], + ); + + Ok(WaybarOutput { + text, + tooltip: Some(format!("Keyboard Layout: {}", layout)), + class: Some("normal".to_string()), + percentage: None, + }) + } +} + +pub struct KeyboardDaemon; + +impl KeyboardDaemon { + pub fn new() -> Self { + Self + } + + pub fn start(&self, tx: watch::Sender) { + tokio::spawn(async move { + loop { + if let Err(e) = Self::listen_loop(&tx).await { + error!("Keyboard layout listener error: {}", e); + // Fallback to waiting before reconnecting to prevent tight loop + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + } + }); + } + + async fn listen_loop(tx: &watch::Sender) -> anyhow::Result<()> { + let path = crate::utils::get_hyprland_socket(".socket2.sock")?; + + info!("Connecting to Hyprland event socket: {:?}", path); + let stream = UnixStream::connect(path).await?; + let reader = BufReader::new(stream); + let mut lines = reader.lines(); + + // Fetch initial layout natively via hyprctl + if let Ok(output) = tokio::process::Command::new("hyprctl") + .args(["devices", "-j"]) + .output() + .await + && let Ok(json) = serde_json::from_slice::(&output.stdout) + && let Some(keyboards) = json.get("keyboards").and_then(|v| v.as_array()) + && let Some(main_kb) = keyboards.last() + { + // The last active one is usually the main one + if let Some(layout) = main_kb.get("active_keymap").and_then(|v| v.as_str()) { + let _ = tx.send(KeyboardState { + layout: layout.to_string(), + }); + } + } + + while let Ok(Some(line)) = lines.next_line().await { + if let Some(payload) = line.strip_prefix("activelayout>>") { + // payload format: keyboard_name,layout_name + let parts: Vec<&str> = payload.splitn(2, ',').collect(); + if parts.len() == 2 { + let layout = parts[1].to_string(); + let _ = tx.send(KeyboardState { layout }); + } + } + } + + Err(anyhow!("Hyprland socket closed or read error")) + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index d308e7f..cf2dac8 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1,12 +1,16 @@ pub mod audio; +pub mod backlight; pub mod bt; pub mod btrfs; pub mod cpu; pub mod disk; +pub mod dnd; pub mod game; pub mod gpu; pub mod hardware; +pub mod keyboard; pub mod memory; +pub mod mpris; pub mod network; pub mod power; pub mod sys; diff --git a/src/modules/mpris.rs b/src/modules/mpris.rs new file mode 100644 index 0000000..d63e1ca --- /dev/null +++ b/src/modules/mpris.rs @@ -0,0 +1,208 @@ +use crate::config::Config; +use crate::error::Result; +use crate::modules::WaybarModule; +use crate::output::WaybarOutput; +use crate::state::{AppReceivers, MprisState}; +use crate::utils::{TokenValue, format_template}; +use tokio::sync::watch; +use tracing::{debug, info}; +use zbus::{Connection, proxy}; + +pub struct MprisModule; + +impl WaybarModule for MprisModule { + async fn run( + &self, + config: &Config, + state: &AppReceivers, + _args: &[&str], + ) -> Result { + let mpris = state.mpris.borrow().clone(); + + if mpris.is_stopped && mpris.title.is_empty() { + return Ok(WaybarOutput { + text: String::new(), + tooltip: None, + class: Some("stopped".to_string()), + percentage: None, + }); + } + + let status_icon = if mpris.is_playing { + "󰏤" + } else if mpris.is_paused { + "󰐊" + } else { + "󰓛" + }; + + let class = if mpris.is_playing { + "playing" + } else if mpris.is_paused { + "paused" + } else { + "stopped" + }; + + let text = format_template( + &config.mpris.format, + &[ + ("artist", TokenValue::String(mpris.artist.clone())), + ("title", TokenValue::String(mpris.title.clone())), + ("album", TokenValue::String(mpris.album.clone())), + ("status_icon", TokenValue::String(status_icon.to_string())), + ], + ); + + Ok(WaybarOutput { + text, + tooltip: Some(format!("{} - {}", mpris.artist, mpris.title)), + class: Some(class.to_string()), + percentage: None, + }) + } +} + +pub struct MprisDaemon; + +#[proxy( + interface = "org.freedesktop.DBus", + default_service = "org.freedesktop.DBus", + default_path = "/org/freedesktop/DBus" +)] +trait DBus { + fn list_names(&self) -> zbus::Result>; +} + +#[proxy( + interface = "org.mpris.MediaPlayer2.Player", + default_path = "/org/mpris/MediaPlayer2" +)] +trait MprisPlayer { + #[zbus(property)] + fn playback_status(&self) -> zbus::Result; + + #[zbus(property)] + fn metadata( + &self, + ) -> zbus::Result>>; +} + +impl MprisDaemon { + pub fn new() -> Self { + Self + } + + pub fn start(&self, tx: watch::Sender) { + tokio::spawn(async move { + loop { + if let Err(e) = Self::listen_loop(&tx).await { + debug!("MPRIS listener ended or error: {}", e); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + } + } + }); + } + + async fn listen_loop(tx: &watch::Sender) -> anyhow::Result<()> { + let connection = Connection::session().await?; + + info!("Connected to D-Bus for MPRIS monitoring"); + + // Simple poll loop for MPRIS since players can come and go, and tracking dynamically + // with pure signals across multiple dynamically spawned DBus names is complex. + // A robust hybrid approach: poll every 2s for active players, and update state. + + let dbus_proxy = DBusProxy::new(&connection).await?; + + loop { + let names = dbus_proxy.list_names().await?; + let mut active_player = None; + + for name in names { + if name.starts_with("org.mpris.MediaPlayer2.") { + active_player = Some(name); + break; // Just grab the first active player for now + } + } + + if let Some(player_name) = active_player { + if let Ok(player_proxy) = MprisPlayerProxy::builder(&connection) + .destination(player_name.clone())? + .build() + .await + { + let status = player_proxy.playback_status().await.unwrap_or_default(); + let metadata = player_proxy.metadata().await.unwrap_or_default(); + + let is_playing = status == "Playing"; + let is_paused = status == "Paused"; + let is_stopped = status == "Stopped"; + + let mut artist = String::new(); + let mut title = String::new(); + let mut album = String::new(); + + if let Some(v) = metadata.get("xesam:artist") { + if let Ok(arr) = zbus::zvariant::Array::try_from(v) { + let mut artists = Vec::new(); + for i in 0..arr.len() { + if let Ok(Some(s)) = arr.get::<&str>(i) { + artists.push(s.to_string()); + } + } + artist = artists.join(", "); + } else if let Ok(a) = <&str>::try_from(v) { + artist = a.to_string(); + } + } + if let Some(v) = metadata.get("xesam:title") + && let Ok(t) = <&str>::try_from(v) + { + title = t.to_string(); + } + if let Some(v) = metadata.get("xesam:album") + && let Ok(a) = <&str>::try_from(v) + { + album = a.to_string(); + } + + // Only send if changed + let current = tx.borrow(); + if current.is_playing != is_playing + || current.is_paused != is_paused + || current.is_stopped != is_stopped + || current.title != title + || current.artist != artist + || current.album != album + { + drop(current); // Drop borrow before send + let _ = tx.send(MprisState { + is_playing, + is_paused, + is_stopped, + artist, + title, + album, + }); + } + } + } else { + let current = tx.borrow(); + if !current.is_stopped || !current.title.is_empty() { + drop(current); + let _ = tx.send(MprisState { + is_playing: false, + is_paused: false, + is_stopped: true, + artist: String::new(), + title: String::new(), + album: String::new(), + }); + } + } + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + } + } +} diff --git a/src/output.rs b/src/output.rs index 76cfeea..1e1f64b 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,6 +1,6 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; -#[derive(Serialize, Default)] +#[derive(Serialize, Deserialize, Clone, Debug, Default)] pub struct WaybarOutput { pub text: String, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/signaler.rs b/src/signaler.rs new file mode 100644 index 0000000..0ea9f86 --- /dev/null +++ b/src/signaler.rs @@ -0,0 +1,156 @@ +use crate::config::Config; +use crate::state::AppReceivers; +use nix::libc; +use nix::sys::signal::{Signal, kill}; +use nix::unistd::Pid; +use std::collections::HashMap; +use std::sync::Arc; +use sysinfo::{ProcessesToUpdate, System}; +use tokio::sync::RwLock; +use tokio::time::{Duration, Instant, sleep}; +use tracing::{debug, warn}; + +pub struct WaybarSignaler { + cached_pid: Option, + sys: System, + last_signal_sent: HashMap, +} + +impl WaybarSignaler { + pub fn new() -> Self { + Self { + cached_pid: None, + sys: System::new(), + last_signal_sent: HashMap::new(), + } + } + + fn find_waybar_pid(&mut self) -> Option { + self.sys.refresh_processes(ProcessesToUpdate::All, true); + for (pid, process) in self.sys.processes() { + if process.name() == "waybar" { + return Some(Pid::from_raw(pid.as_u32() as i32)); + } + } + None + } + + fn send_signal(&mut self, signal_num: i32) { + if let Some(last) = self.last_signal_sent.get(&signal_num) + && last.elapsed() < Duration::from_millis(50) + { + return; + } + + let mut valid_pid = false; + if let Some(pid) = self.cached_pid + && kill(pid, None).is_ok() + { + valid_pid = true; + } + + if !valid_pid { + self.cached_pid = self.find_waybar_pid(); + } + + if let Some(pid) = self.cached_pid { + let rt_sig = match Signal::try_from(libc::SIGRTMIN() + signal_num) { + Ok(sig) => sig, + Err(_) => { + unsafe { + if libc::kill(pid.as_raw(), libc::SIGRTMIN() + signal_num) == 0 { + debug!("Sent raw SIGRTMIN+{} to waybar (PID: {})", signal_num, pid); + self.last_signal_sent.insert(signal_num, Instant::now()); + } else { + warn!("Failed to send raw SIGRTMIN+{} to waybar", signal_num); + } + } + return; + } + }; + + if let Err(e) = kill(pid, rt_sig) { + warn!("Failed to signal waybar: {}", e); + self.cached_pid = None; + } else { + debug!("Sent SIGRTMIN+{} to waybar (PID: {})", signal_num, pid); + self.last_signal_sent.insert(signal_num, Instant::now()); + } + } else { + debug!("Waybar process not found, skipping signal."); + } + } + + pub async fn run(mut self, config_lock: Arc>, mut receivers: AppReceivers) { + let mut last_outputs: HashMap<&'static str, String> = HashMap::new(); + + loop { + let signals = config_lock.read().await.signals.clone(); + + macro_rules! check_and_signal { + ($module_name:expr, $signal_opt:expr) => { + if let Some(sig) = $signal_opt { + let config = config_lock.read().await; + if let Some(out) = crate::daemon::evaluate_module_for_signaler( + $module_name, + &receivers, + &config, + ) + .await + { + if last_outputs.get($module_name) != Some(&out) { + last_outputs.insert($module_name, out); + self.send_signal(sig); + } + } + } + }; + } + + tokio::select! { + res = receivers.network.changed(), if signals.network.is_some() => { + if res.is_ok() { check_and_signal!("net", signals.network); } + } + res = receivers.cpu.changed(), if signals.cpu.is_some() => { + if res.is_ok() { check_and_signal!("cpu", signals.cpu); } + } + res = receivers.memory.changed(), if signals.memory.is_some() => { + if res.is_ok() { check_and_signal!("mem", signals.memory); } + } + res = receivers.sys.changed(), if signals.sys.is_some() => { + if res.is_ok() { check_and_signal!("sys", signals.sys); } + } + res = receivers.gpu.changed(), if signals.gpu.is_some() => { + if res.is_ok() { check_and_signal!("gpu", signals.gpu); } + } + res = receivers.disks.changed(), if signals.disk.is_some() => { + if res.is_ok() { check_and_signal!("disk", signals.disk); } + } + res = receivers.bluetooth.changed(), if signals.bt.is_some() => { + if res.is_ok() { check_and_signal!("bt", signals.bt); } + } + res = receivers.audio.changed(), if signals.audio.is_some() => { + if res.is_ok() { + check_and_signal!("vol", signals.audio); + check_and_signal!("mic", signals.audio); + } + } + res = receivers.backlight.changed(), if signals.backlight.is_some() => { + if res.is_ok() { check_and_signal!("backlight", signals.backlight); } + } + res = receivers.keyboard.changed(), if signals.keyboard.is_some() => { + if res.is_ok() { check_and_signal!("keyboard", signals.keyboard); } + } + res = receivers.dnd.changed(), if signals.dnd.is_some() => { + if res.is_ok() { check_and_signal!("dnd", signals.dnd); } + } + res = receivers.mpris.changed(), if signals.mpris.is_some() => { + if res.is_ok() { check_and_signal!("mpris", signals.mpris); } + } + _ = sleep(Duration::from_secs(5)) => { + // loop and refresh config + } + } + } + } +} diff --git a/src/state.rs b/src/state.rs index d65824a..dad6b42 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,7 @@ +use crate::output::WaybarOutput; use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::{RwLock, watch}; +use tokio::sync::{RwLock, mpsc, watch}; use tokio::time::Instant; #[derive(Clone)] @@ -13,7 +14,13 @@ pub struct AppReceivers { pub disks: watch::Receiver>, pub bluetooth: watch::Receiver, pub audio: watch::Receiver, + pub mpris: watch::Receiver, + pub backlight: watch::Receiver, + pub keyboard: watch::Receiver, + pub dnd: watch::Receiver, pub health: Arc>>, + pub bt_force_poll: mpsc::Sender<()>, + pub audio_cmd_tx: mpsc::Sender, } #[derive(Clone, Default)] @@ -21,12 +28,15 @@ pub struct ModuleHealth { pub consecutive_failures: u32, pub last_failure: Option, pub backoff_until: Option, + pub last_successful_output: Option, } #[derive(Default, Clone)] pub struct AudioState { pub sink: AudioDeviceInfo, pub source: AudioSourceInfo, + pub available_sinks: Vec, + pub available_sources: Vec, } #[derive(Default, Clone)] @@ -35,6 +45,7 @@ pub struct AudioDeviceInfo { pub description: String, pub volume: u8, pub muted: bool, + pub channels: u8, } #[derive(Default, Clone)] @@ -43,6 +54,7 @@ pub struct AudioSourceInfo { pub description: String, pub volume: u8, pub muted: bool, + pub channels: u8, } #[derive(Default, Clone)] @@ -128,6 +140,31 @@ impl Default for GpuState { } } +#[derive(Default, Clone)] +pub struct DndState { + pub is_dnd: bool, +} + +#[derive(Default, Clone)] +pub struct KeyboardState { + pub layout: String, +} + +#[derive(Default, Clone)] +pub struct BacklightState { + pub percentage: u8, +} + +#[derive(Default, Clone)] +pub struct MprisState { + pub is_playing: bool, + pub is_paused: bool, + pub is_stopped: bool, + pub artist: String, + pub title: String, + pub album: String, +} + #[cfg(test)] pub struct MockState { pub receivers: AppReceivers, @@ -140,6 +177,10 @@ pub struct MockState { _disks_tx: watch::Sender>, _bt_tx: watch::Sender, _audio_tx: watch::Sender, + _mpris_tx: watch::Sender, + _backlight_tx: watch::Sender, + _keyboard_tx: watch::Sender, + _dnd_tx: watch::Sender, } #[cfg(test)] @@ -153,6 +194,10 @@ pub struct AppState { pub disks: Vec, pub bluetooth: BtState, pub audio: AudioState, + pub mpris: MprisState, + pub backlight: BacklightState, + pub keyboard: KeyboardState, + pub dnd: DndState, pub health: HashMap, } @@ -166,6 +211,12 @@ pub fn mock_state(state: AppState) -> MockState { let (disks_tx, disks_rx) = watch::channel(state.disks); let (bt_tx, bt_rx) = watch::channel(state.bluetooth); let (audio_tx, audio_rx) = watch::channel(state.audio); + let (mpris_tx, mpris_rx) = watch::channel(state.mpris); + let (backlight_tx, backlight_rx) = watch::channel(state.backlight); + let (keyboard_tx, keyboard_rx) = watch::channel(state.keyboard); + let (dnd_tx, dnd_rx) = watch::channel(state.dnd); + let (bt_force_tx, _) = mpsc::channel(1); + let (audio_cmd_tx, _) = mpsc::channel(1); MockState { receivers: AppReceivers { @@ -177,7 +228,13 @@ pub fn mock_state(state: AppState) -> MockState { disks: disks_rx, bluetooth: bt_rx, audio: audio_rx, + mpris: mpris_rx, + backlight: backlight_rx, + keyboard: keyboard_rx, + dnd: dnd_rx, health: Arc::new(RwLock::new(state.health)), + bt_force_poll: bt_force_tx, + audio_cmd_tx, }, _net_tx: net_tx, _cpu_tx: cpu_tx, @@ -187,5 +244,9 @@ pub fn mock_state(state: AppState) -> MockState { _disks_tx: disks_tx, _bt_tx: bt_tx, _audio_tx: audio_tx, + _mpris_tx: mpris_tx, + _backlight_tx: backlight_tx, + _keyboard_tx: keyboard_tx, + _dnd_tx: dnd_tx, } } diff --git a/src/utils.rs b/src/utils.rs index c32b246..16893ea 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,17 +3,21 @@ use std::io::Write; use std::process::{Command, Stdio}; pub fn show_menu(prompt: &str, items: &[String], menu_cmd: &str) -> Result { + // Backward compatibility for {prompt}, but environment variable is safer let cmd_str = menu_cmd.replace("{prompt}", prompt); let mut child = Command::new("sh") .arg("-c") .arg(&cmd_str) + .env("FLUXO_PROMPT", prompt) // Safer shell injection .stdin(Stdio::piped()) .stdout(Stdio::piped()) + .stderr(Stdio::null()) // Suppress GTK/Wayland warnings from tools like wofi .spawn() .context("Failed to spawn menu command")?; if let Some(mut stdin) = child.stdin.take() { - let input = items.join("\n"); + let mut input = items.join("\n"); + input.push('\n'); // Ensure trailing newline for wofi/rofi stdin .write_all(input.as_bytes()) .context("Failed to write to menu stdin")?; @@ -33,6 +37,36 @@ pub fn show_menu(prompt: &str, items: &[String], menu_cmd: &str) -> Result Result { + let signature = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") + .context("HYPRLAND_INSTANCE_SIGNATURE not set")?; + + // Try XDG_RUNTIME_DIR first (usually /run/user/1000) + if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + let path = std::path::PathBuf::from(runtime_dir) + .join("hypr") + .join(&signature) + .join(socket_name); + if path.exists() { + return Ok(path); + } + } + + // Fallback to /tmp + let path = std::path::PathBuf::from("/tmp/hypr") + .join(&signature) + .join(socket_name); + + if path.exists() { + Ok(path) + } else { + Err(anyhow::anyhow!( + "Hyprland socket {} not found in runtime dir or /tmp", + socket_name + )) + } +} + use regex::Regex; use std::sync::LazyLock;