main
Drew Owens 0.9.25 source 96caa42 9d ago
//! SOLIDWORKS VISUALIZE — start it, and say what it is waiting on (0.9.19).
//!
//! MEASURED 2026-10-05 on Drew's laptop:
//!   * Starting `SLDWORKSVisualize.exe` DIRECTLY is wrong on this edition: the
//!     next start raised a `#32770` titled "SOLIDWORKS Visualize": "SOLIDWORKS
//!     Visualize must be opened from the 3DEXPERIENCE Platform or from the
//!     desktop shortcut created from the Platform. Do you want to go to the
//!     3DEXPERIENCE Platform?" — Yes / No (No = automationId CommandButton_7,
//!     which exits the process).
//!   * The right way is the PLATFORM SHORTCUT, the same form as SOLIDWORKS
//!     Design's: `C:\Users\Public\Desktop\SOLIDWORKS Visualize.lnk`, or the
//!     Start-Menu group's, → `CATSTART.exe -run "SWXDesktopLauncher.exe" -object
//!     "--AppName=\"SWXCVIZ_AP\" …"`. Invoked with SOLIDWORKS already signed in,
//!     no login appeared; after 45 s: `SLDWORKSVisualize` (main window class
//!     `HwndWrapper[SLDWORKSVisualize.exe;Main Application;<guid>]`, title
//!     "SOLIDWORKS Visualize"), `SWVisualize.Exchange.PluginHost`,
//!     `SWVisualize.Queue.Server`.
//!   * FIRST RUN: a WPF dialog titled "SOLIDWORKS Visualize": "Please choose a
//!     location for your 'SOLIDWORKS Visualize Content' folder…", an edit
//!     prefilled `C:\Users\drew\Documents`, "…" and "Continue". Answering it
//!     creates a folder on the user's machine — a standing change — so the bridge
//!     NEVER answers it: `waitingOn:"userVisualizeContentFolder"`.
//!
//! MEASURED 2026-10-05 (0.9.19): swx_status read the REAL WPF first-run dialog
//! through the desktop_ui_tree fallback — waitingOn userVisualizeContentFolder,
//! its text, prefilledPath "C:/Users/drew/Documents".
//!
//! The bridge answers NONE of Visualize's dialogs. A 3DEXPERIENCE login goes to
//! `swx_sign_in {userAuthorized:true, window:"visualize"}` on the user's
//! say-so. Quitting Visualize is UNMEASURED and not built. Whether the shortcut
//! accepts a model path is UNMEASURED; a `path` is not passed to it.

use serde_json::{json, Value};

pub const IMAGE: &str = "SLDWORKSVisualize.exe";

pub const SHORTCUT_GLOBS: &[&str] = &[
    r"%PUBLIC%\Desktop\SOLIDWORKS Visualize.lnk",
    r"%ProgramData%\Microsoft\Windows\Start Menu\Programs\Dassault Systemes SOLIDWORKS 3DEXPERIENCE*\SOLIDWORKS Visualize.lnk",
    r"%USERPROFILE%\Desktop\SOLIDWORKS Visualize.lnk",
    r"%APPDATA%\Microsoft\Windows\Start Menu\Programs\Dassault Systemes SOLIDWORKS 3DEXPERIENCE*\SOLIDWORKS Visualize.lnk",
];

/// The Visualize platform shortcut. `SWX_VISUALIZE_SHORTCUT` overrides. Only a
/// `.lnk` named "SOLIDWORKS Visualize" — never the executable.
pub fn find_shortcut() -> Option<String> {
    if let Ok(p) = std::env::var("SWX_VISUALIZE_SHORTCUT") {
        if !p.trim().is_empty() {
            return Some(p);
        }
    }
    SHORTCUT_GLOBS
        .iter()
        .flat_map(|g| crate::detect::expand_glob(g))
        .find(|p| p.to_lowercase().ends_with("solidworks visualize.lnk"))
}

/// What a Visualize window is, by its WORDS.
pub fn classify(title: &str, class: &str, words: &str) -> Option<&'static str> {
    let w = words.to_lowercase();
    if crate::launch::is_sign_in_window(class, title) {
        return Some("signIn");
    }
    if w.contains("must be opened from the 3dexperience platform") {
        return Some("platformNotice");
    }
    if w.contains("visualize content") && w.contains("folder") {
        return Some("contentFolder");
    }
    None
}

fn is_main_window(class: &str) -> bool {
    class.to_ascii_lowercase().starts_with("hwndwrapper[sldworksvisualize.exe;")
}

/// A window's words: its Win32 child text and buttons; a WPF window has none, so
/// its accessibility tree's names (and an edit's value, for the prefilled path —
/// not a secret) are read through ab when the bridge can.
fn words_of(os: &dyn crate::launchos::LaunchOs, hwnd: i64) -> (String, Option<String>) {
    let mut words = String::new();
    let mut path: Option<String> = None;
    if let Ok(c) = os.dialog_contents(hwnd) {
        words = c.text.iter().chain(c.buttons.iter()).cloned().collect::<Vec<_>>().join(" | ");
        path = c.text.iter().find(|t| t.len() > 3 && t.as_bytes().get(1) == Some(&b':') && t.contains('\\')).cloned();
    }
    if words.trim().is_empty() && crate::caller::ab_available() {
        let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 20, "maxNodes": 300}), std::time::Duration::from_secs(30));
        fn walk(v: &Value, names: &mut Vec<String>, path: &mut Option<String>) {
            match v {
                Value::Array(a) => a.iter().for_each(|x| walk(x, names, path)),
                Value::Object(m) => {
                    if let Some(n) = m.get("name").and_then(|n| n.as_str()).filter(|n| !n.is_empty()) {
                        names.push(n.to_string());
                    }
                    let role = m.get("role").or_else(|| m.get("controlType")).and_then(|r| r.as_str()).unwrap_or("");
                    if role.eq_ignore_ascii_case("edit") && path.is_none() {
                        // REPORTED AS UIA GAVE IT. MEASURED 2026-10-05: the
                        // dialog SHOWS C:\Users\drew\Documents and UIA's value is
                        // "C:/Users/drew/Documents" — forward slashes from the
                        // page itself; the bridge does not normalise either way.
                        *path = m.get("value").and_then(|v| v.as_str()).map(String::from);
                    }
                    m.values().for_each(|x| walk(x, names, path));
                }
                _ => {}
            }
        }
        let mut names = Vec::new();
        walk(&r, &mut names, &mut path);
        words = names.join(" | ");
    }
    (words, path)
}

/// THE STATE of Visualize on this machine: running, pid, main window, and what it
/// is waiting on. Read-only; answers nothing.
pub fn status() -> Value {
    let os = crate::launchos::os();
    let census = match os.processes() {
        Ok(c) => c,
        Err(e) => return json!({"running": Value::Null, "why": format!("no process census: {e}")}),
    };
    let Some(p) = census.iter().find(|p| p.image.eq_ignore_ascii_case(IMAGE)) else {
        // PARKED BEHIND THE PLATFORM'S SIGN-IN (0.9.23). MEASURED 2026-10-12:
        // after a lapsed platform session, invoking the Visualize shortcut
        // started SWXDesktopUpdate.exe, which showed a 3DEXPERIENCE ID login
        // titled just "3DEXPERIENCE"; Visualize itself never started, and this
        // block said running:false, waitingOn:null — the bridge saw nothing.
        if let Some((hwnd, title)) = crate::signin::desktop_update_login(os.as_ref()) {
            return json!({"running": false, "pid": Value::Null, "hwnd": Value::Null, "ready": false,
                          "waitingOn": "userSignIn",
                          "signInWindow": {"hwnd": hwnd, "title": title, "ownerImage": crate::signin::DESKTOP_UPDATE_IMAGE, "host": "desktopUpdate"},
                          "parkedBehind": "the 3DEXPERIENCE platform sign-in (SWXDesktopUpdate.exe) — Visualize starts after it; the bridge can prepare that page (user name, Continue), but ab's policy keeps the password with the user until ab issue #237 lands",
                          "how": "On the user's say-so, swx_sign_in {reason, userAuthorized:true, window:<signInWindow.hwnd>} fills the user name and moves to the password page; the USER types the password (ab gives saved passwords only to a verified local caller, not a bridge — ab issue #237)."});
        }
        return json!({"running": false, "pid": Value::Null, "hwnd": Value::Null, "waitingOn": Value::Null});
    };
    let windows = os.windows_of_pid(p.pid).unwrap_or_default();
    let mut main: Option<i64> = None;
    let mut waiting: Option<Value> = None;
    // THE MAIN WINDOW, dialog or not (0.9.20). MEASURED 2026-10-05: on first run
    // the content-folder dialog IS the main window (same hwnd, class
    // HwndWrapper[SLDWORKSVisualize.exe;Main Application;…]) and the block said
    // hwnd:null beside dialog.hwnd. Matched case-insensitively over EVERY window
    // of the process (visible or not), before any dialog classification.
    for w in windows.iter() {
        if is_main_window(&w.class_name) {
            main = Some(w.hwnd);
            break;
        }
    }
    for w in windows.iter().filter(|w| w.visible) {
        let (words, path) = words_of(os.as_ref(), w.hwnd);
        if let Some(kind) = classify(&w.title, &w.class_name, &words) {
            waiting = Some(match kind {
                "signIn" => json!({"waitingOn": "userSignIn", "hwnd": w.hwnd, "title": w.title,
                                   "how": "swx_sign_in {reason, userAuthorized:true, window:\"visualize\"} — only on the user's say-so — prepares the page (user name, Continue); the user types the password (ab's policy, until ab issue #237)"}),
                "contentFolder" => json!({"waitingOn": "userVisualizeContentFolder", "hwnd": w.hwnd, "title": w.title,
                                          "text": words, "prefilledPath": path,
                                          "how": "The user chooses where Visualize keeps its content (it creates a folder on their machine). The bridge never answers this."}),
                _ => json!({"waitingOn": "visualizePlatformNotice", "hwnd": w.hwnd, "title": w.title, "text": words,
                            "how": "Visualize was started directly. Do not answer it (No exits Visualize; Yes opens the platform). Start Visualize through its platform shortcut: swx_start {app:\"visualize\"}."}),
            });
            break;
        }
    }
    let waiting_on = waiting.as_ref().and_then(|w| w.get("waitingOn").cloned()).unwrap_or(Value::Null);
    json!({
        "running": true,
        "pid": p.pid,
        "hwnd": main,
        "ready": main.is_some() && waiting.is_none(),
        "waitingOn": waiting_on,
        "dialog": waiting,
        "companions": census.iter().filter(|q| q.image.starts_with("SWVisualize.")).map(|q| json!({"pid": q.pid, "image": q.image})).collect::<Vec<_>>(),
        "quit": "UNMEASURED — the bridge does not quit Visualize yet",
    })
}

/// `swx_start {app:"visualize", reason, waitSeconds?}`.
pub fn start(ctx: &crate::verbs::Ctx, args: &Value) -> Value {
    let Some(reason) = args.get("reason").and_then(|r| r.as_str()).filter(|r| !r.trim().is_empty()) else {
        return crate::verbs::err_code("reason_required", "starting SOLIDWORKS Visualize needs a reason", "Say why; it is ledgered.");
    };
    let path_note = args.get("path").filter(|p| !p.is_null()).map(|_| {
        json!("`path` was not passed to Visualize: whether its platform shortcut accepts a model path is UNMEASURED. Open the model from inside Visualize.")
    });
    let now = status();
    if now.get("running") == Some(&json!(true)) {
        if now.get("waitingOn") == Some(&json!("visualizePlatformNotice")) {
            return refuse_direct(now);
        }
        return crate::verbs::ok(json!({"app": "visualize", "launchedNow": false, "alreadyRunning": true,
                                       "visualize": now, "pathNote": path_note,
                                       "_hint": "Visualize is already running; nothing was launched. `visualize.waitingOn` says if it needs the user."}));
    }
    let Some(lnk) = find_shortcut() else {
        return crate::verbs::err_code(
            "visualize_shortcut_missing",
            "no SOLIDWORKS Visualize platform shortcut was found",
            "Visualize must be started from the 3DEXPERIENCE Platform or its desktop shortcut (\"SOLIDWORKS Visualize.lnk\" on the Public desktop or in the Start-Menu group). Ask the user to create it from the Platform. The bridge never starts SLDWORKSVisualize.exe directly.",
        );
    };
    let r = ctx.call("launch_visualize", json!({"shortcut": lnk}), 60);
    crate::ledger::record("visualize_launch", json!({"shortcut": lnk, "reason": reason, "ok": r.is_ok()}));
    if let Err(e) = r {
        return crate::verbs::err_code("visualize_launch_failed", format!("the Visualize shortcut could not be invoked: {}", e.message), "Nothing else was attempted.");
    }
    let wait = args.get("waitSeconds").and_then(|w| w.as_u64()).unwrap_or(20).min(120);
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(wait);
    let mut st = status();
    while std::time::Instant::now() < deadline
        && !(st.get("ready") == Some(&json!(true)) || !st.get("waitingOn").map(|w| w.is_null()).unwrap_or(true))
    {
        std::thread::sleep(std::time::Duration::from_millis(500));
        st = status();
    }
    if st.get("waitingOn") == Some(&json!("visualizePlatformNotice")) {
        return refuse_direct(st);
    }
    let stage = if st.get("ready") == Some(&json!(true)) {
        "ready"
    } else if !st.get("waitingOn").map(|w| w.is_null()).unwrap_or(true) {
        "waitingOn"
    } else {
        "launching"
    };
    crate::verbs::ok(json!({
        "app": "visualize",
        "launchedNow": true,
        "shortcut": lnk,
        "stage": stage,
        "waitingOn": st.get("waitingOn").cloned().unwrap_or(Value::Null),
        "signInWindow": st.get("signInWindow").cloned().unwrap_or(Value::Null),
        "visualize": st,
        "pathNote": path_note,
        "_hint": match stage {
            "ready" => "Visualize is up (its main window exists, no dialog is waiting).",
            "waitingOn" if st.get("waitingOn") == Some(&json!("userSignIn")) && st.get("running") == Some(&json!(false)) => "Visualize has not started: the launch is parked behind the 3DEXPERIENCE platform sign-in (visualize.signInWindow, hosted by SWXDesktopUpdate.exe). On the user's say-so, swx_sign_in {reason, userAuthorized:true, window:<signInWindow.hwnd>} prepares the page (user name, Continue); the USER types the password — ab's policy keeps it with them until ab issue #237. Visualize starts after it. Then poll swx_status.",
            "waitingOn" => "Visualize is waiting on the USER (see visualize.dialog). The bridge never answers Visualize's dialogs; a 3DEXPERIENCE login can be answered with swx_sign_in {userAuthorized:true, window:\"visualize\"} on the user's say-so.",
            _ => "Visualize is starting (measured: ~45 s). Poll swx_status — its `visualize` block says running / ready / waitingOn.",
        },
    }))
}

fn refuse_direct(st: Value) -> Value {
    let mut out = crate::verbs::err_code(
        "visualize_direct_start_refused",
        "SOLIDWORKS Visualize is showing \"must be opened from the 3DEXPERIENCE Platform or from the desktop shortcut\" — it was started directly",
        "The fix: start Visualize through its platform shortcut (swx_start {app:\"visualize\"}). The bridge does not answer that dialog (No exits Visualize; Yes opens the Platform) — the user does.",
    );
    if let Value::Object(m) = &mut out {
        m.insert("visualize".into(), st);
    }
    out
}

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

    #[test]
    fn the_three_measured_dialogs_are_told_apart_by_their_words() {
        assert_eq!(
            classify("SOLIDWORKS Visualize", "#32770", "SOLIDWORKS Visualize must be opened from the 3DEXPERIENCE Platform or from the desktop shortcut created from the Platform. Do you want to go to the 3DEXPERIENCE Platform? | Yes | No"),
            Some("platformNotice")
        );
        assert_eq!(
            classify("SOLIDWORKS Visualize", "HwndWrapper[SLDWORKSVisualize.exe;Main Application;x]", "Please choose a location for your 'SOLIDWORKS Visualize Content' folder. | ... | Continue"),
            Some("contentFolder")
        );
        assert_eq!(classify("Login - 3DEXPERIENCE ID - Dassault Systemes", "WindowsForms10.Window.8.app", ""), Some("signIn"));
        assert_eq!(classify("SOLIDWORKS Visualize", "HwndWrapper[SLDWORKSVisualize.exe;Main Application;x]", "File | Edit | View"), None);
    }
}