master
John Lauer 1.3.0: Dock Bar contract (serve --print-url, /api/status LEDs, CORS, say strip), settings modal, taskbar progress, pup vault for logins, adom-bridge + pup_* transport, dashboard overhaul per adom-ui-design, doctor for first run 83b2ae8 1mo ago
//! Windows taskbar progress on the dashboard's pup window.
//!
//! John (issue adom/adom-chip-fetcher#3): a part lookup is a long, open-ended
//! process, so it gets an INDETERMINATE taskbar bar for as long as it runs,
//! never a false estimate. Short verbs (cse, ds2sf, thumbnails) get a
//! determinate bar that fills over a persisted running average, the way pup
//! does it (`~/.adom/pup-verb-timing.json` shape: `{"<op>":{"n":N,"avgMs":M}}`).
//!
//! Everything here is best effort and gated by the `taskbar_progress`
//! setting: a paint must never fail a fetch. Each paint is one
//! `adom-bridge pup_progress` process, so we paint on change only.

use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};

pub const DASHBOARD_SESSION: &str = "adom-chip-fetcher-dashboard";

fn enabled() -> bool {
    crate::settings::get_str("taskbar_progress") != "off"
}

fn push(mode: &str, value: Option<u8>, note: &str) {
    if !enabled() || !crate::bridge::available() {
        return;
    }
    let mut body = json!({"sessionId": DASHBOARD_SESSION, "mode": mode});
    if let Some(v) = value {
        body["value"] = json!(v.min(100));
    }
    if !note.is_empty() {
        body["note"] = json!(note);
    }
    // Fire and forget, bounded: a stuck bridge must not stall the caller.
    let _ = std::process::Command::new("timeout")
        .arg("8")
        .arg(bridge_bin())
        .args(["--ai-thread", &crate::bridge::ai_thread(), "pup_progress", &body.to_string()])
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status();
}

fn bridge_bin() -> String {
    let mut c = crate::bridge::command();
    c.get_program().to_string_lossy().to_string()
}

/// Indeterminate bar for the whole lookup.
pub fn start_indeterminate(note: &str) {
    push("indeterminate", None, note);
}

pub fn progress(pct: u8, note: &str) {
    push("value", Some(pct), note);
}

pub fn clear() {
    push("off", None, "");
}

// ------------------------------------------------------------- durations

fn durations_path() -> PathBuf {
    crate::settings::config_dir().join("durations.json")
}

fn load_durations() -> Value {
    std::fs::read_to_string(durations_path())
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_else(|| json!({}))
}

const DEFAULT_MS: &[(&str, u64)] = &[("cse", 45_000), ("ds2sf", 60_000), ("thumbnails", 20_000), ("validate", 30_000), ("refetch", 60_000)];

pub fn estimate_ms(op: &str) -> u64 {
    load_durations()
        .get(op)
        .and_then(|t| t.get("avgMs"))
        .and_then(|v| v.as_u64())
        .or_else(|| DEFAULT_MS.iter().find(|(k, _)| *k == op).map(|(_, v)| *v))
        .unwrap_or(30_000)
}

/// pup's rule: first sample sets the average, then a 0.75/0.25 EMA; absurd
/// samples (a clock jump, a forgotten terminal) are not samples.
pub fn record(op: &str, ms: u64) {
    if ms > 30 * 60 * 1000 {
        return;
    }
    let mut d = load_durations();
    let (n, avg) = d
        .get(op)
        .map(|t| (t["n"].as_u64().unwrap_or(0), t["avgMs"].as_u64().unwrap_or(ms)))
        .unwrap_or((0, ms));
    let avg = if n == 0 { ms } else { (avg * 3 + ms) / 4 };
    d[op] = json!({"n": (n + 1).min(100_000), "avgMs": avg});
    let _ = std::fs::create_dir_all(crate::settings::config_dir());
    let _ = std::fs::write(durations_path(), d.to_string());
}

pub fn durations_json() -> String {
    json!({"ok": true, "path": durations_path().display().to_string(), "durations": load_durations()}).to_string()
}

// ------------------------------------------------------- determinate run

/// A determinate bar that fills to 92% over the running-average estimate and
/// completes on `finish()`. Dropped early (an `?` error path) it clears.
pub struct ProgressRun {
    stop: Arc<AtomicBool>,
    handle: Option<std::thread::JoinHandle<()>>,
    op: String,
    started: Instant,
    finished: bool,
}

impl ProgressRun {
    pub fn start(op: &str, note: &str) -> Self {
        let est = estimate_ms(op).max(200) as f64;
        let stop = Arc::new(AtomicBool::new(false));
        let (s2, n2) = (stop.clone(), note.to_string());
        let started = Instant::now();
        let active = enabled();
        let handle = std::thread::spawn(move || {
            if !active {
                return;
            }
            let mut last = 0u8;
            while !s2.load(Ordering::Relaxed) {
                let pct = ((started.elapsed().as_millis() as f64 / est).min(0.92) * 100.0) as u8;
                let pct = pct.max(2);
                if pct != last {
                    progress(pct, &n2);
                    last = pct;
                }
                std::thread::sleep(Duration::from_millis(500));
            }
        });
        ProgressRun { stop, handle: Some(handle), op: op.into(), started, finished: false }
    }

    pub fn finish(mut self) {
        self.stop.store(true, Ordering::Relaxed);
        if let Some(h) = self.handle.take() {
            let _ = h.join();
        }
        record(&self.op, self.started.elapsed().as_millis() as u64);
        if enabled() {
            progress(100, "done");
            std::thread::sleep(Duration::from_millis(220));
            clear();
        }
        self.finished = true;
    }
}

impl Drop for ProgressRun {
    fn drop(&mut self) {
        if self.finished {
            return;
        }
        self.stop.store(true, Ordering::Relaxed);
        if let Some(h) = self.handle.take() {
            let _ = h.join();
        }
        clear();
    }
}