← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.22+0.9.23
Cargo.lock+1−1
@@ -122,7 +122,7 @@ dependencies = [  [[package]] name = "swx-bridge"-version = "0.9.22"+version = "0.9.23" dependencies = [  "miniz_oxide",  "serde",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.22"+version = "0.9.23" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+25−1
@@ -442,6 +442,15 @@ You type the password; the bridge never asks for, stores or transmits one. Tick stage lasts depends on when someone answers the window, not on any timer the bridge owns. +The same sign-in page has two more hosts, both measured. SOLIDWORKS Visualize+shows it in a WinForms window titled "Login - 3DEXPERIENCE ID - Dassault+Systemes". The platform updater `SWXDesktopUpdate.exe` (started from `%TEMP%` the+moment a platform shortcut is invoked) shows it in a window titled just+"3DEXPERIENCE", and every app start waits behind that one. Since **0.9.23** the+bridge recognises a login by its CONTENT (an "Email or username" or "Password"+field), whatever its title. `swx_status` and `swx_start` then report+`waitingOn:"userSignIn"` with `signInWindow {hwnd, title, ownerImage, host}`.+ ### The launch stages  `swx_status.launch.stage` — and `swx_start`'s own `launch` block — reports exactly@@ -483,7 +492,22 @@ not.  ## Roadmap -- v0.9.22 (this): **the skill carries the whole job.** The public+- v0.9.23 (this): **a third sign-in host, found by content.** After a week+  away (2026-10-12) the platform updater `SWXDesktopUpdate.exe` showed the+  3DEXPERIENCE ID login in a window titled just "3DEXPERIENCE". The bridge saw+  nothing: `swx_start {app:"visualize"}` said "launching" and `swx_status` said+  `waitingOn:null`. It also refused the window when the user named it. Now a+  login is recognised by its content (an Edit "Email or username" or+  "Password"), the updater is in the census, and `swx_status` / `swx_start` (both+  apps) report `waitingOn:"userSignIn"` with+  `signInWindow {hwnd, title, ownerImage, host:"desktopUpdate"}`. That field is+  now an object; it used to be a title. A window the user names is probed by+  content and never refused on census grounds. When the tree holds only the+  frame (measured: 14 nodes), `swx_sign_in` finds each control with+  `desktop_find_control`: by automation id first, never a role alone. It clears+  each field before setting it, which fixes the measured stray trailing "s". 558+  tests.+- v0.9.22: **the skill carries the whole job.** The public   `solidworks-bridge` skill's everyday flow now covers rendering   (`swx_render_image`, the measured recipe and `zoomFactor`), SOLIDWORKS   Visualize (`swx_start {app:"visualize"}`, the first-run folder dialog that is
SKILL.md+7−3
@@ -68,9 +68,13 @@ BEFORE you do it:    SOLIDWORKS (measured 2026-10-01), so every verb that reaches it refuses    `host_app_starting` until `solidworks.ready` is true — wait `retryAfterSeconds`    and call again. `startingUnverifiable:true` means only reads may run.-   `waitingOn:"userSignIn"` means the 3DEXPERIENCE sign-in is up: tell the user,-   or call `swx_sign_in {reason}` to fill it from THEIR local vault (it never-   shows you the password, and never retries a rejected one). The measured+   `waitingOn:"userSignIn"` means a 3DEXPERIENCE sign-in is up. `signInWindow`+   names it `{hwnd, title, ownerImage, host}`, and the host can be the platform+   updater (`desktopUpdate`, a window titled just "3DEXPERIENCE" that parks+   every app start). Tell the user, or call `swx_sign_in {reason}` (add+   `userAuthorized:true, window:<hwnd>` on their say-so for a window the+   bridge's launch did not raise) to fill it from THEIR local vault. It never+   shows you the password, and never retries a rejected one. The measured    login flow is in the **solidworks-sign-in** skill. Never launch    `sldworks.exe` or the launcher any other way. 7. **Name the document you mean.** Pass `targetDocument` (a title) or `path` on
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "solidworks",   "displayName": "SOLIDWORKS",-  "version": "0.9.22",+  "version": "0.9.23",   "description": "Bridge for SOLIDWORKS desktop CAD — create and open documents, a modeling DSL, file management (find/open/save/export/Pack and Go), custom properties, BOM generation, design analysis (mass properties, bodies, parameters, interference, rebuild health) and background window/dialog etiquette, over the SOLIDWORKS COM API. Single native binary: no interpreter, no PowerShell, ~10ms cold start.",   "author": "Adom Inc.",   "license": "MIT",
docs/LAUNCH-RESILIENCE.md+19
@@ -1356,3 +1356,22 @@ unmeasured, and NO credentials — only where they come from in general terms. | P3 — focus | a plain click does not take focus while the user's app is active (the login thread's focus was 0); `desktop_raise_window {hwnd, reason}` brings it forward and then the click lands; `desktop_type {text, hwnd:<Chrome_RenderWidgetHostHWND child>}` types; reading the user name back works | Raise, confirm the login window is the foreground window, click, then select-all and type to the render widget when the tree names its handle (else the window) | | P4 — the vault | now holds the login (host iam.3ds.com, stored with `credential_set`) | The reveal asks the host the vault lists on the 3ds.com domain (`iam.*` first) | | P5 — policy | typing the password through ab FROM THE CLOUD is refused by the auto-mode classifier ("Credential Leakage") — rightly | The only path is the bridge reading the vault LOCALLY and typing locally. So `swx_sign_in {userAuthorized:true}` (the user's say-so) answers ANY 3DEXPERIENCE ID login window on the machine — our launch chain's, SOLIDWORKS Visualize's (`SLDWORKSVisualize.exe` is now in the census), any Dassault process's — and reports which process owns it; `window:"visualize"\|"solidworks"\|<hwnd>` picks one when several are up (`sign_in_window_ambiguous` otherwise). Without the authorization, only the bridge's own launch's window. This wires the unwired half of T4 |++**MEASURED 2026-10-12 on a third login host, and 0.9.23.** After a week the+platform session had lapsed. `swx_start {app:"visualize"}` answered+`stage:"launching"` after 91 s, and `swx_status` then said `visualize.running:+false, waitingOn: null`. The bridge saw nothing, while a sign-in sat on screen.++| | Measured | 0.9.23 |+|---|---|---|+| U1 — the host | a window titled exactly "3DEXPERIENCE", class `WindowsForms10.Window.8.app.0.30495d1_r6_ad1`, UIA automationId `SWXDesktopUpdateForm`, owned by `SWXDesktopUpdate.exe` from `C:\Users\drew\AppData\Local\Temp\SWXDesktopUpdate\` (started the second the shortcut was invoked). Content: "Welcome to the 3DEXPERIENCE platform / Sign in to continue / Email or username / Continue", a "Manage cookies" link, no cookie banner | `SWXDesktopUpdate.exe` is in the census (by image name, so any path counts; never terminable). A login is recognised BY CONTENT: a visible, titled window (not SOLIDWORKS's own, not Visualize's main window) in which `desktop_find_control` finds an Edit "Email or username" (page 1) or "Password" (page 2), whatever its title, class or owner. The class/title rule stays as the fast path. Probes are cached per window for 3 s (`SWX_SIGN_IN_PROBE_TTL_MS`) |+| U2 — the refusal | `swx_sign_in {userAuthorized:true, window:857684}` → `sign_in_not_waiting` "no 3DEXPERIENCE ID login window matching the request is open" | A window handle the caller names (with `userAuthorized`) is never refused on census grounds: it is probed by content, and refused only as `sign_in_window_not_a_login` with `found` (every control looked for, and what was found). `window:"desktopUpdate"` picks the updater's login |+| U3 — the tree is blind | `desktop_ui_tree {hwnd}` = 14 nodes (Window, MenuBar, Minimize/Maximize/Close) even at maxNodes 600 / depth 40 and after `desktop_ui_from_point`; `desktop_find_control` reaches the page: `{role:"Edit", name:"Email or username"}` → automationId `username`, settable; `{name:"Continue"}` → Button, invokable; `{role:"Edit"}` alone → "Address and search bar" (view_1012), a wrong match from elsewhere | When the tree holds no text field, each control is located with `desktop_find_control`: automation id first, then role + exact name, then role + name-contains, never a role alone; a match counts only if its role and its id or (cleaned) name say so. `pages[].located` records the source and every control looked for. The window is remembered as tree-blind so later reads skip the useless wakes |+| U4 — page 2 | Edit `username` (prefilled); Edit automationId `password`, NAME "Password " + a private-use icon glyph (an exact-name query does NOT match; `{automationId:"password"}` does); Text "Password"; Text "Remember me" (no CheckBox by name); Button "Log in"; Button "Back" | Fields are targeted by automation id. "Remember me": its label is clicked (idle user, window raised and in front); its state is unreadable, so `rememberTicked: null` and the step says so |+| U5 — the set | `desktop_ui_set {hwnd, automationId:"username", text}` WORKS here (ValuePattern accepted), but the first set left the user name plus a stray trailing "s"; setting "" first, then the value, fixed it. `find_control`'s reply carries NO `value` | Every field found this way is CLEARED then set (`""`, then the value). The user name is read back when anything can read it (the tree, or a `value` in a find reply); on this host nothing can, so the step says `readBack: "unreadable"` rather than claiming a match. The password is never read back. On "NO ValuePattern" the keystroke path runs as before, with its geometry from `find_control` |+| U6 — parked | Visualize never started while the updater's login was up | `swx_status` reports `waitingOn:"userSignIn"` + `signInWindow {hwnd, title, ownerImage, host, recognizedBy}` whenever ANY login window is up (`signInWindow` is now an object; it was a title). `visualize.waitingOn:"userSignIn"` + `parkedBehind` while Visualize is not running and the updater's login is up; `swx_start {app:"visualize"}` returns `stage:"waitingOn"` at once instead of waiting out `waitSeconds` and saying "launching". A SOLIDWORKS launch treats the updater's login as its own sign-in stage (deadlines paused, nothing killed); the updater counts as the bridge's own launch when it started at or after the bridge's invoke |++**Unmeasured, stated:** what this host shows after "Log in" (a wrong password, a+second step: rejection markers cannot be read from a blind tree, so an+unclosed window is `sign_in_unconfirmed`, never retried); whether "Remember me"+can be read here; whether any ab verb reads a field's value on this host.
install.sh

  
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "solidworks-bridge",-  "version": "0.9.22",+  "version": "0.9.23",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the SOLIDWORKS bridge (swx_* verbs). The bridge runtime itself is the release zip — a single native swx-bridge.exe; Adom Bridge loads that.",   "tags": [
references/launch-and-sign-in.md+24−6
@@ -204,9 +204,17 @@ Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPE     `launch_process` — launching it DIRECTLY, bypassing the launcher chain, is     what produces the *"SOLIDWORKS Design must be launched from the     3DEXPERIENCE Platform"* modal.-- **`waitingOn:"userSignIn"`** on `swx_start` / `swx_status` means the launch is-  waiting for the user to sign in to 3DEXPERIENCE in the window `signInWindow`-  names. Only the user can answer it; tell them, and poll `swx_status`.+- **`waitingOn:"userSignIn"`** on `swx_start` / `swx_status` means a+  3DEXPERIENCE ID login is up and the launch is waiting for the user to sign in.+  `signInWindow` is `{hwnd, title, ownerImage, host, recognizedBy}`; it was a+  bare title before 0.9.23. Any login window on the machine counts, found by+  class or title OR by content (an Edit "Email or username" or "Password" in+  it). Measured 2026-10-12: the platform updater `SWXDesktopUpdate.exe` showed a+  login titled just "3DEXPERIENCE" (`host:"desktopUpdate"`) and the Visualize+  start was parked behind it. `swx_start {app:"visualize"}` now says+  `stage:"waitingOn"` with `visualize.parkedBehind` instead of+  `stage:"launching"`. Only the user can answer it; tell them, and poll+  `swx_status`. - **Signing in for the user — `swx_sign_in {reason}`** (0.9.13). Only for a   launch this bridge started whose own launcher is showing the 3DEXPERIENCE ID   window. It reads the window's inventory (control types, names, IsPassword —@@ -230,6 +238,16 @@ Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPE   skill, which ships with this one. - **Any login window, on the user's say-so:** `swx_sign_in {reason,   userAuthorized:true}` also answers a 3DEXPERIENCE ID login window the-  bridge's launch did not raise — SOLIDWORKS Visualize's, for one. Add-  `window:"visualize"` / `"solidworks"` / an hwnd when several are open; the-  reply's `window` names the owning process.+  bridge's launch did not raise — SOLIDWORKS Visualize's, or the platform+  updater's. Add `window:"visualize"` / `"solidworks"` / `"desktopUpdate"` / an+  hwnd when several are open; the reply's `window` names the owning process. A+  named hwnd is checked by its CONTENT and never refused because a census missed+  it; with neither field in it the reply is `sign_in_window_not_a_login`, with+  `found` (every control looked for and what came back).+- **When the tree shows only the frame** (the updater's login, measured: 14+  nodes), `swx_sign_in` finds each control with `desktop_find_control`. It looks+  up by automation id first, then role and exact name, then role and+  name-contains, and never by a role alone. It clears each field and then sets+  it by automation id. The user name cannot be read back on that host+  (`readBack: "unreadable"`), and "Remember me" is a label whose state cannot be+  read (`rememberTicked: null`). `pages[].located` says how each page was read.
references/modelling-and-export.md+6−2
@@ -145,5 +145,9 @@ a verdict.   direct start). Its dialogs are reported in `visualize.waitingOn` and NEVER   answered: `userVisualizeContentFolder` (first run — the user picks a folder),   `userSignIn` (`swx_sign_in {userAuthorized:true, window:"visualize"}` on the-  user's say-so), `visualizePlatformNotice` (started the wrong way). No render-  verb for Visualize yet.+  user's say-so), `visualizePlatformNotice` (started the wrong way). If the+  platform session has lapsed, Visualize does not start at all: the platform+  updater shows the sign-in first. `swx_start` then answers `stage:"waitingOn"`,+  with `visualize.parkedBehind` and `signInWindow.host:"desktopUpdate"`. Answer+  it with `swx_sign_in {userAuthorized:true, window:<signInWindow.hwnd>}` on the+  user's say-so. No render verb for Visualize yet.
skills/solidworks-sign-in/SKILL.md+58−7
@@ -6,7 +6,7 @@ user-invocable: true  Parent skill: **solidworks-bridge** -Every line below is marked **[measured]** (seen on a real machine, 2026-10-02) or+Every line below is marked **[measured]** (seen on a real machine, 2026-10-02 to 2026-10-12) or **[unmeasured]** (not seen yet — treat as a guess and read the window first).  ## When the window appears@@ -26,6 +26,11 @@ Every line below is marked **[measured]** (seen on a real machine, 2026-10-02) o   desktop shortcut created from the Platform"). Started through its platform   shortcut (`swx_start {app:"visualize"}`, 2026-10-05) while the 3DEXPERIENCE   Launcher already held the session, it raised no login at all.+- [measured] After a week away (2026-10-12) the platform session had lapsed.+  Starting Visualize through its shortcut started `SWXDesktopUpdate.exe` (from+  `%TEMP%\SWXDesktopUpdate\`) the same second, and THAT showed the sign-in;+  Visualize itself did not start until the sign-in was done. Any app start can+  be parked behind it.  ## Finding the window @@ -34,8 +39,51 @@ Every line below is marked **[measured]** (seen on a real machine, 2026-10-02) o   Systèmes", owned by SWXDesktopLauncher.exe. - [measured] Visualize shows the same web page in a WinForms window: title   "Login - 3DEXPERIENCE ID - Dassault Systemes", 968x968.-- Find it by a title containing "3DEXPERIENCE ID". `swx_status` reports-  `waitingOn: "userSignIn"` and `signInWindow` while the launcher's window is up.+- [measured] A THIRD host: `SWXDesktopUpdate.exe`, the platform updater. Title+  just "3DEXPERIENCE" (no "ID", no "Login"), class+  `WindowsForms10.Window.8.app.0.30495d1_r6_ad1`, UIA automationId+  `SWXDesktopUpdateForm`. Same web page ("Welcome to the 3DEXPERIENCE platform",+  "Email or username", "Continue"), and no cookie banner, only a "Manage+  cookies" link at the bottom right.+- FIND IT BY CONTENT, not by title. A window that contains an Edit "Email or+  username" (page 1) or an Edit "Password" (page 2) is a 3DEXPERIENCE ID login,+  whatever its title, class or owner. The two titles above are only shortcuts.+  `swx_status` reports `waitingOn: "userSignIn"` and+  `signInWindow {hwnd, title, ownerImage, host, recognizedBy}` (host is+  `solidworks`, `visualize` or `desktopUpdate`) while any of them is up.++## When the tree shows only the frame++- [measured] On the SWXDesktopUpdate host, `desktop_ui_tree` returned 14 nodes+  (Window, MenuBar, Minimize/Maximize/Close), even with maxNodes 600 and depth+  40, and even after a `desktop_ui_from_point` wake. The page is not in it.+- [measured] `desktop_find_control {hwnd, ...}` DOES reach the page. Page 1:+  `{automationId:"username"}` or `{role:"Edit", name:"Email or username"}` gives+  the Edit (settable:true); `{role:"Button", name:"Continue"}` gives the button+  (invokable:true).+- [measured] Page 2: Edit `username` (prefilled); Edit `password` whose NAME is+  "Password " plus an icon glyph, so `{role:"Edit", name:"Password "}` does NOT+  match it and `{automationId:"password"}` does; Text "Password" (its label);+  Text "Remember me" (no CheckBox found by name); Button "Log in"; Button+  "Back".+- [measured] `{role:"Edit"}` with no name matched "Address and search bar"+  (view_1012), a control somewhere else entirely. Never query a role alone.+  Look up by automation id first, then role and exact name, then role and+  name-contains, and check the match's role and id or name before using it.+- [measured] `desktop_ui_set {hwnd, automationId:"username", text}` WORKS on this+  host (ValuePattern accepted), unlike the Visualize host. But the first set+  left the user name followed by a stray "s". Set "" first, then the value.+  That fixed it.+- [measured] `desktop_find_control`'s reply carries NO `value`, and the tree has+  no page, so on this host the user name cannot be read back. `swx_sign_in`+  says `readBack: "unreadable"` in its steps rather than claiming a match.+- [measured] `desktop_ui_click {hwnd, name:"Continue"}` (background Invoke)+  moved to page 2.+- [unmeasured] Whether "Remember me" can be read once clicked here. The bridge+  clicks the label (only while the user is idle, with the window in front) and+  reports `rememberTicked: null`.+- [unmeasured] What this host shows after "Log in": a wrong password or a+  second step. Read it; never retry.  ## The flow @@ -116,7 +164,10 @@ wiki page, a log line or a chat message.  `swx_sign_in` is the path: it does every step above, in order, and fails closed with a reason when it cannot. For a login window the bridge's own launch did not-raise (SOLIDWORKS Visualize's, or one already up), pass `userAuthorized:true` —-only on the user's say-so — and `window:"visualize"` or `"solidworks"` (or the-window handle) when more than one is open; the reply names the process that-owns the window.+raise (SOLIDWORKS Visualize's, the platform updater's, or one already up), pass+`userAuthorized:true` (only on the user's say-so) and `window:"visualize"`,+`"solidworks"`, `"desktopUpdate"` or the window handle when more than one is+open; the reply names the process that owns the window. A window handle the+user names is checked by its content and never refused because a census missed+it. If it has neither field, the reply is `sign_in_window_not_a_login` with+what was found.
src/launch.rs+18−2
@@ -475,17 +475,33 @@ fn announce_sign_in(title: &str) { /// very window it could not see. pub fn sign_in_window(os: &dyn LaunchOs, ours: &[u32]) -> Option<Option<(i64, String)>> {     let mut any_read = false;+    let census = os.processes().ok();+    let image_of = |pid: u32| census.as_ref().and_then(|c| c.iter().find(|p| p.pid == pid).map(|p| p.image.clone())).unwrap_or_default();     for pid in ours {         match os.windows_of_pid(*pid) {             Ok(ws) => {                 any_read = true;+                // The fast path (class / title), then CONTENT (0.9.23).                 if let Some(w) = ws.iter().find(|w| is_sign_in_window(&w.class_name, &w.title)) {                     return Some(Some((w.hwnd, w.title.clone())));                 }+                let image = image_of(*pid);+                if let Some(w) = ws.iter().find(|w| crate::signin::window_is_login(&image, w).is_some()) {+                    return Some(Some((w.hwnd, w.title.clone())));+                }             }             Err(_) => {}         }     }+    // THE PLATFORM UPDATER'S LOGIN (0.9.23, MEASURED 2026-10-12): hosted by+    // SWXDesktopUpdate.exe, which the chain census does not link. The platform+    // session gates every launch, so while that login is up this launch is+    // waiting on the user too — a pause is always the safe answer.+    if !ours.is_empty() {+        if let Some(found) = crate::signin::desktop_update_login(os) {+            return Some(Some(found));+        }+    }     if any_read {         Some(None)     } else {@@ -532,9 +548,9 @@ pub fn arm_auto_sign_in(os: &dyn LaunchOs) { /// the launch's own polling never waits on ab. fn on_sign_in_window(hwnd: i64) {     std::thread::spawn(move || {-        if let Ok(r) = crate::signin::inventory(hwnd) {+        if let Ok(r) = crate::signin::read_page(hwnd) {             note_sign_in_inventory(crate::signin::inventory_json(&r.elements));-            note_sign_in_wake(r.wake_json());+            note_sign_in_wake(r.wake.clone());         }         let auto = attempt().map(|a| a.sign_in_auto && !a.sign_in_auto_tried && !a.sign_in_rejected).unwrap_or(false);         if auto {
src/launchos.rs+5
@@ -60,6 +60,11 @@ pub const WATCHED_IMAGES: &[&str] = &[     "SWVisualize.Exchange.PluginHost.exe",     "SWVisualize.Queue.Server.exe",     "SWXDesktopHelper.exe",+    // THE PLATFORM UPDATER (0.9.23): it hosts a third 3DEXPERIENCE ID login —+    // measured 2026-10-12 running from %TEMP%\SWXDesktopUpdate\, started the+    // moment the platform shortcut was invoked. Matched by image name, so any+    // path counts. Never terminable.+    "SWXDesktopUpdate.exe",     "sldBgDwld.exe",     // PARENT LINKS ONLY — never terminable (see `launch::NEVER_TERMINATE`).     // A CATSTART's parent is the shell that ran the .lnk, and without these in
src/signin.rs+595−47
@@ -152,6 +152,11 @@ pub struct Element {     /// Screen centre — ab's own `rect.centerX/centerY` when it gives them     /// (MEASURED 2026-10-02: click there, in screen px).     pub center: Option<(i64, i64)>,+    /// LOCATED BY `desktop_find_control`, not read from the tree (0.9.23): the+    /// SWXDesktopUpdate-hosted login's tree holds only the frame (measured+    /// 2026-10-12, 14 nodes), so its controls are found one by one by name /+    /// automation id. Such an element is targeted by its automation id.+    pub by_find: bool, }  impl Element {@@ -160,11 +165,16 @@ impl Element {     pub fn offscreen(&self) -> bool {         self.center.map(|(x, y)| x < -1000 || y < -1000).unwrap_or(false)     }+    /// Off-screen, or a zero-size rectangle (a hidden input a find can still+    /// match). Never a page's field.+    pub fn hidden(&self) -> bool {+        self.offscreen() || self.rect.map(|[_, _, w, h]| w <= 0 || h <= 0).unwrap_or(false)+    } }  impl Element {     pub fn to_json(&self) -> Value {-        json!({+        let mut v = json!({             "controlType": self.control_type,             "name": self.name,             "automationId": self.automation_id,@@ -173,7 +183,11 @@ impl Element {             "enabled": self.enabled,             "invokable": self.invokable,             "settable": self.settable,-        })+        });+        if self.by_find {+            v["locatedBy"] = json!("desktop_find_control");+        }+        v     }     fn is(&self, t: &str) -> bool {         self.control_type == t@@ -195,6 +209,41 @@ fn str_of(m: &serde_json::Map<String, Value>, keys: &[&str]) -> String {     keys.iter().find_map(|k| m.get(*k).and_then(|v| v.as_str())).unwrap_or("").to_string() } +/// One element from one ab node (a tree node or a find_control match) — its+/// SHAPE, never its value.+fn element_of(m: &serde_json::Map<String, Value>) -> Option<Element> {+    let ty = str_of(m, &["controlType", "control_type", "role", "type", "localizedControlType"]);+    if ty.is_empty() || !(m.contains_key("name") || m.contains_key("automationId") || m.contains_key("automation_id")) {+        return None;+    }+    let mut e = Element {+        control_type: norm_type(&ty),+        name: str_of(m, &["name"]),+        automation_id: str_of(m, &["automationId", "automation_id", "id"]),+        is_password: m.get("isPassword").or_else(|| m.get("is_password")).and_then(|x| x.as_bool()),+        toggle_state: m+            .get("toggleState")+            .or_else(|| m.get("toggle_state"))+            .or_else(|| m.get("checked"))+            .map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())),+        enabled: m.get("isEnabled").or_else(|| m.get("enabled")).and_then(|x| x.as_bool()),+        invokable: m.get("invokable").and_then(|x| x.as_bool()),+        settable: m.get("settable").and_then(|x| x.as_bool()),+        rect: m.get("rect").and_then(rect_xywh),+        center: m.get("rect").and_then(rect_center),+        by_find: false,+    };+    // THE PASSWORD FIELD (MEASURED 2026-10-02: ab's nodes carry no+    // `isPassword` key): an explicit password flag, OR an edit whose name /+    // automation id says password.+    if e.control_type == "edit" && e.is_password.is_none() {+        let flag = m.get("password").and_then(|x| x.as_bool());+        let named = looks_like_password(&e.name) || looks_like_password(&e.automation_id);+        e.is_password = Some(flag == Some(true) || named);+    }+    Some(e)+}+ /// Every element in ab's tree reply, depth first. Lenient about the reply's /// shape (it has not been measured) and STRICT about what is copied: type, /// name, automation id, IsPassword, toggle state, enabled — never a value.@@ -203,34 +252,8 @@ pub fn parse_inventory(reply: &Value) -> Vec<Element> {         match v {             Value::Array(a) => a.iter().for_each(|x| walk(x, out)),             Value::Object(m) => {-                let ty = str_of(m, &["controlType", "control_type", "role", "type", "localizedControlType"]);-                if !ty.is_empty() && (m.contains_key("name") || m.contains_key("automationId") || m.contains_key("automation_id")) {-                    out.push(Element {-                        control_type: norm_type(&ty),-                        name: str_of(m, &["name"]),-                        automation_id: str_of(m, &["automationId", "automation_id", "id"]),-                        is_password: m.get("isPassword").or_else(|| m.get("is_password")).and_then(|x| x.as_bool()),-                        toggle_state: m-                            .get("toggleState")-                            .or_else(|| m.get("toggle_state"))-                            .or_else(|| m.get("checked"))-                            .map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())),-                        enabled: m.get("isEnabled").or_else(|| m.get("enabled")).and_then(|x| x.as_bool()),-                        invokable: m.get("invokable").and_then(|x| x.as_bool()),-                        settable: m.get("settable").and_then(|x| x.as_bool()),-                        rect: m.get("rect").and_then(rect_xywh),-                        center: m.get("rect").and_then(rect_center),-                    });-                    // THE PASSWORD FIELD (MEASURED 2026-10-02: ab's nodes carry no-                    // `isPassword` key): an explicit password flag, OR an edit-                    // whose name / automation id says password.-                    if let Some(last) = out.last_mut() {-                        if last.control_type == "edit" && last.is_password.is_none() {-                            let flag = m.get("password").and_then(|x| x.as_bool());-                            let named = looks_like_password(&last.name) || looks_like_password(&last.automation_id);-                            last.is_password = Some(flag == Some(true) || named);-                        }-                    }+                if let Some(e) = element_of(m) {+                    out.push(e);                 }                 for (k, x) in m {                     if matches!(k.as_str(), "children" | "elements" | "nodes" | "tree" | "root" | "items" | "controls") {@@ -368,6 +391,284 @@ pub fn inventory_json(inv: &[Element]) -> Value {     json!(inv.iter().map(|e| e.to_json()).collect::<Vec<_>>()) } +// ── the page the tree does not show: desktop_find_control (0.9.23) ──────────+//+// MEASURED 2026-10-12 on adom_drew2: a THIRD login host — a window titled just+// "3DEXPERIENCE", class `WindowsForms10.Window.8.app.0.30495d1_r6_ad1`, UIA+// automationId `SWXDesktopUpdateForm`, owned by `SWXDesktopUpdate.exe` running+// from %TEMP%\SWXDesktopUpdate\ (started the moment the platform shortcut was+// invoked). Its content is the same 3DEXPERIENCE ID sign-in, but+// `desktop_ui_tree` returns only the frame (14 nodes: Window, MenuBar,+// Minimize/Maximize/Close) even at maxNodes 600 / depth 40 and after a+// `desktop_ui_from_point` wake. `desktop_find_control` DOES reach the page:+//   page 1: Edit automationId "username" (name "Email or username",+//           settable:true), Button "Continue" (invokable);+//   page 2: Edit "username" (prefilled), Edit automationId "password" whose+//           NAME is "Password " + a private-use icon glyph (an exact-name query+//           does NOT match it; `{automationId:"password"}` does), Text+//           "Password", Text "Remember me" (no CheckBox found by name),+//           Button "Log in", Button "Back".+//   `{role:"Edit"}` with no name matched "Address and search bar" (view_1012)+//   somewhere else entirely — so a query NEVER goes out with a role alone.+// So a control is located by automation id first, then by role + exact name,+// then by role + name-contains, and a match is accepted only if its role and+// its id or (cleaned) name say it is the control asked for.++/// One control to look for: its role, its automation id (if known), its name.+pub struct Want {+    pub role: &'static str,+    pub automation_id: Option<&'static str>,+    pub name: &'static str,+}++const PAGE_WANTS: &[Want] = &[+    Want { role: "Edit", automation_id: Some("username"), name: "Email or username" },+    Want { role: "Edit", automation_id: Some("password"), name: "Password" },+    Want { role: "Button", automation_id: None, name: "Continue" },+    Want { role: "Button", automation_id: None, name: "Log in" },+    Want { role: "Button", automation_id: None, name: "Back" },+    Want { role: "CheckBox", automation_id: None, name: "Remember me" },+    Want { role: "Text", automation_id: None, name: "Remember me" },+    Want { role: "Button", automation_id: None, name: "Accept All" },+    Want { role: "Button", automation_id: None, name: "Manage my preferences" },+];++/// Lower case, letters/digits/spaces only, trimmed — "Password \u{e9a0}" and+/// "Remember me…" compare as "password" and "remember me".+fn clean(s: &str) -> String {+    let t: String = s.chars().filter(|c| c.is_ascii_alphanumeric() || *c == ' ').collect::<String>().to_lowercase();+    t.split_whitespace().collect::<Vec<_>>().join(" ")+}++/// Every element-like object anywhere in a find_control reply.+fn found_elements(reply: &Value) -> Vec<Element> {+    fn walk(v: &Value, out: &mut Vec<Element>) {+        match v {+            Value::Array(a) => a.iter().for_each(|x| walk(x, out)),+            Value::Object(m) => {+                if let Some(e) = element_of(m) {+                    out.push(e);+                }+                m.values().for_each(|x| walk(x, out));+            }+            _ => {}+        }+    }+    let mut out = Vec::new();+    if !ab_ok(reply) {+        return out;+    }+    let top = unwrap_ab(reply);+    if top.get("found").and_then(|f| f.as_bool()) == Some(false) {+        return out;+    }+    walk(&top, &mut out);+    out+}++/// Is `e` the control `w` asks for?+fn is_wanted(e: &Element, w: &Want) -> bool {+    if e.control_type != norm_type(w.role) {+        return false;+    }+    if let Some(id) = w.automation_id {+        if e.automation_id.eq_ignore_ascii_case(id) {+            return true;+        }+    }+    let (n, want) = (clean(&e.name), clean(w.name));+    !want.is_empty() && (n == want || n.starts_with(&want))+}++/// LOCATE ONE CONTROL by `desktop_find_control`: automation id, then role ++/// name, then role + name-contains. Never a role alone. Each attempt is+/// returned (the query and what came back), for the reply.+pub fn find_one(hwnd: i64, w: &Want) -> (Option<Element>, Vec<Value>) {+    let mut queries: Vec<Value> = Vec::new();+    if let Some(id) = w.automation_id {+        queries.push(json!({"hwnd": hwnd, "automationId": id}));+    }+    queries.push(json!({"hwnd": hwnd, "role": w.role, "name": w.name}));+    queries.push(json!({"hwnd": hwnd, "role": w.role, "contains": w.name}));+    let mut tried = Vec::new();+    for q in queries {+        let r = crate::caller::ad_callback("desktop_find_control", q.clone(), Duration::from_secs(20));+        let hit = found_elements(&r).into_iter().find(|e| is_wanted(e, w));+        let mut q_report = q.clone();+        if let Value::Object(m) = &mut q_report {+            m.remove("hwnd");+        }+        tried.push(json!({"query": q_report, "matched": hit.as_ref().map(|e| e.to_json())}));+        if let Some(mut e) = hit {+            e.by_find = true;+            if e.control_type == "edit" && w.automation_id == Some("password") {+                e.is_password = Some(true);+            }+            return (Some(e), tried);+        }+    }+    (None, tried)+}++/// The page, control by control. Off-screen edits (a hidden input) are not+/// the page's fields.+pub fn read_by_find(hwnd: i64) -> (Vec<Element>, Value) {+    let mut els: Vec<Element> = Vec::new();+    let mut report = serde_json::Map::new();+    for w in PAGE_WANTS {+        let (hit, _tried) = find_one(hwnd, w);+        let key = format!("{} \"{}\"", w.role, w.name);+        match hit {+            Some(e) if !(e.control_type == "edit" && e.hidden()) => {+                report.insert(key, json!({"found": true, "name": e.name, "automationId": e.automation_id,+                                          "center": e.center.map(|(x, y)| json!([x, y])), "settable": e.settable, "invokable": e.invokable}));+                if !els.iter().any(|x| x.control_type == e.control_type && x.name == e.name && x.automation_id == e.automation_id) {+                    els.push(e);+                }+            }+            _ => {+                report.insert(key, json!({"found": false}));+            }+        }+    }+    (els, Value::Object(report))+}++/// IS THIS WINDOW A 3DEXPERIENCE ID LOGIN, BY ITS CONTENT? An Edit+/// "Email or username" (page 1) or "Password" (page 2) found in it. Some(1|2)+/// = the page; None = neither field. Needs ab.+pub fn probe_login(hwnd: i64) -> Option<u8> {+    if !crate::caller::ab_available() {+        return None;+    }+    let (pw, _) = find_one(hwnd, &PAGE_WANTS[1]);+    if pw.filter(|e| !e.hidden()).is_some() {+        return Some(2);+    }+    let (user, _) = find_one(hwnd, &PAGE_WANTS[0]);+    user.filter(|e| !e.hidden()).map(|_| 1)+}++static PROBES: std::sync::Mutex<Option<std::collections::HashMap<i64, (std::time::Instant, Option<u8>)>>> = std::sync::Mutex::new(None);++fn probe_ttl() -> Duration {+    Duration::from_millis(std::env::var("SWX_SIGN_IN_PROBE_TTL_MS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(3000))+}++/// `probe_login`, remembered for a few seconds per window (a census runs on+/// every swx_status and every launch poll).+pub fn probe_login_cached(hwnd: i64) -> Option<u8> {+    if let Some((at, v)) = PROBES.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|m| m.get(&hwnd).cloned()) {+        if at.elapsed() < probe_ttl() {+            return v;+        }+    }+    let v = probe_login(hwnd);+    PROBES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(hwnd, (std::time::Instant::now(), v));+    v+}++/// The platform's updater, which hosts the third login (measured 2026-10-12).+pub const DESKTOP_UPDATE_IMAGE: &str = "SWXDesktopUpdate.exe";++/// Worth a content probe? Visible, titled, and not a window the probe would be+/// wasted on: SOLIDWORKS's own (its login is hosted by the launcher) and+/// Visualize's main window (its dialogs are classified by their words).+pub fn probe_eligible(image: &str, w: &crate::launchos::WindowInfo) -> bool {+    w.visible+        && !w.title.trim().is_empty()+        && !image.eq_ignore_ascii_case("SLDWORKS.exe")+        && !SHELLS.iter().any(|s| image.eq_ignore_ascii_case(s))+        && !w.class_name.to_ascii_lowercase().starts_with("hwndwrapper[sldworksvisualize.exe;")+}++/// Is this window a login: by its class / title (the fast path) or by its+/// CONTENT? `Some("titleOrClass" | "content")`.+pub fn window_is_login(image: &str, w: &crate::launchos::WindowInfo) -> Option<(&'static str, Option<u8>)> {+    if !w.visible {+        return None;+    }+    if crate::launch::is_sign_in_window(&w.class_name, &w.title) {+        return Some(("titleOrClass", None));+    }+    if probe_eligible(image, w) {+        if let Some(page) = probe_login_cached(w.hwnd) {+            return Some(("content", Some(page)));+        }+    }+    None+}++/// A login window hosted by SWXDesktopUpdate.exe, anywhere on the machine —+/// the platform's own sign-in, which parks every app start behind it.+pub fn desktop_update_login(os: &dyn crate::launchos::LaunchOs) -> Option<(i64, String)> {+    let census = os.processes().ok()?;+    for p in census.iter().filter(|p| p.image.eq_ignore_ascii_case(DESKTOP_UPDATE_IMAGE)) {+        if let Ok(ws) = os.windows_of_pid(p.pid) {+            if let Some(w) = ws.iter().find(|w| window_is_login(&p.image, w).is_some()) {+                return Some((w.hwnd, w.title.clone()));+            }+        }+    }+    None+}++/// The tree holds no page for this window (remembered so the next read skips+/// the wakes that never help on this host).+static TREE_BLIND: std::sync::Mutex<Vec<i64>> = std::sync::Mutex::new(Vec::new());++/// A page, read: from the tree when the tree shows it, otherwise control by+/// control through desktop_find_control.+pub struct Page {+    pub elements: Vec<Element>,+    pub source: &'static str,+    pub wake: Value,+    pub found: Value,+}++impl Page {+    pub fn report(&self) -> Value {+        json!({"source": self.source, "wake": self.wake, "findControl": self.found})+    }+}++/// READ THE LOGIN PAGE — the tree first; when the tree has no text field (the+/// SWXDesktopUpdate host, measured), `desktop_find_control` by name.+pub fn read_page(hwnd: i64) -> Result<Page, String> {+    let blind = TREE_BLIND.lock().unwrap_or_else(|e| e.into_inner()).contains(&hwnd);+    let tree: Result<(Vec<Element>, Value), String> = if blind {+        if !crate::caller::ab_available() {+            Err("ab's direct API is not available to this bridge (it was not spawned by Adom Bridge)".into())+        } else {+            read_once(hwnd).map(|(e, n, _)| (e, json!({"nodeCounts": [n], "wakes": 0, "treeBlind": true})))+        }+    } else {+        inventory(hwnd).map(|r| (r.elements.clone(), r.wake_json()))+    };+    if let Ok((els, wake)) = &tree {+        if els.iter().any(|e| e.is("edit")) || has_marker(els, SECOND_STEP).is_some() || has_marker(els, REJECTED).is_some() {+            return Ok(Page { elements: els.clone(), source: "tree", wake: wake.clone(), found: Value::Null });+        }+    }+    if !crate::caller::ab_available() {+        return tree.map(|(e, w)| Page { elements: e, source: "tree", wake: w, found: Value::Null });+    }+    let (found, report) = read_by_find(hwnd);+    if found.iter().any(|e| e.is("edit")) {+        let mut b = TREE_BLIND.lock().unwrap_or_else(|e| e.into_inner());+        if !b.contains(&hwnd) {+            b.push(hwnd);+        }+        let wake = tree.as_ref().map(|(_, w)| w.clone()).unwrap_or(Value::Null);+        return Ok(Page { elements: found, source: "desktop_find_control", wake, found: report });+    }+    match tree {+        Ok((e, w)) => Ok(Page { elements: e, source: "tree", wake: w, found: report }),+        Err(e) if !found.is_empty() => Ok(Page { elements: found, source: "desktop_find_control", wake: json!({"treeError": e}), found: report }),+        Err(e) => Err(e),+    }+}+ // ── what the window is asking ───────────────────────────────────────────────  const SECOND_STEP: &[&str] = &[@@ -491,6 +792,17 @@ pub fn recognize(inv: &[Element]) -> (Reading, Option<Form>) { /// with no name answers "control found but has NO InvokePattern"). The /// automation id is sent too, as a reported field — never relied on. fn target(e: &Element, role: &str) -> Value {+    // LOCATED BY find_control (0.9.23): by automation id when it has one —+    // MEASURED 2026-10-12: `desktop_ui_set {hwnd, automationId:"username",+    // text}` works on the SWXDesktopUpdate host, and the password edit's NAME+    // ("Password " + an icon glyph) does not match an exact-name query.+    if e.by_find && !e.automation_id.is_empty() {+        return json!({"automationId": e.automation_id});+    }+    if e.by_find {+        let _ = role;+        return json!({"name": e.name});+    }     json!({         "name": e.name,         "contains": e.name,@@ -711,13 +1023,29 @@ pub struct LoginWindow {     pub owner_image: String,     /// Owned by a process of THIS bridge's launch chain.     pub ours: bool,+    /// How it was recognised: "titleOrClass" (the fast path) or "content" (an+    /// Edit "Email or username" / "Password" found in it — 0.9.23).+    pub found_by: &'static str,+    /// The page a content probe saw (1 = user name, 2 = password).+    pub page: Option<u8>, }  impl LoginWindow {+    pub fn host(&self) -> &'static str {+        if self.owner_image.eq_ignore_ascii_case("SLDWORKSVisualize.exe") {+            "visualize"+        } else if self.owner_image.eq_ignore_ascii_case(DESKTOP_UPDATE_IMAGE) {+            "desktopUpdate"+        } else if self.owner_image.is_empty() {+            "unknown"+        } else {+            "solidworks"+        }+    }     pub fn to_json(&self) -> Value {         json!({"hwnd": self.hwnd, "title": self.title, "ownerPid": self.owner_pid,                "ownerImage": self.owner_image, "ownedByThisBridgesLaunch": self.ours,-               "host": if self.owner_image.eq_ignore_ascii_case("SLDWORKSVisualize.exe") { "visualize" } else { "solidworks" }})+               "host": self.host(), "recognizedBy": self.found_by, "page": self.page})     } } @@ -736,6 +1064,18 @@ pub fn login_windows(os: &dyn crate::launchos::LaunchOs, all: bool) -> Result<Ve     for p in &ours {         candidates.push((*p, image_of(*p), true));     }+    // THE PLATFORM UPDATER (0.9.23): SWXDesktopUpdate.exe starts the moment a+    // platform shortcut is invoked (measured 2026-10-12) and is not a child the+    // chain census links. One started at or after THIS bridge's invoke, while+    // its launch is in flight, is that launch's.+    if let (Some(a), Ok(c)) = (crate::launch::attempt().filter(|a| !a.finished), &census) {+        for p in c.iter().filter(|p| p.image.eq_ignore_ascii_case(DESKTOP_UPDATE_IMAGE)) {+            let since_invoke = p.created_at.map(|t| t + 2 >= a.invoked_at).unwrap_or(false);+            if since_invoke && !candidates.iter().any(|(q, _, _)| *q == p.pid) {+                candidates.push((p.pid, p.image.clone(), true));+            }+        }+    }     if all {         match &census {             Ok(c) => {@@ -754,9 +1094,12 @@ pub fn login_windows(os: &dyn crate::launchos::LaunchOs, all: bool) -> Result<Ve     for (pid, image, is_ours) in candidates {         if let Ok(ws) = os.windows_of_pid(pid) {             read_any = true;-            for w in ws.iter().filter(|w| w.visible && crate::launch::is_sign_in_window(&w.class_name, &w.title)) {-                if !out.iter().any(|o| o.hwnd == w.hwnd) {-                    out.push(LoginWindow { hwnd: w.hwnd, title: w.title.clone(), owner_pid: pid, owner_image: image.clone(), ours: is_ours });+            for w in ws.iter() {+                if out.iter().any(|o| o.hwnd == w.hwnd) {+                    continue;+                }+                if let Some((how, page)) = window_is_login(&image, w) {+                    out.push(LoginWindow { hwnd: w.hwnd, title: w.title.clone(), owner_pid: pid, owner_image: image.clone(), ours: is_ours, found_by: how, page });                 }             }         }@@ -772,9 +1115,54 @@ pub fn login_windows(os: &dyn crate::launchos::LaunchOs, all: bool) -> Result<Ve  /// Is that login window still up? `None` = cannot tell. fn still_up(os: &dyn crate::launchos::LaunchOs, lw: &LoginWindow) -> Option<bool> {+    if lw.owner_pid == 0 {+        // A window the census does not hold (named by the caller): ab's list.+        return crate::abwin::list_windows(Duration::from_secs(10))+            .ok()+            .map(|ws| ws.iter().any(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(lw.hwnd)));+    }     os.windows_of_pid(lw.owner_pid).ok().map(|ws| ws.iter().any(|w| w.hwnd == lw.hwnd && w.visible)) } +/// THE WINDOW THE CALLER NAMED, when no census holds it as a login (0.9.23).+/// MEASURED 2026-10-12: `swx_sign_in {userAuthorized:true, window:857684}`+/// was refused "no 3DEXPERIENCE ID login window matching the request" while+/// that window WAS the sign-in (SWXDesktopUpdate.exe, title "3DEXPERIENCE").+/// A named window is judged by its CONTENT; refused only when neither field is+/// in it, with what was found.+fn named_window(os: &dyn crate::launchos::LaunchOs, hwnd: i64) -> Result<LoginWindow, Value> {+    let (found, report) = read_by_find(hwnd);+    let user = found.iter().any(|e| e.is("edit") && is_wanted(e, &PAGE_WANTS[0]));+    let pass = found.iter().any(|e| e.is("edit") && is_wanted(e, &PAGE_WANTS[1]));+    // Who owns it, if the census can say.+    let mut owner: Option<(u32, String, String)> = None;+    if let Ok(c) = os.processes() {+        for p in &c {+            if let Ok(ws) = os.windows_of_pid(p.pid) {+                if let Some(w) = ws.iter().find(|w| w.hwnd == hwnd) {+                    owner = Some((p.pid, p.image.clone(), w.title.clone()));+                    break;+                }+            }+        }+    }+    let title = owner.as_ref().map(|o| o.2.clone()).or_else(|| {+        crate::abwin::list_windows(Duration::from_secs(10)).ok().and_then(|ws| {+            ws.iter().find(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(hwnd)).and_then(|w| w.get("title").and_then(|t| t.as_str()).map(String::from))+        })+    });+    if !user && !pass {+        return Err(err(+            "sign_in_window_not_a_login",+            format!("window {hwnd} shows neither an \"Email or username\" nor a \"Password\" field"),+            "Nothing was typed. `found` lists every control looked for and what desktop_find_control answered.",+            json!({"hwnd": hwnd, "title": title, "found": report}),+        ));+    }+    let (pid, image) = owner.map(|o| (o.0, o.1)).unwrap_or((0, String::new()));+    Ok(LoginWindow { hwnd, title: title.unwrap_or_default(), owner_pid: pid, owner_image: image, ours: false, found_by: "content", page: Some(if pass { 2 } else { 1 }) })+}+ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {     let os = crate::launchos::os();     let user_confirmed_new_password = opts.user_confirmed_new_password;@@ -787,18 +1175,34 @@ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {     if !opts.user_authorized && attempt.is_none() {         return err("sign_in_not_waiting", "no launch by this bridge is recorded", "swx_start begins one; swx_sign_in answers only the sign-in window of a launch this bridge started — or, with userAuthorized:true (the user's say-so), any 3DEXPERIENCE ID login window on this machine.", json!({}));     }+    let named_hwnd = opts.window.as_deref().and_then(|w| w.trim().parse::<i64>().ok()).filter(|_| opts.user_authorized);     let found = match login_windows(os.as_ref(), opts.user_authorized) {         Ok(f) => f,+        // A window the CALLER NAMED is judged by its content, not refused+        // because a census could not be taken (0.9.23).+        Err(_) if named_hwnd.is_some() => Vec::new(),         Err(e) => return err("sign_in_not_waiting", format!("the login windows could not be read: {e}"), "Nothing was typed anywhere.", json!({})),     };     let pool: Vec<LoginWindow> = if opts.user_authorized { found } else { found.into_iter().filter(|w| w.ours).collect() };     let picked: Vec<LoginWindow> = match opts.window.as_deref().map(|w| w.trim().to_lowercase()) {         None => pool.clone(),         Some(w) if w == "visualize" => pool.iter().filter(|l| l.owner_image.eq_ignore_ascii_case("SLDWORKSVisualize.exe")).cloned().collect(),-        Some(w) if w == "solidworks" => pool.iter().filter(|l| !l.owner_image.eq_ignore_ascii_case("SLDWORKSVisualize.exe")).cloned().collect(),+        Some(w) if w == "solidworks" => pool.iter().filter(|l| l.host() == "solidworks").cloned().collect(),+        Some(w) if w == "desktopupdate" => pool.iter().filter(|l| l.host() == "desktopUpdate").cloned().collect(),         Some(w) => match w.parse::<i64>() {-            Ok(h) => pool.iter().filter(|l| l.hwnd == h).cloned().collect(),-            Err(_) => return err("sign_in_bad_window", format!("window {w:?} is not \"visualize\", \"solidworks\" or an hwnd"), "Pass window:\"visualize\", \"solidworks\" or a window handle.", json!({})),+            Ok(h) => {+                let hit: Vec<LoginWindow> = pool.iter().filter(|l| l.hwnd == h).cloned().collect();+                if hit.is_empty() && opts.user_authorized {+                    // NAMED BY THE CALLER: never refused on census grounds.+                    match named_window(os.as_ref(), h) {+                        Ok(lw) => vec![lw],+                        Err(e) => return e,+                    }+                } else {+                    hit+                }+            }+            Err(_) => return err("sign_in_bad_window", format!("window {w:?} is not \"visualize\", \"solidworks\", \"desktopUpdate\" or an hwnd"), "Pass window:\"visualize\", \"solidworks\", \"desktopUpdate\" or a window handle.", json!({})),         },     };     let listed = json!(pool.iter().map(|l| l.to_json()).collect::<Vec<_>>());@@ -833,14 +1237,17 @@ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {         );     }     // 1. THE INVENTORY, captured every time (woken first if the page is lazy).-    let read = match inventory(hwnd) {+    //    When the tree holds only the frame (the SWXDesktopUpdate host,+    //    measured 2026-10-12), the page is located control by control.+    let read = match read_page(hwnd) {         Ok(r) => r,         Err(e) => return err("sign_in_inventory_unreadable", e, "Nothing was typed. The user can sign in by hand.", json!({"hwnd": hwnd})),     };     crate::launch::note_sign_in_inventory(inventory_json(&read.elements));-    crate::launch::note_sign_in_wake(read.wake_json());+    crate::launch::note_sign_in_wake(read.wake.clone());     let inv_j = inventory_json(&read.elements);-    let wake_1 = read.wake_json();+    let wake_1 = read.wake.clone();+    let located_1 = read.report();     let banner = cookie_banner(&read.elements);     let (reading, form) = recognize(&read.elements);     let form = match (reading, form) {@@ -851,7 +1258,7 @@ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {         }         (r, _) => {             let why = match r { Reading::Unrecognized(w) | Reading::Rejected(w) => w, _ => "unrecognized".into() };-            return err("sign_in_window_unrecognized", format!("the sign-in window does not look like the form this bridge fills: {why}"), "Nothing was typed. The inventory is attached so the form can be recognized next time; the user can sign in by hand.", json!({"inventory": inv_j, "wake": wake_1, "cookieBanner": banner}));+            return err("sign_in_window_unrecognized", format!("the sign-in window does not look like the form this bridge fills: {why}"), "Nothing was typed. The inventory is attached so the form can be recognized next time; the user can sign in by hand.", json!({"inventory": inv_j, "wake": wake_1, "located": located_1, "cookieBanner": banner, "window": window_j}));         }     };     // 2. THE SECRET, locally — ONCE for the whole sign-in, however many pages it@@ -878,7 +1285,7 @@ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {     };     let needles = secret.needles();     let mut steps: Vec<Value> = Vec::new();-    let mut pages: Vec<Value> = vec![json!({"page": 1, "inventory": inv_j, "wake": wake_1, "cookieBanner": banner})];+    let mut pages: Vec<Value> = vec![json!({"page": 1, "inventory": inv_j, "wake": wake_1, "located": located_1, "cookieBanner": banner})];     let mut remember_ticked = Value::Null;     // 3. PAGE ONE (and, on a one-page form, the whole sign-in).     if let Err(fe) = fill_page(hwnd, &read.elements, &form, &secret, &mut steps, &mut remember_ticked) {@@ -899,12 +1306,12 @@ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {             if still_up(os.as_ref(), &lw) == Some(false) {                 break; // the window closed after the user name alone             }-            if let Ok(r2) = inventory(hwnd) {+            if let Ok(r2) = read_page(hwnd) {                 last = r2.elements.clone();                 match recognize(&r2.elements) {                     (Reading::Form, Some(f2)) if f2.password.is_some() => {-                        pages.push(json!({"page": 2, "inventory": inventory_json(&r2.elements), "wake": r2.wake_json(),-                                          "cookieBanner": cookie_banner(&r2.elements)}));+                        pages.push(json!({"page": 2, "inventory": inventory_json(&r2.elements), "wake": r2.wake.clone(),+                                          "located": r2.report(), "cookieBanner": cookie_banner(&r2.elements)}));                         crate::launch::note_sign_in_inventory(inventory_json(&r2.elements));                         page2_els = r2.elements.clone();                         page2 = Some(f2);@@ -974,7 +1381,7 @@ fn sign_in_inner(reason: &str, opts: &Opts) -> Value {         }     }     // STILL UP: read it again and say what it is asking.-    let after = inventory(hwnd).map(|r| r.elements).unwrap_or_default();+    let after = read_page(hwnd).map(|r| r.elements).unwrap_or_default();     let after_j = inventory_json(&after);     let out = if let Some(m) = has_marker(&after, REJECTED) {         crate::launch::note_sign_in_rejected();@@ -1008,6 +1415,15 @@ fn fill_page(hwnd: i64, page: &[Element], form: &Form, secret: &Secret, steps: &     }     if let Some(rm) = &form.remember {         *remember_ticked = tick_remember(hwnd, page, rm);+    } else if form.password.is_some() {+        // THE SWXDesktopUpdate HOST (MEASURED 2026-10-12): page 2 shows a Text+        // "Remember me" and no CheckBox could be found by name. The label is+        // clicked (as on the Visualize host) only while the user is idle and the+        // login window is in front; its state is NOT readable, so+        // `rememberTicked` stays null and the step says what was done.+        if let Some(label) = page.iter().find(|e| e.by_find && e.is("text") && clean(&e.name).starts_with("remember me") && e.center.is_some() && !e.offscreen()) {+            steps.push(click_remember_label(hwnd, label));+        }     }     let r = crate::caller::ad_callback("desktop_ui_click", merge(target(&form.submit, "button"), json!({"hwnd": hwnd, "reason": FILL_REASON})), AB_TIMEOUT);     steps.push(json!({"step": format!("press \"{}\"", form.submit.name), "ok": ab_ok(&r), "via": "uiaInvoke"}));@@ -1071,6 +1487,25 @@ fn tick_remember(hwnd: i64, page: &[Element], rm: &Element) -> Value {     json!({"ticked": ab_ok(&r) && is_on(after.as_deref()), "via": "labelClick", "label": label.name, "toggleStateAfter": after}) } +/// A real click on the "Remember me" label, guarded like every real input.+fn click_remember_label(hwnd: i64, label: &Element) -> Value {+    if let Err(why) = user_idle() {+        return json!({"step": "remember me (label)", "ok": false, "why": format!("skipped: {why}"), "state": "unreadable"});+    }+    let _ = crate::caller::ad_callback("desktop_raise_window", json!({"hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);+    let settle = std::time::Instant::now() + Duration::from_millis(1500);+    while login_in_front(hwnd).is_err() && std::time::Instant::now() < settle {+        std::thread::sleep(Duration::from_millis(100));+    }+    if let Err(why) = login_in_front(hwnd) {+        return json!({"step": "remember me (label)", "ok": false, "why": format!("skipped: {why}"), "state": "unreadable"});+    }+    let (x, y) = label.center.unwrap_or_default();+    let r = crate::caller::ad_callback("desktop_click", json!({"x": x, "y": y, "hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);+    json!({"step": "remember me (label)", "ok": ab_ok(&r), "via": "labelClick", "label": label.name,+           "state": "unreadable — no CheckBox was found on this host, so whether it is ticked cannot be read"})+}+ pub(crate) enum FillErr {     /// The user is typing (or the idle time cannot be read): no keystroke sent.     NeedsIdle(String),@@ -1129,7 +1564,24 @@ fn field_value(hwnd: i64, el: &Element) -> Option<String> {             _ => None,         }     }-    walk(&unwrap_ab(&r), el)+    if let Some(v) = walk(&unwrap_ab(&r), el) {+        return Some(v);+    }+    if !el.by_find {+        return None;+    }+    // Located by find_control: its reply's `value`, IF ab ever carries one+    // (measured 2026-10-12: it does not).+    let q = if el.automation_id.is_empty() { json!({"hwnd": hwnd, "role": "Edit", "name": el.name}) } else { json!({"hwnd": hwnd, "automationId": el.automation_id}) };+    let r = crate::caller::ad_callback("desktop_find_control", q, AB_TIMEOUT);+    fn val(v: &Value) -> Option<String> {+        match v {+            Value::Array(a) => a.iter().find_map(val),+            Value::Object(m) => m.get("value").and_then(|x| x.as_str()).map(String::from).or_else(|| m.values().find_map(val)),+            _ => None,+        }+    }+    if ab_ok(&r) { val(&unwrap_ab(&r)) } else { None } }  /// Where a field is NOW: its centre (ab's own), whether a cookie-banner@@ -1141,6 +1593,21 @@ struct Spot { }  fn locate(hwnd: i64, el: &Element) -> Option<Spot> {+    if el.by_find {+        // Fresh geometry by find_control; the banner's buttons too.+        let q = if el.automation_id.is_empty() { json!({"hwnd": hwnd, "role": "Edit", "name": el.name}) } else { json!({"hwnd": hwnd, "automationId": el.automation_id}) };+        let r = crate::caller::ad_callback("desktop_find_control", q, AB_TIMEOUT);+        let field = found_elements(&r).into_iter().find(|e| e.is("edit") && (e.automation_id == el.automation_id || e.name == el.name))?;+        let center = field.center?;+        let covered = match field.rect {+            Some(fr) => ["Accept All", "Manage my preferences"].iter().any(|n| {+                let (b, _) = find_one(hwnd, &Want { role: "Button", automation_id: None, name: n });+                b.and_then(|b| b.rect).map(|br| overlaps(fr, br)).unwrap_or(false)+            }),+            None => false,+        };+        return Some(Spot { center, covered, render_widget: None });+    }     let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 30, "maxNodes": 400}), AB_TIMEOUT);     if !ab_ok(&r) {         return None;@@ -1177,6 +1644,76 @@ fn locate(hwnd: i64, el: &Element) -> Option<Spot> {     Some(Spot { center, covered, render_widget: widget(&unwrap_ab(&r)) }) } +/// CLEAR, THEN SET, a find_control-located field (0.9.23). MEASURED+/// 2026-10-12 on the SWXDesktopUpdate host: the first `desktop_ui_set` of the+/// user name left the name plus a stray trailing "s" (a leftover keystroke+/// or SetValue inserting, origin unknown); setting "" first and then the value+/// fixed it. So: set "", read back (when readable) that it is empty, set the+/// value, read the user name back (when readable). `desktop_find_control`'s+/// reply carries NO `value` (measured), and this host's tree holds no page, so+/// the read-back is usually UNREADABLE there — said in the step, never+/// assumed. The password is never read back. `Ok(false)` = no ValuePattern+/// (fall back to keystrokes).+fn set_cleared(hwnd: i64, el: &Element, typed: &str, read_back: bool, label: &str, steps: &mut Vec<Value>) -> Result<bool, FillErr> {+    let set = |text: &str| {+        crate::caller::ad_callback(+            "desktop_ui_set",+            merge(target(el, "edit"), json!({"hwnd": hwnd, "text": text, "sensitive": true, "reason": FILL_REASON})),+            AB_TIMEOUT,+        )+    };+    let r = set("");+    if !ab_ok(&r) {+        let why = ab_error(&r);+        if why.to_lowercase().contains("no valuepattern") {+            return Ok(false);+        }+        steps.push(json!({"step": format!("{label}: clear"), "ok": false, "via": "uiaValuePattern"}));+        return Err(FillErr::Failed { step: format!("{label} (UIA clear)"), why, observed: None, may_be_banner: true });+    }+    let cleared = field_value(hwnd, el);+    if read_back {+        if let Some(v) = cleared.as_deref().filter(|v| !v.is_empty()) {+            return Err(FillErr::Failed {+                step: format!("{label}: clear"),+                why: "the field was set to empty but still holds text".into(),+                observed: Some(json!({"fieldText": v, "length": v.chars().count()})),+                may_be_banner: false,+            });+        }+    }+    let r = set(typed);+    if !ab_ok(&r) {+        steps.push(json!({"step": label, "ok": false, "via": "uiaValuePattern", "cleared": true}));+        return Err(FillErr::Failed { step: format!("{label} (UIA set)"), why: ab_error(&r), observed: None, may_be_banner: true });+    }+    if !read_back {+        steps.push(json!({"step": label, "ok": true, "via": "uiaValuePattern", "cleared": true, "readBack": "notReadPassword"}));+        return Ok(true);+    }+    let got = field_value(hwnd, el);+    match got {+        Some(g) if g == typed => {+            steps.push(json!({"step": label, "ok": true, "via": "uiaValuePattern", "cleared": true, "readBack": "matched"}));+            Ok(true)+        }+        Some(g) => {+            steps.push(json!({"step": label, "ok": false, "via": "uiaValuePattern", "cleared": true, "readBack": "mismatch"}));+            Err(FillErr::Failed {+                step: format!("{label}: read-back"),+                why: "the user-name field does not hold what was set".into(),+                observed: Some(json!({"fieldText": g, "length": g.chars().count()})),+                may_be_banner: false,+            })+        }+        None => {+            steps.push(json!({"step": label, "ok": true, "via": "uiaValuePattern", "cleared": true,+                              "readBack": "unreadable (desktop_find_control carries no value; the tree holds no page on this host)"}));+            Ok(true)+        }+    }+}+ /// FILL ONE FIELD. First the background UIA ValuePattern set; MEASURED /// 2026-10-02 the 3DEXPERIENCE ID page refuses it ("control found but has NO /// ValuePattern … try a foregrounded desktop_type") even on an edit the tree@@ -1194,6 +1731,12 @@ fn locate(hwnd: i64, el: &Element) -> Option<Spot> { fn fill_field(hwnd: i64, el: &Element, text: &[u8], read_back: bool, steps: &mut Vec<Value>) -> Result<(), FillErr> {     let label = if read_back { "user name" } else { "password" };     let typed = String::from_utf8_lossy(text).to_string();+    if el.by_find {+        match set_cleared(hwnd, el, &typed, read_back, label, steps)? {+            true => return Ok(()),+            false => {} // no ValuePattern: real keystrokes, below+        }+    } else {     let r = crate::caller::ad_callback(         "desktop_ui_set",         merge(target(el, "edit"), json!({"hwnd": hwnd, "text": typed, "sensitive": true, "reason": FILL_REASON})),@@ -1209,6 +1752,7 @@ fn fill_field(hwnd: i64, el: &Element, text: &[u8], read_back: bool, steps: &mut         steps.push(json!({"step": label, "ok": false, "via": "uiaValuePattern"}));         return Err(FillErr::Failed { step: format!("{label} (UIA set)"), why, observed: None, may_be_banner: true });     }+    }     // ── REAL KEYSTROKES ──────────────────────────────────────────────────────     user_idle().map_err(FillErr::NeedsIdle)?;     // FRESH GEOMETRY (MEASURED 2026-10-02: moving the window re-laid the page@@ -1277,6 +1821,10 @@ fn fill_field(hwnd: i64, el: &Element, text: &[u8], read_back: bool, steps: &mut             std::thread::sleep(Duration::from_millis(150));             got = field_value(hwnd, el);         }+        if got.is_none() && el.by_find {+            steps.push(json!({"step": label, "ok": true, "via": "keystrokes", "readBack": "unreadable (desktop_find_control carries no value; the tree holds no page on this host)"}));+            return Ok(());+        }         if got.as_deref() != Some(typed.as_str()) {             steps.push(json!({"step": label, "ok": false, "via": "keystrokes", "readBack": "mismatch"}));             return Err(FillErr::Failed {
src/verbs.rs+52−29
@@ -3017,7 +3017,7 @@ pub const CATALOG: &[(&str, &str, &str)] = &[     ("swx_close_all_safely", "exec", "save EVERY open document into a recovery folder, then close it — nothing closes before it is on disk; needs only a reason"),     ("swx_shutdown", "exec", "close_all_safely, then quit SOLIDWORKS; idempotent, and the quit is skipped unless the session really emptied"),     ("swx_recover_list", "read", "what previous sweeps preserved, and where — newest first"),-    ("swx_sign_in", "exec", "sign in to 3DEXPERIENCE for a launch this bridge started (or, with userAuthorized:true, any 3DEXPERIENCE ID login window, e.g. Visualize's; window picks one), from the user's local vault (the password never leaves the laptop or reaches a reply); fails closed and never retries a rejected password"),+    ("swx_sign_in", "exec", "sign in to 3DEXPERIENCE for a launch this bridge started (or, with userAuthorized:true, any 3DEXPERIENCE ID login window, e.g. Visualize's or the platform updater's, recognized by its content; window:visualize|solidworks|desktopUpdate|<hwnd> picks one, and a named hwnd is probed by content, never refused on census grounds), from the user's local vault (the password never leaves the laptop or reaches a reply); fails closed and never retries a rejected password"),     ("swx_user_presence", "read", "is the user working in this SOLIDWORKS? foreground + last input + documents the bridge did not open (the guard every quit and sweep passes)"),     ("swx_kill", "process", "force-kill SOLIDWORKS (reason + confirm; discards unsaved work)"),     ("swx_window_info", "read", "SOLIDWORKS' windows and its main hwnd (read-only, nothing raised)"),@@ -3422,37 +3422,60 @@ fn dispatch_after_gate(ctx: &Ctx, command: &str, args: &Value) -> Value {         }     }     // BLOCKED ON THE USER'S SIGN-IN (0.9.12): said plainly, with the window.+    // 0.9.23: `signInWindow` is an OBJECT {hwnd, title, ownerImage, host, …},+    // and ANY 3DEXPERIENCE ID login on the machine counts — by class/title or by+    // CONTENT (measured 2026-10-12: the SWXDesktopUpdate-hosted login, titled+    // just "3DEXPERIENCE", was up while swx_status said waitingOn:null).     if command == "swx_start" || command == "swx_status" {-        if let Some(a) = crate::launch::attempt() {-            // From a LIVE census whenever the record names the sign-in stage —-            // the monitor that first saw the window may have died with an older-            // bridge process (0.9.13).-            let live = if a.stage.name() == "helperSignIn" {-                match crate::launch::sign_in_now(crate::launchos::os().as_ref()) {-                    Some(Some((_, t))) => Some(t),-                    _ => None,+        let os = crate::launchos::os();+        let attempt = crate::launch::attempt();+        // From a LIVE census whenever the record names the sign-in stage —+        // the monitor that first saw the window may have died with an older+        // bridge process (0.9.13).+        let live = match &attempt {+            Some(a) if a.stage.name() == "helperSignIn" => match crate::launch::sign_in_now(os.as_ref()) {+                Some(Some((h, t))) => Some((Some(h), t)),+                _ => None,+            },+            _ => None,+        };+        let recorded = attempt.as_ref().and_then(|a| a.sign_in_window.clone().filter(|_| !a.finished)).map(|t| (None, t));+        let machine = crate::signin::login_windows(os.as_ref(), true).unwrap_or_default();+        let ours_title = live.or(recorded);+        let window: Option<Value> = match &ours_title {+            Some((h, t)) => Some(+                machine+                    .iter()+                    .find(|l| Some(l.hwnd) == *h || (h.is_none() && l.title == *t))+                    .map(|l| l.to_json())+                    .unwrap_or_else(|| json!({"hwnd": h, "title": t, "ownerImage": Value::Null, "host": Value::Null})),+            ),+            None => machine.iter().find(|l| l.ours).or_else(|| machine.first()).map(|l| l.to_json()),+        };+        if let Some(w) = window {+            if let Value::Object(m) = &mut out {+                if let Some(a) = attempt.as_ref().filter(|_| ours_title.is_some()) {+                    m.insert("signInInventory".into(), a.sign_in_inventory.clone().unwrap_or(Value::Null));+                    m.insert("signInMode".into(), json!(if a.sign_in_auto { "auto" } else { "wait" }));                 }-            } else {-                None-            };-            let title = live.or_else(|| a.sign_in_window.clone().filter(|_| !a.finished));-            if let Some(m) = out.as_object_mut().filter(|_| title.is_some()) {-                m.insert("signInInventory".into(), a.sign_in_inventory.clone().unwrap_or(Value::Null));-                m.insert("signInMode".into(), json!(if a.sign_in_auto { "auto" } else { "wait" }));-            }-            if let Some(title) = title {-                if let Value::Object(m) = &mut out {-                    m.insert("waitingOn".into(), json!("userSignIn"));-                    m.insert("signInWindow".into(), json!(title));-                    m.insert(-                        "_signInHint".into(),-                        json!("SOLIDWORKS is waiting for the user to sign in to 3DEXPERIENCE in the \-                               window named here. Only the user can answer it; the launch's deadlines \-                               are paused and nothing is killed. Tell the user, or — if they have a \-                               3DEXPERIENCE login saved in their local vault — call swx_sign_in \-                               {reason}. Then poll swx_status."),-                    );+                m.insert("waitingOn".into(), json!("userSignIn"));+                m.insert("signInWindow".into(), w);+                if machine.len() > 1 {+                    m.insert("loginWindows".into(), json!(machine.iter().map(|l| l.to_json()).collect::<Vec<_>>()));+                }+                // swx_start must not say "launching" while the start is parked+                // behind a login (0.9.23).+                if command == "swx_start" && m.get("stage").and_then(|v| v.as_str()) == Some("launching") {+                    m.insert("stage".into(), json!("waitingOn"));                 }+                m.insert(+                    "_signInHint".into(),+                    json!("A 3DEXPERIENCE sign-in window is waiting for the user (signInWindow). Only the user \+                           can answer it; a launch's deadlines are paused and nothing is killed. Tell the user, \+                           or — if they have a 3DEXPERIENCE login saved in their local vault — call swx_sign_in \+                           {reason} (a launch this bridge started) or swx_sign_in {reason, userAuthorized:true, \+                           window:<signInWindow.hwnd>} on the user's say-so. Then poll swx_status."),+                );             }         }     }
src/visualize.rs+14
@@ -122,6 +122,18 @@ pub fn status() -> Value {         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",+                          "how": "Only the user can answer it — or swx_sign_in {reason, userAuthorized:true, window:<signInWindow.hwnd>} on the user's say-so."});+        }         return json!({"running": false, "pid": Value::Null, "hwnd": Value::Null, "waitingOn": Value::Null});     };     let windows = os.windows_of_pid(p.pid).unwrap_or_default();@@ -220,10 +232,12 @@ pub fn start(ctx: &crate::verbs::Ctx, args: &Value) -> Value {         "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). Tell the user, or on their say-so call swx_sign_in {reason, userAuthorized:true, window:<signInWindow.hwnd>}; 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.",         },
tests/common/mod.rs+23−2
@@ -359,6 +359,10 @@ pub struct AbStub {     /// swapped BEFORE `.0` is answered, so a test can model "the page changed as     /// a result of this call" without racing the bridge.     triggers: std::sync::Arc<Mutex<Vec<(String, String, Value)>>>,+    /// Replies chosen by the REQUEST'S ARGS (0.9.23): `desktop_find_control`+    /// answers by the name / automation id asked for. First match wins; every+    /// key of the rule's `when` must equal the request's arg.+    rules: std::sync::Arc<Mutex<Vec<(String, Value, Value)>>>,     _thread: std::thread::JoinHandle<()>, } @@ -376,6 +380,8 @@ impl AbStub {         let reps = std::sync::Arc::clone(&replies);         let triggers: std::sync::Arc<Mutex<Vec<(String, String, Value)>>> = std::sync::Arc::new(Mutex::new(Vec::new()));         let trig = std::sync::Arc::clone(&triggers);+        let rules: std::sync::Arc<Mutex<Vec<(String, Value, Value)>>> = std::sync::Arc::new(Mutex::new(Vec::new()));+        let rls = std::sync::Arc::clone(&rules);         let thread = std::thread::spawn(move || {             for stream in listener.incoming() {                 let Ok(mut s) = stream else { break };@@ -383,6 +389,7 @@ impl AbStub {                 let mut buf = Vec::new();                 let mut chunk = [0u8; 4096];                 let mut command = String::new();+                let mut req_args = Value::Null;                 // Read headers, then exactly Content-Length bytes.                 loop {                     let n = match s.read(&mut chunk) {@@ -407,6 +414,7 @@ impl AbStub {                                     .and_then(|c| c.as_str())                                     .unwrap_or("")                                     .to_string();+                                req_args = v.get("args").cloned().unwrap_or(Value::Null);                                 sink.lock().unwrap().push(v);                             }                             break;@@ -421,7 +429,10 @@ impl AbStub {                         reps.lock().unwrap().insert(target, payload);                     }                 }-                let payload = match reps.lock().unwrap().get(&command) {+                let ruled = rls.lock().unwrap().iter().find(|(c, when, _)| {+                    *c == command && when.as_object().map(|w| w.iter().all(|(k, v)| req_args.get(k) == Some(v))).unwrap_or(false)+                }).map(|(_, _, p)| p.clone());+                let payload = match ruled.as_ref().or(reps.lock().unwrap().get(&command)) {                     Some(custom) => custom.clone(),                     None => json!({                         "success": true,@@ -440,7 +451,7 @@ impl AbStub {                 );             }         });-        AbStub { url: format!("http://127.0.0.1:{port}"), calls, windows, replies, triggers, _thread: thread }+        AbStub { url: format!("http://127.0.0.1:{port}"), calls, windows, replies, triggers, rules, _thread: thread }     }      /// When `trigger` arrives (once), `target` answers `payload` from then on.@@ -448,6 +459,16 @@ impl AbStub {         self.triggers.lock().unwrap().push((trigger.to_string(), target.to_string(), payload));     } +    /// Answer `command` with `payload` when the request's args include `when`.+    pub fn when(&self, command: &str, when: Value, payload: Value) {+        self.rules.lock().unwrap().push((command.to_string(), when, payload));+    }++    /// Drop every args-matched rule for `command`.+    pub fn clear_rules(&self, command: &str) {+        self.rules.lock().unwrap().retain(|(c, _, _)| c != command);+    }+     /// Answer one ab verb with a specific payload.     pub fn set_response(&self, command: &str, payload: Value) {         self.replies.lock().unwrap().insert(command.to_string(), payload);
tests/launch.rs+1−1
@@ -1235,7 +1235,7 @@ fn a_sign_in_window_pauses_every_deadline_instead_of_killing_the_chain() {         b,         "...swx_status says it is waiting on the USER's sign-in, naming the window",         st["waitingOn"] == "userSignIn"-            && st["signInWindow"].as_str().unwrap_or("").contains("3DEXPERIENCE ID"),+            && st["signInWindow"]["title"].as_str().unwrap_or("").contains("3DEXPERIENCE ID") && st["signInWindow"]["hwnd"].is_number(),         s(&st)     ); 
tests/signin.rs+213−2
@@ -102,7 +102,11 @@ fn wait_for_sign_in_stage(b: &Bridge) -> Value {     let deadline = Instant::now() + Duration::from_secs(10);     loop {         let st = b.call("swx_status", json!({}));-        if st["waitingOn"] == "userSignIn" || Instant::now() > deadline {+        // 0.9.23: `waitingOn` comes from a machine-wide census the moment the+        // window is up; the LAUNCH has reached the sign-in when it reports it+        // as its own (`signInMode`).+        // ...and its inventory is read off the monitor thread: wait for it too.+        if (st["waitingOn"] == "userSignIn" && st["signInMode"].is_string() && !st["signInInventory"].is_null()) || Instant::now() > deadline {             return st;         }         std::thread::sleep(Duration::from_millis(150));@@ -361,7 +365,7 @@ fn a_respawned_bridge_still_sees_the_sign_in_and_reconciles_after_it() {     check!(         b,         "after a respawn mid-launch, waitingOn and the window come from a LIVE census",-        st["waitingOn"] == "userSignIn" && st["signInWindow"].as_str().unwrap_or("").contains("3DEXPERIENCE ID"),+        st["waitingOn"] == "userSignIn" && st["signInWindow"]["title"].as_str().unwrap_or("").contains("3DEXPERIENCE ID") && st["signInWindow"]["hwnd"].is_number(),         s(&st)     );     // The user signs in by hand; SOLIDWORKS comes up and COM attaches.@@ -1089,3 +1093,210 @@ fn a_cookie_banner_over_the_field_is_never_clicked_through() {         s(&r)     ); }++// ── 0.9.23: the THIRD host — SWXDesktopUpdate.exe, found by CONTENT ─────────+//+// MEASURED 2026-10-12: title "3DEXPERIENCE" (no "ID", no "login"), class+// WindowsForms10.Window.8.app.0.30495d1_r6_ad1, owned by SWXDesktopUpdate.exe+// (from %TEMP%). desktop_ui_tree returns only the frame (14 nodes);+// desktop_find_control reaches the page by name / automation id.++const UPDATE_HWND: i64 = 857684;+const UPDATE_CLASS: &str = "WindowsForms10.Window.8.app.0.30495d1_r6_ad1";++fn update_machine(tag: &str) -> Machine {+    let m = Machine::new(tag);+    std::fs::write(m.dir.join("preexisting.json"), json!([{"pid": 61000, "image": "SWXDesktopUpdate.exe", "ageSeconds": 30}]).to_string()).unwrap();+    std::fs::write(+        m.dir.join("sign-in.json"),+        json!({"hwnd": UPDATE_HWND, "ownerPid": 61000, "className": UPDATE_CLASS, "title": "3DEXPERIENCE"}).to_string(),+    )+    .unwrap();+    m+}++/// The measured tree: the frame only, even after a wake.+fn frame_tree() -> Value {+    json!({"success": true, "status": "ok", "hwnd": UPDATE_HWND, "maxNodes": 600, "nodeCount": 14, "truncated": false,+           "window": {"title": "3DEXPERIENCE", "rect": {"x": 828, "y": 273, "w": 1316, "h": 1076}},+           "root": {"role": "Window", "name": "3DEXPERIENCE", "automationId": "SWXDesktopUpdateForm", "invokable": false, "settable": false, "children": [+               {"role": "TitleBar", "name": "", "automationId": "", "children": [+                   {"role": "MenuBar", "name": "System Menu Bar", "automationId": ""},+                   {"role": "Button", "name": "Minimize", "automationId": "", "invokable": true},+                   {"role": "Button", "name": "Maximize", "automationId": "", "invokable": true},+                   {"role": "Button", "name": "Close", "automationId": "", "invokable": true}+               ]},+               {"role": "Pane", "name": "", "automationId": ""}+           ]}})+}++fn ctl(role: &str, name: &str, aid: &str, cx: i64, cy: i64, settable: bool, invokable: bool) -> Value {+    json!({"success": true, "data": {"found": true, "control": {"role": role, "name": name, "automationId": aid,+           "invokable": invokable, "settable": settable,+           "rect": {"x": cx - 200, "y": cy - 20, "w": 400, "h": 40, "centerX": cx, "centerY": cy}}}})+}++fn not_found() -> Value {+    json!({"success": false, "error": "no control matched"})+}++const FIND: &str = "desktop_find_control";++/// Page one, as measured: the user-name edit (by automation id or exact+/// name), Continue. A query with a role ALONE answers the measured wrong match.+fn update_page_one(ab: &common::AbStub) {+    ab.clear_rules(FIND);+    let user = ctl("Edit", "Email or username", "username", 1486, 844, true, false);+    ab.when(FIND, json!({"automationId": "username"}), user.clone());+    ab.when(FIND, json!({"role": "Edit", "name": "Email or username"}), user);+    ab.when(FIND, json!({"role": "Button", "name": "Continue"}), ctl("Button", "Continue", "", 1486, 940, false, true));+    ab.when(FIND, json!({"name": "Manage cookies"}), ctl("Hyperlink", "Manage cookies", "", 2034, 1264, false, true));+    ab.set_response(FIND, not_found());+}++/// Page two, as measured: the password edit's NAME carries an icon glyph, so+/// only `{automationId:"password"}` finds it; "Remember me" is a Text.+fn update_page_two(ab: &common::AbStub) {+    ab.clear_rules(FIND);+    let user = ctl("Edit", "Email or username", "username", 1486, 727, true, false);+    ab.when(FIND, json!({"automationId": "username"}), user.clone());+    ab.when(FIND, json!({"role": "Edit", "name": "Email or username"}), user);+    ab.when(FIND, json!({"automationId": "password"}), ctl("Edit", "Password \u{e9a0}", "password", 1486, 856, true, false));+    ab.when(FIND, json!({"role": "Text", "name": "Remember me"}), ctl("Text", "Remember me", "", 1255, 923, false, false));+    ab.when(FIND, json!({"role": "Button", "name": "Log in"}), ctl("Button", "Log in", "", 1486, 1006, false, true));+    ab.when(FIND, json!({"role": "Button", "name": "Back"}), ctl("Button", "Back", "", 1486, 1093, false, true));+    ab.set_response(FIND, not_found());+}++fn update_ab() -> common::AbStub {+    let ab = ab_for(frame_tree(), vault_ok());+    ab.set_response("desktop_raise_window", json!({"success": true}));+    ab.set_response("desktop_click", json!({"success": true}));+    update_page_one(&ab);+    ab+}++/// Continue → page two; Log in → the window closes.+fn drive_update(m: &Machine, ab: &common::AbStub) {+    let deadline = Instant::now() + Duration::from_secs(25);+    let mut state = 0;+    while Instant::now() < deadline {+        let clicks = ab.calls_to("desktop_ui_click");+        let clicked = |n: &str| clicks.iter().any(|c| c["args"]["name"] == n);+        match state {+            0 if clicked("Continue") => {+                update_page_two(ab);+                state = 1;+            }+            1 if clicked("Log in") => {+                let _ = std::fs::remove_file(m.dir.join("sign-in.json"));+                return;+            }+            _ => {}+        }+        std::thread::sleep(Duration::from_millis(15));+    }+}++#[test]+fn the_desktop_update_login_is_found_by_its_content_and_signed_in() {+    assert!(+        !swx_bridge::launch::is_sign_in_window(UPDATE_CLASS, "3DEXPERIENCE"),+        "the fast path does NOT know this window (its title is just \"3DEXPERIENCE\") — content must"+    );+    assert!(swx_bridge::launchos::WATCHED_IMAGES.contains(&"SWXDesktopUpdate.exe"), "the census must see the updater");+    let m = update_machine("signin-update");+    std::fs::write(m.dir.join("foreground.json"), json!({"hwnd": UPDATE_HWND, "pid": 61000, "title": "3DEXPERIENCE", "className": UPDATE_CLASS}).to_string()).unwrap();+    let ab = update_ab();+    let b = m.boot(TempDir::new("signin-update-scratch"), &ab, &[("SWX_SIGN_IN_WAKE_MS", "20"), ("SWX_SIGN_IN_VERIFY_SECS", "8")]);+    let st = b.call("swx_status", json!({}));+    check!(+        b,+        "swx_status: waitingOn userSignIn, signInWindow {hwnd, title, ownerImage, host:desktopUpdate}, recognized by content",+        st["waitingOn"] == "userSignIn" && st["signInWindow"]["hwnd"] == UPDATE_HWND && st["signInWindow"]["title"] == "3DEXPERIENCE"+            && st["signInWindow"]["ownerImage"] == "SWXDesktopUpdate.exe" && st["signInWindow"]["host"] == "desktopUpdate"+            && st["signInWindow"]["recognizedBy"] == "content",+        s(&st)+    );+    let r = std::thread::scope(|sc| {+        sc.spawn(|| drive_update(&m, &ab));+        b.call("swx_sign_in", json!({"reason": "Drew: sign me in", "userAuthorized": true, "window": UPDATE_HWND}))+    });+    check!(+        b,+        "signed in over both pages, located by desktop_find_control, verified by the window closing",+        r["signedIn"] == true && r["twoPage"] == true && r["window"]["host"] == "desktopUpdate" && !m.sign_in_up()+            && r["pages"][0]["located"]["source"] == "desktop_find_control" && r["pages"][1]["located"]["source"] == "desktop_find_control",+        s(&r)+    );+    let sets = ab.calls_to("desktop_ui_set");+    let set = |i: usize| (sets.get(i).map(|c| c["args"]["automationId"].clone()), sets.get(i).map(|c| c["args"]["text"].clone()));+    check!(+        b,+        "CLEAR THEN SET, by automation id: username \"\" then the user name; password \"\" then the password",+        sets.len() == 4+            && set(0) == (Some(json!("username")), Some(json!(""))) && set(1) == (Some(json!("username")), Some(json!(USER)))+            && set(2) == (Some(json!("password")), Some(json!(""))) && set(3) == (Some(json!("password")), Some(json!(PW))),+        format!("{sets:?}")+    );+    check!(+        b,+        "no find_control query ever went out with a role alone (measured: it matched the browser's address bar)",+        ab.calls_to(FIND).iter().all(|c| c["args"].get("name").is_some() || c["args"].get("contains").is_some() || c["args"].get("automationId").is_some()),+        format!("{:?}", ab.calls_to(FIND))+    );+    check!(+        b,+        "Remember me: the label was clicked, its state is unreadable, rememberTicked is null",+        r["rememberTicked"].is_null()+            && ab.calls_to("desktop_click").iter().any(|c| c["args"]["x"] == 1255 && c["args"]["y"] == 923),+        s(&r["steps"])+    );+    let all = everything_but_the_fill(&b, &ab, &[st, r]);+    check!(b, "the password appears nowhere", !all.contains(PW), String::new());+}++#[test]+fn a_named_window_is_judged_by_its_content_never_refused_on_census_grounds() {+    // The window is NOT in any census (no sign-in.json): only ab sees it.+    let m = Machine::new("signin-named");+    let _ = std::fs::remove_file(m.dir.join("sign-in.json"));+    let ab = update_ab();+    ab.set_response("pup_credential_reveal", json!({"success": false, "error": "no credential stored for that host", "errorCode": "not_found"}));+    ab.set_response("pup_credentials", json!({"success": true, "data": {"credentials": []}}));+    let b = m.boot(TempDir::new("signin-named-scratch"), &ab, &[("SWX_SIGN_IN_WAKE_MS", "20")]);+    let r = b.call("swx_sign_in", json!({"reason": "Drew: sign me in", "userAuthorized": true, "window": UPDATE_HWND}));+    check!(+        b,+        "a named hwnd with the user-name field is TAKEN (it gets as far as the vault), not refused sign_in_not_waiting",+        r["errorCode"] != "sign_in_not_waiting" && r["errorCode"] != "sign_in_window_not_a_login"+            && ab.calls_to(FIND).iter().any(|c| c["args"]["hwnd"] == UPDATE_HWND),+        s(&r)+    );+    ab.clear_rules(FIND);+    let r = b.call("swx_sign_in", json!({"reason": "Drew: sign me in", "userAuthorized": true, "window": 4242}));+    check!(+        b,+        "a named hwnd with neither field: sign_in_window_not_a_login, with what WAS looked for and found; nothing typed",+        r["errorCode"] == "sign_in_window_not_a_login" && r["found"]["Edit \"Email or username\""]["found"] == false+            && r["found"]["Edit \"Password\""]["found"] == false && ab.calls_to("desktop_ui_set").is_empty(),+        s(&r)+    );+}++#[test]+fn a_launch_parked_behind_the_updater_login_says_so() {+    // SOLIDWORKS: the launch pauses (never killed) and swx_status names the updater's login.+    let m = update_machine("signin-update-launch");+    let ab = update_ab();+    let b = m.boot(TempDir::new("signin-update-launch-scratch"), &ab, &[("SWX_SIGN_IN_WAKE_MS", "20")]);+    let r = b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));+    let st = wait_for_sign_in_stage(&b);+    check!(+        b,+        "swx_start / swx_status: waitingOn userSignIn with the updater's window; the launch reached its sign-in stage",+        st["waitingOn"] == "userSignIn" && st["signInWindow"]["host"] == "desktopUpdate" && st["signInMode"] == "wait"+            && r["stage"] != "launching",+        format!("{}\n{}", s(&r), s(&st))+    );+}
tests/visualize.rs+54
@@ -166,3 +166,57 @@ fn no_shortcut_means_no_start_and_a_reason_is_required() {     let r = b.call("swx_start", json!({"app": "photoshop", "reason": "x"}));     check!(b, "an unknown app is refused", r["errorCode"] == "bad_app", s(&r)); }++/// MEASURED 2026-10-12: after a lapsed platform session, invoking the+/// Visualize shortcut started SWXDesktopUpdate.exe and its 3DEXPERIENCE login+/// (title "3DEXPERIENCE"); Visualize never started; swx_start said+/// stage:"launching" and swx_status said waitingOn:null.+#[test]+fn a_start_parked_behind_the_platform_sign_in_says_so_instead_of_launching() {+    let ab = common::AbStub::start(json!([]));+    ab.set_response("desktop_ui_tree", json!({"success": true, "nodeCount": 14, "root": {"role": "Window", "name": "3DEXPERIENCE", "automationId": "SWXDesktopUpdateForm"}}));+    ab.when("desktop_find_control", json!({"automationId": "username"}),+        json!({"success": true, "data": {"found": true, "control": {"role": "Edit", "name": "Email or username", "automationId": "username", "settable": true,+               "rect": {"x": 1286, "y": 824, "w": 400, "h": 40, "centerX": 1486, "centerY": 844}}}}));+    ab.set_response("desktop_find_control", json!({"success": false, "error": "no control matched"}));+    let m = Machine::new("viz-parked", json!({"pid": 56488, "startsOnInvoke": false, "windows": []}));+    std::fs::write(m.dir.join("preexisting.json"), json!([{"pid": 61000, "image": "SWXDesktopUpdate.exe", "ageSeconds": 1}]).to_string()).unwrap();+    std::fs::write(m.dir.join("sign-in.json"), json!({"hwnd": 857684, "ownerPid": 61000,+        "className": "WindowsForms10.Window.8.app.0.30495d1_r6_ad1", "title": "3DEXPERIENCE"}).to_string()).unwrap();+    let b = m.boot(Some(&ab));+    let started = std::time::Instant::now();+    let r = b.call("swx_start", json!({"app": "visualize", "reason": "Drew asked for Visualize", "waitSeconds": 20}));+    check!(+        b,+        "swx_start: stage waitingOn, waitingOn userSignIn, the updater's window named — not \"launching\", and no 20 s wait",+        r["stage"] == "waitingOn" && r["waitingOn"] == "userSignIn" && r["signInWindow"]["hwnd"] == 857684+            && r["visualize"]["parkedBehind"].is_string() && started.elapsed() < std::time::Duration::from_secs(15),+        s(&r)+    );+    let st = b.call("swx_status", json!({}));+    check!(+        b,+        "swx_status: visualize.waitingOn userSignIn and the top-level waitingOn too",+        st["visualize"]["waitingOn"] == "userSignIn" && st["visualize"]["signInWindow"]["host"] == "desktopUpdate"+            && st["waitingOn"] == "userSignIn" && st["signInWindow"]["host"] == "desktopUpdate",+        s(&st)+    );+}++/// Any 3DEXPERIENCE login on the machine (here the launcher's, found by its+/// class) is surfaced by swx_start {app:"visualize"} instead of "launching".+#[test]+fn a_start_with_any_login_up_is_not_reported_as_launching() {+    let m = Machine::new("viz-parked-launcher", json!({"pid": 56488, "startsOnInvoke": false, "windows": []}));+    std::fs::write(m.dir.join("preexisting.json"), json!([{"pid": 70002, "image": "SWXDesktopLauncher.exe", "ageSeconds": 5}]).to_string()).unwrap();+    std::fs::write(m.dir.join("sign-in.json"), json!({"hwnd": 1002, "ownerPid": 70002,+        "className": "DSYSysLoginPanelWebView2Class", "title": "Login | 3DEXPERIENCE ID | Dassault Systèmes"}).to_string()).unwrap();+    let b = m.boot(None);+    let r = b.call("swx_start", json!({"app": "visualize", "reason": "Drew asked for Visualize", "waitSeconds": 1}));+    check!(+        b,+        "stage waitingOn, waitingOn userSignIn, the login named",+        r["stage"] == "waitingOn" && r["waitingOn"] == "userSignIn" && r["signInWindow"]["hwnd"] == 1002,+        s(&r)+    );+}
uninstall.sh