main
Drew Owens 0.11.2 source 784acbf 5d ago
//! WHICH VERB PUT THAT DIALOG UP? (0.11.1)
//!
//! MEASURED 2026-10-15 on adom_drew2: a SOLIDWORKS File ▸ Open dialog (title
//! "Open ", `#32770`, owned by the SOLIDWORKS main frame, file name empty)
//! appeared between 14:57 and 15:00Z, DURING a bridge battery, with Drew away
//! from the machine — and from then on every `swx_stop` was
//! `stop_blocked_by_dialog`. The census called it `kind:"unknown"`,
//! `bridgeCaused:false`: it could not say where it came from, and so the
//! session the bridge had started could never be ended by the bridge.
//!
//! So every verb is now BRACKETED by a census of SOLIDWORKS' dialogs: one
//! before it runs, one after. A dialog first seen in the after-census, that
//! the before-census (taken after the verb was marked in flight) proved
//! ABSENT, appeared DURING that verb — `appearedDuring`. Sightings persist
//! (data root, `dialog-sightings.json`) with the time the dialog was first
//! seen, the last time it was proved absent, and the user's last input. A
//! dialog first seen outside a bracket stays UNATTRIBUTED: the bridge never
//! claims what it cannot prove.
//!
//! Policy is unchanged — the bridge never answers a user's dialog — with ONE
//! carve-out: the standard Windows FILE dialog, with an EMPTY file name, that
//! the bridge PROVABLY provoked (appeared during a bridge verb, no user input
//! since that verb began). Cancelling that is a no-op the bridge owes the
//! user; `swx_dismiss_blocking_dialogs` may do it, ledgered with the
//! attribution. Anything with text typed into it is the user's.

use serde_json::{json, Value};
use std::sync::Mutex;

/// One verb in flight: what, and since when (ms since the epoch).
#[derive(Clone, Debug, PartialEq)]
pub struct InFlight {
    pub id: u64,
    pub verb: String,
    pub started_ms: u64,
}

static IN_FLIGHT: Mutex<Vec<InFlight>> = Mutex::new(Vec::new());
static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
static FILE_LOCK: Mutex<()> = Mutex::new(());

fn now_ms() -> u64 {
    crate::launchos::epoch_ms() as u64
}

fn sightings_path() -> std::path::PathBuf {
    crate::scratch::root().join("dialog-sightings.json")
}

/// The persisted sightings (hwnd → what is known about its appearance).
pub fn sightings() -> Vec<Value> {
    std::fs::read_to_string(sightings_path()).ok().and_then(|t| serde_json::from_str::<Vec<Value>>(&t).ok()).unwrap_or_default()
}

fn save(v: &[Value]) {
    let _ = std::fs::write(sightings_path(), serde_json::to_string(v).unwrap_or_else(|_| "[]".into()));
}

/// The dialogs SOLIDWORKS is showing now: every top-level window of its
/// process(es) and their CEF children that is a `#32770`, or is a visible,
/// non-tool window OWNED by another SOLIDWORKS window. `None` = could not look.
/// Deterministic: the native window list, every time — never ab's.
pub fn census_windows(os: &dyn crate::launchos::LaunchOs) -> Option<(Vec<crate::launchos::WindowInfo>, Vec<u32>)> {
    let procs = os.processes().ok()?;
    let sw: Vec<&crate::launchos::ProcInfo> = procs.iter().filter(|p| p.is("SLDWORKS.exe")).collect();
    let mut pids: Vec<u32> = Vec::new();
    for p in &sw {
        pids.push(p.pid);
        pids.extend(procs.iter().filter(|c| c.is("swCefSubProc.exe") && c.ppid == Some(p.pid)).map(|c| c.pid));
    }
    let mut all = Vec::new();
    for pid in &pids {
        all.extend(os.windows_of_pid(*pid).ok()?);
    }
    let ours: std::collections::HashSet<i64> = all.iter().map(|w| w.hwnd).collect();
    let rows = all
        .iter()
        .filter(|w| {
            w.is_dialog()
                || (w.visible
                    && w.owner.map(|o| ours.contains(&o)).unwrap_or(false)
                    && !w.exstyle.map(|e| e & crate::launchos::WS_EX_TOOLWINDOW != 0).unwrap_or(false))
        })
        .cloned()
        .collect();
    Some((rows, sw.iter().map(|p| p.pid).collect()))
}

/// OBSERVE: record every dialog not seen before, attributed to the verbs in
/// flight; strike off sightings whose window is gone. Returns the sightings of
/// the dialogs that are up now.
pub fn observe(os: &dyn crate::launchos::LaunchOs) -> Option<Vec<Value>> {
    let (rows, _) = census_windows(os)?;
    let _g = FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let mut seen = sightings();
    let last_ok = seen.iter().filter_map(|s| s.get("censusMs").and_then(|v| v.as_u64())).max();
    let now = now_ms();
    let flight: Vec<InFlight> = IN_FLIGHT.lock().unwrap_or_else(|e| e.into_inner()).clone();
    let last_input_ms = os.seconds_since_last_input().ok().map(|s| now.saturating_sub(s * 1000));
    let up: std::collections::HashSet<i64> = rows.iter().map(|w| w.hwnd).collect();
    // Gone windows are forgotten; the census time moves on for the rest.
    seen.retain(|s| s.get("hwnd").and_then(|h| h.as_i64()).map(|h| up.contains(&h)).unwrap_or(false));
    let mut current = Vec::new();
    for w in &rows {
        if let Some(s) = seen.iter_mut().find(|s| s["hwnd"] == w.hwnd && s["title"] == w.title.as_str()) {
            s["lastSeenMs"] = json!(now);
            current.push(s.clone());
            continue;
        }
        // A window under a REUSED hwnd with a new title is a new sighting.
        seen.retain(|s| s["hwnd"] != w.hwnd);
        let s = json!({
            "hwnd": w.hwnd,
            "title": w.title,
            "className": w.class_name,
            "owner": w.owner,
            "firstSeenMs": now,
            "lastSeenMs": now,
            // The last census that could see SOLIDWORKS' windows and did not
            // contain this one: it appeared after that.
            "absentAtMs": last_ok,
            "appearedDuring": flight.iter().map(|f| json!({"verb": f.verb, "startedMs": f.started_ms})).collect::<Vec<_>>(),
            "lastInputMs": last_input_ms,
        });
        current.push(s.clone());
        seen.push(s);
    }
    // Every sighting carries the time of the latest census, so the next new
    // dialog knows when it was last proved absent.
    let mut out = seen;
    out.push(json!({"hwnd": Value::Null, "censusMs": now}));
    out.retain(|s| !s["hwnd"].is_null() || s.get("censusMs").and_then(|v| v.as_u64()) == Some(now));
    save(&out);
    Some(current)
}

/// IS THIS SIGHTING PROVABLY THE BRIDGE'S? It appeared during a bridge verb —
/// proved ABSENT at or after that verb began, first seen while it was in
/// flight — and the user gave no input from that verb's start until `now`
/// (`last_input_now_ms`: the latest input as read now). Returns the verb.
pub fn bridge_caused(s: &Value, last_input_now_ms: Option<u64>) -> Option<String> {
    let absent = s.get("absentAtMs").and_then(|v| v.as_u64())?;
    let input_then = s.get("lastInputMs").and_then(|v| v.as_u64())?;
    let input_now = last_input_now_ms?;
    // The most recent verb first: a retry in flight beside an earlier route's
    // grace window is the one that raised it.
    s.get("appearedDuring")?.as_array()?.iter().rev().find_map(|d| {
        let started = d.get("startedMs").and_then(|v| v.as_u64())?;
        let verb = d.get("verb").and_then(|v| v.as_str())?;
        (started <= absent && input_then < started && input_now < started).then(|| verb.to_string())
    })
}

/// What is known about one dialog's appearance, for a report.
pub fn attribution(hwnd: i64, os: &dyn crate::launchos::LaunchOs) -> Value {
    let Some(s) = sightings().into_iter().find(|s| s["hwnd"] == hwnd) else {
        return json!({"known": false, "why": "no census saw this window appear: it was up before the bridge first looked, so where it came from cannot be said"});
    };
    let now = now_ms();
    let input_now = os.seconds_since_last_input().ok().map(|x| now.saturating_sub(x * 1000));
    let during = s.get("appearedDuring").and_then(|d| d.as_array()).cloned().unwrap_or_default();
    let caused = bridge_caused(&s, input_now);
    json!({
        "known": true,
        "firstSeenMs": s["firstSeenMs"],
        "ageSeconds": s.get("firstSeenMs").and_then(|v| v.as_u64()).map(|f| now.saturating_sub(f) / 1000),
        "appearedDuring": if during.is_empty() { Value::Null } else { json!(during.iter().filter_map(|d| d.get("verb").cloned()).collect::<Vec<_>>()) },
        "appearedBetweenVerbs": during.is_empty(),
        "provedAbsentAtMs": s["absentAtMs"],
        "bridgeCaused": caused.is_some(),
        "bridgeCausedBy": caused,
        "userInputSinceItAppeared": match (input_now, s.get("firstSeenMs").and_then(|v| v.as_u64())) {
            (Some(i), Some(f)) => json!(i >= f),
            _ => Value::Null,
        },
    })
}

/// A verb begins: marked in flight FIRST, then the census — so the census
/// that proves a dialog absent is one taken after the verb began.
pub fn begin(verb: &str) -> u64 {
    let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
    IN_FLIGHT.lock().unwrap_or_else(|e| e.into_inner()).push(InFlight { id, verb: verb.to_string(), started_ms: now_ms() });
    let os = crate::launchos::os();
    let _ = observe(os.as_ref());
    id
}

/// A verb ends: the census FIRST (a dialog it raised is attributed to it),
/// then it leaves the in-flight list. New dialogs are ledgered.
pub fn end(id: u64) -> Vec<Value> {
    let os = crate::launchos::os();
    let before: std::collections::HashSet<i64> = sightings().iter().filter_map(|s| s.get("hwnd").and_then(|h| h.as_i64())).collect();
    let now = observe(os.as_ref()).unwrap_or_default();
    IN_FLIGHT.lock().unwrap_or_else(|e| e.into_inner()).retain(|f| f.id != id);
    let fresh: Vec<Value> = now.into_iter().filter(|s| s.get("hwnd").and_then(|h| h.as_i64()).map(|h| !before.contains(&h)).unwrap_or(false)).collect();
    for s in &fresh {
        crate::ledger::record("dialog_appeared", json!({"hwnd": s["hwnd"], "title": s["title"], "className": s["className"],
                                                         "appearedDuring": s["appearedDuring"], "owner": s["owner"]}));
    }
    fresh
}

/// Cancel every dialog that is up, provably raised by the LAUNCH supervisor
/// (`launch.invoke` / `launch.retry`), and is the standard file dialog with an
/// empty name — what a re-invoke at a starting SOLIDWORKS raises (measured
/// 2026-10-15). Ledgered; a launch never leaves the session blocked by its own
/// Open dialog. Everything else is left alone.
pub fn cancel_launch_file_dialogs(os: &dyn crate::launchos::LaunchOs) -> Vec<Value> {
    let mut out = Vec::new();
    let Some(up) = observe(os) else { return out };
    for s in up {
        let Some(h) = s.get("hwnd").and_then(|h| h.as_i64()) else { continue };
        if let Ok((verb, sighting)) = may_cancel(h, os) {
            if !verb.starts_with("launch.") {
                continue;
            }
            let r = os.cancel_dialog(h);
            crate::ledger::record(
                "dialog_cancelled",
                json!({"hwnd": h, "title": sighting["title"], "raisedDuring": verb, "by": "the launch supervisor",
                       "sighting": sighting, "ok": r.is_ok(), "error": r.as_ref().err()}),
            );
            out.push(json!({"hwnd": h, "title": sighting["title"], "raisedDuring": verb, "ok": r.is_ok()}));
        }
    }
    out
}

/// A launch route, bracketed like a verb: in flight from its invoke until
/// `grace` after it ends, watched (and its own empty File ▸ Open cancelled)
/// all that time on a thread of its own.
pub struct LaunchBracket {
    id: u64,
    grace: std::time::Duration,
}

impl LaunchBracket {
    pub fn begin(label: &str, grace: std::time::Duration) -> LaunchBracket {
        LaunchBracket { id: begin(label), grace }
    }
}

impl Drop for LaunchBracket {
    fn drop(&mut self) {
        let (id, grace) = (self.id, self.grace);
        std::thread::spawn(move || {
            let os = crate::launchos::os();
            let until = std::time::Instant::now() + grace;
            loop {
                let _ = cancel_launch_file_dialogs(os.as_ref());
                if std::time::Instant::now() >= until {
                    break;
                }
                std::thread::sleep(std::time::Duration::from_millis(500));
            }
            end(id);
        });
    }
}

/// Does `dispatch` bracket this verb? Every known verb except the ones that
/// only read the bridge's own state (they never touch SOLIDWORKS).
pub fn brackets(verb: &str) -> bool {
    !matches!(verb, "swx_progress" | "swx_action_ledger" | "swx_verb_times" | "swx_describe")
}

/// The standard Windows FILE dialog with an empty name, provoked by the bridge:
/// the one dialog the bridge may cancel. `Err` says why not.
pub fn may_cancel(hwnd: i64, os: &dyn crate::launchos::LaunchOs) -> Result<(String, Value), String> {
    let s = sightings().into_iter().find(|s| s["hwnd"] == hwnd).ok_or("no census saw it appear: its origin is unknown")?;
    let now = now_ms();
    let input_now = os.seconds_since_last_input().ok().map(|x| now.saturating_sub(x * 1000));
    let verb = bridge_caused(&s, input_now).ok_or(
        "it is not PROVABLY the bridge's: it did not appear during a bridge verb, or the user has given input since that verb began",
    )?;
    let fd = os.file_dialog(hwnd).map_err(|e| format!("whether it is a file dialog could not be read ({e})"))?;
    if !fd.standard {
        return Err("it is not the standard Windows file dialog".into());
    }
    match fd.file_name.as_deref() {
        None => return Err("its file-name box could not be read".into()),
        Some(t) if !t.trim().is_empty() => return Err(format!("its file-name box holds text ({} characters): somebody typed into it", t.chars().count())),
        _ => {}
    }
    Ok((verb, s))
}

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

    #[test]
    fn only_a_dialog_proved_absent_after_a_verb_began_with_no_input_since_is_the_bridges() {
        let s = json!({"absentAtMs": 1000, "lastInputMs": 500, "appearedDuring": [{"verb": "swx_start", "startedMs": 900}]});
        assert_eq!(bridge_caused(&s, Some(500)), Some("swx_start".to_string()));
        // Input after the verb began: the user may have opened it.
        assert_eq!(bridge_caused(&s, Some(950)), None, "input since the verb began");
        assert_eq!(bridge_caused(&json!({"absentAtMs": 1000, "lastInputMs": 950, "appearedDuring": [{"verb": "v", "startedMs": 900}]}), Some(950)), None);
        // Last proved absent BEFORE the verb began: it may predate the verb.
        assert_eq!(bridge_caused(&json!({"absentAtMs": 800, "lastInputMs": 500, "appearedDuring": [{"verb": "v", "startedMs": 900}]}), Some(500)), None);
        // Appeared between verbs, or never proved absent, or input unreadable.
        assert_eq!(bridge_caused(&json!({"absentAtMs": 1000, "lastInputMs": 500, "appearedDuring": []}), Some(500)), None);
        assert_eq!(bridge_caused(&json!({"absentAtMs": null, "lastInputMs": 500, "appearedDuring": [{"verb": "v", "startedMs": 900}]}), Some(500)), None);
        assert_eq!(bridge_caused(&s, None), None, "an unreadable input time proves nothing");
    }
}