diff --git a/Cargo.lock b/Cargo.lock index f00c74d..63ebd8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -572,7 +572,7 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "fluxo-rs" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "bluer", diff --git a/Cargo.toml b/Cargo.toml index cee90ab..2e0f75b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxo-rs" -version = "0.5.0" +version = "0.5.1" edition = "2024" [features] diff --git a/src/config.rs b/src/config.rs index 94cba54..9975f5d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,16 @@ +//! Configuration loading + top-level `Config` struct. +//! +//! Every module section `#[derive(Deserialize)]`s its own struct with `serde` +//! defaults, so missing TOML sections/fields simply fall back to baked-in +//! values. The live `Config` is held behind an `Arc>` by the daemon +//! and replaced atomically on reload (see [`crate::daemon::reload_config`]). + use serde::Deserialize; use std::fs; use std::path::PathBuf; use tracing::{debug, info, warn}; +/// Top-level parsed `config.toml`. Each field corresponds to a `[section]`. #[derive(Deserialize, Default, Clone)] pub struct Config { #[serde(default)] @@ -56,8 +64,10 @@ pub struct Config { pub dnd: DndConfig, } +/// Process-wide settings that aren't tied to a single module. #[derive(Deserialize, Clone)] pub struct GeneralConfig { + /// Shell command spawned by [`crate::utils::show_menu`] for interactive pickers. pub menu_command: String, } @@ -69,6 +79,9 @@ impl Default for GeneralConfig { } } +/// Which Waybar `SIGRTMIN+N` signal each module should trigger, if any. +/// +/// `None` disables signalling for that module entirely. #[allow(dead_code)] #[derive(Deserialize, Default, Clone)] pub struct SignalsConfig { @@ -89,6 +102,7 @@ pub struct SignalsConfig { pub dnd: Option, } +/// Network module config. `format` tokens: `interface`, `ip`, `rx`, `tx`. #[derive(Deserialize, Clone)] pub struct NetworkConfig { #[serde(default = "default_true")] @@ -105,6 +119,7 @@ impl Default for NetworkConfig { } } +/// CPU module config. `format` tokens: `usage`, `temp`. #[derive(Deserialize, Clone)] pub struct CpuConfig { #[serde(default = "default_true")] @@ -121,6 +136,7 @@ impl Default for CpuConfig { } } +/// Memory module config. `format` tokens: `used`, `total` (gigabytes). #[derive(Deserialize, Clone)] pub struct MemoryConfig { #[serde(default = "default_true")] @@ -137,6 +153,7 @@ impl Default for MemoryConfig { } } +/// GPU module config with per-vendor format strings. #[derive(Deserialize, Clone)] pub struct GpuConfig { #[serde(default = "default_true")] @@ -159,6 +176,7 @@ impl Default for GpuConfig { } } +/// Sys module config. `format` tokens: `uptime`, `load1`, `load5`, `load15`, `procs`. #[derive(Deserialize, Clone)] pub struct SysConfig { #[serde(default = "default_true")] @@ -175,6 +193,7 @@ impl Default for SysConfig { } } +/// Disk module config. `format` tokens: `mount`, `used`, `total`. #[derive(Deserialize, Clone)] pub struct DiskConfig { #[serde(default = "default_true")] @@ -191,6 +210,7 @@ impl Default for DiskConfig { } } +/// Btrfs pool module config. `format` tokens: `used`, `total`. #[derive(Deserialize, Clone)] pub struct PoolConfig { #[serde(default = "default_true")] @@ -207,6 +227,7 @@ impl Default for PoolConfig { } } +/// Battery/power module config. `format` tokens: `percentage`, `icon`. #[derive(Deserialize, Clone)] pub struct PowerConfig { #[serde(default = "default_true")] @@ -223,6 +244,7 @@ impl Default for PowerConfig { } } +/// Audio module config, one format per (sink|source) × (muted|unmuted) state. #[derive(Deserialize, Clone)] pub struct AudioConfig { #[serde(default = "default_true")] @@ -245,6 +267,8 @@ impl Default for AudioConfig { } } +/// Bluetooth module config. Plugin line tokens: `alias`, `left`, `right`, +/// `anc`, `mac`. Plain connect line tokens: `alias`. #[derive(Deserialize, Clone)] pub struct BtConfig { #[serde(default = "default_true")] @@ -267,6 +291,7 @@ impl Default for BtConfig { } } +/// Gamemode indicator config (active/inactive glyphs). #[derive(Deserialize, Clone)] pub struct GameConfig { #[serde(default = "default_true")] @@ -285,6 +310,8 @@ impl Default for GameConfig { } } +/// MPRIS module config. `format` tokens: `artist`, `title`, `album`, +/// `status_icon`. Optional marquee scrolling when `scroll = true`. #[derive(Deserialize, Clone)] pub struct MprisConfig { #[serde(default = "default_true")] @@ -321,6 +348,7 @@ impl Default for MprisConfig { } } +/// Backlight module config. `format` tokens: `percentage`, `icon`. #[derive(Deserialize, Clone)] pub struct BacklightConfig { #[serde(default = "default_true")] @@ -337,6 +365,7 @@ impl Default for BacklightConfig { } } +/// Keyboard layout module config. `format` tokens: `layout`. #[derive(Deserialize, Clone)] pub struct KeyboardConfig { #[serde(default = "default_true")] @@ -353,6 +382,7 @@ impl Default for KeyboardConfig { } } +/// Do-Not-Disturb indicator config (dnd/normal glyphs). #[derive(Deserialize, Clone)] pub struct DndConfig { #[serde(default = "default_true")] @@ -420,6 +450,8 @@ impl Config { for_each_watched_module!(gen_enabled_match) } + /// Warn-log any `{token}` placeholders used in format strings that the + /// corresponding module does not know how to fill in. pub fn validate(&self) { #[cfg(feature = "mod-network")] validate_format( @@ -496,6 +528,8 @@ impl Config { } } +/// Resolve the default config path: `$XDG_CONFIG_HOME/fluxo/config.toml` +/// (or `~/.config/fluxo/config.toml`). pub fn default_config_path() -> PathBuf { let config_dir = std::env::var("XDG_CONFIG_HOME") .map(PathBuf::from) @@ -506,6 +540,8 @@ pub fn default_config_path() -> PathBuf { config_dir.join("fluxo/config.toml") } +/// Load and validate the config, falling back to [`Config::default`] when +/// the file is missing or malformed. Never panics. pub fn load_config(custom_path: Option) -> Config { let config_path = custom_path.unwrap_or_else(default_config_path); diff --git a/src/daemon.rs b/src/daemon.rs index e231665..39e75dc 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,3 +1,18 @@ +//! Daemon entry point: orchestrates polling tasks, signal handling, config +//! hot-reloading, and the IPC server. +//! +//! Layout of [`run_daemon`]: +//! +//! 1. **Channels** — `watch::channel()` pairs for every module that pushes +//! state from a background task. +//! 2. **Polling / event tasks** — one per module; each writes into its sender, +//! the signaler and request handlers read from the matching receiver. +//! 3. **Config watchers** — filesystem notifier + `SIGHUP` handler refresh the +//! [`Config`] in place so modules see updates immediately. +//! 4. **Signaler** — watches all state receivers and pokes Waybar. +//! 5. **IPC loop** — `UnixListener` accepting client requests; each connection +//! dispatches to [`crate::registry::dispatch`] and returns JSON. + use crate::config::Config; use crate::ipc::socket_path; #[cfg(feature = "mod-audio")] @@ -31,7 +46,11 @@ use tokio::time::{Duration, sleep}; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info}; -/// Spawn a health-tracked polling loop. `$poll_expr` is awaited each cycle. +/// Spawn a health-tracked polling loop. +/// +/// Each iteration: skip if in backoff, else await `$poll_expr` and feed the +/// `Result` to [`crate::health::handle_poll_result`]. The loop breaks when +/// `$token` is cancelled. macro_rules! spawn_poll_loop { ($name:expr, $interval:expr, $health:expr, $token:expr, $poll_expr:expr) => { tokio::spawn(async move { @@ -51,7 +70,10 @@ macro_rules! spawn_poll_loop { }; } -/// Spawn a health-tracked polling loop with an extra trigger channel for early wake-up. +/// Spawn a health-tracked polling loop with an extra trigger channel. +/// +/// Identical to [`spawn_poll_loop`] but `$trigger` can wake the loop early — +/// used by the Bluetooth daemon when a client forces an immediate refresh. macro_rules! spawn_poll_loop_triggered { ($name:expr, $interval:expr, $health:expr, $token:expr, $trigger:expr, $poll_expr:expr) => { tokio::spawn(async move { @@ -72,7 +94,10 @@ macro_rules! spawn_poll_loop_triggered { }; } -/// Spawn a simple polling loop without health tracking. +/// Spawn a polling loop with no health tracking. +/// +/// Used for internal daemons (hardware fast/slow) whose poll functions are +/// infallible and whose failures don't drive client-visible backoff. macro_rules! spawn_poll_loop_simple { ($name:expr, $interval:expr, $token:expr, $poll_expr:expr) => { tokio::spawn(async move { @@ -104,6 +129,11 @@ fn get_config_path(custom_path: Option) -> PathBuf { custom_path.unwrap_or_else(crate::config::default_config_path) } +/// Run the daemon to completion. +/// +/// Sets up the socket, spawns all enabled module tasks, hooks up config +/// hot-reloading, and finally enters the IPC accept loop. Returns only on +/// a fatal error or `Ctrl+C`. pub async fn run_daemon(config_path: Option) -> Result<()> { let sock_path = socket_path(); @@ -191,7 +221,8 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { path: sock_path.clone(), }; - // Signal handling for graceful shutdown + // Ctrl+C triggers a graceful shutdown by cancelling this token; every + // spawned polling task checks it in its `select!`. let cancel_token = CancellationToken::new(); let token_clone = cancel_token.clone(); tokio::spawn(async move { @@ -204,7 +235,6 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { let config = Arc::new(RwLock::new(crate::config::load_config(config_path.clone()))); spawn_config_watchers(&config, &resolved_config_path); - // 1. Network Task #[cfg(feature = "mod-network")] if config.read().await.network.enabled { let mut daemon = NetworkDaemon::new(); @@ -219,7 +249,7 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { ); } - // 2. Fast Hardware Task (CPU, Mem, Load) + // Fast-cycle hardware (cpu/mem/load) polled at 1 Hz. #[cfg(feature = "mod-hardware")] { let cfg = config.read().await; @@ -237,7 +267,7 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { } } - // 3. Slow Hardware Task (GPU, Disks) + // Slow-cycle hardware (gpu/disks) polled every 5 s — expensive to sample. #[cfg(feature = "mod-hardware")] { let cfg = config.read().await; @@ -255,7 +285,6 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { } } - // 4. Bluetooth Task #[cfg(feature = "mod-bt")] if config.read().await.bt.enabled { let mut daemon = BtDaemon::new(); @@ -270,41 +299,38 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { }); } - // 5. Audio Thread (Event driven) + // Event-driven subsystems — these spawn their own threads internally and + // push into their watch sender as events arrive (no polling loop). #[cfg(feature = "mod-audio")] if config.read().await.audio.enabled { let audio_daemon = AudioDaemon::new(); audio_daemon.start(&audio_tx, audio_cmd_rx); } - // 5.1 Backlight Thread (Event driven) #[cfg(feature = "mod-dbus")] if config.read().await.backlight.enabled { let backlight_daemon = BacklightDaemon::new(); backlight_daemon.start(backlight_tx); } - // 5.2 Keyboard Thread (Event driven) #[cfg(feature = "mod-dbus")] if config.read().await.keyboard.enabled { let keyboard_daemon = KeyboardDaemon::new(); keyboard_daemon.start(keyboard_tx); } - // 5.3 DND Thread (Event driven) #[cfg(feature = "mod-dbus")] if config.read().await.dnd.enabled { let dnd_daemon = DndDaemon::new(); dnd_daemon.start(dnd_tx); } - // 5.4 MPRIS Thread #[cfg(feature = "mod-dbus")] if config.read().await.mpris.enabled { let mpris_daemon = MprisDaemon::new(); mpris_daemon.start(mpris_tx); - // Scroll ticker for MPRIS marquee animation + // Ticks the scroll offset forward for the marquee animation. let scroll_config = Arc::clone(&config); let scroll_rx = receivers.mpris.clone(); let scroll_state = Arc::clone(&mpris_scroll); @@ -319,7 +345,6 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { }); } - // 6. Waybar Signaler Task let signaler = WaybarSignaler::new(); let sig_config = Arc::clone(&config); let sig_receivers = receivers.clone(); @@ -332,8 +357,14 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { run_ipc_loop(listener, receivers, config, config_path, cancel_token).await } +/// Spawn background tasks that hot-reload the daemon's [`Config`]. +/// +/// Installs a `notify`-based filesystem watcher on the config file's parent +/// directory, plus a `SIGHUP` handler — either triggers a reload of the +/// shared `Arc>`. fn spawn_config_watchers(config: &Arc>, resolved_path: &std::path::Path) { - // File watcher (hot reload on modify/create) + // `notify` recursively tracks the parent dir so atomic-write editors + // (which rename a new file into place) still get picked up. let watcher_config = Arc::clone(config); let watcher_path = resolved_path.to_path_buf(); tokio::spawn(async move { @@ -359,6 +390,7 @@ fn spawn_config_watchers(config: &Arc>, resolved_path: &std::path loop { tokio::select! { _ = ev_rx.recv() => { + // Coalesce rapid editor writes into one reload. sleep(Duration::from_millis(100)).await; while ev_rx.try_recv().is_ok() {} reload_config(&watcher_config, Some(watcher_path.clone())).await; @@ -367,7 +399,6 @@ fn spawn_config_watchers(config: &Arc>, resolved_path: &std::path } }); - // SIGHUP handler let hup_config = Arc::clone(config); let hup_path = resolved_path.to_path_buf(); tokio::spawn(async move { @@ -381,6 +412,12 @@ fn spawn_config_watchers(config: &Arc>, resolved_path: &std::path }); } +/// Accept loop for the client Unix socket. +/// +/// Each client request spawns a short-lived task that reads one line, looks +/// up the module via [`crate::registry::dispatch`], and writes the JSON +/// response back. Broken-pipe errors are logged at `debug` — they just mean +/// the client timed out before we responded. async fn run_ipc_loop( listener: UnixListener, receivers: AppReceivers, @@ -447,6 +484,7 @@ async fn run_ipc_loop( Ok(()) } +/// Re-read the configuration file and swap it into the shared lock. pub async fn reload_config(config_lock: &Arc>, path: Option) { info!("Reloading configuration..."); let new_config = crate::config::load_config(path); @@ -455,6 +493,10 @@ pub async fn reload_config(config_lock: &Arc>, path: Option bool { matches!( self, @@ -54,4 +63,5 @@ impl FluxoError { } } +/// Crate-wide `Result` alias using [`FluxoError`]. pub type Result = std::result::Result; diff --git a/src/health.rs b/src/health.rs index bff2e45..6d3728c 100644 --- a/src/health.rs +++ b/src/health.rs @@ -1,3 +1,10 @@ +//! Per-module health tracking and exponential backoff. +//! +//! Both the IPC request handler (for on-demand module evaluation) and the +//! background polling loops consult this module. Transient errors increment a +//! failure counter that grows the cooldown window; permanent errors trigger an +//! immediate long cooldown. + use crate::output::WaybarOutput; use crate::state::{AppReceivers, ModuleHealth}; use std::collections::HashMap; @@ -101,6 +108,10 @@ pub async fn handle_poll_result( } } +/// Serialise a response to return while a module is cooling down. +/// +/// If a cached successful output exists, it is returned with a `warning` CSS +/// class appended; otherwise a generic "Cooling down" placeholder is emitted. pub fn backoff_response(module_name: &str, cached: Option) -> String { if let Some(mut cached) = cached { let class = cached.class.unwrap_or_default(); @@ -113,6 +124,10 @@ pub fn backoff_response(module_name: &str, cached: Option) -> Stri ) } +/// Serialise a fallback response for a module that errored this request. +/// +/// Prefers showing the last successful cached output (with a `warning` class) +/// over a bare error text, to keep the bar visually stable. pub fn error_response( module_name: &str, e: &crate::error::FluxoError, diff --git a/src/ipc.rs b/src/ipc.rs index 2cf0f29..35f635d 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -1,8 +1,16 @@ +//! Client/daemon Unix socket IPC. +//! +//! Requests are newline-terminated `module arg1 arg2...` lines; responses are +//! the daemon's JSON payload written until EOF. + use std::io::{Read, Write}; use std::os::unix::net::UnixStream; use std::time::Duration; use tracing::debug; +/// Returns the path to the daemon's Unix socket. +/// +/// Prefers `$XDG_RUNTIME_DIR/fluxo.sock`, falling back to `/tmp/fluxo.sock`. pub fn socket_path() -> String { if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") { format!("{}/fluxo.sock", dir) @@ -11,13 +19,15 @@ pub fn socket_path() -> String { } } +/// Send a module invocation to the daemon and return its response body. +/// +/// Blocks for up to 5 seconds waiting for the daemon to reply. pub fn request_data(module: &str, args: &[&str]) -> anyhow::Result { let sock = socket_path(); debug!(module, ?args, "Connecting to daemon socket: {}", sock); let mut stream = UnixStream::connect(&sock)?; stream.set_read_timeout(Some(Duration::from_secs(5)))?; - // Send module and args let mut request = module.to_string(); for arg in args { request.push(' '); diff --git a/src/macros.rs b/src/macros.rs index 7defb8c..bb52dd3 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,3 +1,10 @@ +//! Central declarative macro that registers every watched module. +//! +//! Every piece of per-module boilerplate (AppReceivers field, IPC dispatch arm, +//! signaler future binding, signaler select arm, config enabled-lookup, default +//! signaler args) is generated from this single table. Adding a new module is +//! a one-line edit here plus writing the module file itself. + /// Central module registry. Defines all modules with watch channels in one place. /// /// Invoke with a callback macro name. The callback receives repeated entries of the form: diff --git a/src/main.rs b/src/main.rs index 07af785..9022113 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,17 @@ +//! `fluxo` — high-performance daemon/client for Waybar custom modules. +//! +//! The binary has two faces: +//! * `fluxo daemon` — starts a long-lived process that polls system state +//! (network, cpu, audio, bluetooth, …) on background tasks and exposes the +//! results over a Unix socket. It also sends `SIGRTMIN+N` signals to Waybar +//! when module output changes, so the bar refreshes instantly. +//! * `fluxo [args]` — a tiny client that asks the daemon to evaluate +//! a single module and prints the Waybar-compatible JSON to stdout. +//! +//! Modules are feature-gated at compile time (`mod-audio`, `mod-bt`, `mod-dbus`, +//! `mod-hardware`, `mod-network`) and registered centrally via the +//! [`for_each_watched_module!`] macro in [`mod@macros`]. + #[macro_use] mod macros; mod config; @@ -79,14 +93,15 @@ fn main() { } if let Some(module) = &cli.module { - // Special case for client-side Bluetooth menu which requires UI + // Bluetooth menu is handled client-side: it needs access to the user's + // menu command (rofi/dmenu/wofi) which the daemon has no business spawning. #[cfg(feature = "mod-bt")] if module == "bt" && cli.args.first().map(|s| s.as_str()) == Some("menu") { let config = config::load_config(None); let mut items = Vec::new(); - // Parse menu_data to get connected and paired devices - let mut connected: Vec<(String, String)> = Vec::new(); // (alias, mac) + // Ask the daemon for the device list; tuples are (alias, mac). + let mut connected: Vec<(String, String)> = Vec::new(); let mut paired: Vec<(String, String)> = Vec::new(); if let Ok(json_str) = ipc::request_data("bt", &["menu_data"]) @@ -106,9 +121,7 @@ fn main() { } } - // Per-device sections for connected devices for (alias, mac) in &connected { - // Get modes for this specific device if let Ok(json_str) = ipc::request_data("bt", &["get_modes", mac]) && let Ok(val) = serde_json::from_str::(&json_str) && let Some(modes_str) = val.get("text").and_then(|t| t.as_str()) @@ -121,7 +134,6 @@ fn main() { items.push(format!("Disconnect {} [{}]", alias, mac)); } - // Separator and paired devices for connecting if !paired.is_empty() { items.push("--- Connect Device ---".to_string()); for (alias, mac) in &paired { @@ -134,7 +146,7 @@ fn main() { utils::show_menu("BT Menu: ", &items, &config.general.menu_command) { if selected.contains(": Mode: ") { - // ": Mode: []" + // Parse ": Mode: []". if let Some(bracket_start) = selected.rfind('[') && let Some(bracket_end) = selected.rfind(']') { @@ -149,7 +161,7 @@ fn main() { } } } else if selected.starts_with("Disconnect ") { - // "Disconnect []" + // Parse "Disconnect []". if let Some(bracket_start) = selected.rfind('[') && let Some(bracket_end) = selected.rfind(']') { @@ -157,7 +169,7 @@ fn main() { handle_ipc_response(ipc::request_data("bt", &["disconnect", mac])); } } else if selected == "--- Connect Device ---" { - // separator, do nothing + // section header } else if let Some(mac_start) = selected.rfind('(') && let Some(mac_end) = selected.rfind(')') { @@ -171,8 +183,8 @@ fn main() { return; } - // Generic module dispatch - // Translate module-specific shorthand targets + // `vol` and `mic` both dispatch to the audio module; we just prepend + // the "sink" / "source" argument so the server picks the right device. let (actual_module, actual_args) = if module == "vol" { let mut new_args = vec!["sink".to_string()]; new_args.extend(cli.args.clone()); @@ -193,6 +205,13 @@ fn main() { } } +/// Post-process the daemon's response for direct output to Waybar. +/// +/// Normal spaces are replaced with figure-spaces (U+2007) so Waybar's +/// proportional font does not jitter between updates, and the text is wrapped +/// in zero-width spaces (U+200B) as a cosmetic padding trick. Markup strings +/// (containing `<`) pass through untouched. On IPC failure an `error` output +/// is emitted and the client exits non-zero. fn handle_ipc_response(response: anyhow::Result) { match response { Ok(json_str) => match serde_json::from_str::(&json_str) { diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 658c2c7..5edbf08 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1,3 +1,17 @@ +//! Waybar module implementations. +//! +//! Each submodule exposes a [`WaybarModule`] type (CPU, network, audio, …) +//! and is feature-gated by a `mod-` flag. The [`WaybarModule`] trait is +//! what [`crate::registry::dispatch`] uses to evaluate a module on demand. +//! +//! Modules come in two flavours: +//! +//! * **Watched** — the daemon spawns a background polling/event task that +//! pushes state into a `watch::Receiver`, which the module reads lock-free +//! (network, cpu, audio, bluetooth, …). +//! * **Dispatch-only** — evaluated on demand only, without a watch channel +//! (power, game, btrfs). + #[cfg(feature = "mod-audio")] pub mod audio; #[cfg(feature = "mod-dbus")] @@ -36,7 +50,14 @@ use crate::error::Result as FluxoResult; use crate::output::WaybarOutput; use crate::state::AppReceivers; +/// Common interface implemented by every Waybar module. +/// +/// Given the current daemon config, the shared state receivers, and any +/// caller-supplied arguments, a module produces a single [`WaybarOutput`]. +/// Implementations must be cheap to evaluate — they are invoked on every +/// client request and on every signaler state change. pub trait WaybarModule { + /// Evaluate the module and return its rendered output. fn run( &self, config: &Config, diff --git a/src/output.rs b/src/output.rs index 1e1f64b..680a67b 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,12 +1,22 @@ +//! JSON payload returned to Waybar custom modules. + use serde::{Deserialize, Serialize}; +/// A Waybar custom module return value. +/// +/// Serialises to the schema Waybar's `return-type: json` expects — the +/// optional fields are omitted from the output when unset. #[derive(Serialize, Deserialize, Clone, Debug, Default)] pub struct WaybarOutput { + /// Primary text shown in the bar. pub text: String, + /// Tooltip text shown on hover. #[serde(skip_serializing_if = "Option::is_none")] pub tooltip: Option, + /// CSS class applied to the module (for styling). #[serde(skip_serializing_if = "Option::is_none")] pub class: Option, + /// Optional 0-100 value usable by bar progress indicators. #[serde(skip_serializing_if = "Option::is_none")] pub percentage: Option, } diff --git a/src/registry.rs b/src/registry.rs index e4bd07d..3ffcd2d 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -1,3 +1,11 @@ +//! Central module dispatch: name → [`WaybarModule::run`] lookup. +//! +//! The `dispatch` function is called by the IPC request handler with a module +//! name (e.g. `"cpu"`, `"vol"`) and returns the rendered [`WaybarOutput`]. All +//! per-module match arms are generated by [`for_each_watched_module!`]; +//! dispatch-only modules without a watch channel (power/game/btrfs) are +//! listed manually. + use crate::config::Config; use crate::error::{FluxoError, Result as FluxoResult}; use crate::output::WaybarOutput; @@ -6,8 +14,14 @@ use crate::state::AppReceivers; #[allow(unused_imports)] use crate::modules::WaybarModule; +/// Expands the module registry into a single [`dispatch`] match and a +/// [`signaler_default_args`] lookup. macro_rules! gen_dispatch { ($( { $feature:literal, $field:ident, $state:ty, [$($name:literal),+], [$($sig_name:literal),+], $module:path, $signal:ident, [$($default_arg:literal),*], $config:ident } )*) => { + /// Look up a module by name and render its [`WaybarOutput`]. + /// + /// Returns [`FluxoError::Disabled`] when the module is disabled in + /// config, and [`FluxoError::Ipc`] when the name is unknown. pub async fn dispatch( module_name: &str, #[allow(unused)] config: &Config, diff --git a/src/signaler.rs b/src/signaler.rs index 78725e4..123dafe 100644 --- a/src/signaler.rs +++ b/src/signaler.rs @@ -1,3 +1,11 @@ +//! Waybar signalling: watch state channels, send `SIGRTMIN+N` on changes. +//! +//! Waybar's custom modules use `signal = N` to rerun their command when they +//! receive `SIGRTMIN+N`. This task subscribes to every watched module's +//! `watch::Receiver`, evaluates the module when its channel fires, and only +//! signals Waybar when the rendered output actually changed. A 50 ms per-signal +//! debounce prevents storms during rapid state churn. + use crate::config::Config; use crate::state::AppReceivers; use std::collections::HashMap; @@ -7,6 +15,10 @@ use tokio::sync::RwLock; use tokio::time::{Duration, Instant, sleep}; use tracing::{debug, warn}; +/// Sends real-time signals to the Waybar process. +/// +/// Resolves Waybar's PID lazily and caches it — the PID is invalidated on +/// signal failure (e.g. Waybar was restarted) and rediscovered via `sysinfo`. pub struct WaybarSignaler { cached_pid: Option, sys: System, @@ -14,6 +26,7 @@ pub struct WaybarSignaler { } impl WaybarSignaler { + /// Create a new signaler with no cached PID. pub fn new() -> Self { Self { cached_pid: None, @@ -65,9 +78,19 @@ impl WaybarSignaler { } } +/// Generates [`WaybarSignaler::run`] from the central module registry. +/// +/// For each watched module we emit: +/// * a cfg-gated `watch::Receiver::changed()` future (or `pending::<()>` when +/// the feature is disabled, so the `tokio::select!` arm compiles uniformly), +/// * a `select!` arm that re-evaluates the module and signals Waybar once per +/// changed output. macro_rules! gen_signaler_run { ($( { $feature:literal, $field:ident, $state:ty, [$($name:literal),+], [$($sig_name:literal),+], $module:path, $signal:ident, [$($default_arg:literal),*], $config:ident } )*) => { impl WaybarSignaler { + /// Run the signaler event loop forever. + /// + /// Consumes `self`; intended to be spawned as a long-lived task. pub async fn run(mut self, config_lock: Arc>, mut receivers: AppReceivers) { let mut last_outputs: HashMap<&'static str, String> = HashMap::new(); @@ -128,7 +151,7 @@ macro_rules! gen_signaler_run { } _ = sleep(Duration::from_secs(5)) => { - // loop and refresh config + // heartbeat: re-read signals config on each iteration } } } diff --git a/src/state.rs b/src/state.rs index f5ec544..2c016cf 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,11 +1,27 @@ +//! Shared state types exchanged between daemon tasks and modules. +//! +//! Every "watched" module has a `watch::Receiver` held by +//! [`AppReceivers`]; background tasks write into the paired sender. Readers +//! (the request handler, the signaler) take a cheap snapshot via +//! `Receiver::borrow()` and render their output from it. + use crate::output::WaybarOutput; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::{RwLock, mpsc, watch}; use tokio::time::Instant; +/// Generates the [`AppReceivers`] struct, with one `watch::Receiver` field +/// per watched module (cfg-gated by feature flag). macro_rules! gen_app_receivers { ($( { $feature:literal, $field:ident, $state:ty, [$($name:literal),+], [$($sig_name:literal),+], $module:path, $signal:ident, [$($default_arg:literal),*], $config:ident } )*) => { + /// Bundle of every watch-channel receiver and control-channel handle. + /// + /// Cloned into each IPC request handler and each module daemon so that + /// they can snapshot state without locking. Per-module receivers are + /// generated from [`for_each_watched_module!`]; the remaining fields + /// (`health`, `bt_cycle`, `audio_cmd_tx`, `mpris_scroll_*`) are shared + /// control state. #[derive(Clone)] pub struct AppReceivers { $( @@ -28,6 +44,7 @@ macro_rules! gen_app_receivers { } for_each_watched_module!(gen_app_receivers); +/// Runtime health statistics used for backoff decisions. #[derive(Clone, Default)] pub struct ModuleHealth { pub consecutive_failures: u32, @@ -36,6 +53,7 @@ pub struct ModuleHealth { pub last_successful_output: Option, } +/// Current default audio sink + source plus all available devices. #[derive(Default, Clone)] pub struct AudioState { pub sink: AudioDeviceInfo, @@ -44,6 +62,7 @@ pub struct AudioState { pub available_sources: Vec, } +/// Metadata for one PulseAudio sink or source. #[derive(Default, Clone)] pub struct AudioDeviceInfo { pub name: String, @@ -53,6 +72,7 @@ pub struct AudioDeviceInfo { pub channels: u8, } +/// A single BlueZ device, optionally annotated by a plugin (buds, maestro…). #[derive(Default, Clone)] pub struct BtDeviceInfo { pub device_alias: String, @@ -61,6 +81,7 @@ pub struct BtDeviceInfo { pub plugin_data: Vec<(String, String)>, } +/// Snapshot of the Bluetooth adapter plus every tracked device. #[derive(Default, Clone)] pub struct BtState { pub adapter_powered: bool, @@ -68,6 +89,10 @@ pub struct BtState { } impl BtState { + /// Return the device the client is currently cycled to, if any. + /// + /// The `index` is taken modulo `devices.len()` so cycling past the end + /// wraps around naturally. pub fn active_device(&self, index: usize) -> Option<&BtDeviceInfo> { if self.devices.is_empty() { None @@ -77,6 +102,7 @@ impl BtState { } } +/// Per-mountpoint disk usage. #[derive(Default, Clone)] pub struct DiskInfo { pub mount_point: String, @@ -85,6 +111,7 @@ pub struct DiskInfo { pub available_bytes: u64, } +/// Throughput and identity of the active network interface. #[derive(Default, Clone)] pub struct NetworkState { pub rx_mbps: f64, @@ -93,6 +120,7 @@ pub struct NetworkState { pub ip: String, } +/// CPU utilisation and temperature. #[derive(Clone)] pub struct CpuState { pub usage: f64, @@ -110,12 +138,14 @@ impl Default for CpuState { } } +/// RAM usage in gigabytes. #[derive(Default, Clone)] pub struct MemoryState { pub used_gb: f64, pub total_gb: f64, } +/// Load averages, uptime, and process count. #[derive(Default, Clone)] pub struct SysState { pub load_1: f64, @@ -125,6 +155,9 @@ pub struct SysState { pub process_count: usize, } +/// GPU snapshot (vendor-agnostic). +/// +/// `active` is `false` until detection finds a supported GPU. #[derive(Clone)] pub struct GpuState { pub active: bool, @@ -150,27 +183,32 @@ impl Default for GpuState { } } +/// Do-Not-Disturb toggle state. #[derive(Default, Clone)] pub struct DndState { pub is_dnd: bool, } +/// Currently active keyboard layout. #[derive(Default, Clone)] pub struct KeyboardState { pub layout: String, } +/// Display backlight brightness as a 0-100 percentage. #[derive(Default, Clone)] pub struct BacklightState { pub percentage: u8, } +/// Marquee scroll position for the MPRIS module. #[derive(Default, Clone)] pub struct MprisScrollState { pub offset: usize, pub full_text: String, } +/// Currently playing media metadata from an MPRIS player. #[derive(Default, Clone)] pub struct MprisState { pub is_playing: bool, @@ -181,6 +219,10 @@ pub struct MprisState { pub album: String, } +/// Test harness holding a synthetic [`AppReceivers`] plus its senders. +/// +/// The senders are kept alive via `_*_tx` fields so test code can drive the +/// watch channels without them being dropped. #[cfg(test)] pub struct MockState { pub receivers: AppReceivers, @@ -210,6 +252,7 @@ pub struct MockState { _dnd_tx: watch::Sender, } +/// Plain-data counterpart of [`AppReceivers`] used to seed a [`MockState`]. #[cfg(test)] #[derive(Default, Clone)] pub struct AppState { @@ -240,6 +283,7 @@ pub struct AppState { pub health: HashMap, } +/// Build a [`MockState`] from a plain [`AppState`] snapshot for unit tests. #[cfg(test)] pub fn mock_state(state: AppState) -> MockState { #[cfg(feature = "mod-network")] diff --git a/src/utils.rs b/src/utils.rs index 4cf95ef..07fffa9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,23 +1,31 @@ +//! Shared helpers: menu spawning, Hyprland socket lookup, and template formatting. + use anyhow::{Context, Result}; use std::io::Write; use std::process::{Command, Stdio}; +/// Pipe `items` into an external menu program (rofi/dmenu/wofi) and return +/// the user's selection. +/// +/// The prompt is exposed to the command as `$FLUXO_PROMPT` (preferred) and +/// as a legacy `{prompt}` placeholder that is substituted in the shell string. 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 + .env("FLUXO_PROMPT", prompt) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()) // Suppress GTK/Wayland warnings from tools like wofi + // Suppress GTK/Wayland noise from tools like wofi. + .stderr(Stdio::null()) .spawn() .context("Failed to spawn menu command")?; if let Some(mut stdin) = child.stdin.take() { let mut input = items.join("\n"); - input.push('\n'); // Ensure trailing newline for wofi/rofi + // Trailing newline is required by wofi/rofi. + input.push('\n'); stdin .write_all(input.as_bytes()) .context("Failed to write to menu stdin")?; @@ -37,11 +45,14 @@ 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") @@ -52,7 +63,6 @@ pub fn get_hyprland_socket(socket_name: &str) -> Result { } } - // Fallback to /tmp let path = std::path::PathBuf::from("/tmp/hypr") .join(&signature) .join(socket_name); @@ -70,6 +80,7 @@ pub fn get_hyprland_socket(socket_name: &str) -> Result { use regex::Regex; use std::sync::LazyLock; +/// Bucket `value` into `"normal"`, `"high"`, or `"max"` for CSS class output. pub fn classify_usage(value: f64, high: f64, max: f64) -> &'static str { if value > max { "max" @@ -80,15 +91,22 @@ pub fn classify_usage(value: f64, high: f64, max: f64) -> &'static str { } } +/// A typed value supplied to [`format_template`] — chosen at call site so +/// formatting handles precision and alignment correctly per type. pub enum TokenValue { Float(f64), Int(i64), String(String), } +/// Token grammar: `{name}`, `{name:>5}`, `{name:<8.2}`, `{name:^6}`, etc. pub static TOKEN_RE: LazyLock = LazyLock::new(|| Regex::new(r"\{([a-zA-Z0-9_]+)(?::([<>\^])?(\d+)?(?:\.(\d+))?)?\}").unwrap()); +/// Substitute `{name[:align[width[.precision]]]}` tokens in a template string. +/// +/// Unknown tokens are left verbatim. Width/alignment/precision follow Rust's +/// `std::fmt` semantics (`<` left, `^` center, `>` right). pub fn format_template(template: &str, values: &[(K, TokenValue)]) -> String where K: AsRef,