main
John Lauer 0.2.1: a finished verb can no longer leave a stale partial progress bar on Altium's taskbar icon — the terminal clear i… ca58f6d 1mo ago
//! "Adom is driving Altium" on the Windows taskbar — badge overlay + progress bar, both painted
//! by ab's generic `desktop_taskbar` verb (never an in-process ITaskbarList3 call: that returns
//! S_OK cross-process and the shell silently ignores it; pup, fusion and kicad each paid for it).
//! No AUMID stamping — a per-window AppUserModelID poisons the shell's icon cache (kicad's lesson).
//!
//! John's rules: progress rides the taskbar icon, never toasts; the overlay auto-clears after
//! 5 minutes without a driving verb (a permanent overlay stops meaning "an AI is driving now").
use crate::{ab, instance};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::Mutex;
use std::time::Duration;

static BADGED: Mutex<BTreeSet<i64>> = Mutex::new(BTreeSet::new());
static LAST_DRIVING: AtomicI64 = AtomicI64::new(0);
static IDLE_WATCHER: AtomicBool = AtomicBool::new(false);
struct ProgressHandle {
    stop: std::sync::Arc<AtomicBool>,
    failed: std::sync::Arc<AtomicBool>,
}

static PROGRESS_STOP: Mutex<BTreeMap<String, ProgressHandle>> = Mutex::new(BTreeMap::new());

pub const IDLE_CLEAR_SEC: i64 = 300;

fn now() -> i64 {
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
}

fn badge_b64() -> String {
    static B64: Mutex<Option<String>> = Mutex::new(None);
    if let Ok(g) = B64.lock() {
        if let Some(s) = g.as_ref() {
            return s.clone();
        }
    }
    let p = std::env::current_exe()
        .ok()
        .and_then(|e| e.parent().map(|d| d.join("resources").join("adom-overlay-32.png")));
    let s = p.and_then(|p| std::fs::read(p).ok()).map(|b| b64encode(&b)).unwrap_or_default();
    if let Ok(mut g) = B64.lock() {
        *g = Some(s.clone());
    }
    s
}

fn taskbar(args: Value) -> bool {
    let r = ab::call("desktop_taskbar", args, &[], 15);
    r.get("success").and_then(|b| b.as_bool()).unwrap_or(false)
        || r.to_string().contains("Taskbar updated")
}

fn main_hwnd() -> Option<i64> {
    instance::main_window().and_then(|w| w.get("hwnd").and_then(|h| h.as_i64()))
}

// ---- badge -------------------------------------------------------------------

pub fn set_driving_badge() -> bool {
    let hwnd = match main_hwnd() {
        Some(h) => h,
        None => return false,
    };
    if BADGED.lock().map(|g| g.contains(&hwnd)).unwrap_or(false) {
        return true;
    }
    let icon = badge_b64();
    if icon.is_empty() {
        return false; // badge asset missing beside the exe — not worth failing a verb over
    }
    let ok = taskbar(json!({"hwnd": hwnd, "overlay": {"icon": icon, "tooltip": "Adom is driving Altium Designer"}}));
    if ok {
        if let Ok(mut g) = BADGED.lock() {
            g.insert(hwnd);
        }
    }
    ok
}

pub fn clear_driving_badge() -> bool {
    let targets: Vec<i64> = BADGED.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default();
    let mut ok = true;
    for h in targets {
        if taskbar(json!({"hwnd": h, "overlay": {"badge": "none"}})) {
            if let Ok(mut g) = BADGED.lock() {
                g.remove(&h);
            }
        } else {
            ok = false;
        }
    }
    ok
}

pub fn badge_active() -> bool {
    BADGED.lock().map(|g| !g.is_empty()).unwrap_or(false)
}

/// Called by dispatch on every driving verb; feeds the idle clock.
pub fn note_driving() {
    LAST_DRIVING.store(now(), Ordering::Relaxed);
}

/// Clear the overlay after IDLE_CLEAR_SEC without a driving verb. Idempotent; the next driving
/// verb re-badges instantly.
pub fn start_idle_watcher() -> bool {
    if IDLE_WATCHER.swap(true, Ordering::SeqCst) {
        return false;
    }
    std::thread::Builder::new()
        .name("altium-badge-idle-clear".into())
        .spawn(|| loop {
            std::thread::sleep(Duration::from_secs(15));
            let last = LAST_DRIVING.load(Ordering::Relaxed);
            if badge_active() && last > 0 && now() - last > IDLE_CLEAR_SEC {
                clear_driving_badge();
            }
        })
        .is_ok()
}

// ---- progress ----------------------------------------------------------------

fn taskbar_progress(state: &str, value: Option<i64>) -> bool {
    let hwnd = match main_hwnd() {
        Some(h) => h,
        None => return false,
    };
    let mut prog = json!({"state": state});
    if let Some(v) = value {
        prog["value"] = json!(v.clamp(0, 100));
    }
    taskbar(json!({"hwnd": hwnd, "progress": prog}))
}

/// Begin a progress bar for `verb`, paced by its measured estimate (marquee when none).
///
/// The FINAL clear is painted by this ticker thread, not by `end_activity_progress`: every paint
/// is an ab round trip, so a clear painted from another thread can lose the race to a tick still
/// in flight — leaving a stale partial bar parked on Altium's taskbar icon forever (John saw
/// exactly that, 2026-09-02, minutes after the Rust swap). Paints are sequential within this
/// thread, so a clear IT paints is guaranteed to be last.
pub fn start_activity_progress(verb: &str, estimate_s: Option<f64>, confidence: &str) {
    let stop = std::sync::Arc::new(AtomicBool::new(false));
    let failed = std::sync::Arc::new(AtomicBool::new(false));
    if let Ok(mut g) = PROGRESS_STOP.lock() {
        g.insert(verb.to_string(), ProgressHandle { stop: stop.clone(), failed: failed.clone() });
    }
    let est = estimate_s.filter(|e| *e > 0.0 && confidence != "unknown");
    let _ = std::thread::Builder::new().name(format!("altium-progress-{verb}")).spawn(move || {
        let started = std::time::Instant::now();
        match est {
            None => {
                taskbar_progress("indeterminate", None);
                while !stop.load(Ordering::Relaxed) {
                    std::thread::sleep(Duration::from_millis(250));
                }
            }
            Some(e) => {
                let mut fails = 0u32;
                'ticks: while !stop.load(Ordering::Relaxed) {
                    // sleep in small slices so the final clear lands promptly after the verb ends
                    for _ in 0..7 {
                        std::thread::sleep(Duration::from_millis(100));
                        if stop.load(Ordering::Relaxed) {
                            break 'ticks;
                        }
                    }
                    let pct = ((95.0 * started.elapsed().as_secs_f64() / e) as i64).min(95);
                    if taskbar_progress("normal", Some(pct)) {
                        fails = 0;
                    } else {
                        fails += 1; // window may not exist yet during a cold launch — keep trying
                        if fails > 120 {
                            break; // still fall through to the clear below
                        }
                    }
                }
            }
        }
        // terminal frames, from THIS thread only
        if failed.load(Ordering::Relaxed) {
            taskbar_progress("error", Some(100));
            std::thread::sleep(Duration::from_millis(1200));
        }
        taskbar_progress("none", None);
        if let Some(h) = main_hwnd() {
            // cancel Windows' own orange attention flash (John: "can u not orange flash the taskbar")
            taskbar(json!({"hwnd": h, "flash": "stop"}));
        }
    });
}

pub fn end_activity_progress(verb: &str, failed: bool) {
    if let Some(h) = PROGRESS_STOP.lock().ok().and_then(|mut g| g.remove(verb)) {
        h.failed.store(failed, Ordering::Relaxed);
        h.stop.store(true, Ordering::Relaxed); // the ticker paints the clear (see above)
    }
}

// ---- base64 (no external crate) ----------------------------------------------

fn b64encode(data: &[u8]) -> String {
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
    for chunk in data.chunks(3) {
        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
        out.push(T[(n >> 18) as usize & 63] as char);
        out.push(T[(n >> 12) as usize & 63] as char);
        out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });
        out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });
    }
    out
}

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

    #[test]
    fn base64_reference_vectors() {
        assert_eq!(b64encode(b""), "");
        assert_eq!(b64encode(b"f"), "Zg==");
        assert_eq!(b64encode(b"fo"), "Zm8=");
        assert_eq!(b64encode(b"foo"), "Zm9v");
        assert_eq!(b64encode(b"foobar"), "Zm9vYmFy");
    }
}