Download
410 lines 13.4 KiBRustModified 2026-08-02 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
//! Readers for system state: the one home for "read a system source" —
//! process output (`run`), URL fetches (`fetch_url`), streaming process
//! output (`spawn_line_watcher`), and sysfs/procfs files. Each reader is
//! a thin impure fetch over a pure `parse_*` function, so the parsing is
//! unit-testable with string fixtures.
//!
//! The volume/brightness/wifi readers are both polled by the stats loop
//! and returned fresh from control commands (so widgets update instantly
//! after an adjustment instead of waiting for the next poll).

use serde_json::{json, Value};
use std::io::{BufRead, BufReader};
use std::ops::ControlFlow;
use std::path::Path;
use std::process::{Command, Stdio};
use std::thread::JoinHandle;
use std::time::Duration;

pub fn run(cmd: &str, args: &[&str]) -> Option<String> {
    let output = Command::new(cmd).args(args).output().ok()?;
    if !output.status.success() {
        return None;
    }
    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

/// Fetches a URL with curl, returning the response body on success.
pub fn fetch_url(url: &str, max_time_secs: u32) -> Option<Vec<u8>> {
    let output = Command::new("curl")
        .args(["-sf", "--max-time", &max_time_secs.to_string(), url])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    Some(output.stdout)
}

/// A `spawn_line_watcher` callback event.
pub enum LineEvent<'a> {
    /// About to (re)start the watched command — fired before every spawn
    /// attempt, including retries, so state derived from the stream can
    /// be re-synced after a gap.
    Start,
    Line(&'a str),
}

/// Spawns a thread that runs `cmd` and feeds each stdout line to
/// `on_event`, restarting the command after `backoff` whenever it dies —
/// the streaming sibling of `run`, for processes that outlive one output.
/// `on_event` returns `ControlFlow::Break` to end the watcher and its
/// thread (production watchers run for the app's life; tests join).
pub fn spawn_line_watcher(
    cmd: &'static str,
    args: &'static [&'static str],
    backoff: Duration,
    mut on_event: impl FnMut(LineEvent) -> ControlFlow<()> + Send + 'static,
) -> JoinHandle<()> {
    std::thread::spawn(move || loop {
        if on_event(LineEvent::Start).is_break() {
            return;
        }
        match Command::new(cmd)
            .args(args)
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
        {
            Ok(mut child) => {
                if let Some(stdout) = child.stdout.take() {
                    for line in BufReader::new(stdout).lines() {
                        let Ok(line) = line else { break };
                        if on_event(LineEvent::Line(&line)).is_break() {
                            let _ = child.kill();
                            let _ = child.wait();
                            return;
                        }
                    }
                }
                let _ = child.wait();
            }
            Err(err) => {
                eprintln!("divebar: line watcher {cmd} failed: {err}");
            }
        }
        // The command died (daemon restart?) — retry with backoff.
        std::thread::sleep(backoff);
    })
}

pub fn read_volume() -> Value {
    match run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]) {
        Some(output) => parse_volume(&output),
        None => Value::Null,
    }
}

/// `wpctl get-volume @DEFAULT_AUDIO_SINK@` → "Volume: 0.55 [MUTED]"
fn parse_volume(output: &str) -> Value {
    let muted = output.contains("[MUTED]");
    let Some(volume) = output
        .split_whitespace()
        .find_map(|tok| tok.parse::<f64>().ok())
    else {
        return Value::Null;
    };
    json!({
        "percent": (volume * 100.0).round() as u32,
        "muted": muted,
    })
}

/// Reads the first backlight under /sys/class/backlight directly —
/// this runs on every stats round, so no brightnessctl spawn (setting
/// still goes through brightnessctl, but only on user action).
pub fn read_brightness() -> Value {
    let Ok(entries) = std::fs::read_dir("/sys/class/backlight") else {
        return Value::Null;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        let read_num = |name: &str| -> Option<f64> {
            std::fs::read_to_string(path.join(name))
                .ok()?
                .trim()
                .parse::<f64>()
                .ok()
        };
        if let Some(percent) = brightness_percent(read_num("brightness"), read_num("max_brightness"))
        {
            return json!({ "percent": percent });
        }
    }
    Value::Null
}

/// Raw brightness / max_brightness → whole percent.
fn brightness_percent(now: Option<f64>, max: Option<f64>) -> Option<u32> {
    let (now, max) = (now?, max?);
    if max <= 0.0 || now < 0.0 {
        return None;
    }
    Some(((now / max * 100.0).round() as u32).min(100))
}

/// SSID from iwgetid; signal quality from /proc/net/wireless.
pub fn read_wifi() -> Value {
    let ssid = run("iwgetid", &["-r"]).filter(|s| !s.is_empty());
    let signal_percent = std::fs::read_to_string("/proc/net/wireless")
        .ok()
        .and_then(|contents| parse_wireless_link(&contents))
        .map(link_to_percent);
    json!({
        "ssid": ssid,
        "signalPercent": signal_percent,
    })
}

/// Extracts the link-quality number from /proc/net/wireless contents.
fn parse_wireless_link(contents: &str) -> Option<f64> {
    // First two lines are headers; data lines are "wlan0: 0000   65.  -45. ..."
    let line = contents.lines().nth(2)?;
    line.split_whitespace()
        .nth(2)?
        .trim_end_matches('.')
        .parse::<f64>()
        .ok()
}

/// Link quality is out of 70 on most drivers.
fn link_to_percent(link: f64) -> u32 {
    ((link / 70.0 * 100.0).round() as u32).min(100)
}

/// Reads the first battery found under /sys/class/power_supply.
/// Returns null on desktop machines with no battery.
pub fn read_battery() -> Value {
    let Ok(entries) = std::fs::read_dir("/sys/class/power_supply") else {
        return Value::Null;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        let is_battery = std::fs::read_to_string(path.join("type"))
            .map(|t| t.trim() == "Battery")
            .unwrap_or(false);
        if !is_battery {
            continue;
        }
        let capacity = std::fs::read_to_string(path.join("capacity"))
            .ok()
            .and_then(|s| s.trim().parse::<u8>().ok());
        let status = std::fs::read_to_string(path.join("status"))
            .map(|s| s.trim().to_string())
            .unwrap_or_default();
        if let Some(percent) = capacity {
            // "charging" really means "on AC": "Not charging" is the
            // kernel's plugged-in-but-holding state (charge threshold
            // reached, or firmware pausing at full).
            let plugged = matches!(status.as_str(), "Charging" | "Full" | "Not charging");
            return json!({
                "percent": percent,
                "charging": plugged,
                "timeRemainingMinutes": battery_time_remaining(&path, &status),
            });
        }
    }
    Value::Null
}

/// Reads the battery's fill-level and rate numbers and estimates time
/// remaining. Either energy_* (µWh with power µW) or charge_* (µAh with
/// current µA); the ratio works out the same either way.
fn battery_time_remaining(path: &Path, status: &str) -> Value {
    let read_num = |name: &str| -> Option<f64> {
        std::fs::read_to_string(path.join(name))
            .ok()?
            .trim()
            .parse::<f64>()
            .ok()
    };
    let (now, full, rate) = if let (Some(now), Some(full), Some(rate)) = (
        read_num("energy_now"),
        read_num("energy_full"),
        read_num("power_now"),
    ) {
        (now, full, rate)
    } else if let (Some(now), Some(full), Some(rate)) = (
        read_num("charge_now"),
        read_num("charge_full"),
        read_num("current_now"),
    ) {
        (now, full, rate)
    } else {
        return Value::Null;
    };
    time_remaining_minutes(now, full, rate, status)
}

/// Estimated minutes to empty (discharging) or full (charging) from the
/// battery's instantaneous draw. Null when the rate is ~0, the status is
/// neither, or the estimate is implausible (>48h).
fn time_remaining_minutes(now: f64, full: f64, rate: f64, status: &str) -> Value {
    if rate < 1.0 {
        return Value::Null;
    }
    let hours = match status {
        "Discharging" => now / rate,
        "Charging" => (full - now).max(0.0) / rate,
        _ => return Value::Null,
    };
    if !hours.is_finite() || hours > 48.0 {
        return Value::Null;
    }
    json!((hours * 60.0).round() as u32)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn volume_basic() {
        assert_eq!(
            parse_volume("Volume: 0.55"),
            json!({ "percent": 55, "muted": false })
        );
    }

    #[test]
    fn volume_muted_and_full() {
        assert_eq!(
            parse_volume("Volume: 1.00 [MUTED]"),
            json!({ "percent": 100, "muted": true })
        );
    }

    #[test]
    fn volume_garbage_is_null() {
        assert_eq!(parse_volume("no numbers here"), Value::Null);
    }

    #[test]
    fn brightness_percent_from_raw_values() {
        assert_eq!(brightness_percent(Some(19200.0), Some(48000.0)), Some(40));
        assert_eq!(brightness_percent(Some(0.0), Some(48000.0)), Some(0));
        assert_eq!(brightness_percent(Some(48000.0), Some(48000.0)), Some(100));
        assert_eq!(brightness_percent(Some(50000.0), Some(48000.0)), Some(100)); // capped
    }

    #[test]
    fn brightness_percent_bad_inputs_are_none() {
        assert_eq!(brightness_percent(None, Some(48000.0)), None);
        assert_eq!(brightness_percent(Some(19200.0), None), None);
        assert_eq!(brightness_percent(Some(19200.0), Some(0.0)), None);
        assert_eq!(brightness_percent(Some(-1.0), Some(48000.0)), None);
    }

    #[test]
    fn wireless_link_from_proc() {
        let contents = "Inter-| sta-|   Quality        |   Discarded packets\n \
                        face | tus | link level noise |  nwid  crypt   frag\n \
                        wlan0: 0000   65.  -45.  -256        0      0      0\n";
        assert_eq!(parse_wireless_link(contents), Some(65.0));
        assert_eq!(link_to_percent(65.0), 93);
        assert_eq!(link_to_percent(80.0), 100); // capped
    }

    #[test]
    fn wireless_no_data_line_is_none() {
        assert_eq!(parse_wireless_link("header\nheader\n"), None);
    }

    #[test]
    fn time_remaining_discharging() {
        // 50 µWh left at 25 µW → 2 h → 120 min.
        assert_eq!(
            time_remaining_minutes(50.0, 100.0, 25.0, "Discharging"),
            json!(120)
        );
    }

    #[test]
    fn time_remaining_charging() {
        // 60 µWh to full at 30 µW → 2 h → 120 min.
        assert_eq!(
            time_remaining_minutes(40.0, 100.0, 30.0, "Charging"),
            json!(120)
        );
    }

    #[test]
    fn time_remaining_full_or_unknown_is_null() {
        assert_eq!(time_remaining_minutes(50.0, 100.0, 25.0, "Full"), Value::Null);
        assert_eq!(time_remaining_minutes(50.0, 100.0, 25.0, ""), Value::Null);
    }

    #[test]
    fn time_remaining_zero_rate_is_null() {
        assert_eq!(
            time_remaining_minutes(50.0, 100.0, 0.0, "Discharging"),
            Value::Null
        );
    }

    #[test]
    fn time_remaining_implausible_is_null() {
        // 5000 h estimate → null rather than a silly number.
        assert_eq!(
            time_remaining_minutes(5000.0, 10000.0, 1.0, "Discharging"),
            Value::Null
        );
    }

    fn watcher_events(
        cmd: &'static str,
        args: &'static [&'static str],
        backoff: Duration,
        stop_after: impl Fn(&[String]) -> bool + Send + 'static,
    ) -> Vec<String> {
        let (tx, rx) = std::sync::mpsc::channel();
        let mut seen = Vec::new();
        let handle = spawn_line_watcher(cmd, args, backoff, move |event| {
            seen.push(match event {
                LineEvent::Start => "<start>".to_string(),
                LineEvent::Line(line) => line.to_string(),
            });
            tx.send(seen.last().unwrap().clone()).unwrap();
            if stop_after(&seen) {
                ControlFlow::Break(())
            } else {
                ControlFlow::Continue(())
            }
        });
        handle.join().unwrap();
        rx.try_iter().collect()
    }

    #[test]
    fn line_watcher_signals_start_then_streams_lines() {
        let events = watcher_events(
            "sh",
            &["-c", "echo alpha; echo beta"],
            Duration::from_secs(60),
            |seen| seen.last().is_some_and(|e| e == "beta"),
        );
        assert_eq!(events, ["<start>", "alpha", "beta"]);
    }

    #[test]
    fn line_watcher_restarts_with_a_fresh_start_event_after_the_command_exits() {
        let events = watcher_events(
            "sh",
            &["-c", "echo tick"],
            Duration::from_millis(5),
            |seen| seen.iter().filter(|e| *e == "tick").count() == 2,
        );
        assert_eq!(events, ["<start>", "tick", "<start>", "tick"]);
    }

    #[test]
    fn line_watcher_keeps_retrying_when_the_command_cannot_spawn() {
        let events = watcher_events(
            "divebar-test-no-such-command",
            &[],
            Duration::from_millis(5),
            |seen| seen.len() == 3,
        );
        assert_eq!(events, ["<start>", "<start>", "<start>"]);
    }
}