3 Commits

Author SHA1 Message Date
nvrl 2d35313d75 version bump
Release / Build and Release (push) Successful in 2m49s
2026-04-07 11:43:20 +02:00
nvrl eaeba84092 refactored cli, extracted menu to cli, removed deprecated
Release / Build and Release (push) Has been cancelled
2026-04-07 11:42:57 +02:00
nvrl ffdb689ef9 added missing docs
Release / Build and Release (push) Successful in 23s
2026-04-05 21:12:11 +02:00
27 changed files with 983 additions and 209 deletions
Generated
+1 -1
View File
@@ -572,7 +572,7 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]] [[package]]
name = "fluxo-rs" name = "fluxo-rs"
version = "0.5.1" version = "0.5.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bluer", "bluer",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "fluxo-rs" name = "fluxo-rs"
version = "0.5.1" version = "0.5.2"
edition = "2024" edition = "2024"
[features] [features]
+135
View File
@@ -0,0 +1,135 @@
//! Bluetooth interactive menu (client-side).
//!
//! Runs entirely in the client process because it needs to spawn the user's
//! menu command (rofi/dmenu/wofi) — the daemon has no business opening GUI
//! windows. Communicates with the daemon via IPC to fetch device lists and
//! dispatch connect/disconnect/mode commands.
/// Format strings used both when *building* menu items and when *parsing*
/// the user's selection back. Keeping them together prevents drift.
mod fmt {
/// Connected device with a plugin mode: `"<alias>: Mode: <mode> [<mac>]"`.
pub const MODE_INFIX: &str = ": Mode: ";
/// Disconnect action: `"Disconnect <alias> [<mac>]"`.
pub const DISCONNECT_PREFIX: &str = "Disconnect ";
/// Visual separator before paired-but-not-connected devices.
pub const CONNECT_HEADER: &str = "--- Connect Device ---";
}
/// Extract a MAC address enclosed in `[…]` at the end of a string.
fn parse_mac_from_brackets(s: &str) -> Option<&str> {
let start = s.rfind('[')?;
let end = s.rfind(']')?;
if end > start + 1 {
Some(&s[start + 1..end])
} else {
None
}
}
/// Extract a MAC address enclosed in `(…)` at the end of a string.
fn parse_mac_from_parens(s: &str) -> Option<&str> {
let start = s.rfind('(')?;
let end = s.rfind(')')?;
if end > start + 1 {
Some(&s[start + 1..end])
} else {
None
}
}
/// Parse a mode selection line: `"<alias>: Mode: <mode> [<mac>]"`.
/// Returns `(mode, mac)`.
fn parse_mode_selection(s: &str) -> Option<(&str, &str)> {
let mac = parse_mac_from_brackets(s)?;
let mode_start = s.find(fmt::MODE_INFIX)?;
let mode_begin = mode_start + fmt::MODE_INFIX.len();
let bracket_start = s.rfind('[')?;
if bracket_start > mode_begin {
let mode = s[mode_begin..bracket_start].trim_end();
Some((mode, mac))
} else {
None
}
}
/// Run the interactive Bluetooth device menu.
///
/// Fetches connected/paired devices from the daemon, presents them in the
/// user's configured menu command, and dispatches the selected action back
/// to the daemon.
pub fn run_bt_menu() {
let config = crate::config::load_config(None);
let mut items = Vec::new();
let mut connected: Vec<(String, String)> = Vec::new();
let mut paired: Vec<(String, String)> = Vec::new();
// Fetch the device list from the daemon.
if let Ok(json_str) = crate::ipc::request_data("bt", &["menu_data"])
&& let Ok(val) = serde_json::from_str::<serde_json::Value>(&json_str)
&& let Some(text) = val.get("text").and_then(|t| t.as_str())
{
for line in text.lines() {
if let Some(rest) = line.strip_prefix("CONNECTED:")
&& let Some((alias, mac)) = rest.split_once('|')
{
connected.push((alias.to_string(), mac.to_string()));
} else if let Some(rest) = line.strip_prefix("PAIRED:")
&& let Some((alias, mac)) = rest.split_once('|')
{
paired.push((alias.to_string(), mac.to_string()));
}
}
}
// Build menu items for connected devices (modes + disconnect).
for (alias, mac) in &connected {
if let Ok(json_str) = crate::ipc::request_data("bt", &["get_modes", mac])
&& let Ok(val) = serde_json::from_str::<serde_json::Value>(&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!("{}{}{} [{}]", alias, fmt::MODE_INFIX, mode, mac));
}
}
items.push(format!("{}{} [{}]", fmt::DISCONNECT_PREFIX, alias, mac));
}
// Paired-but-not-connected devices go below a separator.
if !paired.is_empty() {
items.push(fmt::CONNECT_HEADER.to_string());
for (alias, mac) in &paired {
items.push(format!("{} ({})", alias, mac));
}
}
if items.is_empty() {
tracing::info!("No Bluetooth options found.");
return;
}
let Ok(selected) = crate::utils::show_menu("BT Menu: ", &items, &config.general.menu_command)
else {
return;
};
if let Some((mode, mac)) = parse_mode_selection(&selected) {
crate::output::print_waybar_response(crate::ipc::request_data(
"bt",
&["set_mode", mode, mac],
));
} else if selected.starts_with(fmt::DISCONNECT_PREFIX) {
if let Some(mac) = parse_mac_from_brackets(&selected) {
crate::output::print_waybar_response(crate::ipc::request_data(
"bt",
&["disconnect", mac],
));
}
} else if selected == fmt::CONNECT_HEADER {
// Section header — no action.
} else if let Some(mac) = parse_mac_from_parens(&selected) {
crate::output::print_waybar_response(crate::ipc::request_data("bt", &["connect", mac]));
}
}
+44
View File
@@ -0,0 +1,44 @@
//! Client-side module command dispatch.
//!
//! Resolves CLI aliases (e.g. `mic` → audio source), delegates to
//! special-case handlers (BT menu), and falls through to the standard
//! IPC → daemon → Waybar JSON path for everything else.
/// Resolve client-side module aliases that prepend implicit arguments.
///
/// `vol` maps to the audio sink, `mic` maps to the audio source — both
/// dispatch to the `"vol"` module on the daemon with a `"sink"` or
/// `"source"` prefix argument.
fn resolve_alias(module: &str, args: &[String]) -> (String, Vec<String>) {
match module {
"vol" => {
let mut a = vec!["sink".to_string()];
a.extend(args.iter().cloned());
("vol".to_string(), a)
}
"mic" => {
let mut a = vec!["source".to_string()];
a.extend(args.iter().cloned());
("vol".to_string(), a)
}
_ => (module.to_string(), args.to_vec()),
}
}
/// Entry point for all `fluxo <module> [args...]` invocations.
///
/// Handles the BT menu special case client-side, resolves aliases, and
/// sends the request to the daemon via IPC.
pub fn run_module_command(module: &str, args: &[String]) {
// Bluetooth menu runs client-side because it spawns the user's menu
// command (rofi/dmenu/wofi) — the daemon must not open GUI windows.
#[cfg(feature = "mod-bt")]
if module == "bt" && args.first().map(|s| s.as_str()) == Some("menu") {
crate::bt_menu::run_bt_menu();
return;
}
let (actual_module, actual_args) = resolve_alias(module, args);
let args_ref: Vec<&str> = actual_args.iter().map(|s| s.as_str()).collect();
crate::output::print_waybar_response(crate::ipc::request_data(&actual_module, &args_ref));
}
+2 -1
View File
@@ -529,7 +529,8 @@ async fn handle_request(
match result { match result {
Ok(output) => serde_json::to_string(&output).unwrap_or_else(|_| "{}".to_string()), Ok(output) => serde_json::to_string(&output).unwrap_or_else(|_| "{}".to_string()),
Err(crate::error::FluxoError::Disabled(_)) => { Err(crate::error::FluxoError::Disabled(_)) => {
"{\"text\":\"\",\"tooltip\":\"Module disabled\",\"class\":\"disabled\"}".to_string() serde_json::to_string(&crate::output::WaybarOutput::disabled())
.unwrap_or_else(|_| "{}".to_string())
} }
Err(e) => crate::health::error_response(module_name, &e, cached_output), Err(e) => crate::health::error_response(module_name, &e, cached_output),
} }
+3 -10
View File
@@ -118,10 +118,8 @@ pub fn backoff_response(module_name: &str, cached: Option<WaybarOutput>) -> Stri
cached.class = Some(format!("{} warning", class).trim().to_string()); cached.class = Some(format!("{} warning", class).trim().to_string());
return serde_json::to_string(&cached).unwrap_or_else(|_| "{}".to_string()); return serde_json::to_string(&cached).unwrap_or_else(|_| "{}".to_string());
} }
format!( let zws = crate::output::ZERO_WIDTH_SPACE;
"{{\"text\":\"\u{200B}Cooling down ({})\u{200B}\",\"class\":\"error\"}}", format!("{{\"text\":\"{zws}Cooling down ({module_name}){zws}\",\"class\":\"error\"}}")
module_name
)
} }
/// Serialise a fallback response for a module that errored this request. /// Serialise a fallback response for a module that errored this request.
@@ -141,11 +139,6 @@ pub fn error_response(
let error_msg = e.to_string(); let error_msg = e.to_string();
error!(module = module_name, error = %error_msg, "Module execution failed"); error!(module = module_name, error = %error_msg, "Module execution failed");
let err_out = WaybarOutput { let err_out = WaybarOutput::error(&error_msg);
text: "\u{200B}Error\u{200B}".to_string(),
tooltip: Some(error_msg),
class: Some("error".to_string()),
percentage: None,
};
serde_json::to_string(&err_out).unwrap_or_else(|_| "{}".to_string()) serde_json::to_string(&err_out).unwrap_or_else(|_| "{}".to_string())
} }
+498
View File
@@ -0,0 +1,498 @@
//! Human-readable help output for all available modules.
//!
//! `fluxo help` prints an overview of every module with its aliases, arguments,
//! and format tokens. `fluxo help <module>` shows the detailed page for a
//! single module.
/// Module help descriptor used to build the help output.
struct ModuleHelp {
/// Primary display name.
name: &'static str,
/// CLI aliases that dispatch to this module.
aliases: &'static [&'static str],
/// Cargo feature gate required at compile time.
feature: &'static str,
/// One-line summary of what the module does.
summary: &'static str,
/// Argument synopsis in `[arg]` notation.
args_synopsis: &'static str,
/// Detailed argument descriptions.
args_detail: &'static [(&'static str, &'static str)],
/// Format tokens available in `config.toml`.
tokens: &'static [(&'static str, &'static str)],
/// Concrete usage examples.
examples: &'static [(&'static str, &'static str)],
}
/// All module descriptors, ordered by category.
const MODULES: &[ModuleHelp] = &[
// ── Hardware ─────────────────────────────────────────────────────
ModuleHelp {
name: "cpu",
aliases: &["cpu"],
feature: "mod-hardware",
summary: "CPU usage percentage and temperature.",
args_synopsis: "",
args_detail: &[],
tokens: &[
("usage", "CPU usage as a percentage (0.0 - 100.0)"),
("temp", "CPU temperature in degrees Celsius"),
],
examples: &[("fluxo cpu", "Show current CPU usage and temperature")],
},
ModuleHelp {
name: "memory",
aliases: &["mem", "memory"],
feature: "mod-hardware",
summary: "RAM usage in gigabytes with usage classification.",
args_synopsis: "",
args_detail: &[],
tokens: &[
("used", "Used memory in GB"),
("total", "Total memory in GB"),
],
examples: &[("fluxo mem", "Show current RAM usage")],
},
ModuleHelp {
name: "sys",
aliases: &["sys"],
feature: "mod-hardware",
summary: "Uptime, load averages, and process count.",
args_synopsis: "",
args_detail: &[],
tokens: &[
("uptime", "Human-readable uptime (e.g. \"2d 5h\")"),
("load1", "1-minute load average"),
("load5", "5-minute load average"),
("load15", "15-minute load average"),
("procs", "Number of running processes"),
],
examples: &[("fluxo sys", "Show system uptime and load")],
},
ModuleHelp {
name: "gpu",
aliases: &["gpu"],
feature: "mod-hardware",
summary: "GPU usage, VRAM, and temperature (AMD/NVIDIA/Intel).",
args_synopsis: "",
args_detail: &[],
tokens: &[
("usage", "GPU utilisation percentage"),
("vram_used", "Used VRAM in GB (AMD/NVIDIA)"),
("vram_total", "Total VRAM in GB (AMD/NVIDIA)"),
("temp", "GPU temperature in Celsius (AMD/NVIDIA)"),
("freq", "GPU frequency in MHz (Intel)"),
],
examples: &[("fluxo gpu", "Show GPU stats for the detected vendor")],
},
ModuleHelp {
name: "disk",
aliases: &["disk"],
feature: "mod-hardware",
summary: "Filesystem usage for a given mount point.",
args_synopsis: "[mountpoint]",
args_detail: &[(
"mountpoint",
"Path to the mount point to display (default: \"/\")",
)],
tokens: &[
("mount", "The mount point path"),
("used", "Used space in GB"),
("total", "Total space in GB"),
],
examples: &[
("fluxo disk", "Show usage of the root filesystem (/)"),
("fluxo disk /home", "Show usage of /home"),
],
},
ModuleHelp {
name: "pool",
aliases: &["pool", "btrfs"],
feature: "mod-hardware",
summary: "Aggregated Btrfs pool usage across all btrfs mounts.",
args_synopsis: "",
args_detail: &[],
tokens: &[
("used", "Total used space in GB across all btrfs mounts"),
("total", "Total capacity in GB across all btrfs mounts"),
],
examples: &[
("fluxo pool", "Show combined Btrfs pool usage"),
("fluxo btrfs", "Same as above (alias)"),
],
},
ModuleHelp {
name: "power",
aliases: &["power"],
feature: "mod-hardware",
summary: "Battery percentage and charge state from sysfs.",
args_synopsis: "",
args_detail: &[],
tokens: &[
("percentage", "Battery level (0 - 100)"),
("icon", "State icon (varies by charge level and AC status)"),
],
examples: &[("fluxo power", "Show battery status")],
},
ModuleHelp {
name: "game",
aliases: &["game"],
feature: "mod-hardware",
summary: "Gamemode indicator (Hyprland animation state).",
args_synopsis: "",
args_detail: &[],
tokens: &[],
examples: &[("fluxo game", "Show whether gamemode is active")],
},
// ── Network ──────────────────────────────────────────────────────
ModuleHelp {
name: "network",
aliases: &["net", "network"],
feature: "mod-network",
summary: "Primary network interface, IP, and transfer rates.",
args_synopsis: "",
args_detail: &[],
tokens: &[
("interface", "Active interface name (e.g. \"wlan0\")"),
("ip", "IPv4 address of the active interface"),
("rx", "Receive rate in MB/s"),
("tx", "Transmit rate in MB/s"),
],
examples: &[("fluxo net", "Show network status and throughput")],
},
// ── Audio ────────────────────────────────────────────────────────
ModuleHelp {
name: "vol (sink)",
aliases: &["vol"],
feature: "mod-audio",
summary: "PulseAudio/PipeWire output (sink) volume and controls.",
args_synopsis: "[show|up|down|mute|cycle] [step]",
args_detail: &[
(
"show",
"Display current sink volume and mute state (default)",
),
("up", "Increase volume by <step> percent (default: 5)"),
("down", "Decrease volume by <step> percent (default: 5)"),
("mute", "Toggle mute on the default sink"),
("cycle", "Switch to the next available output device"),
("step", "Volume change increment in percent (default: 5)"),
],
tokens: &[
("name", "Device description (truncated to 20 chars)"),
("icon", "Volume-level icon (changes with volume/mute)"),
("volume", "Current volume percentage (0 - 150)"),
],
examples: &[
("fluxo vol", "Show current sink volume"),
("fluxo vol up", "Increase volume by 5%"),
("fluxo vol up 10", "Increase volume by 10%"),
("fluxo vol down 2", "Decrease volume by 2%"),
("fluxo vol mute", "Toggle sink mute"),
("fluxo vol cycle", "Switch to next output device"),
],
},
ModuleHelp {
name: "mic (source)",
aliases: &["mic"],
feature: "mod-audio",
summary: "PulseAudio/PipeWire input (source/microphone) controls.",
args_synopsis: "[show|up|down|mute|cycle] [step]",
args_detail: &[
(
"show",
"Display current source volume and mute state (default)",
),
("up", "Increase mic volume by <step> percent (default: 5)"),
("down", "Decrease mic volume by <step> percent (default: 5)"),
("mute", "Toggle mute on the default source"),
("cycle", "Switch to the next available input device"),
("step", "Volume change increment in percent (default: 5)"),
],
tokens: &[
("name", "Device description (truncated to 20 chars)"),
("icon", "Microphone icon (changes with mute state)"),
("volume", "Current volume percentage (0 - 150)"),
],
examples: &[
("fluxo mic", "Show current microphone volume"),
("fluxo mic mute", "Toggle microphone mute"),
("fluxo mic up 10", "Increase mic volume by 10%"),
],
},
// ── Bluetooth ────────────────────────────────────────────────────
ModuleHelp {
name: "bluetooth",
aliases: &["bt", "bluetooth"],
feature: "mod-bt",
summary: "Bluetooth device status, connection management, and plugin modes.",
args_synopsis: "[show|connect|disconnect|cycle|menu|get_modes|set_mode|cycle_mode] [args...]",
args_detail: &[
("show", "Display the active device's status (default)"),
(
"connect <mac>",
"Connect to the device with the given MAC address",
),
(
"disconnect [mac]",
"Disconnect the active device, or a specific MAC",
),
(
"cycle",
"Cycle through connected devices (multi-device setups)",
),
(
"menu",
"Open an interactive device picker (client-side, uses menu_command)",
),
(
"get_modes [mac]",
"List available plugin modes (e.g. ANC modes for Pixel Buds)",
),
(
"set_mode <mode> [mac]",
"Set a plugin mode on the active or specified device",
),
("cycle_mode [mac]", "Advance to the next plugin mode"),
],
tokens: &[
("alias", "Device display name (e.g. \"Pixel Buds Pro\")"),
("mac", "Device MAC address"),
("left", "Left earbud battery (plugin, e.g. \"85%\")"),
("right", "Right earbud battery (plugin, e.g. \"90%\")"),
(
"anc",
"ANC mode label (plugin, e.g. \"ANC\", \"Aware\", \"Off\")",
),
],
examples: &[
("fluxo bt", "Show the active BT device"),
(
"fluxo bt connect AA:BB:CC:DD:EE:FF",
"Connect to a specific device",
),
("fluxo bt disconnect", "Disconnect the active device"),
("fluxo bt menu", "Open the interactive BT device menu"),
("fluxo bt cycle_mode", "Toggle ANC mode on Pixel Buds"),
("fluxo bt set_mode aware", "Set ANC to aware mode"),
],
},
// ── D-Bus ────────────────────────────────────────────────────────
ModuleHelp {
name: "mpris",
aliases: &["mpris"],
feature: "mod-dbus",
summary: "MPRIS media player status (artist, title, playback state).",
args_synopsis: "",
args_detail: &[],
tokens: &[
("artist", "Current track artist"),
("title", "Current track title"),
("album", "Current track album"),
("status_icon", "Playback icon (play/pause/stop glyph)"),
],
examples: &[("fluxo mpris", "Show current media player status")],
},
ModuleHelp {
name: "backlight",
aliases: &["backlight"],
feature: "mod-dbus",
summary: "Screen brightness percentage (inotify-driven).",
args_synopsis: "",
args_detail: &[],
tokens: &[
("percentage", "Current brightness level (0 - 100)"),
("icon", "Brightness bucket icon"),
],
examples: &[("fluxo backlight", "Show current screen brightness")],
},
ModuleHelp {
name: "keyboard",
aliases: &["kbd", "keyboard"],
feature: "mod-dbus",
summary: "Active keyboard layout (Hyprland event-driven).",
args_synopsis: "",
args_detail: &[],
tokens: &[(
"layout",
"Active keyboard layout name (e.g. \"English (US)\")",
)],
examples: &[("fluxo kbd", "Show the current keyboard layout")],
},
ModuleHelp {
name: "dnd",
aliases: &["dnd"],
feature: "mod-dbus",
summary: "Do-Not-Disturb toggle (SwayNC signal-driven / Dunst polling).",
args_synopsis: "[show|toggle]",
args_detail: &[
("show", "Display the current DND state (default)"),
("toggle", "Toggle DND on/off via the notification daemon"),
],
tokens: &[],
examples: &[
("fluxo dnd", "Show current DND state"),
("fluxo dnd toggle", "Toggle Do-Not-Disturb"),
],
},
];
/// Print help for all modules or a single module by name.
pub fn print_help(module: Option<&str>) {
if let Some(name) = module {
let found = MODULES.iter().find(|m| {
m.aliases.iter().any(|a| a.eq_ignore_ascii_case(name))
|| m.name.eq_ignore_ascii_case(name)
});
match found {
Some(m) => print_module_detail(m),
None => {
eprintln!("Unknown module: \"{}\"\n", name);
eprintln!("Run `fluxo help` to see all available modules.");
std::process::exit(1);
}
}
} else {
print_overview();
}
}
fn print_overview() {
println!("\x1b[1;36mfluxo\x1b[0m — high-performance daemon/client for Waybar custom modules\n");
println!("\x1b[1mUSAGE:\x1b[0m");
println!(" fluxo daemon [--config <path>] Start the background daemon");
println!(" fluxo reload Hot-reload the daemon config");
println!(" fluxo <module> [args...] Query or control a module");
println!(" fluxo help [module] Show this help or module details\n");
println!("\x1b[1mCONFIGURATION:\x1b[0m");
println!(" Config file: $XDG_CONFIG_HOME/fluxo/config.toml");
println!(" Format tokens in config strings use {{token}} syntax.");
println!(" Run `fluxo help <module>` to see available tokens.\n");
let categories: &[(&str, &[&str])] = &[
(
"Hardware",
&[
"cpu", "memory", "sys", "gpu", "disk", "pool", "power", "game",
],
),
("Network", &["network"]),
("Audio", &["vol (sink)", "mic (source)"]),
("Bluetooth", &["bluetooth"]),
("D-Bus", &["mpris", "backlight", "keyboard", "dnd"]),
];
println!("\x1b[1mMODULES:\x1b[0m\n");
for (category, names) in categories {
println!(
" \x1b[1;33m{}\x1b[0m ({})",
category,
feature_for_category(category)
);
for module_name in *names {
if let Some(m) = MODULES.iter().find(|m| m.name == *module_name) {
let aliases = m.aliases.join(", ");
println!(" \x1b[1;32m{:<18}\x1b[0m {}", aliases, m.summary,);
if !m.args_synopsis.is_empty() {
println!(" {:<18} args: {}", "", m.args_synopsis,);
}
}
}
println!();
}
println!("\x1b[1mEXAMPLES:\x1b[0m\n");
println!(" fluxo daemon Start the daemon");
println!(" fluxo cpu Show CPU usage and temperature");
println!(" fluxo vol up 10 Increase volume by 10%");
println!(" fluxo bt menu Open Bluetooth device picker");
println!(" fluxo dnd toggle Toggle Do-Not-Disturb");
println!(" fluxo help vol Show detailed help for the volume module");
println!();
println!("For detailed module info: \x1b[1mfluxo help <module>\x1b[0m");
}
fn print_module_detail(m: &ModuleHelp) {
println!("\x1b[1;36mfluxo {}\x1b[0m — {}\n", m.name, m.summary);
// Aliases
if m.aliases.len() > 1
|| m.aliases.first() != Some(&m.name.split_whitespace().next().unwrap_or(m.name))
{
println!("\x1b[1mALIASES:\x1b[0m {}", m.aliases.join(", "));
println!();
}
// Feature gate
println!("\x1b[1mFEATURE:\x1b[0m {}", m.feature);
println!();
// Usage
println!("\x1b[1mUSAGE:\x1b[0m");
let primary = m.aliases.first().unwrap_or(&m.name);
if m.args_synopsis.is_empty() {
println!(" fluxo {}", primary);
} else {
println!(" fluxo {} {}", primary, m.args_synopsis);
}
println!();
// Arguments
if !m.args_detail.is_empty() {
println!("\x1b[1mARGUMENTS:\x1b[0m\n");
let max_name = m
.args_detail
.iter()
.map(|(n, _)| n.len())
.max()
.unwrap_or(0);
for (name, desc) in m.args_detail {
println!(
" \x1b[32m{:<width$}\x1b[0m {}",
name,
desc,
width = max_name
);
}
println!();
}
// Format tokens
if !m.tokens.is_empty() {
println!("\x1b[1mFORMAT TOKENS:\x1b[0m (for use in config.toml format strings)\n");
let max_token = m.tokens.iter().map(|(t, _)| t.len()).max().unwrap_or(0);
for (token, desc) in m.tokens {
println!(
" \x1b[33m{{{:<width$}}}\x1b[0m {}",
token,
desc,
width = max_token
);
}
println!();
}
// Examples
if !m.examples.is_empty() {
println!("\x1b[1mEXAMPLES:\x1b[0m\n");
for (cmd, desc) in m.examples {
println!(" \x1b[1m$\x1b[0m {:<34} # {}", cmd, desc);
}
println!();
}
}
fn feature_for_category(category: &str) -> &'static str {
match category {
"Hardware" => "mod-hardware",
"Network" => "mod-network",
"Audio" => "mod-audio",
"Bluetooth" => "mod-bt",
"D-Bus" => "mod-dbus",
_ => "default",
}
}
+15 -146
View File
@@ -14,10 +14,14 @@
#[macro_use] #[macro_use]
mod macros; mod macros;
#[cfg(feature = "mod-bt")]
mod bt_menu;
mod client;
mod config; mod config;
mod daemon; mod daemon;
mod error; mod error;
mod health; mod health;
mod help;
mod ipc; mod ipc;
mod modules; mod modules;
mod output; mod output;
@@ -35,6 +39,7 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "fluxo")] #[command(name = "fluxo")]
#[command(about = "A high-performance daemon/client for Waybar custom modules", long_about = None)] #[command(about = "A high-performance daemon/client for Waybar custom modules", long_about = None)]
#[command(disable_help_subcommand = true)]
struct Cli { struct Cli {
#[command(subcommand)] #[command(subcommand)]
command: Option<Commands>, command: Option<Commands>,
@@ -57,6 +62,11 @@ enum Commands {
}, },
/// Reload the daemon configuration /// Reload the daemon configuration
Reload, Reload,
/// Show detailed help for all modules or a specific module
Help {
/// Optional module name to show detailed help for
module: Option<String>,
},
} }
fn main() { fn main() {
@@ -88,157 +98,16 @@ fn main() {
process::exit(1); process::exit(1);
} }
}, },
Commands::Help { module } => {
help::print_help(module.as_deref());
}
} }
return; return;
} }
if let Some(module) = &cli.module { if let Some(module) = &cli.module {
// Bluetooth menu is handled client-side: it needs access to the user's client::run_module_command(module, &cli.args);
// 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();
// 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"])
&& let Ok(val) = serde_json::from_str::<serde_json::Value>(&json_str)
&& let Some(text) = val.get("text").and_then(|t| t.as_str())
{
for line in text.lines() {
if let Some(rest) = line.strip_prefix("CONNECTED:")
&& let Some((alias, mac)) = rest.split_once('|')
{
connected.push((alias.to_string(), mac.to_string()));
} else if let Some(rest) = line.strip_prefix("PAIRED:")
&& let Some((alias, mac)) = rest.split_once('|')
{
paired.push((alias.to_string(), mac.to_string()));
}
}
}
for (alias, mac) in &connected {
if let Ok(json_str) = ipc::request_data("bt", &["get_modes", mac])
&& let Ok(val) = serde_json::from_str::<serde_json::Value>(&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: {} [{}]", alias, mode, mac));
}
}
items.push(format!("Disconnect {} [{}]", alias, mac));
}
if !paired.is_empty() {
items.push("--- Connect Device ---".to_string());
for (alias, mac) in &paired {
items.push(format!("{} ({})", alias, mac));
}
}
if !items.is_empty() {
if let Ok(selected) =
utils::show_menu("BT Menu: ", &items, &config.general.menu_command)
{
if selected.contains(": Mode: ") {
// Parse "<alias>: Mode: <mode> [<MAC>]".
if let Some(bracket_start) = selected.rfind('[')
&& let Some(bracket_end) = selected.rfind(']')
{
let mac = &selected[bracket_start + 1..bracket_end];
if let Some(mode_start) = selected.find(": Mode: ") {
let mode =
&selected[mode_start + ": Mode: ".len()..bracket_start - 1];
handle_ipc_response(ipc::request_data(
"bt",
&["set_mode", mode, mac],
));
}
}
} else if selected.starts_with("Disconnect ") {
// Parse "Disconnect <alias> [<MAC>]".
if let Some(bracket_start) = selected.rfind('[')
&& let Some(bracket_end) = selected.rfind(']')
{
let mac = &selected[bracket_start + 1..bracket_end];
handle_ipc_response(ipc::request_data("bt", &["disconnect", mac]));
}
} else if selected == "--- Connect Device ---" {
// section header
} else if let Some(mac_start) = selected.rfind('(')
&& let Some(mac_end) = selected.rfind(')')
{
let mac = &selected[mac_start + 1..mac_end];
handle_ipc_response(ipc::request_data("bt", &["connect", mac]));
}
}
} else { } else {
info!("No Bluetooth options found."); help::print_help(None);
}
return;
}
// `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());
("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);
}
}
/// 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<String>) {
match response {
Ok(json_str) => match serde_json::from_str::<serde_json::Value>(&json_str) {
Ok(mut val) => {
if let Some(text) = val.get_mut("text").and_then(|t| t.as_str()) {
let processed_text = if text.contains('<') {
text.to_string()
} else {
text.replace(' ', "\u{2007}")
};
let fixed_text = format!("\u{200B}{}\u{200B}", processed_text);
val["text"] = serde_json::Value::String(fixed_text);
}
println!("{}", serde_json::to_string(&val).unwrap());
}
Err(_) => println!("{}", json_str),
},
Err(e) => {
let err_out = output::WaybarOutput {
text: format!("\u{200B}Daemon offline ({})\u{200B}", e),
tooltip: Some(e.to_string()),
class: Some("error".to_string()),
percentage: None,
};
println!("{}", serde_json::to_string(&err_out).unwrap());
process::exit(1);
}
} }
} }
+21 -8
View File
@@ -1,3 +1,10 @@
//! PulseAudio/PipeWire sink + source indicator with live event subscription.
//!
//! The daemon runs on its own OS thread because libpulse's threaded mainloop
//! must drive callbacks inside its own lock scope. Volume/mute changes are
//! routed back via an async [`mpsc`] channel — the module handlers [`run`]s
//! only push commands; the thread performs the actual libpulse calls.
use crate::config::Config; use crate::config::Config;
use crate::error::{FluxoError, Result}; use crate::error::{FluxoError, Result};
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -13,6 +20,7 @@ use std::sync::Arc;
use tokio::sync::{mpsc, watch}; use tokio::sync::{mpsc, watch};
use tracing::error; use tracing::error;
/// Commands the module handler sends to the audio daemon thread.
pub enum AudioCommand { pub enum AudioCommand {
ChangeVolume { ChangeVolume {
is_sink: bool, is_sink: bool,
@@ -27,13 +35,17 @@ pub enum AudioCommand {
}, },
} }
/// Long-lived daemon driving libpulse's threaded mainloop.
pub struct AudioDaemon; pub struct AudioDaemon;
impl AudioDaemon { impl AudioDaemon {
/// Construct a new (stateless) daemon.
pub fn new() -> Self { pub fn new() -> Self {
Self Self
} }
/// Spawn the audio thread, subscribe to sink/source/server events, and
/// start consuming [`AudioCommand`]s.
pub fn start( pub fn start(
&self, &self,
state_tx: &watch::Sender<AudioState>, state_tx: &watch::Sender<AudioState>,
@@ -56,7 +68,6 @@ impl AudioDaemon {
mainloop.lock(); mainloop.lock();
// Wait for context to be ready
loop { loop {
match context.get_state() { match context.get_state() {
libpulse_binding::context::State::Ready => break, libpulse_binding::context::State::Ready => break,
@@ -74,10 +85,8 @@ impl AudioDaemon {
} }
} }
// Initial fetch
let _ = fetch_audio_data_sync(&mut context, &state_tx); let _ = fetch_audio_data_sync(&mut context, &state_tx);
// Subscribe to events
let interest = let interest =
InterestMaskSet::SINK | InterestMaskSet::SOURCE | InterestMaskSet::SERVER; InterestMaskSet::SINK | InterestMaskSet::SOURCE | InterestMaskSet::SERVER;
context.subscribe(interest, |_| {}); context.subscribe(interest, |_| {});
@@ -196,7 +205,6 @@ impl AudioDaemon {
mainloop.lock(); mainloop.lock();
// Fetch data and update available sinks/sources
let _ = fetch_audio_data_sync(&mut context, &state_tx); let _ = fetch_audio_data_sync(&mut context, &state_tx);
mainloop.unlock(); mainloop.unlock();
@@ -207,13 +215,12 @@ impl AudioDaemon {
use std::time::Duration; use std::time::Duration;
/// Trigger async libpulse introspection: server defaults + sink/source lists.
/// Callbacks publish onto `state_tx` as results land.
fn fetch_audio_data_sync( fn fetch_audio_data_sync(
context: &mut Context, context: &mut Context,
state_tx: &watch::Sender<AudioState>, state_tx: &watch::Sender<AudioState>,
) -> Result<()> { ) -> Result<()> {
// We fetch all sinks and sources, and also server info to know defaults.
// The order doesn't strictly matter as long as we update correctly.
let tx_server = state_tx.clone(); let tx_server = state_tx.clone();
context.introspect().get_server_info(move |info| { context.introspect().get_server_info(move |info| {
let mut current = tx_server.borrow().clone(); let mut current = tx_server.borrow().clone();
@@ -269,6 +276,8 @@ fn device_info_from(
(desc, vol, muted, channels) (desc, vol, muted, channels)
} }
/// Write `info` into `target` only when `item_name` matches the currently
/// selected default device — other sinks/sources are ignored here.
fn apply_device_info(target: &mut AudioDeviceInfo, item_name: &str, info: (String, u8, bool, u8)) { fn apply_device_info(target: &mut AudioDeviceInfo, item_name: &str, info: (String, u8, bool, u8)) {
if item_name == target.name { if item_name == target.name {
target.description = info.0; target.description = info.0;
@@ -278,6 +287,7 @@ fn apply_device_info(target: &mut AudioDeviceInfo, item_name: &str, info: (Strin
} }
} }
/// Dispatch `get_sink_info_list` and collect names into `available_sinks`.
fn fetch_sinks(context: &mut Context, state_tx: &watch::Sender<AudioState>) { fn fetch_sinks(context: &mut Context, state_tx: &watch::Sender<AudioState>) {
let tx = state_tx.clone(); let tx = state_tx.clone();
let pending = PendingList::new(); let pending = PendingList::new();
@@ -313,6 +323,8 @@ fn fetch_sinks(context: &mut Context, state_tx: &watch::Sender<AudioState>) {
}); });
} }
/// Dispatch `get_source_info_list` and collect names (skipping `.monitor`
/// virtual sources) into `available_sources`.
fn fetch_sources(context: &mut Context, state_tx: &watch::Sender<AudioState>) { fn fetch_sources(context: &mut Context, state_tx: &watch::Sender<AudioState>) {
let tx = state_tx.clone(); let tx = state_tx.clone();
let pending = PendingList::new(); let pending = PendingList::new();
@@ -348,6 +360,8 @@ fn fetch_sources(context: &mut Context, state_tx: &watch::Sender<AudioState>) {
}); });
} }
/// Renders sink/source + dispatches volume/mute/cycle commands.
/// Args: `[sink|source] [show|up|down|mute|cycle] [step]`.
pub struct AudioModule; pub struct AudioModule;
impl WaybarModule for AudioModule { impl WaybarModule for AudioModule {
@@ -413,7 +427,6 @@ impl AudioModule {
}; };
if name.is_empty() { if name.is_empty() {
// Fallback if daemon hasn't populated state yet
return Ok(WaybarOutput { return Ok(WaybarOutput {
text: "Audio Loading...".to_string(), text: "Audio Loading...".to_string(),
..Default::default() ..Default::default()
+10 -5
View File
@@ -1,3 +1,7 @@
//! Screen backlight indicator, driven by `inotify` on
//! `/sys/class/backlight/*/actual_brightness`. Falls back to a 5 s poll loop
//! to catch any missed events.
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -11,6 +15,7 @@ use std::time::Duration;
use tokio::sync::watch; use tokio::sync::watch;
use tracing::{error, info}; use tracing::{error, info};
/// Renders the brightness percentage with a vendor-agnostic icon bucket.
pub struct BacklightModule; pub struct BacklightModule;
impl WaybarModule for BacklightModule { impl WaybarModule for BacklightModule {
@@ -47,13 +52,16 @@ impl WaybarModule for BacklightModule {
} }
} }
/// Background `inotify` watcher thread for the sysfs backlight file.
pub struct BacklightDaemon; pub struct BacklightDaemon;
impl BacklightDaemon { impl BacklightDaemon {
/// Construct a new (stateless) daemon.
pub fn new() -> Self { pub fn new() -> Self {
Self Self
} }
/// Spawn an OS thread that publishes brightness changes onto `tx`.
pub fn start(&self, tx: watch::Sender<BacklightState>) { pub fn start(&self, tx: watch::Sender<BacklightState>) {
std::thread::spawn(move || { std::thread::spawn(move || {
let base_dir = PathBuf::from("/sys/class/backlight"); let base_dir = PathBuf::from("/sys/class/backlight");
@@ -105,12 +113,10 @@ impl BacklightDaemon {
} }
}; };
// Initial poll
let _ = tx.send(BacklightState { let _ = tx.send(BacklightState {
percentage: get_percentage(), percentage: get_percentage(),
}); });
// Set up notify watcher
let (ev_tx, ev_rx) = mpsc::channel(); let (ev_tx, ev_rx) = mpsc::channel();
let mut watcher = RecommendedWatcher::new( let mut watcher = RecommendedWatcher::new(
move |res: notify::Result<Event>| { move |res: notify::Result<Event>| {
@@ -130,9 +136,8 @@ impl BacklightDaemon {
} }
loop { loop {
// Block until an event occurs or a timeout to catch missed events
if ev_rx.recv_timeout(Duration::from_secs(5)).is_ok() { if ev_rx.recv_timeout(Duration::from_secs(5)).is_ok() {
// Debounce rapid events // Debounce bursts from scroll-driven brightness changes.
std::thread::sleep(Duration::from_millis(50)); std::thread::sleep(Duration::from_millis(50));
while ev_rx.try_recv().is_ok() {} while ev_rx.try_recv().is_ok() {}
@@ -140,7 +145,7 @@ impl BacklightDaemon {
percentage: get_percentage(), percentage: get_percentage(),
}); });
} else { } else {
// Timeout hit, poll just in case // Timeout reached — resync in case an event was missed.
let current = get_percentage(); let current = get_percentage();
if tx.borrow().percentage != current { if tx.borrow().percentage != current {
let _ = tx.send(BacklightState { let _ = tx.send(BacklightState {
+12
View File
@@ -1,3 +1,6 @@
//! Per-device BT plugin trait + PixelBuds Pro implementation on top of the
//! Maestro GATT connection.
use crate::config::Config; use crate::config::Config;
use crate::error::{FluxoError, Result as FluxoResult}; use crate::error::{FluxoError, Result as FluxoResult};
use crate::modules::bt::maestro::BudsCommand; use crate::modules::bt::maestro::BudsCommand;
@@ -5,29 +8,38 @@ use crate::state::AppReceivers;
use crate::utils::TokenValue; use crate::utils::TokenValue;
use futures::future::BoxFuture; use futures::future::BoxFuture;
/// A device-specific adapter that can enrich [`BtState`](crate::state::BtState)
/// with extra metadata and expose control actions (modes).
pub trait BtPlugin: Send + Sync { pub trait BtPlugin: Send + Sync {
/// Plugin identifier used for logging.
fn name(&self) -> &str; fn name(&self) -> &str;
/// Return true if this plugin handles a device with `alias`/`mac`.
fn can_handle(&self, alias: &str, mac: &str) -> bool; fn can_handle(&self, alias: &str, mac: &str) -> bool;
/// Return `(token_name, value)` pairs merged into the rendered template.
fn get_data( fn get_data(
&self, &self,
config: &Config, config: &Config,
state: &AppReceivers, state: &AppReceivers,
mac: &str, mac: &str,
) -> BoxFuture<'static, FluxoResult<Vec<(String, TokenValue)>>>; ) -> BoxFuture<'static, FluxoResult<Vec<(String, TokenValue)>>>;
/// List of mode identifiers the plugin can switch between.
fn get_modes( fn get_modes(
&self, &self,
mac: &str, mac: &str,
state: &AppReceivers, state: &AppReceivers,
) -> BoxFuture<'static, FluxoResult<Vec<String>>>; ) -> BoxFuture<'static, FluxoResult<Vec<String>>>;
/// Switch device to `mode` (must be one returned by `get_modes`).
fn set_mode( fn set_mode(
&self, &self,
mode: &str, mode: &str,
mac: &str, mac: &str,
state: &AppReceivers, state: &AppReceivers,
) -> BoxFuture<'static, FluxoResult<()>>; ) -> BoxFuture<'static, FluxoResult<()>>;
/// Advance to the next mode in the list (wraps around).
fn cycle_mode(&self, mac: &str, state: &AppReceivers) -> BoxFuture<'static, FluxoResult<()>>; fn cycle_mode(&self, mac: &str, state: &AppReceivers) -> BoxFuture<'static, FluxoResult<()>>;
} }
/// Google Pixel Buds Pro plugin. Reads battery + ANC state via Maestro GATT.
pub struct PixelBudsPlugin; pub struct PixelBudsPlugin;
impl BtPlugin for PixelBudsPlugin { impl BtPlugin for PixelBudsPlugin {
+28 -8
View File
@@ -1,3 +1,11 @@
//! Google Maestro (PixelBuds GATT) integration.
//!
//! Each connected device gets its own [`buds_task`] running on a dedicated
//! single-threaded runtime. The task opens an RFCOMM channel, speaks the
//! Maestro protocol to read battery + ANC state, and listens for settings
//! changes. External callers interact via [`MaestroManager::send_command`]
//! and [`MaestroManager::get_status`].
use crate::state::AppReceivers; use crate::state::AppReceivers;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use futures::StreamExt; use futures::StreamExt;
@@ -7,12 +15,12 @@ use std::time::{Duration, Instant};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
// Maestro imports
use maestro::protocol::codec::Codec; use maestro::protocol::codec::Codec;
use maestro::pwrpc::client::Client; use maestro::pwrpc::client::Client;
use maestro::service::MaestroService; use maestro::service::MaestroService;
use maestro::service::settings::{self, SettingValue}; use maestro::service::settings::{self, SettingValue};
/// Cached per-device snapshot returned to BT plugin consumers.
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct BudsStatus { pub struct BudsStatus {
pub left_battery: Option<u8>, pub left_battery: Option<u8>,
@@ -24,28 +32,35 @@ pub struct BudsStatus {
pub error: Option<String>, pub error: Option<String>,
} }
/// Command that can be issued against a connected buds device.
pub enum BudsCommand { pub enum BudsCommand {
/// Set the ANC mode: `active`, `aware`, or `off`.
SetAnc(String), SetAnc(String),
} }
/// Messages sent to the [`MaestroManager`] control thread.
pub enum ManagerCommand { pub enum ManagerCommand {
/// Ensure a [`buds_task`] is running for `mac`; spawn if absent.
EnsureTask(String), EnsureTask(String),
/// Forward a [`BudsCommand`] to the task for `mac`.
SendCommand(String, BudsCommand), SendCommand(String, BudsCommand),
} }
/// Owns all buds-task lifetimes and a shared status cache.
pub struct MaestroManager { pub struct MaestroManager {
statuses: Arc<Mutex<HashMap<String, BudsStatus>>>, statuses: Arc<Mutex<HashMap<String, BudsStatus>>>,
management_tx: mpsc::UnboundedSender<ManagerCommand>, management_tx: mpsc::UnboundedSender<ManagerCommand>,
} }
impl MaestroManager { impl MaestroManager {
/// Spawn the management thread + runtime and return a handle.
pub fn new(state: AppReceivers) -> Self { pub fn new(state: AppReceivers) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel::<ManagerCommand>(); let (tx, mut rx) = mpsc::unbounded_channel::<ManagerCommand>();
let statuses = Arc::new(Mutex::new(HashMap::new())); let statuses = Arc::new(Mutex::new(HashMap::new()));
let statuses_clone = Arc::clone(&statuses); let statuses_clone = Arc::clone(&statuses);
let state_clone = state.clone(); let state_clone = state.clone();
// Start dedicated BT management thread // Dedicated thread — bluer uses per-thread local tasks.
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
@@ -85,7 +100,7 @@ impl MaestroManager {
} }
} }
_ = tokio::time::sleep(Duration::from_millis(100)) => { _ = tokio::time::sleep(Duration::from_millis(100)) => {
// Cleanup dropped tasks if needed // Wake tick: future hook for task-lifecycle cleanup.
} }
} }
} }
@@ -98,17 +113,20 @@ impl MaestroManager {
} }
} }
/// Return the cached [`BudsStatus`] for `mac` (default if absent).
pub fn get_status(&self, mac: &str) -> BudsStatus { pub fn get_status(&self, mac: &str) -> BudsStatus {
let statuses = self.statuses.lock().unwrap(); let statuses = self.statuses.lock().unwrap();
statuses.get(mac).cloned().unwrap_or_default() statuses.get(mac).cloned().unwrap_or_default()
} }
/// Request that a buds task be running for `mac`. Idempotent.
pub fn ensure_task(&self, mac: &str) { pub fn ensure_task(&self, mac: &str) {
let _ = self let _ = self
.management_tx .management_tx
.send(ManagerCommand::EnsureTask(mac.to_string())); .send(ManagerCommand::EnsureTask(mac.to_string()));
} }
/// Ensure a task exists and forward `cmd` to it.
pub fn send_command(&self, mac: &str, cmd: BudsCommand) -> Result<()> { pub fn send_command(&self, mac: &str, cmd: BudsCommand) -> Result<()> {
self.ensure_task(mac); self.ensure_task(mac);
let _ = self let _ = self
@@ -118,6 +136,8 @@ impl MaestroManager {
} }
} }
/// Per-device async task: opens RFCOMM, runs the Maestro codec, mirrors
/// battery/ANC state into the shared status map, and consumes commands.
async fn buds_task( async fn buds_task(
mac: &str, mac: &str,
statuses: Arc<Mutex<HashMap<String, BudsStatus>>>, statuses: Arc<Mutex<HashMap<String, BudsStatus>>>,
@@ -150,7 +170,7 @@ async fn buds_task(
break; break;
} }
// Connect to Maestro RFCOMM service // Maestro historically listens on channel 1 or 2 — probe both.
let mut stream = None; let mut stream = None;
for channel in [1, 2] { for channel in [1, 2] {
let socket = match bluer::rfcomm::Socket::new() { let socket = match bluer::rfcomm::Socket::new() {
@@ -190,13 +210,11 @@ async fn buds_task(
info!("Connected Maestro RFCOMM to {} on channel", mac); info!("Connected Maestro RFCOMM to {} on channel", mac);
// Initialize Maestro communication stack
let codec = Codec::new(); let codec = Codec::new();
let stream = codec.wrap(stream); let stream = codec.wrap(stream);
let mut client = Client::new(stream); let mut client = Client::new(stream);
let handle = client.handle(); let handle = client.handle();
// Resolve Maestro channel
let channel = match maestro::protocol::utils::resolve_channel(&mut client).await { let channel = match maestro::protocol::utils::resolve_channel(&mut client).await {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@@ -213,7 +231,7 @@ async fn buds_task(
let mut service = MaestroService::new(handle, channel); let mut service = MaestroService::new(handle, channel);
// Update health // Successful connect — clear health backoff for bt.buds.
{ {
let mut lock = state.health.write().await; let mut lock = state.health.write().await;
let health = lock.entry("bt.buds".to_string()).or_default(); let health = lock.entry("bt.buds".to_string()).or_default();
@@ -221,7 +239,6 @@ async fn buds_task(
health.backoff_until = None; health.backoff_until = None;
} }
// Query initial ANC state
if let Ok(val) = service if let Ok(val) = service
.read_setting_var(settings::SettingId::CurrentAncrState) .read_setting_var(settings::SettingId::CurrentAncrState)
.await .await
@@ -337,6 +354,7 @@ async fn buds_task(
Ok(()) Ok(())
} }
/// String ("active"/"aware"/"off") → Maestro enum; unknown falls back to `Off`.
fn mode_to_anc_state(mode: &str) -> settings::AncState { fn mode_to_anc_state(mode: &str) -> settings::AncState {
match mode { match mode {
"active" => settings::AncState::Active, "active" => settings::AncState::Active,
@@ -346,6 +364,7 @@ fn mode_to_anc_state(mode: &str) -> settings::AncState {
} }
} }
/// Inverse of [`mode_to_anc_state`] for status readout.
pub fn anc_state_to_string(state: &settings::AncState) -> String { pub fn anc_state_to_string(state: &settings::AncState) -> String {
match state { match state {
settings::AncState::Active => "active".to_string(), settings::AncState::Active => "active".to_string(),
@@ -357,6 +376,7 @@ pub fn anc_state_to_string(state: &settings::AncState) -> String {
static MAESTRO: OnceLock<MaestroManager> = OnceLock::new(); static MAESTRO: OnceLock<MaestroManager> = OnceLock::new();
/// Lazily initialise the process-wide [`MaestroManager`] and return a reference.
pub fn get_maestro(state: &AppReceivers) -> &MaestroManager { pub fn get_maestro(state: &AppReceivers) -> &MaestroManager {
MAESTRO.get_or_init(|| MaestroManager::new(state.clone())) MAESTRO.get_or_init(|| MaestroManager::new(state.clone()))
} }
+15 -3
View File
@@ -1,3 +1,11 @@
//! Bluetooth indicator + control (BlueZ via `bluer`).
//!
//! Core loop: filter paired+connected audio-sink devices, enrich them via
//! per-device [`BtPlugin`]s (currently PixelBuds via the Maestro GATT
//! protocol), and publish the result as [`BtState`]. The module handler
//! exposes `connect`, `disconnect`, `cycle`, `menu_data`, `get_modes`,
//! `set_mode`, `cycle_mode` actions for the Waybar menu.
pub mod buds; pub mod buds;
pub mod maestro; pub mod maestro;
@@ -14,15 +22,18 @@ use tracing::{error, warn};
use self::buds::{BtPlugin, PixelBudsPlugin}; use self::buds::{BtPlugin, PixelBudsPlugin};
/// Background poller that syncs connected BlueZ devices into [`BtState`].
pub struct BtDaemon { pub struct BtDaemon {
session: Option<bluer::Session>, session: Option<bluer::Session>,
} }
impl BtDaemon { impl BtDaemon {
/// Construct a new daemon. The BlueZ session is lazily created on first poll.
pub fn new() -> Self { pub fn new() -> Self {
Self { session: None } Self { session: None }
} }
/// Poll wrapper that logs + swallows errors so the outer loop keeps running.
pub async fn poll( pub async fn poll(
&mut self, &mut self,
tx: &watch::Sender<BtState>, tx: &watch::Sender<BtState>,
@@ -113,6 +124,8 @@ impl BtDaemon {
static PLUGINS: LazyLock<Vec<Box<dyn BtPlugin>>> = static PLUGINS: LazyLock<Vec<Box<dyn BtPlugin>>> =
LazyLock::new(|| vec![Box::new(PixelBudsPlugin)]); LazyLock::new(|| vec![Box::new(PixelBudsPlugin)]);
/// After a user-initiated connect/disconnect, schedule a staircase of
/// forced polls so the UI catches up even if BlueZ is slow to settle.
fn trigger_robust_poll(state: AppReceivers) { fn trigger_robust_poll(state: AppReceivers) {
tokio::spawn(async move { tokio::spawn(async move {
for delay in [200, 500, 1000, 2000, 3000] { for delay in [200, 500, 1000, 2000, 3000] {
@@ -142,6 +155,7 @@ fn find_device<'a>(bt_state: &'a BtState, mac: &str) -> Option<&'a BtDeviceInfo>
bt_state.devices.iter().find(|d| d.device_address == mac) bt_state.devices.iter().find(|d| d.device_address == mac)
} }
/// Renders the current BT status + handles control actions.
pub struct BtModule; pub struct BtModule;
impl WaybarModule for BtModule { impl WaybarModule for BtModule {
@@ -194,7 +208,6 @@ impl WaybarModule for BtModule {
"menu_data" => { "menu_data" => {
let mut lines = Vec::new(); let mut lines = Vec::new();
// Connected devices
for dev in &bt_state.devices { for dev in &bt_state.devices {
lines.push(format!( lines.push(format!(
"CONNECTED:{}|{}", "CONNECTED:{}|{}",
@@ -202,7 +215,7 @@ impl WaybarModule for BtModule {
)); ));
} }
// Paired-but-not-connected devices // Also surface paired-but-not-connected devices for the menu.
if let Ok(session) = bluer::Session::new().await if let Ok(session) = bluer::Session::new().await
&& let Ok(adapter) = session.default_adapter().await && let Ok(adapter) = session.default_adapter().await
&& let Ok(addresses) = adapter.device_addresses().await && let Ok(addresses) = adapter.device_addresses().await
@@ -286,7 +299,6 @@ impl WaybarModule for BtModule {
_ => {} _ => {}
} }
// "show" and fallthrough
if !bt_state.adapter_powered { if !bt_state.adapter_powered {
return Ok(WaybarOutput { return Ok(WaybarOutput {
text: config.bt.format_disabled.clone(), text: config.bt.format_disabled.clone(),
+5
View File
@@ -1,3 +1,6 @@
//! Btrfs pool renderer: sums usage across all btrfs-typed mounts seen in the
//! `disks` watch channel. Dispatch-only (no dedicated poll task).
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -5,6 +8,8 @@ use crate::output::WaybarOutput;
use crate::state::AppReceivers; use crate::state::AppReceivers;
use crate::utils::{TokenValue, classify_usage, format_template}; use crate::utils::{TokenValue, classify_usage, format_template};
/// Aggregates used/total across every mount whose filesystem name contains
/// `btrfs`. Emits `No BTRFS` when none are present.
pub struct BtrfsModule; pub struct BtrfsModule;
impl WaybarModule for BtrfsModule { impl WaybarModule for BtrfsModule {
+3
View File
@@ -1,3 +1,5 @@
//! CPU usage + temperature renderer. Reads from the `cpu` watch channel.
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -5,6 +7,7 @@ use crate::output::WaybarOutput;
use crate::state::AppReceivers; use crate::state::AppReceivers;
use crate::utils::{TokenValue, classify_usage, format_template}; use crate::utils::{TokenValue, classify_usage, format_template};
/// Renders CPU usage/temp using [`CpuConfig::format`](crate::config::CpuConfig).
pub struct CpuModule; pub struct CpuModule;
impl WaybarModule for CpuModule { impl WaybarModule for CpuModule {
+4
View File
@@ -1,3 +1,5 @@
//! Filesystem usage renderer. Args: `[mountpoint]` (default `/`).
use crate::config::Config; use crate::config::Config;
use crate::error::{FluxoError, Result}; use crate::error::{FluxoError, Result};
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -5,6 +7,8 @@ use crate::output::WaybarOutput;
use crate::state::AppReceivers; use crate::state::AppReceivers;
use crate::utils::{TokenValue, classify_usage, format_template}; use crate::utils::{TokenValue, classify_usage, format_template};
/// Renders used/total for a given mount point. Returns [`FluxoError::Module`]
/// if the mount point isn't present in the current disk snapshot.
pub struct DiskModule; pub struct DiskModule;
impl WaybarModule for DiskModule { impl WaybarModule for DiskModule {
+13 -4
View File
@@ -1,3 +1,9 @@
//! Do-Not-Disturb toggle + status for SwayNC or Dunst.
//!
//! SwayNC exposes a `dnd` property on its `org.erikreider.swaync.control`
//! interface that fires PropertiesChanged signals, so we subscribe. Dunst has
//! no change signal for its `paused` property, so we fall back to a 2 s poll.
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -11,6 +17,7 @@ use zbus::proxy;
use zbus::zvariant::OwnedValue; use zbus::zvariant::OwnedValue;
use zbus::{Connection, fdo::PropertiesProxy}; use zbus::{Connection, fdo::PropertiesProxy};
/// Renders + toggles DND state. Args: `["show"]` (default) or `["toggle"]`.
pub struct DndModule; pub struct DndModule;
/// Read dunst's `paused` property via raw D-Bus call. /// Read dunst's `paused` property via raw D-Bus call.
@@ -61,7 +68,6 @@ impl WaybarModule for DndModule {
message: format!("DBus connection failed: {}", e), message: format!("DBus connection failed: {}", e),
})?; })?;
// Try SwayNC
if let Ok(proxy) = SwayncControlProxy::new(&connection).await if let Ok(proxy) = SwayncControlProxy::new(&connection).await
&& let Ok(is_dnd) = proxy.dnd().await && let Ok(is_dnd) = proxy.dnd().await
{ {
@@ -69,7 +75,6 @@ impl WaybarModule for DndModule {
return Ok(WaybarOutput::default()); return Ok(WaybarOutput::default());
} }
// Try Dunst via raw D-Bus
if let Ok(is_paused) = dunst_get_paused(&connection).await { if let Ok(is_paused) = dunst_get_paused(&connection).await {
let _ = dunst_set_paused(&connection, !is_paused).await; let _ = dunst_set_paused(&connection, !is_paused).await;
return Ok(WaybarOutput::default()); return Ok(WaybarOutput::default());
@@ -101,6 +106,8 @@ impl WaybarModule for DndModule {
} }
} }
/// Background watcher that keeps [`DndState`] in sync with the active
/// notification daemon (SwayNC via signals, Dunst via polling).
pub struct DndDaemon; pub struct DndDaemon;
#[proxy( #[proxy(
@@ -116,10 +123,12 @@ trait SwayncControl {
} }
impl DndDaemon { impl DndDaemon {
/// Construct a new (stateless) daemon.
pub fn new() -> Self { pub fn new() -> Self {
Self Self
} }
/// Spawn a supervised listen loop that reconnects with a 5 s backoff.
pub fn start(&self, tx: watch::Sender<DndState>) { pub fn start(&self, tx: watch::Sender<DndState>) {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
@@ -136,7 +145,6 @@ impl DndDaemon {
info!("Connected to D-Bus for DND monitoring"); info!("Connected to D-Bus for DND monitoring");
// Try SwayNC first (signal-based)
if let Ok(proxy) = SwayncControlProxy::new(&connection).await if let Ok(proxy) = SwayncControlProxy::new(&connection).await
&& let Ok(is_dnd) = proxy.dnd().await && let Ok(is_dnd) = proxy.dnd().await
{ {
@@ -164,7 +172,8 @@ impl DndDaemon {
return Err(anyhow::anyhow!("SwayNC DND stream ended")); return Err(anyhow::anyhow!("SwayNC DND stream ended"));
} }
// Try Dunst via raw D-Bus calls (bypasses zbus proxy issues) // Dunst: raw D-Bus call avoids zbus proxy typing quirks with its
// non-standard `org.dunstproject.cmd0` interface.
match dunst_get_paused(&connection).await { match dunst_get_paused(&connection).await {
Ok(is_paused) => { Ok(is_paused) => {
info!("Found Dunst, using polling-based DND monitoring"); info!("Found Dunst, using polling-based DND monitoring");
+5
View File
@@ -1,3 +1,6 @@
//! Gamemode indicator. Queries Hyprland's animation setting over its IPC
//! socket; animations disabled => gamemode active. Dispatch-only.
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -6,6 +9,7 @@ use crate::state::AppReceivers;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream; use tokio::net::UnixStream;
/// Renders a glyph depending on whether Hyprland animations are disabled.
pub struct GameModule; pub struct GameModule;
impl WaybarModule for GameModule { impl WaybarModule for GameModule {
@@ -38,6 +42,7 @@ impl WaybarModule for GameModule {
} }
} }
/// Send `cmd` to Hyprland's `.socket.sock` and return the response body.
async fn hyprland_ipc(cmd: &str) -> Result<String> { async fn hyprland_ipc(cmd: &str) -> Result<String> {
let path = crate::utils::get_hyprland_socket(".socket.sock")?; let path = crate::utils::get_hyprland_socket(".socket.sock")?;
+4
View File
@@ -1,3 +1,6 @@
//! GPU renderer. Picks a vendor-specific format string (AMD/Intel/NVIDIA) and
//! reads from the `gpu` watch channel.
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -5,6 +8,7 @@ use crate::output::WaybarOutput;
use crate::state::AppReceivers; use crate::state::AppReceivers;
use crate::utils::{TokenValue, classify_usage, format_template}; use crate::utils::{TokenValue, classify_usage, format_template};
/// Renders GPU usage / VRAM / temp using the per-vendor format from config.
pub struct GpuModule; pub struct GpuModule;
impl WaybarModule for GpuModule { impl WaybarModule for GpuModule {
+22 -5
View File
@@ -1,7 +1,16 @@
//! Unified CPU/memory/sys/GPU/disk poller.
//!
//! CPU/memory/sys are sampled every fast tick (1 s). GPU polls every 5th fast
//! tick via [`poll_slow`], and disks every 10th (they rarely change). GPU
//! vendor is detected once by probing nvidia-smi / `/sys/class/drm/*`, then
//! cached so subsequent polls take the fast path.
use crate::state::{CpuState, DiskInfo, GpuState, MemoryState, SysState}; use crate::state::{CpuState, DiskInfo, GpuState, MemoryState, SysState};
use sysinfo::{Components, Disks, System}; use sysinfo::{Components, Disks, System};
use tokio::sync::watch; use tokio::sync::watch;
/// Long-lived hardware sampler. Holds the `sysinfo::System` handle so
/// successive refreshes can diff against prior samples.
pub struct HardwareDaemon { pub struct HardwareDaemon {
sys: System, sys: System,
components: Components, components: Components,
@@ -11,6 +20,7 @@ pub struct HardwareDaemon {
} }
impl HardwareDaemon { impl HardwareDaemon {
/// Build a new daemon with an initial `sysinfo` snapshot.
pub fn new() -> Self { pub fn new() -> Self {
let mut sys = System::new(); let mut sys = System::new();
sys.refresh_cpu_usage(); sys.refresh_cpu_usage();
@@ -21,10 +31,13 @@ impl HardwareDaemon {
components, components,
gpu_vendor: None, gpu_vendor: None,
gpu_poll_counter: 0, gpu_poll_counter: 0,
disk_poll_counter: 9, // Start at 9 to poll on the first tick // Start at 9 so (counter + 1) % 10 == 0 on the first tick.
disk_poll_counter: 9,
} }
} }
/// Fast path: refresh CPU usage, memory, temperatures, load avg, uptime.
/// Called every daemon tick.
pub async fn poll_fast( pub async fn poll_fast(
&mut self, &mut self,
cpu_tx: &watch::Sender<CpuState>, cpu_tx: &watch::Sender<CpuState>,
@@ -96,12 +109,13 @@ impl HardwareDaemon {
let _ = sys_tx.send(sys); let _ = sys_tx.send(sys);
} }
/// Slow path: GPU every 5 ticks, disks every 10 ticks. Each sub-poll
/// runs off the hot loop before any state is published.
pub async fn poll_slow( pub async fn poll_slow(
&mut self, &mut self,
gpu_tx: &watch::Sender<GpuState>, gpu_tx: &watch::Sender<GpuState>,
disks_tx: &watch::Sender<Vec<DiskInfo>>, disks_tx: &watch::Sender<Vec<DiskInfo>>,
) { ) {
// 1. Gather GPU data outside of lock
let mut gpu_state = crate::state::GpuState::default(); let mut gpu_state = crate::state::GpuState::default();
self.gpu_poll_counter = (self.gpu_poll_counter + 1) % 5; self.gpu_poll_counter = (self.gpu_poll_counter + 1) % 5;
let should_poll_gpu = self.gpu_poll_counter == 0; let should_poll_gpu = self.gpu_poll_counter == 0;
@@ -109,7 +123,6 @@ impl HardwareDaemon {
self.poll_gpu(&mut gpu_state).await; self.poll_gpu(&mut gpu_state).await;
} }
// 2. Gather Disk data outside of lock
let mut disks_data = None; let mut disks_data = None;
self.disk_poll_counter = (self.disk_poll_counter + 1) % 10; self.disk_poll_counter = (self.disk_poll_counter + 1) % 10;
if self.disk_poll_counter == 0 { if self.disk_poll_counter == 0 {
@@ -130,7 +143,6 @@ impl HardwareDaemon {
); );
} }
// 3. Apply to state
if should_poll_gpu { if should_poll_gpu {
let _ = gpu_tx.send(gpu_state); let _ = gpu_tx.send(gpu_state);
} }
@@ -140,6 +152,7 @@ impl HardwareDaemon {
} }
} }
/// Dispatch to the cached vendor's probe, or run detection on first call.
async fn poll_gpu(&mut self, gpu: &mut crate::state::GpuState) { async fn poll_gpu(&mut self, gpu: &mut crate::state::GpuState) {
gpu.active = false; gpu.active = false;
@@ -154,7 +167,7 @@ impl HardwareDaemon {
Self::poll_intel(gpu); Self::poll_intel(gpu);
} }
_ => { _ => {
// Detection pass: try each vendor, cache the first that responds. // First run — probe each vendor and cache the first hit.
Self::poll_nvidia(gpu).await; Self::poll_nvidia(gpu).await;
if gpu.active { if gpu.active {
self.gpu_vendor = Some("NVIDIA".to_string()); self.gpu_vendor = Some("NVIDIA".to_string());
@@ -173,6 +186,7 @@ impl HardwareDaemon {
} }
} }
/// Shell out to `nvidia-smi --query-gpu=...` for utilization/VRAM/temp.
async fn poll_nvidia(gpu: &mut crate::state::GpuState) { async fn poll_nvidia(gpu: &mut crate::state::GpuState) {
let Ok(output) = tokio::process::Command::new("nvidia-smi") let Ok(output) = tokio::process::Command::new("nvidia-smi")
.args([ .args([
@@ -202,6 +216,7 @@ impl HardwareDaemon {
} }
} }
/// Read amdgpu sysfs entries under `/sys/class/drm/card*/device`.
fn poll_amd(gpu: &mut crate::state::GpuState) { fn poll_amd(gpu: &mut crate::state::GpuState) {
for i in 0..=3 { for i in 0..=3 {
let base = format!("/sys/class/drm/card{}/device", i); let base = format!("/sys/class/drm/card{}/device", i);
@@ -238,6 +253,8 @@ impl HardwareDaemon {
} }
} }
/// Read i915/xe sysfs `gt_cur_freq_mhz`; approximate "usage" as
/// current/max frequency since Intel has no direct utilization counter.
fn poll_intel(gpu: &mut crate::state::GpuState) { fn poll_intel(gpu: &mut crate::state::GpuState) {
for i in 0..=3 { for i in 0..=3 {
let base = format!("/sys/class/drm/card{}/device", i); let base = format!("/sys/class/drm/card{}/device", i);
+12 -4
View File
@@ -1,3 +1,7 @@
//! Keyboard layout indicator backed by Hyprland's event socket
//! (`.socket2.sock`). Also seeds the initial layout by shelling out to
//! `hyprctl devices -j` once at startup.
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -10,6 +14,7 @@ use tokio::net::UnixStream;
use tokio::sync::watch; use tokio::sync::watch;
use tracing::{error, info}; use tracing::{error, info};
/// Renders the current keyboard layout from [`KeyboardState`].
pub struct KeyboardModule; pub struct KeyboardModule;
impl WaybarModule for KeyboardModule { impl WaybarModule for KeyboardModule {
@@ -44,19 +49,22 @@ impl WaybarModule for KeyboardModule {
} }
} }
/// Background watcher that subscribes to `activelayout>>` events emitted by
/// Hyprland's event socket.
pub struct KeyboardDaemon; pub struct KeyboardDaemon;
impl KeyboardDaemon { impl KeyboardDaemon {
/// Construct a new (stateless) daemon.
pub fn new() -> Self { pub fn new() -> Self {
Self Self
} }
/// Spawn a supervised listen loop that reconnects with a 5 s backoff.
pub fn start(&self, tx: watch::Sender<KeyboardState>) { pub fn start(&self, tx: watch::Sender<KeyboardState>) {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
if let Err(e) = Self::listen_loop(&tx).await { if let Err(e) = Self::listen_loop(&tx).await {
error!("Keyboard layout listener error: {}", e); 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; tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
} }
} }
@@ -71,7 +79,6 @@ impl KeyboardDaemon {
let reader = BufReader::new(stream); let reader = BufReader::new(stream);
let mut lines = reader.lines(); let mut lines = reader.lines();
// Fetch initial layout natively via hyprctl
if let Ok(output) = tokio::process::Command::new("hyprctl") if let Ok(output) = tokio::process::Command::new("hyprctl")
.args(["devices", "-j"]) .args(["devices", "-j"])
.output() .output()
@@ -80,7 +87,8 @@ impl KeyboardDaemon {
&& let Some(keyboards) = json.get("keyboards").and_then(|v| v.as_array()) && let Some(keyboards) = json.get("keyboards").and_then(|v| v.as_array())
&& let Some(main_kb) = keyboards.last() && let Some(main_kb) = keyboards.last()
{ {
// The last active one is usually the main one // `keyboards.last()` is the most recently registered device,
// which is typically the main one for single-keyboard setups.
if let Some(layout) = main_kb.get("active_keymap").and_then(|v| v.as_str()) { if let Some(layout) = main_kb.get("active_keymap").and_then(|v| v.as_str()) {
let _ = tx.send(KeyboardState { let _ = tx.send(KeyboardState {
layout: layout.to_string(), layout: layout.to_string(),
@@ -89,8 +97,8 @@ impl KeyboardDaemon {
} }
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
// Event payload: `keyboard_name,layout_name`.
if let Some(payload) = line.strip_prefix("activelayout>>") { if let Some(payload) = line.strip_prefix("activelayout>>") {
// payload format: keyboard_name,layout_name
let parts: Vec<&str> = payload.splitn(2, ',').collect(); let parts: Vec<&str> = payload.splitn(2, ',').collect();
if parts.len() == 2 { if parts.len() == 2 {
let layout = parts[1].to_string(); let layout = parts[1].to_string();
+3
View File
@@ -1,3 +1,5 @@
//! RAM usage renderer. Reads from the `memory` watch channel.
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -5,6 +7,7 @@ use crate::output::WaybarOutput;
use crate::state::AppReceivers; use crate::state::AppReceivers;
use crate::utils::{TokenValue, classify_usage, format_template}; use crate::utils::{TokenValue, classify_usage, format_template};
/// Renders used/total GB with usage classification for Waybar CSS.
pub struct MemoryModule; pub struct MemoryModule;
impl WaybarModule for MemoryModule { impl WaybarModule for MemoryModule {
+26 -5
View File
@@ -1,3 +1,11 @@
//! MPRIS media player indicator.
//!
//! Subscribes to `PlaybackStatus` and `Metadata` property-changed streams on
//! the first `org.mpris.MediaPlayer2.*` name that appears on the session bus,
//! so the indicator is truly signal-driven (no 2 s polling). A 10 s heartbeat
//! verifies the player is still there. Optional marquee scrolling is driven
//! by [`mpris_scroll_ticker`] from [`crate::daemon`].
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -10,6 +18,7 @@ use tokio::time::Duration;
use tracing::{debug, info}; use tracing::{debug, info};
use zbus::{Connection, proxy}; use zbus::{Connection, proxy};
/// Render the user's format string + derive the Waybar CSS class from state.
fn format_mpris_text(format: &str, mpris: &MprisState) -> (String, &'static str) { fn format_mpris_text(format: &str, mpris: &MprisState) -> (String, &'static str) {
let status_icon = if mpris.is_playing { let status_icon = if mpris.is_playing {
"󰏤" "󰏤"
@@ -40,6 +49,7 @@ fn format_mpris_text(format: &str, mpris: &MprisState) -> (String, &'static str)
(text, class) (text, class)
} }
/// Return a cyclic `max_len`-wide window over `full_text + separator`.
fn apply_scroll_window(full_text: &str, max_len: usize, offset: usize, separator: &str) -> String { fn apply_scroll_window(full_text: &str, max_len: usize, offset: usize, separator: &str) -> String {
let char_count = full_text.chars().count(); let char_count = full_text.chars().count();
let total_len = char_count + separator.chars().count(); let total_len = char_count + separator.chars().count();
@@ -53,6 +63,7 @@ fn apply_scroll_window(full_text: &str, max_len: usize, offset: usize, separator
.collect() .collect()
} }
/// Truncate `text` to `max_len` chars, appending `...` when cut.
fn truncate_with_ellipsis(text: &str, max_len: usize) -> String { fn truncate_with_ellipsis(text: &str, max_len: usize) -> String {
let char_count = text.chars().count(); let char_count = text.chars().count();
if char_count <= max_len { if char_count <= max_len {
@@ -62,6 +73,7 @@ fn truncate_with_ellipsis(text: &str, max_len: usize) -> String {
format!("{}...", truncated) format!("{}...", truncated)
} }
/// Renders the current player state, applying scroll/truncate per config.
pub struct MprisModule; pub struct MprisModule;
impl WaybarModule for MprisModule { impl WaybarModule for MprisModule {
@@ -111,6 +123,9 @@ impl WaybarModule for MprisModule {
} }
} }
/// Drive the marquee animation: advance the offset every `scroll_speed` ms
/// while a track is playing, and emit a fresh generation on `tick_tx` so the
/// mpris signaler arm fires. Resets offset when the track changes.
pub async fn mpris_scroll_ticker( pub async fn mpris_scroll_ticker(
config: Arc<RwLock<Config>>, config: Arc<RwLock<Config>>,
mut mpris_rx: watch::Receiver<MprisState>, mut mpris_rx: watch::Receiver<MprisState>,
@@ -152,13 +167,15 @@ pub async fn mpris_scroll_ticker(
continue; continue;
} }
// Not scrolling — wait for next state change // Not scrolling — sleep until the next player state change.
if mpris_rx.changed().await.is_err() { if mpris_rx.changed().await.is_err() {
break; break;
} }
} }
} }
/// Background watcher that discovers the active MPRIS player and mirrors
/// its `PlaybackStatus` + `Metadata` properties into [`MprisState`].
pub struct MprisDaemon; pub struct MprisDaemon;
#[proxy( #[proxy(
@@ -185,10 +202,12 @@ trait MprisPlayer {
} }
impl MprisDaemon { impl MprisDaemon {
/// Construct a new (stateless) daemon.
pub fn new() -> Self { pub fn new() -> Self {
Self Self
} }
/// Spawn a supervised listen loop with a 5 s reconnect backoff.
pub fn start(&self, tx: watch::Sender<MprisState>) { pub fn start(&self, tx: watch::Sender<MprisState>) {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
@@ -209,7 +228,6 @@ impl MprisDaemon {
let dbus_proxy = DBusProxy::new(&connection).await?; let dbus_proxy = DBusProxy::new(&connection).await?;
loop { loop {
// Discovery pass: find an active MPRIS player.
let names = dbus_proxy.list_names().await?; let names = dbus_proxy.list_names().await?;
let active_player = names let active_player = names
.into_iter() .into_iter()
@@ -217,7 +235,6 @@ impl MprisDaemon {
let Some(player_name) = active_player else { let Some(player_name) = active_player else {
send_stopped_if_changed(tx); send_stopped_if_changed(tx);
// No player — wait and re-discover.
tokio::time::sleep(Duration::from_secs(5)).await; tokio::time::sleep(Duration::from_secs(5)).await;
continue; continue;
}; };
@@ -234,7 +251,6 @@ impl MprisDaemon {
} }
}; };
// Initial fetch and then signal-driven updates via PropertiesChanged.
update_from_player(&player_proxy, tx).await; update_from_player(&player_proxy, tx).await;
let mut status_stream = player_proxy.receive_playback_status_changed().await; let mut status_stream = player_proxy.receive_playback_status_changed().await;
@@ -249,7 +265,7 @@ impl MprisDaemon {
update_from_player(&player_proxy, tx).await; update_from_player(&player_proxy, tx).await;
} }
_ = tokio::time::sleep(Duration::from_secs(10)) => { _ = tokio::time::sleep(Duration::from_secs(10)) => {
// Heartbeat: verify the player is still on the bus. // Heartbeat: re-check that the player name is still owned.
let current = dbus_proxy.list_names().await.unwrap_or_default(); let current = dbus_proxy.list_names().await.unwrap_or_default();
if !current.iter().any(|n| n == &player_name) { if !current.iter().any(|n| n == &player_name) {
break; break;
@@ -261,6 +277,8 @@ impl MprisDaemon {
} }
} }
/// Fetch `PlaybackStatus` + `Metadata` and publish only when they differ
/// from the previous [`MprisState`] (to avoid spurious watch wake-ups).
async fn update_from_player(player: &MprisPlayerProxy<'_>, tx: &watch::Sender<MprisState>) { async fn update_from_player(player: &MprisPlayerProxy<'_>, tx: &watch::Sender<MprisState>) {
let status = player.playback_status().await.unwrap_or_default(); let status = player.playback_status().await.unwrap_or_default();
let metadata = player.metadata().await.unwrap_or_default(); let metadata = player.metadata().await.unwrap_or_default();
@@ -291,6 +309,7 @@ async fn update_from_player(player: &MprisPlayerProxy<'_>, tx: &watch::Sender<Mp
} }
} }
/// Extract `xesam:artist` (string or array), `xesam:title`, `xesam:album`.
fn parse_metadata( fn parse_metadata(
metadata: &std::collections::HashMap<String, zbus::zvariant::Value<'_>>, metadata: &std::collections::HashMap<String, zbus::zvariant::Value<'_>>,
) -> (String, String, String) { ) -> (String, String, String) {
@@ -325,6 +344,8 @@ fn parse_metadata(
(artist, title, album) (artist, title, album)
} }
/// Publish a cleared/stopped [`MprisState`] if the current state isn't already
/// that. Called when no player is on the bus.
fn send_stopped_if_changed(tx: &watch::Sender<MprisState>) { fn send_stopped_if_changed(tx: &watch::Sender<MprisState>) {
let current = tx.borrow(); let current = tx.borrow();
if !current.is_stopped || !current.title.is_empty() { if !current.is_stopped || !current.title.is_empty() {
+18 -5
View File
@@ -1,3 +1,10 @@
//! Primary-interface throughput renderer + polling daemon.
//!
//! The daemon picks the interface with the longest-prefix default route (see
//! [`get_primary_interface`]) and computes rx/tx rates as byte-count deltas
//! between successive polls. Well-known VPN interface prefixes get a lock
//! glyph prepended to the rendered text.
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -9,8 +16,10 @@ use std::fs;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::watch; use tokio::sync::watch;
/// Renders interface / IP / rx / tx for the detected primary route.
pub struct NetworkModule; pub struct NetworkModule;
/// Background poller that tracks byte counters across ticks to derive rates.
pub struct NetworkDaemon { pub struct NetworkDaemon {
last_time: u64, last_time: u64,
last_rx_bytes: u64, last_rx_bytes: u64,
@@ -22,6 +31,7 @@ pub struct NetworkDaemon {
type PollResult = crate::error::Result<(String, Option<String>, Option<(u64, u64)>)>; type PollResult = crate::error::Result<(String, Option<String>, Option<(u64, u64)>)>;
impl NetworkDaemon { impl NetworkDaemon {
/// Build a fresh daemon with no prior byte-count samples.
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
last_time: 0, last_time: 0,
@@ -32,6 +42,9 @@ impl NetworkDaemon {
} }
} }
/// Detect the primary interface, read `/sys/class/net/*/statistics`, and
/// publish a new [`NetworkState`] onto `state_tx`. Interface/byte reads
/// run via [`tokio::task::spawn_blocking`] so the runtime isn't starved.
pub async fn poll( pub async fn poll(
&mut self, &mut self,
state_tx: &watch::Sender<NetworkState>, state_tx: &watch::Sender<NetworkState>,
@@ -56,7 +69,6 @@ impl NetworkDaemon {
} else { } else {
self.cached_interface = None; self.cached_interface = None;
self.cached_ip = None; self.cached_ip = None;
// Provide a default state for "No connection"
let mut network = state_tx.borrow().clone(); let mut network = state_tx.borrow().clone();
network.interface.clear(); network.interface.clear();
network.ip.clear(); network.ip.clear();
@@ -71,7 +83,6 @@ impl NetworkDaemon {
let interface = if let Some(ref interface) = self.cached_interface { let interface = if let Some(ref interface) = self.cached_interface {
interface.clone() interface.clone()
} else { } else {
// No interface detected
let mut network = state_tx.borrow().clone(); let mut network = state_tx.borrow().clone();
network.interface.clear(); network.interface.clear();
network.ip.clear(); network.ip.clear();
@@ -107,7 +118,7 @@ impl NetworkDaemon {
network.ip = self.cached_ip.clone().unwrap_or_default(); network.ip = self.cached_ip.clone().unwrap_or_default();
let _ = state_tx.send(network); let _ = state_tx.send(network);
} else { } else {
// First poll: no speed data yet, but update interface/ip // First poll has no prior sample — publish iface/ip only.
let mut network = state_tx.borrow().clone(); let mut network = state_tx.borrow().clone();
network.interface = interface.clone(); network.interface = interface.clone();
network.ip = self.cached_ip.clone().unwrap_or_default(); network.ip = self.cached_ip.clone().unwrap_or_default();
@@ -118,7 +129,6 @@ impl NetworkDaemon {
self.last_rx_bytes = rx_bytes_now; self.last_rx_bytes = rx_bytes_now;
self.last_tx_bytes = tx_bytes_now; self.last_tx_bytes = tx_bytes_now;
} else { } else {
// Read failed, might be down
self.cached_interface = None; self.cached_interface = None;
return Err(crate::error::FluxoError::Network(format!( return Err(crate::error::FluxoError::Network(format!(
"Failed to read bytes for {}", "Failed to read bytes for {}",
@@ -182,6 +192,8 @@ impl WaybarModule for NetworkModule {
} }
} }
/// Parse `/proc/net/route` to find the default-route interface. When several
/// defaults exist, prefer the one with the longest netmask, then lowest metric.
fn get_primary_interface() -> Result<String> { fn get_primary_interface() -> Result<String> {
let content = std::fs::read_to_string("/proc/net/route")?; let content = std::fs::read_to_string("/proc/net/route")?;
@@ -200,7 +212,6 @@ fn get_primary_interface() -> Result<String> {
} }
} }
// Sort by mask descending (longest prefix match first), then by metric ascending
defaults.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); defaults.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
if let Some((_, _, dev)) = defaults.first() { if let Some((_, _, dev)) = defaults.first() {
Ok(dev.clone()) Ok(dev.clone())
@@ -209,6 +220,7 @@ fn get_primary_interface() -> Result<String> {
} }
} }
/// First IPv4 address for `interface`, via `getifaddrs`. `None` if absent.
fn get_ip_address(interface: &str) -> Option<String> { fn get_ip_address(interface: &str) -> Option<String> {
let addrs = getifaddrs().ok()?; let addrs = getifaddrs().ok()?;
for ifaddr in addrs { for ifaddr in addrs {
@@ -222,6 +234,7 @@ fn get_ip_address(interface: &str) -> Option<String> {
None None
} }
/// Read `(rx_bytes, tx_bytes)` counters from sysfs for `interface`.
fn get_bytes(interface: &str) -> Result<(u64, u64)> { fn get_bytes(interface: &str) -> Result<(u64, u64)> {
let rx_path = format!("/sys/class/net/{}/statistics/rx_bytes", interface); let rx_path = format!("/sys/class/net/{}/statistics/rx_bytes", interface);
let tx_path = format!("/sys/class/net/{}/statistics/tx_bytes", interface); let tx_path = format!("/sys/class/net/{}/statistics/tx_bytes", interface);
+4 -2
View File
@@ -1,3 +1,6 @@
//! Battery/AC indicator via `/sys/class/power_supply`. Dispatch-only - reads
//! sysfs on demand rather than polling into a watch channel.
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -6,6 +9,7 @@ use crate::state::AppReceivers;
use crate::utils::{TokenValue, format_template}; use crate::utils::{TokenValue, format_template};
use std::fs; use std::fs;
/// Renders battery percentage + charge state (critical/warning/bat/charging/ac).
pub struct PowerModule; pub struct PowerModule;
impl WaybarModule for PowerModule { impl WaybarModule for PowerModule {
@@ -18,7 +22,6 @@ impl WaybarModule for PowerModule {
let critical_threshold = 15; let critical_threshold = 15;
let warning_threshold = 50; let warning_threshold = 50;
// Find the first battery
let mut battery_path = None; let mut battery_path = None;
if let Ok(entries) = fs::read_dir("/sys/class/power_supply") { if let Ok(entries) = fs::read_dir("/sys/class/power_supply") {
for entry in entries.flatten() { for entry in entries.flatten() {
@@ -30,7 +33,6 @@ impl WaybarModule for PowerModule {
} }
} }
// Check AC status
let mut ac_online = false; let mut ac_online = false;
if let Ok(entries) = fs::read_dir("/sys/class/power_supply") { if let Ok(entries) = fs::read_dir("/sys/class/power_supply") {
for entry in entries.flatten() { for entry in entries.flatten() {
+3
View File
@@ -1,3 +1,5 @@
//! Uptime + load average renderer. Reads from the `sys` watch channel.
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::modules::WaybarModule; use crate::modules::WaybarModule;
@@ -5,6 +7,7 @@ use crate::output::WaybarOutput;
use crate::state::AppReceivers; use crate::state::AppReceivers;
use crate::utils::{TokenValue, format_template}; use crate::utils::{TokenValue, format_template};
/// Renders uptime and load averages with a detailed tooltip.
pub struct SysModule; pub struct SysModule;
impl WaybarModule for SysModule { impl WaybarModule for SysModule {
+76 -1
View File
@@ -1,7 +1,16 @@
//! JSON payload returned to Waybar custom modules. //! JSON payload returned to Waybar custom modules, plus client-side
//! output formatting utilities.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Waybar renders in a proportional font — replacing normal spaces with
/// figure-spaces (U+2007) keeps column widths stable across updates.
pub const FIGURE_SPACE: char = '\u{2007}';
/// Zero-width space used as cosmetic padding around module text so Waybar
/// doesn't clip leading/trailing glyphs.
pub const ZERO_WIDTH_SPACE: char = '\u{200B}';
/// A Waybar custom module return value. /// A Waybar custom module return value.
/// ///
/// Serialises to the schema Waybar's `return-type: json` expects — the /// Serialises to the schema Waybar's `return-type: json` expects — the
@@ -21,6 +30,72 @@ pub struct WaybarOutput {
pub percentage: Option<u8>, pub percentage: Option<u8>,
} }
impl WaybarOutput {
/// A blank output for disabled modules.
pub fn disabled() -> Self {
Self {
text: String::new(),
tooltip: Some("Module disabled".to_string()),
class: Some("disabled".to_string()),
percentage: None,
}
}
/// A user-visible error with tooltip detail.
pub fn error(message: &str) -> Self {
Self {
text: format!("{}Error{}", ZERO_WIDTH_SPACE, ZERO_WIDTH_SPACE),
tooltip: Some(message.to_string()),
class: Some("error".to_string()),
percentage: None,
}
}
}
/// Apply Waybar font-stabilisation to a text string.
///
/// Replaces normal spaces with figure-spaces (unless the string contains
/// markup), and wraps in zero-width spaces for cosmetic padding.
pub fn stabilize_text(text: &str) -> String {
let processed = if text.contains('<') {
text.to_string()
} else {
text.replace(' ', &FIGURE_SPACE.to_string())
};
format!("{}{}{}", ZERO_WIDTH_SPACE, processed, ZERO_WIDTH_SPACE)
}
/// Process an IPC response and print Waybar-compatible JSON to stdout.
///
/// On IPC failure, prints a "Daemon offline" error output and exits
/// non-zero so Waybar surfaces the problem visually.
pub fn print_waybar_response(response: anyhow::Result<String>) {
match response {
Ok(json_str) => match serde_json::from_str::<serde_json::Value>(&json_str) {
Ok(mut val) => {
if let Some(text) = val.get("text").and_then(|t| t.as_str()) {
val["text"] = serde_json::Value::String(stabilize_text(text));
}
println!("{}", serde_json::to_string(&val).unwrap());
}
Err(_) => println!("{}", json_str),
},
Err(e) => {
let err_out = WaybarOutput {
text: format!(
"{}Daemon offline ({}){}",
ZERO_WIDTH_SPACE, e, ZERO_WIDTH_SPACE
),
tooltip: Some(e.to_string()),
class: Some("error".to_string()),
percentage: None,
};
println!("{}", serde_json::to_string(&err_out).unwrap());
std::process::exit(1);
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;