main
Drew Owens 0.9.0: bridge data moved out of the install cache (recovery survives updates), closed-in-place records, gates route thr… 5d02ca9 20d ago
//! SOLIDWORKS' WINDOWS, seen through ab's generic desktop verbs.
//!
//! This bridge deliberately owns no window code. Screenshots, window
//! enumeration, taskbar overlays and WM_CLOSE are ab-core capabilities that
//! every bridge would otherwise reimplement (badly, and differently), so the
//! `swx_*` window verbs are thin, honest delegations: we supply the SOLIDWORKS
//! knowledge (which HWND is the app, which dialog titles are blocking, what is
//! safe to touch) and ab supplies the mechanism.
//!
//! A callback that fails NEVER fails the verb: ab being unreachable means a
//! partial answer, not a broken bridge. Every helper here degrades to "unknown"
//! and says so.

use serde_json::{json, Value};
use std::time::Duration;

/// The Win32 dialog window class. Every SOLIDWORKS modal — Save changes?,
/// Document Recovery, the export option sheets — is one of these.
pub const DIALOG_CLASS: &str = "#32770";

/// Is this a top-level application FRAME rather than a dialog?
///
/// It matters because closing a frame is not "dismissing a dialog", it is
/// QUITTING THE APPLICATION — with Save-changes modals on the way out, and on a
/// Connected edition this bridge cannot start it again.
///
/// The hole this closes: `ownerConfirmed` is true precisely when a window is
/// most provably SOLIDWORKS', and the main frame is the most provable window
/// there is. So the "not a general window killer" guard passed on the one window
/// it should never have passed on — and `swx_status` / `swx_window_info` hand
/// `mainHwnd` to callers, so it was the obvious thing to try.
pub fn is_frame_window(w: &Value, main_hwnd: Option<i64>) -> bool {
    let hwnd = w.get("hwnd").and_then(|h| h.as_i64());
    if hwnd.is_some() && hwnd == main_hwnd {
        return true;
    }
    let class = w.get("className").and_then(|c| c.as_str()).unwrap_or("");
    // MFC frame windows (which is what SOLIDWORKS' own windows are) register as
    // `Afx:<hex>:...`. A dialog is `#32770`; ANYTHING ELSE — including a window
    // ab reports with no className at all — is treated as a frame.
    //
    // The `!class.is_empty() &&` that used to lead this expression inverted its
    // own fail-safe: a window whose class we could not read was declared "not a
    // frame" and therefore closable. Same rule as `kill_is_unsafe`: an
    // unanswerable question is not permission.
    class != DIALOG_CLASS
}

/// Dialog kinds this bridge itself causes. REPORTED, not licensed: closing even
/// one of these needs the process-level write opt-in, because on 2026-09-02
/// closing a stuck SOLIDWORKS modal through the window manager took the whole
/// application down. The flag exists so a caller can see which dialogs on screen
/// are the bridge's own mess.
///
/// Deliberately ONE entry: `export_options` is the sheet a `SaveAs3` raises.
/// Everything else, `settings` and `message` included, can equally be a dialog
/// the USER opened and is part-way through answering.
pub const BRIDGE_CAUSED_DIALOGS: &[&str] = &["export_options"];

/// Ask ab for the desktop's windows. Returns `Err` with a human message when
/// ab could not answer, so a caller can decide whether that is fatal (it never
/// is, here).
pub fn list_windows(timeout: Duration) -> Result<Vec<Value>, String> {
    let resp = crate::caller::ad_callback("desktop_list_windows", json!({}), timeout);
    windows_from(&resp).ok_or_else(|| {
        resp.get("error")
            .and_then(|e| e.as_str())
            .map(|s| s.to_string())
            .unwrap_or_else(|| "ab returned no window list".to_string())
    })
}

/// Dig the window array out of whichever envelope ab used. ab wraps some verb
/// payloads as a JSON STRING in `output` (`{"data":{"windows":[...]}}`) and
/// returns others inline, so both shapes are accepted rather than guessed at.
pub fn windows_from(resp: &Value) -> Option<Vec<Value>> {
    fn dig(v: &Value) -> Option<Vec<Value>> {
        v.get("windows")
            .or_else(|| v.get("data").and_then(|d| d.get("windows")))
            .and_then(|w| w.as_array())
            .cloned()
    }
    if let Some(w) = dig(resp) {
        return Some(w);
    }
    let inner: Value = serde_json::from_str(resp.get("output")?.as_str()?).ok()?;
    dig(&inner)
}

/// How a SOLIDWORKS dialog title is classified. The point of the classification
/// is the `blocking` / `neverAutoAnswer` split: a bridge may report anything,
/// but it must not answer a question about the USER'S unsaved work.
pub fn classify(title: &str) -> (&'static str, bool, bool) {
    let t = title.to_lowercase();
    // (kind, blocking, neverAutoAnswer)
    // The per-document prompt ("Save changes to Part1?") AND the multi-document
    // one, which on a Connected edition is titled "Save Modified Documents" and
    // offers Save all to This PC / Save all to 3DEXPERIENCE / Don't Save /
    // Cancel. That second title contains neither "change" nor "?", so it used to
    // classify as `unknown` — still never auto-answered, but the quit ladder
    // would have reported it as a generic dialog instead of the one finding that
    // matters: a save prompt at a VERIFIED zero open documents means the
    // document count was wrong (docs/QUIT-INVESTIGATION.md §5.5).
    if t.contains("save")
        && (t.contains("change") || t.contains("?") || t.contains("modified") || t.contains("document"))
    {
        return ("save_changes", true, true);
    }
    // IMPORT DIAGNOSTICS. Measured 2026-09-17: `swImportAutoRunImportDiagnostics`
    // (291) and `swImportNeutralRunDiagnostics` (690) are both TRUE on this
    // install, so a STEP with faulty faces raises this MODAL dialog during the
    // import. It classified as `unknown` before — never auto-answered, which was
    // right, but reported as a generic dialog when it is the single most likely
    // thing to stop an import on this machine. It is still NEVER answered: the
    // choices it offers (attempt to heal, or not) change the geometry the user
    // gets, and that is theirs to make.
    if t.contains("import diagnostic") || t.contains("import diagnos") {
        return ("import_diagnostics", true, true);
    }
    if t.contains("recover") {
        return ("document_recovery", true, true);
    }
    if t.contains("licen") || t.contains("activat") || t.contains("3dexperience") {
        return ("license", true, true);
    }
    if t.contains("update") || t.contains("upgrade") {
        return ("update", true, true);
    }
    if t == "export" || t.starts_with("export ") || t.contains("export options") {
        return ("export_options", true, false);
    }
    if t.contains("units") || t.contains("template") || t.contains("options") {
        return ("settings", true, false);
    }
    if t.contains("error") || t.contains("warning") || t.contains("rebuild") {
        return ("message", true, false);
    }
    ("unknown", true, true)
}

/// Is this window plausibly SOLIDWORKS'? `main_hwnd` (from COM's
/// `Frame().GetHWnd()`) identifies the application window exactly; a top-level
/// `#32770` cannot be attributed from the flat list ab returns, so it is
/// reported with `ownerConfirmed:false` rather than silently claimed.
pub fn is_solidworks_window(w: &Value, main_hwnd: Option<i64>, sw_pid: Option<i64>) -> Option<(bool, bool)> {
    let hwnd = w.get("hwnd").and_then(|h| h.as_i64())?;
    let title = w.get("title").and_then(|t| t.as_str()).unwrap_or("");
    let class = w.get("className").and_then(|c| c.as_str()).unwrap_or("");
    let pid = w.get("pid").and_then(|p| p.as_i64());

    // PROOF, in the only two forms available:
    //   1. the hwnd IS the application window COM told us about, or
    //   2. the window's owner pid IS SOLIDWORKS' pid.
    if Some(hwnd) == main_hwnd {
        return Some((true, true));
    }
    if let (Some(p), Some(sw)) = (pid, sw_pid) {
        if p == sw {
            return Some((true, true));
        }
        // A pid we CAN see, that is not SOLIDWORKS', is not ours at all.
        return None;
    }

    // Everything below is a CANDIDATE, never a confirmation.
    //
    // A title merely CONTAINING "SOLIDWORKS" used to count as proof, and it is
    // not: the SOLIDWORKS Installation Manager, a 3DEXPERIENCE launcher dialog
    // and an ordinary Explorer window showing a SOLIDWORKS folder all match,
    // and none of them belong to the process this bridge is driving. With COM
    // down there is no `main_hwnd` either, so the title would be the ONLY test —
    // exactly when being wrong is most likely.
    if title.to_uppercase().contains("SOLIDWORKS") {
        return Some((true, false));
    }
    if class == DIALOG_CLASS {
        return Some((true, false));
    }
    None
}

/// The dialog census: every window that could be a SOLIDWORKS modal, classified.
pub fn dialog_census(windows: &[Value], main_hwnd: Option<i64>, sw_pid: Option<i64>) -> Vec<Value> {
    let mut out = Vec::new();
    for w in windows {
        let class = w.get("className").and_then(|c| c.as_str()).unwrap_or("");
        let title = w.get("title").and_then(|t| t.as_str()).unwrap_or("");
        if class != DIALOG_CLASS {
            continue;
        }
        let Some((ours, confirmed)) = is_solidworks_window(w, main_hwnd, sw_pid) else { continue };
        if !ours {
            continue;
        }
        let (kind, blocking, never_auto) = classify(title);
        out.push(json!({
            "hwnd": w.get("hwnd").cloned().unwrap_or(Value::Null),
            "title": title,
            "className": class,
            "kind": kind,
            "bridgeCaused": BRIDGE_CAUSED_DIALOGS.contains(&kind),
            "blocking": blocking,
            "neverAutoAnswer": never_auto,
            "ownerConfirmed": confirmed,
            "rect": w.get("rect").cloned().unwrap_or(Value::Null),
        }));
    }
    out
}

/// Every window we believe belongs to SOLIDWORKS, for `swx_window_info`.
pub fn solidworks_windows(windows: &[Value], main_hwnd: Option<i64>, sw_pid: Option<i64>) -> Vec<Value> {
    windows
        .iter()
        .filter_map(|w| {
            let (ours, confirmed) = is_solidworks_window(w, main_hwnd, sw_pid)?;
            if !ours {
                return None;
            }
            let class = w.get("className").and_then(|c| c.as_str()).unwrap_or("");
            Some(json!({
                "hwnd": w.get("hwnd").cloned().unwrap_or(Value::Null),
                "title": w.get("title").cloned().unwrap_or(Value::Null),
                "className": class,
                "rect": w.get("rect").cloned().unwrap_or(Value::Null),
                "isMain": w.get("hwnd").and_then(|h| h.as_i64()) == main_hwnd,
                "isDialog": class == DIALOG_CLASS,
                "isFrame": is_frame_window(w, main_hwnd),
                // `closable` is what a caller ACTS on, so it has to fold in
                // every refusal `swx_close_window` applies — not just
                // attribution. Reporting a Save-changes dialog as closable was
                // the same discoverability shape as handing out `mainHwnd`.
                "closable": !is_frame_window(w, main_hwnd)
                    && confirmed
                    && !classify(w.get("title").and_then(|t| t.as_str()).unwrap_or("")).2,
                "pid": w.get("pid").cloned().unwrap_or(Value::Null),
                "ownerConfirmed": confirmed,
            }))
        })
        .collect()
}

/// Close one window through ab's UIA WindowPattern.
pub fn close_window(hwnd: i64, reason: &str, timeout: Duration) -> Value {
    crate::caller::ad_callback(
        "desktop_ui_window",
        json!({"action": "close", "hwnd": hwnd, "reason": reason}),
        timeout,
    )
}

/// Put (or clear) the "Adom is driving SOLIDWORKS" taskbar overlay badge.
pub fn set_badge(hwnd: i64, on: bool, tooltip: &str, timeout: Duration) -> Value {
    crate::caller::ad_callback(
        "desktop_taskbar",
        json!({
            "hwnd": hwnd,
            "overlay": {"badge": if on { "adom" } else { "none" }, "tooltip": tooltip},
        }),
        timeout,
    )
}

/// Background PrintWindow capture of one window — and, per ab's contract, of
/// every popup/dialog that window owns. That owned-popup list is the only
/// AUTHORITATIVE way to attribute a dialog to SOLIDWORKS, which is why
/// `swx_state`'s screenshot doubles as the trustworthy dialog census.
pub fn screenshot(hwnd: i64, timeout: Duration) -> Value {
    crate::caller::ad_callback("desktop_screenshot_window", json!({"hwnd": hwnd}), timeout)
}

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

    fn win(hwnd: i64, title: &str, class: &str) -> Value {
        json!({"hwnd": hwnd, "title": title, "className": class, "rect": {"left": 0}})
    }

    #[test]
    fn unwraps_both_envelopes_ab_actually_uses() {
        // inline
        let inline = json!({"windows": [win(1, "a", "X")]});
        assert_eq!(windows_from(&inline).unwrap().len(), 1);
        // the real one observed live: a JSON STRING under `output`
        let nested = json!({"output": json!({"data": {"windows": [win(1, "a", "X"), win(2, "b", "Y")]}}).to_string()});
        assert_eq!(windows_from(&nested).unwrap().len(), 2);
        // and a shape with neither
        assert!(windows_from(&json!({"error": "nope"})).is_none());
    }

    #[test]
    fn the_dialogs_that_must_never_be_auto_answered_are_flagged() {
        for (title, kind) in [
            ("SOLIDWORKS", "unknown"),
            ("Save changes to Part1?", "save_changes"),
            ("SOLIDWORKS Document Recovery", "document_recovery"),
            ("3DEXPERIENCE", "license"),
        ] {
            let (k, blocking, never) = classify(title);
            assert_eq!(k, kind, "{title}");
            assert!(blocking, "{title}");
            assert!(never, "{title} must never be auto-answered");
        }
        // An export option sheet is classified as answerable IN PRINCIPLE (it is
        // the bridge's own dialog, not a question about the user's work) — but
        // classification is only half the decision: closing also requires the
        // window to be attributable to SOLIDWORKS. See
        // `an_unattributed_dialog_is_never_closable`.
        let (k, _, never) = classify("Export");
        assert_eq!(k, "export_options");
        assert!(!never);
    }

    #[test]
    fn the_application_frame_is_never_closable() {
        // THE round-7 hole. `ownerConfirmed` is true exactly when a window is
        // most provably SOLIDWORKS' — and the main frame is the most provable
        // window there is, so the "not a general window killer" guard passed on
        // the one window that must never be closed this way. Closing a frame is
        // quitting the application, which is swx_stop's job and is gated.
        let main = Some(4242);
        let frame = win(4242, "SOLIDWORKS Design Professional for Makers 2026", "Afx:0000");
        assert!(is_frame_window(&frame, main), "the main window is a frame");
        let ours = solidworks_windows(&[frame.clone()], main, None);
        assert_eq!(ours[0]["isMain"], true);
        assert_eq!(ours[0]["ownerConfirmed"], true, "it IS provably ours...");
        assert_eq!(ours[0]["closable"], false, "...and it is still never closable");

        // A SECOND SOLIDWORKS frame (a second document window) is a frame too,
        // even without being the one COM told us about.
        let other = win(5555, "SOLIDWORKS", "Afx:0001");
        assert!(is_frame_window(&other, main));
        // A dialog is not a frame — that is what this verb is for.
        let mut dialog = win(858186, "Export", DIALOG_CLASS);
        dialog["pid"] = json!(777);
        assert!(!is_frame_window(&dialog, main));
        assert_eq!(solidworks_windows(&[dialog], main, Some(777))[0]["closable"], true);
        // ...but a dialog about the USER'S OWN WORK is never closable, however
        // provably ours it is.
        let mut theirs = win(9001, "Save changes to gearbox?", DIALOG_CLASS);
        theirs["pid"] = json!(777);
        let row = solidworks_windows(&[theirs], main, Some(777));
        assert_eq!(row[0]["ownerConfirmed"], true);
        assert_eq!(row[0]["closable"], false, "never advertise the user's own question as closable");
    }

    #[test]
    fn only_dialogs_the_bridge_itself_raised_are_closable_without_the_opt_in() {
        // `export_options` is the sheet our own SaveAs3 raises. `settings` and
        // `message` can just as easily be something the USER opened and is
        // part-way through answering.
        assert!(BRIDGE_CAUSED_DIALOGS.contains(&"export_options"));
        for user_owned in ["settings", "message", "unknown", "save_changes"] {
            assert!(
                !BRIDGE_CAUSED_DIALOGS.contains(&user_owned),
                "{user_owned} may belong to the user"
            );
        }
    }

    #[test]
    fn an_unattributed_dialog_is_never_closable() {
        // THE regression test for the review finding. ab's flat window list
        // carries no owner pid, so a bare `#32770` could belong to Word's Save
        // dialog just as easily as to SOLIDWORKS. The census must mark it
        // ownerConfirmed:false, and the close path must refuse it on that basis
        // — the earlier version of this test asserted the opposite ("ours to
        // close"), encoding the hazard as intended behaviour.
        let windows = vec![win(858186, "Export", DIALOG_CLASS)];
        let census = dialog_census(&windows, Some(1836650), None);
        assert_eq!(census.len(), 1, "it IS reported");
        assert_eq!(census[0]["ownerConfirmed"], false, "...but never as proven SOLIDWORKS'");
        // A dialog whose title names SOLIDWORKS is attributable, and a #32770 is
        // exactly how SOLIDWORKS' own dialogs present.
        // A title containing "SOLIDWORKS" is NOT proof: the Installation
        // Manager and a 3DEXPERIENCE launcher dialog both match it and neither
        // is the process we are driving.
        let named = vec![win(9, "SOLIDWORKS Installation Manager", DIALOG_CLASS)];
        assert_eq!(dialog_census(&named, None, None)[0]["ownerConfirmed"], false);
        // An owner pid IS proof, in both directions.
        let mut owned = win(11, "Export", DIALOG_CLASS);
        owned["pid"] = json!(4242);
        assert_eq!(dialog_census(&[owned.clone()], None, Some(4242))[0]["ownerConfirmed"], true);
        assert!(dialog_census(&[owned], None, Some(777)).is_empty(), "another app's dialog is not ours at all");
    }

    #[test]
    fn attribution_is_reported_not_assumed() {
        let main = Some(1836650);
        let windows = vec![
            win(1836650, "SOLIDWORKS Design Professional for Makers 2026", "Afx:0000"),
            win(858186, "Export", DIALOG_CLASS),
            win(266266, "gallia - Google Chrome", "Chrome_WidgetWin_1"),
        ];
        let ours = solidworks_windows(&windows, main, None);
        assert_eq!(ours.len(), 2, "Chrome is not ours");
        assert_eq!(ours[0]["isMain"], true);
        assert_eq!(ours[0]["ownerConfirmed"], true);
        // a bare #32770 is a CANDIDATE, and says so
        assert_eq!(ours[1]["isDialog"], true);
        assert_eq!(ours[1]["ownerConfirmed"], false);

        let dialogs = dialog_census(&windows, main, None);
        assert_eq!(dialogs.len(), 1);
        assert_eq!(dialogs[0]["kind"], "export_options");
        assert_eq!(dialogs[0]["hwnd"], 858186);
    }

    #[test]
    fn without_a_com_hwnd_the_main_window_is_still_found_by_title() {
        let windows = vec![win(9, "SOLIDWORKS Design Professional", "Afx:1")];
        let ours = solidworks_windows(&windows, None, None);
        assert_eq!(ours.len(), 1, "it is still REPORTED as a candidate");
        assert_eq!(ours[0]["isMain"], false, "unproven without the COM hwnd");
        assert_eq!(
            ours[0]["ownerConfirmed"], false,
            "a title is not proof of ownership — and with COM down it would be the ONLY test"
        );
    }
}