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.16 source
17 files changed
+573−84
BRIDGE_VERSION+1−1Cargo.lock+1−1Cargo.toml+1−1README.md+11−1bridge.json+1−1docs/LAUNCH-RESILIENCE.md+10install.shpackage.json+1−1references/launch-and-sign-in.md+5skills/solidworks-sign-in/SKILL.md+32−4src/launch.rs+5−1src/launchos.rs+12−2src/signin.rs+305−54src/verbs.rs+7−2tests/common/mod.rs+20−1tests/signin.rs+161−14uninstall.shBRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.15+0.9.16
Cargo.lock+1−1@@ -107,7 +107,7 @@ dependencies = [ [[package]] name = "swx-bridge"-version = "0.9.15"+version = "0.9.16" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.15"+version = "0.9.16" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+11−1@@ -461,7 +461,17 @@ not. ## Roadmap -- v0.9.15 (this): **the real login window, as measured.** The first real+- v0.9.16 (this): **any login window, typed where it really is.** Measured on+ the Visualize login window: page 2 (its "Remember me" checkbox is off-screen,+ so its visible label is what gets clicked), the page re-lays when the window+ moves (clicks now land at the field's own fresh centre, and a banner over the+ field is never clicked through), and a click only takes focus after the window+ is raised. Typing a password from the cloud is refused by policy, so the+ bridge — which reads the vault and types on the desktop — now answers ANY+ 3DEXPERIENCE ID login window on the user's say-so (`userAuthorized:true`,+ `window:"visualize"|"solidworks"|<hwnd>`), naming the process that owns it.+ 536 tests.+- v0.9.15: **the real login window, as measured.** The first real 3DEXPERIENCE ID window (raised by Visualize) was measured on 2026-10-02: its accessibility tree is lazy (13 nodes until a point is queried, then 38), the form is two pages (user name + Continue, then the password), a cookie banner
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "solidworks", "displayName": "SOLIDWORKS",- "version": "0.9.15",+ "version": "0.9.16", "description": "Bridge for SOLIDWORKS desktop CAD \u2014 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+10@@ -1346,3 +1346,13 @@ render has worked by hand, and that waits on his sign-in. **The dependent skill.** `skills/solidworks-sign-in/SKILL.md` ships in the package as a second user skill: the measured flow, each line marked measured or unmeasured, and NO credentials — only where they come from in general terms.++**MEASURED 2026-10-02 on the Visualize login window, and 0.9.16.**++| | Measured | 0.9.16 |+|---|---|---|+| P1 — page 2 | Edit "Email or username" (prefilled), Edit "Password " (automationId `password`), CheckBox "Remember me " (toggleState off, rect OFF-SCREEN: centerX -14493 — the native input is hidden; the visible control is the Text "Remember me…"), Button "Log in", Button "Back", the cookie banner still present | Page 2 is now a measured fixture. "Remember me": already on → nothing; an on-screen invokable box → a background Invoke; an off-screen one → a real click on its visible LABEL (only while the user is idle, with the window raised and in front), then the toggle state read again; a tick that cannot be made safely is skipped and said, never failing the sign-in |+| P2 — geometry moves | moving the window re-laid the page; the cookie banner then COVERED the email box; a click at the earlier point hit the banner, select-all selected page text, the typing went nowhere | Before every real click the tree is RE-READ and the click lands at the field's own `rect.centerX/centerY` in screen px (`desktop_click {x, y}`). A field a cookie-banner element overlaps → the window is made 1100x1450 (`desktop_ui_window {action:"resize"}` — arguments UNMEASURED) and re-read; still covered → `sign_in_needs_user` (the banner is the user's) |+| 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 |
install.shpackage.json+1−1@@ -1,6 +1,6 @@ { "slug": "solidworks-bridge",- "version": "0.9.15",+ "version": "0.9.16", "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+5@@ -228,3 +228,8 @@ Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPE - **The measured login flow** (two pages, a lazy tree, a cookie banner, real keystrokes only while the user is idle) is in the **solidworks-sign-in** 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.
skills/solidworks-sign-in/SKILL.md+32−4@@ -46,12 +46,36 @@ Every line below is marked **[measured]** (seen on a real machine, 2026-10-02) o (a failed attempt leaves the box dirty), then `desktop_type`. Each one needs a `reason`, and each takes focus (the login window comes to the front). 4. [measured] `desktop_ui_click {name: "Continue"}` works in the background.-5. [unmeasured] Page 2 should hold the password box and a sign-in button. Read- its inventory first; do not assume its names.+5. [measured] Page 2 (after a good page-1 fill and Continue): Edit "Email or+ username" (prefilled), Edit "Password " (automationId `password`), CheckBox+ "Remember me " (off), Button "Log in", Button "Back", and the cookie banner+ still there.+ - [measured] The "Remember me" checkbox's own input is OFF-SCREEN (its rect+ centre is far left of the screen). The visible control is its Text label+ "Remember me…": click the LABEL to tick it, then read the toggle state.+ - [unmeasured] What the page shows after "Log in" (a wrong password, a second+ step). Read it; never retry. 6. [measured] A cookie banner sits on the page ("Accept All", "Manage my preferences"). Never touch it: that choice belongs to the user. In the measured layout it does not cover the form. +## Geometry and focus++- [measured] Moving the window RE-LAYS the page: the cookie banner then covered+ the email box, a click at the old point hit the banner, select-all selected+ page text, and the typing went nowhere. After any move or resize, re-read the+ tree and click at the control's OWN `rect.centerX/centerY` (screen pixels).+ A window of about 1100x1450 kept the box clear of the banner.+- [measured] A plain click does not take focus while the user's own app is+ active. `desktop_raise_window {hwnd, reason}` brings the login window+ forward; confirm it IS the foreground window before typing.+- [measured] Typing lands when sent to the page's render widget (a+ `Chrome_RenderWidgetHostHWND` child window). Reading the user name back from+ the tree works.+- [measured] Typing a password through ab from a cloud container is refused by+ policy ("Credential Leakage"), and rightly. The only acceptable path is the+ bridge reading the vault on the desktop and typing there: `swx_sign_in`.+ ## Rules that never bend - [measured] NEVER send keystrokes while the user is typing. On 10-02 keystrokes@@ -84,5 +108,9 @@ own secret store on their desktop, in general terms: An agent must never write a password into this skill, the bridge repository, a wiki page, a log line or a chat message. -Once `swx_sign_in` has succeeded on a real window, it is the preferred path: it-does every step above, in order, and fails closed with a reason when it cannot.+`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.
src/launch.rs+5−1@@ -541,7 +541,7 @@ fn on_sign_in_window(hwnd: i64) { update(|a| a.sign_in_auto_tried = true); crate::signin::sign_in( "swx_start {signIn:\"auto\"}: the launch reached the 3DEXPERIENCE sign-in",- false,+ crate::signin::Opts::default(), crate::caller::self_caller(), ); }@@ -1915,6 +1915,10 @@ fn choose_our_log( } /// The pids currently recorded as our chain.+pub fn our_chain_pids() -> Vec<u32> {+ chain_pids_now()+}+ fn chain_pids_now() -> Vec<u32> { attempt().map(|a| a.chain.iter().map(|p| p.pid).collect()).unwrap_or_default() }
src/launchos.rs+12−2@@ -53,6 +53,9 @@ pub const WATCHED_IMAGES: &[&str] = &[ "swCefSubProc.exe", "ENOPLMCSAClient.exe", "EdmServerV6.exe",+ // SOLIDWORKS Visualize: it raises its OWN 3DEXPERIENCE ID login (measured+ // 2026-10-02), which swx_sign_in answers under the user's authorization.+ "SLDWORKSVisualize.exe", "SWXDesktopHelper.exe", "sldBgDwld.exe", // PARENT LINKS ONLY — never terminable (see `launch::NEVER_TERMINATE`).@@ -1284,8 +1287,15 @@ impl LaunchOs for FakeOs { // owns it. `sign-in.json` is {"ownerPid":N,...} and the file is DELETED // to model the user answering it — which is what lets a test prove that // the paused deadlines resume.- let si = self.read_json("sign-in.json");- if si.is_object() {+ // `sign-in.json` may hold ONE login window or an ARRAY of them (0.9.16:+ // the Visualize host and the launcher's at once).+ let si_all = self.read_json("sign-in.json");+ let rows: Vec<Value> = match &si_all {+ Value::Array(a) => a.clone(),+ v if v.is_object() => vec![v.clone()],+ _ => Vec::new(),+ };+ for si in &rows { let owner = si.get("ownerPid").and_then(|x| x.as_u64()).map(|p| p as u32); // No owner named: it belongs to whichever launcher pid asks, which is // how a scenario says "our chain is showing it".
src/signin.rs+305−54@@ -149,6 +149,17 @@ pub struct Element { pub settable: Option<bool>, /// Screen rectangle (x, y, w, h), when ab reports one — for a real click. pub rect: Option<[i64; 4]>,+ /// 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)>,+}++impl Element {+ /// OFF-SCREEN (MEASURED 2026-10-02: the "Remember me" checkbox's native+ /// input sits at centerX -14493; the visible control is its Text label).+ pub fn offscreen(&self) -> bool {+ self.center.map(|(x, y)| x < -1000 || y < -1000).unwrap_or(false)+ } } impl Element {@@ -208,6 +219,7 @@ pub fn parse_inventory(reply: &Value) -> Vec<Element> { 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@@ -266,6 +278,18 @@ fn rect_xywh(r: &Value) -> Option<[i64; 4]> { Some([x as i64, y as i64, w as i64, h as i64]) } +fn rect_center(r: &Value) -> Option<(i64, i64)> {+ let n = |k: &str| r.get(k).and_then(|v| v.as_f64());+ if let (Some(x), Some(y)) = (n("centerX"), n("centerY")) {+ return Some((x as i64, y as i64));+ }+ rect_xywh(r).map(|[x, y, w, h]| (x + w / 2, y + h / 2))+}++fn overlaps(a: [i64; 4], b: [i64; 4]) -> bool {+ a[0] < b[0] + b[2] && b[0] < a[0] + a[2] && a[1] < b[1] + b[3] && b[1] < a[1] + a[3]+}+ /// One read: the elements, ab's node count, and the window's centre if known. fn read_once(hwnd: i64) -> Result<(Vec<Element>, u64, Option<(i64, i64)>), String> { let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 30, "maxNodes": 400}), AB_TIMEOUT);@@ -647,9 +671,20 @@ fn tell_user(what: &str) { } /// SIGN IN, unattended. `who` is the caller's identity for the ledger.-pub fn sign_in(reason: &str, user_confirmed_new_password: bool, who: Value) -> Value {+/// Which login window, and on whose authority.+#[derive(Clone, Debug, Default)]+pub struct Opts {+ pub user_confirmed_new_password: bool,+ /// The user authorized signing in to ANY 3DEXPERIENCE ID login window on+ /// this machine (0.9.16) — not only one a launch of this bridge raised.+ pub user_authorized: bool,+ /// "visualize" | "solidworks" | an hwnd, when several are up.+ pub window: Option<String>,+}++pub fn sign_in(reason: &str, opts: Opts, who: Value) -> Value { let started = std::time::Instant::now();- let out = sign_in_inner(reason, user_confirmed_new_password);+ let out = sign_in_inner(reason, &opts); let outcome = out.get("errorCode").and_then(|c| c.as_str()).unwrap_or(if out.get("signedIn") == Some(&json!(true)) { "signedIn" } else { "unknown" }).to_string(); crate::ledger::record( "solidworks_sign_in",@@ -660,29 +695,136 @@ pub fn sign_in(reason: &str, user_confirmed_new_password: bool, who: Value) -> V "elapsedSec": started.elapsed().as_secs(), "consent": out.pointer("/vault/consent").cloned().unwrap_or(Value::Null), "rememberTicked": out.get("rememberTicked").cloned().unwrap_or(Value::Null),+ "window": out.get("window").cloned().unwrap_or(Value::Null),+ "userAuthorized": opts.user_authorized, }), ); out } -fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value {+/// A 3DEXPERIENCE ID login window, and the process that owns it.+#[derive(Clone, Debug)]+pub struct LoginWindow {+ pub hwnd: i64,+ pub title: String,+ pub owner_pid: u32,+ pub owner_image: String,+ /// Owned by a process of THIS bridge's launch chain.+ pub ours: bool,+}++impl LoginWindow {+ 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" }})+ }+}++const SHELLS: &[&str] = &["explorer.exe", "cmd.exe", "powershell.exe", "pwsh.exe"];++/// Every 3DEXPERIENCE ID login window: those of OUR launch chain, and — with+/// `all` — those of every Dassault process in the census (SOLIDWORKS, its+/// launcher, SOLIDWORKS Visualize…). `Err` = nothing could be read.+pub fn login_windows(os: &dyn crate::launchos::LaunchOs, all: bool) -> Result<Vec<LoginWindow>, String> {+ let ours = crate::launch::our_chain_pids();+ let census = os.processes();+ let mut candidates: Vec<(u32, String, bool)> = Vec::new();+ let image_of = |pid: u32| -> String {+ census.as_ref().ok().and_then(|c| c.iter().find(|p| p.pid == pid).map(|p| p.image.clone())).unwrap_or_default()+ };+ for p in &ours {+ candidates.push((*p, image_of(*p), true));+ }+ if all {+ match &census {+ Ok(c) => {+ for p in c.iter().filter(|p| !SHELLS.iter().any(|s| p.image.eq_ignore_ascii_case(s))) {+ if !candidates.iter().any(|(q, _, _)| *q == p.pid) {+ candidates.push((p.pid, p.image.clone(), false));+ }+ }+ }+ Err(e) if candidates.is_empty() => return Err(format!("the process census could not be read: {e}")),+ Err(_) => {}+ }+ }+ let mut out: Vec<LoginWindow> = Vec::new();+ let mut read_any = false;+ 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 });+ }+ }+ }+ }+ if !read_any && !out.is_empty() {+ return Ok(out);+ }+ if !read_any {+ return Err("no candidate process's windows could be read".into());+ }+ Ok(out)+}++/// Is that login window still up? `None` = cannot tell.+fn still_up(os: &dyn crate::launchos::LaunchOs, lw: &LoginWindow) -> Option<bool> {+ os.windows_of_pid(lw.owner_pid).ok().map(|ws| ws.iter().any(|w| w.hwnd == lw.hwnd && w.visible))+}++fn sign_in_inner(reason: &str, opts: &Opts) -> Value { let os = crate::launchos::os();- // ONLY OUR LAUNCH'S WINDOW: a launch this bridge started, its sign-in window- // owned by a process of OUR chain.- let Some(a) = crate::launch::attempt() else {- 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.", json!({}));+ let user_confirmed_new_password = opts.user_confirmed_new_password;+ let attempt = crate::launch::attempt();+ // WHICH WINDOW. Without the user's authorization: only the sign-in window+ // of a launch THIS bridge started, owned by its own chain. With it+ // (0.9.16): any 3DEXPERIENCE ID login window on the machine — the+ // launcher's, SOLIDWORKS Visualize's (which asks separately, measured+ // 2026-10-02) — and `window` picks one when several are up.+ 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 found = match login_windows(os.as_ref(), opts.user_authorized) {+ Ok(f) => f,+ Err(e) => return err("sign_in_not_waiting", format!("the login windows could not be read: {e}"), "Nothing was typed anywhere.", json!({})), };- let hwnd = match crate::launch::sign_in_now(os.as_ref()) {- Some(Some((h, _))) => h,- Some(None) => {- return err("sign_in_not_waiting", "no 3DEXPERIENCE sign-in window is open for this bridge's launch", "Nothing to do: swx_status says what the launch is waiting on.", json!({"launchStage": a.stage.name()}))+ 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) => 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!({})),+ },+ };+ let listed = json!(pool.iter().map(|l| l.to_json()).collect::<Vec<_>>());+ let lw = match picked.as_slice() {+ [one] => one.clone(),+ [] => {+ return err(+ "sign_in_not_waiting",+ if opts.user_authorized { "no 3DEXPERIENCE ID login window matching the request is open on this machine" } else { "no 3DEXPERIENCE sign-in window is open for this bridge's launch" },+ "Nothing to do. swx_status says what a launch is waiting on; `loginWindows` lists what is open.",+ json!({"loginWindows": listed, "launchStage": attempt.as_ref().map(|a| a.stage.name())}),+ ) }- None => {- return err("sign_in_not_waiting", "the windows of this launch's processes could not be read, so no sign-in window can be attributed to it", "Nothing was typed anywhere.", json!({}))+ many => {+ return err(+ "sign_in_window_ambiguous",+ format!("{} 3DEXPERIENCE ID login windows are open", many.len()),+ "Pick one: window:\"visualize\", window:\"solidworks\" or its hwnd. Nothing was typed.",+ json!({"loginWindows": listed}),+ ) } };+ let hwnd = lw.hwnd;+ let window_j = lw.to_json(); // NEVER RETRIED after a rejection (lockout risk).- if a.sign_in_rejected || (rejected_before().is_some() && !user_confirmed_new_password) {+ if attempt.as_ref().map(|a| a.sign_in_rejected).unwrap_or(false) || (rejected_before().is_some() && !user_confirmed_new_password) { return err( "sign_in_rejected_before", "a sign-in with the saved password was REJECTED before, and it is never retried (an account lockout is worse than a manual sign-in)",@@ -739,7 +881,7 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value { let mut pages: Vec<Value> = vec![json!({"page": 1, "inventory": inv_j, "wake": wake_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.window_rect, &form, &secret, &mut steps, &mut remember_ticked) {+ if let Err(fe) = fill_page(hwnd, &read.elements, &form, &secret, &mut steps, &mut remember_ticked) { drop(secret); return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles); }@@ -752,9 +894,9 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value { let deadline = std::time::Instant::now() + Duration::from_secs(verify_secs()); let mut last: Vec<Element> = Vec::new(); let mut page2: Option<Form> = None;- let mut page2_rect = read.window_rect;+ let mut page2_els: Vec<Element> = Vec::new(); loop {- if let Some(None) = crate::launch::sign_in_now(os.as_ref()) {+ if still_up(os.as_ref(), &lw) == Some(false) { break; // the window closed after the user name alone } if let Ok(r2) = inventory(hwnd) {@@ -764,7 +906,7 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value { pages.push(json!({"page": 2, "inventory": inventory_json(&r2.elements), "wake": r2.wake_json(), "cookieBanner": cookie_banner(&r2.elements)})); crate::launch::note_sign_in_inventory(inventory_json(&r2.elements));- page2_rect = r2.window_rect.or(page2_rect);+ page2_els = r2.elements.clone(); page2 = Some(f2); break; }@@ -782,12 +924,12 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value { Some(f2) => { // The user name was typed on page one; page two takes the password. let f2 = Form { user: None, ..f2 };- if let Err(fe) = fill_page(hwnd, page2_rect, &f2, &secret, &mut steps, &mut remember_ticked) {+ if let Err(fe) = fill_page(hwnd, &page2_els, &f2, &secret, &mut steps, &mut remember_ticked) { drop(secret); return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles); } }- None if crate::launch::sign_in_now(os.as_ref()) == Some(None) => {}+ None if still_up(os.as_ref(), &lw) == Some(false) => {} None => { drop(secret); let last_j = inventory_json(&last);@@ -810,10 +952,11 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value { // 5. VERIFY BY EFFECT: the window goes away. let deadline = std::time::Instant::now() + Duration::from_secs(verify_secs()); loop {- match crate::launch::sign_in_now(os.as_ref()) {- Some(None) => {+ match still_up(os.as_ref(), &lw) {+ Some(false) => { let out = crate::verbs::ok(json!({ "signedIn": true,+ "window": window_j, "verifiedBy": "the sign-in window closed after the submit", "twoPage": two_page, "cookieBanner": banner,@@ -853,24 +996,18 @@ const FILL_REASON: &str = "signing in to 3DEXPERIENCE for a SOLIDWORKS launch th /// it has that one), "Remember me" (if present and off), then press its submit /// as a background Invoke (MEASURED 2026-10-02: `desktop_ui_click {name: /// "Continue"}` works without focus). Never touches a cookie-consent button.-fn fill_page(hwnd: i64, origin: Option<[i64; 4]>, form: &Form, secret: &Secret, steps: &mut Vec<Value>, remember_ticked: &mut Value) -> Result<(), FillErr> {+fn fill_page(hwnd: i64, page: &[Element], form: &Form, secret: &Secret, steps: &mut Vec<Value>, remember_ticked: &mut Value) -> Result<(), FillErr> { if let Some(u_el) = &form.user { let Some(u) = &secret.user else { return Err(FillErr::Failed { step: "user name".into(), why: "the saved login has no user name to type".into(), observed: None, may_be_banner: false }); };- fill_field(hwnd, origin, u_el, u, true, steps)?;+ fill_field(hwnd, u_el, u, true, steps)?; } if let Some(p_el) = &form.password {- fill_field(hwnd, origin, p_el, &secret.pass, false, steps)?;+ fill_field(hwnd, p_el, &secret.pass, false, steps)?; } if let Some(rm) = &form.remember {- let on = rm.toggle_state.as_deref().map(|t| t.to_lowercase()).map(|t| t == "on" || t == "true" || t == "1" || t == "checked");- if on == Some(true) {- *remember_ticked = json!("alreadyOn");- } else {- let r = crate::caller::ad_callback("desktop_ui_click", merge(target(rm, "checkbox"), json!({"hwnd": hwnd, "reason": FILL_REASON})), AB_TIMEOUT);- *remember_ticked = json!(ab_ok(&r));- }+ *remember_ticked = tick_remember(hwnd, page, rm); } 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"}));@@ -880,6 +1017,60 @@ fn fill_page(hwnd: i64, origin: Option<[i64; 4]>, form: &Form, secret: &Secret, Ok(()) } +/// "REMEMBER ME". MEASURED 2026-10-02: the CheckBox "Remember me " reports+/// toggleState off and an OFF-SCREEN rect (centerX -14493) — the native input is+/// hidden; the visible control is its Text label "Remember me…". So: already+/// on → nothing; an on-screen, invokable checkbox → a background Invoke; an+/// off-screen one → a real click on its LABEL, only while the user is idle and+/// with the login window in front, then the toggle state read again. A tick that+/// cannot be made safely is skipped and said — it never fails the sign-in.+fn tick_remember(hwnd: i64, page: &[Element], rm: &Element) -> Value {+ let is_on = |t: Option<&str>| t.map(|t| t.to_lowercase()).map(|t| t == "on" || t == "true" || t == "1" || t == "checked") == Some(true);+ if is_on(rm.toggle_state.as_deref()) {+ return json!("alreadyOn");+ }+ if !rm.offscreen() && rm.invokable != Some(false) {+ let r = crate::caller::ad_callback("desktop_ui_click", merge(target(rm, "checkbox"), json!({"hwnd": hwnd, "reason": FILL_REASON})), AB_TIMEOUT);+ return json!({"ticked": ab_ok(&r), "via": "uiaInvoke"});+ }+ let stem = rm.name.trim().trim_end_matches(['.', '…']).to_lowercase();+ let Some(label) = page.iter().find(|e| {+ !e.is("checkbox") && !e.offscreen() && e.center.is_some() && {+ let n = e.name.trim().trim_end_matches(['.', '…']).to_lowercase();+ !stem.is_empty() && n.starts_with(&stem)+ }+ }) else {+ return json!({"ticked": false, "why": "the checkbox is off-screen and no visible label for it was found"});+ };+ if let Err(why) = user_idle() {+ return json!({"ticked": false, "why": format!("skipped: {why} (a real click is needed for the label)")});+ }+ 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!({"ticked": false, "why": format!("skipped: {why}")});+ }+ 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);+ // READ THE TOGGLE AGAIN: the label click is evidence only if the box says so.+ let read_toggle = || {+ inventory(hwnd)+ .ok()+ .and_then(|r| r.elements.into_iter().find(|e| e.is("checkbox") && e.name == rm.name))+ .and_then(|e| e.toggle_state)+ };+ let until = std::time::Instant::now() + Duration::from_millis(1500);+ let mut after = read_toggle();+ while !is_on(after.as_deref()) && std::time::Instant::now() < until {+ std::thread::sleep(Duration::from_millis(150));+ after = read_toggle();+ }+ json!({"ticked": ab_ok(&r) && is_on(after.as_deref()), "via": "labelClick", "label": label.name, "toggleStateAfter": after})+}+ pub(crate) enum FillErr { /// The user is typing (or the idle time cannot be read): no keystroke sent. NeedsIdle(String),@@ -941,6 +1132,51 @@ fn field_value(hwnd: i64, el: &Element) -> Option<String> { walk(&unwrap_ab(&r), el) } +/// Where a field is NOW: its centre (ab's own), whether a cookie-banner+/// element overlaps it, and the page's render widget if the tree names one.+struct Spot {+ center: (i64, i64),+ covered: bool,+ render_widget: Option<i64>,+}++fn locate(hwnd: i64, el: &Element) -> Option<Spot> {+ let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 30, "maxNodes": 400}), AB_TIMEOUT);+ if !ab_ok(&r) {+ return None;+ }+ let els = parse_inventory(&r);+ let field = els.iter().find(|e| e.is("edit") && e.name == el.name && e.automation_id == el.automation_id)?;+ let center = field.center?;+ let covered = match field.rect {+ Some(fr) => els+ .iter()+ .filter(|e| {+ let n = e.name.trim().to_lowercase();+ COOKIE_CHOICES.contains(&n.as_str()) || n.contains("cookie")+ })+ .filter_map(|e| e.rect)+ .any(|br| overlaps(fr, br)),+ None => false,+ };+ fn widget(v: &Value) -> Option<i64> {+ match v {+ Value::Array(a) => a.iter().find_map(widget),+ Value::Object(m) => {+ let class = ["className", "class", "class_name"].iter().find_map(|k| m.get(*k).and_then(|c| c.as_str())).unwrap_or("");+ if class == "Chrome_RenderWidgetHostHWND" {+ if let Some(h) = ["hwnd", "nativeWindowHandle", "handle"].iter().find_map(|k| m.get(*k).and_then(|h| h.as_i64())) {+ return Some(h);+ }+ }+ m.values().find_map(widget)+ }+ _ => None,+ }+ }+ Some(Spot { center, covered, render_widget: widget(&unwrap_ab(&r)) })+}+ /// 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@@ -955,7 +1191,7 @@ fn field_value(hwnd: i64, el: &Element) -> Option<String> { /// * the user-name field READ BACK and compared before anything continues; a /// mismatch stops with what was observed. The password is never read back. /// Enter is never pressed: the submit is a background Invoke.-fn fill_field(hwnd: i64, origin: Option<[i64; 4]>, el: &Element, text: &[u8], read_back: bool, steps: &mut Vec<Value>) -> Result<(), FillErr> {+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(); let r = crate::caller::ad_callback(@@ -975,27 +1211,33 @@ fn fill_field(hwnd: i64, origin: Option<[i64; 4]>, el: &Element, text: &[u8], re } // ── REAL KEYSTROKES ────────────────────────────────────────────────────── user_idle().map_err(FillErr::NeedsIdle)?;- // The click lands on the field through a screenshot of the window- // (`desktop_click {space:"image", shotId, x, y}`).- let (Some([ex, ey, ew, eh]), Some([wx, wy, _, _])) = (el.rect, origin) else {- return Err(FillErr::Failed { step: format!("{label} (keystrokes)"), why: "the field's or the window's position is unknown, so the field cannot be clicked".into(), observed: None, may_be_banner: false });- };- let shot = crate::caller::ad_callback("desktop_screenshot_window", json!({"hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);- let shot_d = unwrap_ab(&shot);- let Some(shot_id) = ["shotId", "shot_id", "id"].iter().find_map(|k| shot_d.get(*k).or_else(|| shot.get(*k)).and_then(|v| v.as_str()).map(String::from)) else {- return Err(FillErr::Failed { step: format!("{label} (keystrokes)"), why: format!("the window screenshot gave no shotId: {}", ab_error(&shot)), observed: None, may_be_banner: false });+ // FRESH GEOMETRY (MEASURED 2026-10-02: moving the window re-laid the page+ // and the cookie banner then COVERED the email box; a click at the old+ // point hit the banner and the typing went nowhere). Re-read the tree and+ // click at the field's OWN centre, in screen px.+ let mut spot = locate(hwnd, el);+ if let Some(sp) = &spot {+ if sp.covered {+ // Make the window tall enough that the field clears the banner+ // (MEASURED: 1100x1450 worked). The resize arguments are UNMEASURED.+ let _ = crate::caller::ad_callback(+ "desktop_ui_window",+ json!({"action": "resize", "hwnd": hwnd, "width": 1100, "height": 1450, "reason": FILL_REASON}),+ AB_TIMEOUT,+ );+ std::thread::sleep(Duration::from_millis(wake_ms()));+ spot = locate(hwnd, el);+ }+ }+ let Some(sp) = spot else {+ return Err(FillErr::Failed { step: format!("{label} (keystrokes)"), why: "the field's position could not be read from the window's tree".into(), observed: None, may_be_banner: false }); };- let (x, y) = (ex + ew / 2 - wx, ey + eh / 2 - wy);- let r = crate::caller::ad_callback(- "desktop_click",- json!({"space": "image", "shotId": shot_id, "x": x, "y": y, "hwnd": hwnd, "reason": FILL_REASON}),- AB_TIMEOUT,- );- if !ab_ok(&r) {- return Err(FillErr::Failed { step: format!("click on the {label} field"), why: ab_error(&r), observed: None, may_be_banner: true });+ if sp.covered {+ return Err(FillErr::Failed { step: format!("{label} (keystrokes)"), why: "the cookie banner covers the field, even after making the window taller".into(), observed: None, may_be_banner: true }); }- // THE CLICK TAKES FOCUS — asynchronously: give the window a moment to come- // to the front before the first check.+ // FOCUS (MEASURED: a plain click does not take focus while the user's app is+ // active): raise the login window, then confirm IT is in front.+ 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));@@ -1004,13 +1246,22 @@ fn fill_field(hwnd: i64, origin: Option<[i64; 4]>, el: &Element, text: &[u8], re user_idle().map_err(FillErr::NeedsIdle)?; login_in_front(hwnd).map_err(|e| FillErr::Failed { step: format!("{label}: {what}"), why: e, observed: None, may_be_banner: false }) };+ guard("before the click")?;+ let (x, y) = sp.center;+ let r = crate::caller::ad_callback("desktop_click", json!({"x": x, "y": y, "hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);+ if !ab_ok(&r) {+ return Err(FillErr::Failed { step: format!("click on the {label} field"), why: ab_error(&r), observed: None, may_be_banner: true });+ }+ // Keystrokes go to the page's render widget when the tree names it+ // (MEASURED: desktop_type {text, hwnd:<Chrome_RenderWidgetHostHWND child>}).+ let target_hwnd = sp.render_widget.unwrap_or(hwnd); guard("before select-all")?;- let r = crate::caller::ad_callback("desktop_press_key", json!({"keys": ["ctrl+a"], "hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);+ let r = crate::caller::ad_callback("desktop_press_key", json!({"keys": ["ctrl+a"], "hwnd": target_hwnd, "reason": FILL_REASON}), AB_TIMEOUT); if !ab_ok(&r) { return Err(FillErr::Failed { step: format!("{label}: select-all"), why: ab_error(&r), observed: None, may_be_banner: false }); } guard("before typing")?;- let r = crate::caller::ad_callback("desktop_type", json!({"text": typed, "hwnd": hwnd, "sensitive": true, "reason": FILL_REASON}), AB_TIMEOUT);+ let r = crate::caller::ad_callback("desktop_type", json!({"text": typed, "hwnd": target_hwnd, "sensitive": true, "reason": FILL_REASON}), AB_TIMEOUT); if !ab_ok(&r) { return Err(FillErr::Failed { step: format!("{label}: typing"), why: ab_error(&r), observed: None, may_be_banner: false }); }
src/verbs.rs+7−2@@ -3007,7 +3007,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, 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; 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_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)"),@@ -3054,7 +3054,12 @@ fn swx_sign_in(_ctx: &Ctx, args: &Value) -> Value { let who = crate::caller::current() .map(|c| json!({"aiThread": c.thread, "containerName": c.container})) .unwrap_or(Value::Null);- crate::signin::sign_in(reason, arg_bool(args, "userConfirmedNewPassword", false), who)+ let opts = crate::signin::Opts {+ user_confirmed_new_password: arg_bool(args, "userConfirmedNewPassword", false),+ user_authorized: arg_bool(args, "userAuthorized", false),+ window: args.get("window").and_then(|w| w.as_str().map(String::from).or_else(|| w.as_i64().map(|h| h.to_string()))),+ };+ crate::signin::sign_in(reason, opts, who) } /// `swx_user_presence` — the presence measurement every quit and sweep is
tests/common/mod.rs+20−1@@ -355,6 +355,10 @@ pub struct AbStub { /// like a crash reporter" and "we clicked a button on it", so a stub that /// answered every verb with a window list could not tell those apart. replies: std::sync::Arc<Mutex<std::collections::BTreeMap<String, Value>>>,+ /// One-shot triggers: when `.0` arrives, `.1` answers `.2` from then on —+ /// 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)>>>, _thread: std::thread::JoinHandle<()>, } @@ -370,6 +374,8 @@ impl AbStub { let replies: std::sync::Arc<Mutex<std::collections::BTreeMap<String, Value>>> = std::sync::Arc::new(Mutex::new(std::collections::BTreeMap::new())); 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 thread = std::thread::spawn(move || { for stream in listener.incoming() { let Ok(mut s) = stream else { break };@@ -407,6 +413,14 @@ impl AbStub { } } }+ {+ let mut t = trig.lock().unwrap();+ let fired: Vec<(String, String, Value)> = t.iter().filter(|(c, _, _)| *c == command).cloned().collect();+ t.retain(|(c, _, _)| *c != command);+ for (_, target, payload) in fired {+ reps.lock().unwrap().insert(target, payload);+ }+ } let payload = match reps.lock().unwrap().get(&command) { Some(custom) => custom.clone(), None => json!({@@ -426,7 +440,12 @@ impl AbStub { ); } });- AbStub { url: format!("http://127.0.0.1:{port}"), calls, windows, replies, _thread: thread }+ AbStub { url: format!("http://127.0.0.1:{port}"), calls, windows, replies, triggers, _thread: thread }+ }++ /// When `trigger` arrives (once), `target` answers `payload` from then on.+ pub fn on_call_set(&self, trigger: &str, target: &str, payload: Value) {+ self.triggers.lock().unwrap().push((trigger.to_string(), target.to_string(), payload)); } /// Answer one ab verb with a specific payload.
tests/signin.rs+161−14@@ -175,7 +175,7 @@ fn the_bridge_signs_in_from_the_vault_and_the_password_appears_nowhere() { check!( b, "swx_sign_in: signed in, verified by the window closing, Remember me ticked, consent reported",- r["success"] == true && r["signedIn"] == true && r["rememberTicked"] == true+ r["success"] == true && r["signedIn"] == true && r["rememberTicked"]["ticked"] == true && r["vault"]["consent"] == "perUse" && !m.sign_in_up(), s(&r) );@@ -580,7 +580,7 @@ fn page_one() -> Value { "window": {"title": "Login - 3DEXPERIENCE ID - Dassault Systemes", "rect": {"x": 100, "y": 50, "w": 968, "h": 968}}, "root": {"role": "Window", "name": "Login - 3DEXPERIENCE ID - Dassault Systemes", "automationId": "", "invokable": false, "settable": false, "children": [ {"role": "Document", "name": "3DEXPERIENCE ID", "automationId": "", "invokable": false, "settable": false, "children": [- {"role": "Edit", "name": "Email or username", "automationId": "username", "invokable": false, "settable": true, "value": "", "rect": {"x": 384, "y": 450, "w": 400, "h": 40}},+ {"role": "Edit", "name": "Email or username", "automationId": "username", "invokable": false, "settable": true, "value": "", "rect": {"x": 384, "y": 450, "w": 400, "h": 40, "centerX": 584, "centerY": 470}}, {"role": "Button", "name": "Continue", "automationId": "", "invokable": true, "settable": false}, {"role": "Hyperlink", "name": "Create your account", "automationId": "", "invokable": true, "settable": false}, {"role": "Hyperlink", "name": "Forgot your password?", "automationId": "", "invokable": true, "settable": false},@@ -596,18 +596,37 @@ fn page_one() -> Value { ]}}) } -/// Page TWO — UNMEASURED (nobody has seen it): a password edit and a submit.-fn page_two() -> Value {- json!({"success": true, "status": "ok", "hwnd": 777001, "nodeCount": 30, "truncated": false,+/// Page TWO — MEASURED 2026-10-02 (Visualize host, after a page-one fill ++/// Continue): the user-name box (prefilled), "Password " (automationId+/// password), CheckBox "Remember me " with toggleState off and an OFF-SCREEN+/// rect (centerX -14493) whose visible control is the Text "Remember me…",+/// Log in, Back, and the cookie banner still present.+fn page_two_with(toggle: &str) -> Value {+ json!({"success": true, "status": "ok", "hwnd": 777001, "nodeCount": 41, "truncated": false, "window": {"title": "Login - 3DEXPERIENCE ID - Dassault Systemes", "rect": {"x": 100, "y": 50, "w": 968, "h": 968}}, "root": {"role": "Window", "name": "Login - 3DEXPERIENCE ID - Dassault Systemes", "automationId": "", "children": [- {"role": "Edit", "name": "Password", "automationId": "password", "invokable": false, "settable": true, "value": "", "rect": {"x": 384, "y": 450, "w": 400, "h": 40}},- {"role": "CheckBox", "name": "Remember me", "automationId": "", "invokable": true, "settable": false, "toggleState": "Off"},+ {"role": "Edit", "name": "Email or username", "automationId": "username", "invokable": false, "settable": true, "value": "[email protected]",+ "rect": {"x": 384, "y": 380, "w": 400, "h": 40, "centerX": 584, "centerY": 400}},+ {"role": "Edit", "name": "Password ", "automationId": "password", "invokable": false, "settable": true, "value": "",+ "rect": {"x": 384, "y": 450, "w": 400, "h": 40, "centerX": 584, "centerY": 470}},+ {"role": "CheckBox", "name": "Remember me ", "automationId": "", "invokable": true, "settable": false, "toggleState": toggle,+ "rect": {"x": -14500, "y": 520, "w": 14, "h": 14, "centerX": -14493, "centerY": 527}},+ {"role": "Text", "name": "Remember me…", "automationId": "", "invokable": false, "settable": false,+ "rect": {"x": 410, "y": 518, "w": 120, "h": 18, "centerX": 470, "centerY": 527}}, {"role": "Button", "name": "Log in", "automationId": "", "invokable": true, "settable": false},+ {"role": "Button", "name": "Back", "automationId": "", "invokable": true, "settable": false},+ {"role": "Button", "name": "Accept All", "automationId": "", "invokable": true, "settable": false,+ "rect": {"x": 700, "y": 900, "w": 120, "h": 40}},+ {"role": "Button", "name": "Manage my preferences", "automationId": "", "invokable": true, "settable": false,+ "rect": {"x": 840, "y": 900, "w": 200, "h": 40}}, {"role": "Button", "name": "Close", "automationId": "", "invokable": true, "settable": false} ]}}) } +fn page_two() -> Value {+ page_two_with("Off")+}+ /// Drive the simulated window: lazy until a point is queried; page two (lazy /// again) after Continue; closed after Log in. `to_page_two:false` = the page /// never changes after Continue.@@ -694,7 +713,7 @@ fn the_measured_two_page_lazy_login_is_one_flow_with_one_consent() { b, "the user name went into 'Email or username' on page one, the password into 'Password' on page two", sets.len() == 2 && sets[0]["args"]["name"] == "Email or username" && sets[0]["args"]["text"] == USER- && sets[1]["args"]["name"] == "Password" && sets[1]["args"]["text"] == PW,+ && sets[1]["args"]["name"] == "Password " && sets[1]["args"]["text"] == PW, format!("{sets:?}") ); let pages = r["pages"].as_array().cloned().unwrap_or_default();@@ -707,7 +726,7 @@ fn the_measured_two_page_lazy_login_is_one_flow_with_one_consent() { "THE LAZY TREE: page one was woken, both node counts recorded (13 cold, 38 warm); page two's inventory captured", pages.len() == 2 && (woke(&pages[0]["wake"]) || woke(&first_wake))- && pages[1]["inventory"].as_array().map(|a| a.iter().any(|e| e["name"] == "Password" && e["isPassword"] == true)).unwrap_or(false),+ && pages[1]["inventory"].as_array().map(|a| a.iter().any(|e| e["name"] == "Password " && e["isPassword"] == true)).unwrap_or(false), s(&r["pages"]) ); replies.push(r);@@ -780,7 +799,7 @@ fn with_username_value(v: &str) -> Value { fn keystroke_ab() -> common::AbStub { let ab = ab_for(page_one(), vault_ok()); ab.set_response("desktop_ui_set", no_value_pattern());- ab.set_response("desktop_screenshot_window", json!({"success": true, "data": {"shotId": "shot-1"}}));+ ab.set_response("desktop_raise_window", json!({"success": true})); ab.set_response("desktop_click", json!({"success": true})); ab.set_response("desktop_press_key", json!({"success": true})); ab.set_response("desktop_type", json!({"success": true}));@@ -796,7 +815,7 @@ fn drive_keystrokes(m: &Machine, ab: &common::AbStub, typed_lands: Option<String while Instant::now() < deadline { let clicks = ab.calls_to("desktop_ui_click"); let clicked = |n: &str| clicks.iter().any(|c| c["args"]["name"] == n);- if focus_follows && !ab.calls_to("desktop_click").is_empty() {+ if focus_follows && !ab.calls_to("desktop_raise_window").is_empty() { std::fs::write(m.dir.join("foreground.json"), json!({"hwnd": 777001, "pid": 1, "title": "Login - 3DEXPERIENCE ID", "className": "x"}).to_string()).unwrap(); }@@ -811,7 +830,12 @@ fn drive_keystrokes(m: &Machine, ab: &common::AbStub, typed_lands: Option<String ab.set_response("desktop_ui_tree", page_two()); state = 2; }- 2 if clicked("Log in") => {+ 2 if ab.calls_to("desktop_click").iter().any(|c| c["args"]["x"] == 470 && c["args"]["y"] == 527) => {+ // The LABEL was clicked: the hidden checkbox turns on.+ ab.set_response("desktop_ui_tree", page_two_with("On"));+ state = 3;+ }+ 2 | 3 if clicked("Log in") => { let _ = std::fs::remove_file(m.dir.join("sign-in.json")); return; }@@ -828,11 +852,29 @@ fn the_page_that_refuses_a_background_set_is_typed_safely_and_read_back() { let b = m.boot(TempDir::new("signin-keys-scratch"), &ab, &[("SWX_SIGN_IN_VERIFY_SECS", "8")]); let mut replies = vec![b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}))]; wait_for_sign_in_stage(&b);+ // P2: the page RE-LAYS as the background set is refused (the window was+ // moved): the email box is now centred at (600, 520). A click at the first+ // read's point would miss it.+ let moved: Value = serde_json::from_str(&page_one().to_string().replace(+ r#""centerX":584,"centerY":470"#, r#""centerX":600,"centerY":520"#)).unwrap();+ ab.on_call_set("desktop_ui_set", "desktop_ui_tree", moved); let r = std::thread::scope(|sc| { sc.spawn(|| drive_keystrokes(&m, &ab, Some(USER.to_string()), true)); b.call("swx_sign_in", json!({"reason": "sign in"})) }); check!(b, "signed in by keystrokes over two pages", r["signedIn"] == true && r["twoPage"] == true, s(&r));+ check!(+ b,+ "P2: the user-name click landed where the field IS NOW (a fresh read), not where it was",+ ab.calls_to("desktop_click").iter().any(|c| c["args"]["x"] == 600 && c["args"]["y"] == 520),+ format!("{:?}", ab.calls_to("desktop_click"))+ );+ check!(+ b,+ "P1: Remember me ticked by its visible LABEL (the checkbox is off-screen), verified by the toggle state",+ r["rememberTicked"]["via"] == "labelClick" && r["rememberTicked"]["ticked"] == true,+ s(&r["rememberTicked"])+ ); let steps = r["steps"].to_string(); check!( b,@@ -846,8 +888,10 @@ fn the_page_that_refuses_a_background_set_is_typed_safely_and_read_back() { "select-all before every typing; Enter NEVER pressed (the submit is a background Invoke); every call carries a reason", keys.len() == 2 && keys.iter().all(|k| k["args"]["keys"] == json!(["ctrl+a"]) && k["args"]["reason"].is_string()) && ab.calls_to("desktop_type").iter().all(|t| t["args"]["reason"].is_string())- && ab.calls_to("desktop_click").iter().all(|c| c["args"]["space"] == "image" && c["args"]["shotId"] == "shot-1"- && c["args"]["x"] == 484 && c["args"]["y"] == 420),+ // P2: clicks at the field's OWN centre, in screen px, after a fresh read.+ && ab.calls_to("desktop_click").iter().filter(|c| c["args"]["x"] == 584 || c["args"]["x"] == 600).count() == 2+ // P3: the login window was RAISED before the click.+ && !ab.calls_to("desktop_raise_window").is_empty(), format!("{keys:?} {:?}", ab.calls_to("desktop_click")) ); replies.push(r);@@ -935,3 +979,106 @@ fn a_missing_login_names_every_way_to_grow_the_vault() { s(&r) ); }++// ── 0.9.16: any login window on the machine, under the user's authorization ──++fn visualize_machine(tag: &str, windows: Value) -> Machine {+ let m = Machine::new(tag);+ std::fs::write(+ m.dir.join("preexisting.json"),+ json!([{"pid": 56488, "image": "SLDWORKSVisualize.exe", "ageSeconds": 600},+ {"pid": 70002, "image": "SWXDesktopLauncher.exe", "ageSeconds": 60}]).to_string(),+ )+ .unwrap();+ std::fs::write(m.dir.join("sign-in.json"), windows.to_string()).unwrap();+ m+}++fn visualize_window() -> Value {+ json!({"hwnd": 4262148, "ownerPid": 56488, "className": "WindowsForms10.Window.8.app.0.141b42a_r6_ad1",+ "title": "Login - 3DEXPERIENCE ID - Dassault Systemes"})+}++#[test]+fn the_visualize_login_is_answered_only_on_the_users_authorization() {+ assert!(+ swx_bridge::launchos::WATCHED_IMAGES.contains(&"SLDWORKSVisualize.exe"),+ "the live census must see SOLIDWORKS Visualize, or its login window is never found"+ );+ let m = visualize_machine("signin-visualize", visualize_window());+ let ab = ab_for(form(vec![]), vault_ok());+ let b = m.boot(TempDir::new("signin-visualize-scratch"), &ab, &[]);+ // No launch by this bridge: without the user's authorization, nothing.+ let r = b.call("swx_sign_in", json!({"reason": "sign in"}));+ check!(b, "without userAuthorized: sign_in_not_waiting, the vault never asked", r["errorCode"] == "sign_in_not_waiting" && ab.calls_to("pup_credential_reveal").is_empty(), s(&r));+ let r = std::thread::scope(|sc| {+ sc.spawn(|| close_window_on_submit(&m, &ab));+ b.call("swx_sign_in", json!({"reason": "Drew: sign Visualize in", "userAuthorized": true, "window": "visualize"}))+ });+ check!(+ b,+ "with userAuthorized: the Visualize login is signed in, and the reply says which process owned it",+ r["signedIn"] == true && r["window"]["ownerImage"] == "SLDWORKSVisualize.exe" && r["window"]["host"] == "visualize"+ && r["window"]["ownedByThisBridgesLaunch"] == false,+ s(&r)+ );+ let all = everything_but_the_fill(&b, &ab, &[r]);+ check!(b, "the password appears nowhere", !all.contains(PW), String::new());+}++#[test]+fn several_login_windows_need_a_choice() {+ let m = visualize_machine("signin-several", json!([+ visualize_window(),+ {"hwnd": 1002, "ownerPid": 70002, "className": "DSYSysLoginPanelWebView2Class", "title": "Login | 3DEXPERIENCE ID | Dassault Systèmes"}+ ]));+ let ab = ab_for(form(vec![]), vault_ok());+ let b = m.boot(TempDir::new("signin-several-scratch"), &ab, &[]);+ let r = b.call("swx_sign_in", json!({"reason": "sign in", "userAuthorized": true}));+ check!(+ b,+ "two windows, no choice: sign_in_window_ambiguous listing both, nothing typed",+ r["errorCode"] == "sign_in_window_ambiguous" && r["loginWindows"].as_array().map(|a| a.len() == 2).unwrap_or(false)+ && ab.calls_to("pup_credential_reveal").is_empty(),+ s(&r)+ );+ let r = std::thread::scope(|sc| {+ sc.spawn(|| close_window_on_submit(&m, &ab));+ b.call("swx_sign_in", json!({"reason": "sign in", "userAuthorized": true, "window": "solidworks"}))+ });+ check!(b, "window:\"solidworks\" picks the launcher's", r["signedIn"] == true && r["window"]["hwnd"] == 1002, s(&r));+}++#[test]+fn a_cookie_banner_over_the_field_is_never_clicked_through() {+ let m = Machine::new("signin-covered");+ // P2: after a move, the banner COVERS the email box (and resizing does not help here).+ let mut t = page_one();+ fn put(n: &mut Value) {+ match n {+ Value::Array(a) => a.iter_mut().for_each(put),+ Value::Object(o) => {+ if o.get("name") == Some(&json!("Accept All")) {+ o.insert("rect".into(), json!({"x": 380, "y": 445, "w": 420, "h": 60}));+ }+ o.values_mut().for_each(put);+ }+ _ => {}+ }+ }+ put(&mut t);+ let ab = keystroke_ab();+ ab.set_response("desktop_ui_tree", t);+ let b = m.boot(TempDir::new("signin-covered-scratch"), &ab, &[]);+ b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));+ wait_for_sign_in_stage(&b);+ let r = b.call("swx_sign_in", json!({"reason": "sign in"}));+ check!(+ b,+ "covered: the window was made taller, the field still covered -> sign_in_needs_user (the banner is the user's); no click, no typing",+ r["errorCode"] == "sign_in_needs_user" && r["cookieBanner"] == true+ && ab.calls_to("desktop_ui_window").iter().any(|c| c["args"]["action"] == "resize" && c["args"]["height"] == 1450)+ && ab.calls_to("desktop_click").is_empty() && ab.calls_to("desktop_type").is_empty(),+ s(&r)+ );+}
uninstall.sh