app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
← Commit history
0.9.23 source
22 files changed
+1119−107
BRIDGE_VERSION+1−1Cargo.lock+1−1Cargo.toml+1−1README.md+25−1SKILL.md+7−3bridge.json+1−1docs/LAUNCH-RESILIENCE.md+19install.shpackage.json+1−1references/launch-and-sign-in.md+24−6references/modelling-and-export.md+6−2skills/solidworks-sign-in/SKILL.md+58−7src/launch.rs+18−2src/launchos.rs+5src/signin.rs+595−47src/verbs.rs+52−29src/visualize.rs+14tests/common/mod.rs+23−2tests/launch.rs+1−1tests/signin.rs+213−2tests/visualize.rs+54uninstall.shBRIDGE_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.shpackage.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