Compare commits
5 Commits
f89833a62e
..
v0.5.4
| Author | SHA1 | Date | |
|---|---|---|---|
| c1152ce1b9 | |||
| 230604dae3 | |||
| 2d35313d75 | |||
| eaeba84092 | |||
| ffdb689ef9 |
@@ -64,31 +64,31 @@ jobs:
|
|||||||
VERSION: ${{ steps.get_version.outputs.VERSION }}
|
VERSION: ${{ steps.get_version.outputs.VERSION }}
|
||||||
TAG: ${{ steps.get_version.outputs.TAG }}
|
TAG: ${{ steps.get_version.outputs.TAG }}
|
||||||
run: |
|
run: |
|
||||||
PKG="fluxo-rs_${VERSION}_amd64"
|
PKG="fluxo_${VERSION}_amd64"
|
||||||
|
|
||||||
mkdir -p "${PKG}/DEBIAN"
|
mkdir -p "${PKG}/DEBIAN"
|
||||||
mkdir -p "${PKG}/usr/bin"
|
mkdir -p "${PKG}/usr/bin"
|
||||||
|
|
||||||
cp target/release/fluxo-rs "${PKG}/usr/bin/"
|
cp target/release/fluxo "${PKG}/usr/bin/"
|
||||||
strip "${PKG}/usr/bin/fluxo-rs"
|
strip "${PKG}/usr/bin/fluxo"
|
||||||
|
|
||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
"Package: fluxo-rs" \
|
"Package: fluxo" \
|
||||||
"Version: ${VERSION}" \
|
"Version: ${VERSION}" \
|
||||||
"Section: utils" \
|
"Section: utils" \
|
||||||
"Priority: optional" \
|
"Priority: optional" \
|
||||||
"Architecture: amd64" \
|
"Architecture: amd64" \
|
||||||
"Maintainer: fluxo-rs contributors" \
|
"Maintainer: fluxo contributors" \
|
||||||
"Description: High-performance daemon/client for Waybar custom modules" \
|
"Description: High-performance daemon/client for Waybar custom modules" \
|
||||||
" fluxo-rs is a compiled Rust daemon that polls system metrics and" \
|
" fluxo is a compiled Rust daemon that polls system metrics and" \
|
||||||
" serves formatted JSON output to Waybar custom modules over a Unix" \
|
" serves formatted JSON output to Waybar custom modules over a Unix" \
|
||||||
" domain socket. Replaces shell scripts with a single binary." \
|
" domain socket. Replaces shell scripts with a single binary." \
|
||||||
> "${PKG}/DEBIAN/control"
|
> "${PKG}/DEBIAN/control"
|
||||||
|
|
||||||
dpkg-deb --build "${PKG}"
|
dpkg-deb --build "${PKG}"
|
||||||
|
|
||||||
mv "${PKG}.deb" "fluxo-rs-${TAG}-amd64.deb"
|
mv "${PKG}.deb" "fluxo-${TAG}-amd64.deb"
|
||||||
echo "Built: fluxo-rs-${TAG}-amd64.deb"
|
echo "Built: fluxo-${TAG}-amd64.deb"
|
||||||
|
|
||||||
- name: Create Release and Upload Assets
|
- name: Create Release and Upload Assets
|
||||||
if: steps.check_release.outputs.EXISTS == 'false'
|
if: steps.check_release.outputs.EXISTS == 'false'
|
||||||
@@ -101,8 +101,8 @@ jobs:
|
|||||||
Commit: ${{ github.sha }}
|
Commit: ${{ github.sha }}
|
||||||
Branch: ${{ github.ref_name }}
|
Branch: ${{ github.ref_name }}
|
||||||
files: |
|
files: |
|
||||||
fluxo-rs-${{ steps.get_version.outputs.TAG }}-amd64.deb
|
fluxo-${{ steps.get_version.outputs.TAG }}-amd64.deb
|
||||||
target/release/fluxo-rs
|
target/release/fluxo
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
|
|||||||
Generated
+1
-1
@@ -572,7 +572,7 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fluxo-rs"
|
name = "fluxo-rs"
|
||||||
version = "0.5.1"
|
version = "0.5.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bluer",
|
"bluer",
|
||||||
|
|||||||
+18
-1
@@ -1,8 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "fluxo-rs"
|
name = "fluxo-rs"
|
||||||
version = "0.5.1"
|
version = "0.5.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fluxo"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["mod-audio", "mod-bt", "mod-network", "mod-hardware", "mod-dbus"]
|
default = ["mod-audio", "mod-bt", "mod-network", "mod-hardware", "mod-dbus"]
|
||||||
mod-audio = ["dep:libpulse-binding"]
|
mod-audio = ["dep:libpulse-binding"]
|
||||||
@@ -38,3 +42,16 @@ zbus = { version = "5", optional = true }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
|
[package.metadata.deb]
|
||||||
|
maintainer = "Nils Pukropp"
|
||||||
|
copyright = "2024-2026 Nils Pukropp"
|
||||||
|
depends = "$auto"
|
||||||
|
section = "utils"
|
||||||
|
priority = "optional"
|
||||||
|
assets = [
|
||||||
|
["target/release/fluxo", "usr/bin/", "755"],
|
||||||
|
["dist/fluxo.service", "usr/lib/systemd/user/", "644"],
|
||||||
|
["README.md", "usr/share/doc/fluxo/", "644"],
|
||||||
|
["example.config.toml", "usr/share/doc/fluxo/", "644"],
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# fluxo-rs
|
# fluxo
|
||||||
|
|
||||||
`fluxo-rs` is a high-performance system metrics daemon and client designed specifically for Waybar. It entirely replaces standard shell scripts with a compiled Rust binary that collects data via a background polling loop and serves it over a Unix socket.
|
`fluxo` is a high-performance system metrics daemon and client designed specifically for Waybar. It entirely replaces standard shell scripts with a compiled Rust binary that collects data via a background polling loop and serves it over a Unix socket.
|
||||||
|
|
||||||
With its **100% Native, Content-Based Event-Driven Architecture**, it consumes effectively 0% CPU while idle and signals Waybar to redraw *only* when the rendered UI text or icons physically change.
|
With its **100% Native, Content-Based Event-Driven Architecture**, it consumes effectively 0% CPU while idle and signals Waybar to redraw *only* when the rendered UI text or icons physically change.
|
||||||
|
|
||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
- **100% Native Architecture**: Zero shell-outs or subprocesses. Uses `bluer` for Bluetooth, `libpulse-binding` for audio, `zbus` for MPRIS/DND, and `notify` for backlight.
|
- **100% Native Architecture**: Zero shell-outs or subprocesses. Uses `bluer` for Bluetooth, `libpulse-binding` for audio, `zbus` for MPRIS/DND, and `notify` for backlight.
|
||||||
- **Content-Based Event Signaling**: `fluxo-rs` evaluates your custom configuration formats internally. It only sends a `SIGRTMIN+X` signal to Waybar if the resulting string or CSS class has actually changed, eliminating pointless re-renders from raw polling fluctuations.
|
- **Content-Based Event Signaling**: `fluxo` evaluates your custom configuration formats internally. It only sends a `SIGRTMIN+X` signal to Waybar if the resulting string or CSS class has actually changed, eliminating pointless re-renders from raw polling fluctuations.
|
||||||
- **Zero-Latency Interactions**: Direct library bindings mean that when you change your volume or connect a Bluetooth device via the CLI, the daemon updates instantly.
|
- **Zero-Latency Interactions**: Direct library bindings mean that when you change your volume or connect a Bluetooth device via the CLI, the daemon updates instantly.
|
||||||
- **Circuit Breaker (Failsafe)**: Automatically detects failing modules and enters a "Cool down" state, preventing resource waste and log spam. Fallback caching keeps your bar looking clean even during brief failures.
|
- **Circuit Breaker (Failsafe)**: Automatically detects failing modules and enters a "Cool down" state, preventing resource waste and log spam. Fallback caching keeps your bar looking clean even during brief failures.
|
||||||
- **Multi-threaded Polling**: Decoupled Tokio subsystem threads ensure that a hang in one system (e.g., a slow GPU probe) never freezes your Waybar.
|
- **Multi-threaded Polling**: Decoupled Tokio subsystem threads ensure that a hang in one system (e.g., a slow GPU probe) never freezes your Waybar.
|
||||||
@@ -33,11 +33,60 @@ With its **100% Native, Content-Based Event-Driven Architecture**, it consumes e
|
|||||||
| `kbd` | Keyboard Layout | `{layout}` |
|
| `kbd` | Keyboard Layout | `{layout}` |
|
||||||
| `dnd` | Do Not Disturb (SwayNC) | active/inactive strings |
|
| `dnd` | Do Not Disturb (SwayNC) | active/inactive strings |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
cp target/release/fluxo ~/.cargo/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debian/Ubuntu (.deb)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install cargo-deb
|
||||||
|
cargo deb
|
||||||
|
sudo dpkg -i target/debian/fluxo-rs_*.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.deb` package installs the binary to `/usr/bin/fluxo`, the systemd user service to `/usr/lib/systemd/user/fluxo.service`, and documentation to `/usr/share/doc/fluxo/`.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. **Build**: `cargo build --release`
|
1. **Configure**: Create `~/.config/fluxo/config.toml` (see `example.config.toml`). Ensure you map your `[signals]`.
|
||||||
2. **Configure**: Create `~/.config/fluxo/config.toml` (see `example.config.toml`). Ensure you map your `[signals]`.
|
2. **Start the daemon** via systemd (recommended) or manually:
|
||||||
3. **Daemon**: Start `fluxo-rs daemon`. It is highly recommended to run this as a systemd user service.
|
|
||||||
|
### systemd (recommended)
|
||||||
|
|
||||||
|
If installed from the `.deb`, the service file is already in place. For manual installs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
cp dist/fluxo.service ~/.config/systemd/user/
|
||||||
|
```
|
||||||
|
|
||||||
|
If your binary is not at `~/.cargo/bin/fluxo`, edit the `ExecStart=` path in the service file.
|
||||||
|
|
||||||
|
Then enable and start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now fluxo
|
||||||
|
```
|
||||||
|
|
||||||
|
Check status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user status fluxo
|
||||||
|
journalctl --user -u fluxo -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fluxo daemon
|
||||||
|
```
|
||||||
|
|
||||||
## Waybar Configuration
|
## Waybar Configuration
|
||||||
|
|
||||||
@@ -45,21 +94,21 @@ To achieve zero-latency updates and zero-polling CPU usage, set `interval: 0` on
|
|||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
"custom/volume": {
|
"custom/volume": {
|
||||||
"exec": "fluxo-rs vol",
|
"exec": "fluxo vol",
|
||||||
"return-type": "json",
|
"return-type": "json",
|
||||||
"interval": 0,
|
"interval": 0,
|
||||||
"signal": 8, // Must match the value in config.toml [signals]
|
"signal": 8, // Must match the value in config.toml [signals]
|
||||||
"on-click": "fluxo-rs vol mute",
|
"on-click": "fluxo vol mute",
|
||||||
"on-scroll-up": "fluxo-rs vol up 1",
|
"on-scroll-up": "fluxo vol up 1",
|
||||||
"on-scroll-down": "fluxo-rs vol down 1",
|
"on-scroll-down": "fluxo vol down 1",
|
||||||
"on-click-right": "fluxo-rs vol cycle"
|
"on-click-right": "fluxo vol cycle"
|
||||||
},
|
},
|
||||||
"custom/bluetooth-audio": {
|
"custom/bluetooth-audio": {
|
||||||
"format": "{}",
|
"format": "{}",
|
||||||
"return-type": "json",
|
"return-type": "json",
|
||||||
"exec": "fluxo-rs bt",
|
"exec": "fluxo bt",
|
||||||
"on-click": "fluxo-rs bt menu",
|
"on-click": "fluxo bt menu",
|
||||||
"on-click-right": "fluxo-rs bt cycle_mode",
|
"on-click-right": "fluxo bt cycle_mode",
|
||||||
"signal": 9,
|
"signal": 9,
|
||||||
"interval": 0,
|
"interval": 0,
|
||||||
"tooltip": true
|
"tooltip": true
|
||||||
@@ -68,7 +117,21 @@ To achieve zero-latency updates and zero-polling CPU usage, set `interval: 0` on
|
|||||||
|
|
||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
Start the daemon with `RUST_LOG=debug` to see detailed logs of library interactions and circuit breaker status:
|
Use `--loglevel` to control log verbosity (trace, debug, info, warn, error):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RUST_LOG=debug fluxo-rs daemon
|
fluxo daemon --loglevel debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via the `RUST_LOG` environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug fluxo daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
For module help and available arguments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fluxo help # overview of all modules
|
||||||
|
fluxo help vol # detailed help for a specific module
|
||||||
```
|
```
|
||||||
|
|||||||
+135
@@ -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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
+499
@@ -0,0 +1,499 @@
|
|||||||
|
//! 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 {
|
||||||
|
let padded = format!("{{{}}}", token);
|
||||||
|
println!(
|
||||||
|
" \x1b[33m{:<width$}\x1b[0m {}",
|
||||||
|
padded,
|
||||||
|
desc,
|
||||||
|
width = max_token + 2 // +2 for the braces
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-149
@@ -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;
|
||||||
@@ -26,19 +30,49 @@ mod signaler;
|
|||||||
mod state;
|
mod state;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process;
|
use std::process;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||||
|
|
||||||
|
#[derive(Clone, ValueEnum)]
|
||||||
|
enum LogLevel {
|
||||||
|
Trace,
|
||||||
|
Debug,
|
||||||
|
Info,
|
||||||
|
Warn,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<LogLevel> for tracing::Level {
|
||||||
|
fn from(level: LogLevel) -> Self {
|
||||||
|
match level {
|
||||||
|
LogLevel::Trace => tracing::Level::TRACE,
|
||||||
|
LogLevel::Debug => tracing::Level::DEBUG,
|
||||||
|
LogLevel::Info => tracing::Level::INFO,
|
||||||
|
LogLevel::Warn => tracing::Level::WARN,
|
||||||
|
LogLevel::Error => tracing::Level::ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[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, disable_help_flag = true)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
|
|
||||||
|
/// Print help information
|
||||||
|
#[arg(short, long, global = true)]
|
||||||
|
help: bool,
|
||||||
|
|
||||||
|
/// Set the log level (trace, debug, info, warn, error)
|
||||||
|
#[arg(long, global = true, value_enum)]
|
||||||
|
loglevel: Option<LogLevel>,
|
||||||
|
|
||||||
/// Module name to query or interact with
|
/// Module name to query or interact with
|
||||||
module: Option<String>,
|
module: Option<String>,
|
||||||
|
|
||||||
@@ -57,15 +91,35 @@ 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() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
// Explicit --loglevel takes priority, then RUST_LOG env var, then a
|
||||||
|
// sensible default: INFO for the daemon, WARN for client commands.
|
||||||
|
let default_level = if let Some(level) = &cli.loglevel {
|
||||||
|
tracing::Level::from(level.clone())
|
||||||
|
} else if matches!(&cli.command, Some(Commands::Daemon { .. })) {
|
||||||
|
tracing::Level::INFO
|
||||||
|
} else {
|
||||||
|
tracing::Level::WARN
|
||||||
|
};
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(fmt::layer().with_target(false).pretty())
|
.with(fmt::layer().with_target(false).pretty())
|
||||||
.with(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into()))
|
.with(EnvFilter::from_default_env().add_directive(default_level.into()))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let cli = Cli::parse();
|
if cli.help {
|
||||||
|
help::print_help(None);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(command) = &cli.command {
|
if let Some(command) = &cli.command {
|
||||||
match command {
|
match command {
|
||||||
@@ -88,157 +142,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 {
|
|
||||||
info!("No Bluetooth options found.");
|
|
||||||
}
|
|
||||||
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 {
|
} else {
|
||||||
println!("Please specify a module or command. See --help.");
|
help::print_help(None);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-9
@@ -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>,
|
||||||
@@ -46,7 +58,7 @@ impl AudioDaemon {
|
|||||||
ThreadedMainloop::new().expect("Failed to create pulse threaded mainloop");
|
ThreadedMainloop::new().expect("Failed to create pulse threaded mainloop");
|
||||||
|
|
||||||
let mut context =
|
let mut context =
|
||||||
Context::new(&mainloop, "fluxo-rs").expect("Failed to create pulse context");
|
Context::new(&mainloop, "fluxo").expect("Failed to create pulse context");
|
||||||
|
|
||||||
context
|
context
|
||||||
.connect(None, ContextFlag::NOFLAGS, None)
|
.connect(None, ContextFlag::NOFLAGS, None)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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")?;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -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::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user