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.15 source
18 files changed
+1033−80
BRIDGE_VERSION+1−1Cargo.lock+1−1Cargo.toml+1−1README.md+13−1SKILL.md+2−1bridge.json+1−1docs/LAUNCH-RESILIENCE.md+33install.sh+9package.json+3−2references/launch-and-sign-in.md+3skills/solidworks-sign-in/SKILL.md+88src/launch.rs+14−2src/lib.rs+1src/signin.rs+449−69tests/signin.rs+381−1tests/wiring.rs+27tools/verify-wiki.py+2uninstall.sh+4BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.14+0.9.15
Cargo.lock+1−1@@ -107,7 +107,7 @@ dependencies = [ [[package]] name = "swx-bridge"-version = "0.9.14"+version = "0.9.15" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "swx-bridge"-version = "0.9.14"+version = "0.9.15" edition = "2021" rust-version = "1.80" description = "Adom SOLIDWORKS bridge — swx_* verbs over the SOLIDWORKS COM API, as a single native binary"
README.md+13−1@@ -461,7 +461,19 @@ not. ## Roadmap -- v0.9.14 (this): **the sign-in speaks ab's measured dialect.** Before the+- v0.9.15 (this): **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+ sits on it, and it refuses a background text set. `swx_sign_in` is now one+ flow over both pages with ONE consent, wakes the tree, never touches the+ cookie banner, and types real keystrokes only safely — never while the user is+ typing (a measured collision put the user's keystrokes in the box), only with+ the login window in front, select-all first, the user name read back before+ Continue. A missing login names every way to grow the vault. A second skill,+ **solidworks-sign-in**, ships the measured flow for every agent — with no+ credentials. 533 tests.+- v0.9.14: **the sign-in speaks ab's measured dialect.** Before the first real sign-in, the three ab verbs it depends on were measured on Drew's laptop: `desktop_ui_set` takes `text` (0.9.13 sent `value` — every fill would have failed), the UI tree's nodes carry `role`/`invokable`/`settable` and no
SKILL.md+2−1@@ -70,7 +70,8 @@ BEFORE you do it: 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). Never launch+ 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 every verb that changes something; the active document follows window focus,
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "solidworks", "displayName": "SOLIDWORKS",- "version": "0.9.14",+ "version": "0.9.15", "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+33@@ -1313,3 +1313,36 @@ verified each time, never assumed). | `pup_credential_reveal {}` | "needs {host, reason} (reason 8+ chars; it is shown to the user)"; hint: "Reveal ONE saved login (username + password) to a LOCAL caller on the desktop, gated by a sticky consent toast the user must click. One host per call, never bulk. A relayed (cloud container) call is refused with relay_forbidden" | Consent is PER USE (`vault.consent:"perUse"`, from ab's own text). The toast reads "Sign in to SOLIDWORKS with your saved 3DEXPERIENCE login". `consent_pending` is re-called until answered or `SWX_SIGN_IN_CONSENT_SECS` (120 s) → `sign_in_consent_pending`; a refusal → `sign_in_consent_denied`; `relay_forbidden` → `sign_in_relay_forbidden`, reported as a bridge wiring bug (the bridge must reach ab as a local caller), not a user problem | | `desktop_ui_click {hwnd}` with no name | "control found but has NO InvokePattern…" — it needs `name`/`contains` | Every click targets by `name` + `contains` | | The vault | no 3DEXPERIENCE entry yet (import consent pending on Drew's side) | EXISTENCE FIRST: `pup_credentials {}` (host + username, no toast) is read before any reveal; no login on the `3ds.com` domain → `sign_in_credential_missing` WITHOUT raising the consent toast. The reveal asks the vault host found there (preferring `iam.*`) |++**THE FIRST REAL LOGIN WINDOW, MEASURED 2026-10-02, and 0.9.15.** Raised by+SOLIDWORKS Visualize 2026 (pid 56488): class `WindowsForms10.Window.8.app…`,+title "Login - 3DEXPERIENCE ID - Dassault Systemes", 968x968 — the same web page+SOLIDWORKS's launcher hosts in `DSYSysLoginPanelWebView2Class` ("Login |+3DEXPERIENCE ID | Dassault Systèmes"), which is still unmeasured.++| | Measured | 0.9.15 |+|---|---|---|+| T1 — the tree is lazy | the first `desktop_ui_tree {maxDepth:30}` gave 13 nodes, one control (Button "Close"); after one `desktop_ui_from_point` query on the page, 38 nodes with the form | A read with no form wakes the page — `desktop_ui_from_point` at the window's centre, a short pause, read again, up to three times — and records every node count (`wake.nodeCounts`, `signInWake`) |+| T2 — two pages | page 1: Edit "Email or username" (automationId `username`, settable), Button "Continue", hyperlinks, a cookie banner, Button "Close"; NO password box. Page 2 unmeasured | ONE flow: page 1 recognized (one box that says it is the user name + a Continue-like button) → user name → Continue → wait for the page to CHANGE (woken) → page 2 recognized (a password box + a submit), its inventory captured → password → submit → verified by effect. The vault is asked ONCE; the secret is held only across the two fills, then wiped. A password page that never comes types no password (`sign_in_unconfirmed`) |+| T3 — cookie banner | "Accept All", "Manage my preferences", Text "Continue with only necessary cookies"; it does not cover the form | Never clicked (the choice is the user's), never a submit candidate; reported `cookieBanner:true`; a set or click that fails with the banner up is `sign_in_needs_user`, and the user is told |+| T4 — both hosts | — | The title rule already matches the Visualize host ("3DEXPERIENCE ID" + "Login"); the measured cold (13) and warm (38) page-1 reads are test fixtures. The bridge does not launch Visualize, so a Visualize login is not one `swx_sign_in` will answer |+| K1 — no background set | `desktop_ui_set {name:"Email or username", text}` on the `settable:true` Edit: "control found but has NO ValuePattern (can't set text via UIA — try a foregrounded desktop_type)" | The UIA set is tried first; on that refusal the bridge types REAL keystrokes: `desktop_screenshot_window` → `desktop_click {space:"image", shotId, x, y}` on the box → `desktop_press_key ["ctrl+a"]` → `desktop_type`, every call with a `reason` |+| K2 — collision | keystrokes sent while Drew typed in Chrome (last input 0 s) left "ogin" — HIS keystrokes — in the box, and the text did not land | No keystroke unless the user has been idle (`SWX_SIGN_IN_IDLE_SECS`, 5 s; unreadable = not idle) → `sign_in_needs_user_idle` + a toast asking for five seconds hands-off; the login window confirmed as the FOREGROUND window before every batch (aborts if focus moved); the user-name box READ BACK and compared (the password box never is); a mismatch stops with what was observed. Enter is never pressed |+| K3 — dirty field | "ogin" stayed in the box after the failed attempt | Select-all before typing |+| K4 — background submit | `desktop_ui_click {name:"Continue"}` worked without focus | Submits stay background Invokes |+| K5 — the vault | `pup_import_browser_logins {hostFilter:"3ds.com"}` answered `no_data` — Drew's browser holds no saved 3DEXPERIENCE login | `sign_in_credential_missing` names all three ways to grow the vault: the import, signing in once in a pup window (pup captures it), or `credential_set` by the user on their desktop |++**Unmeasured, stated:** whether keystrokes posted to the embedded browser's own+child window work without focus (would avoid stealing focus); the image-space+mapping (`x = element centre − window origin` on a window screenshot); page 2.++**T5 — SOLIDWORKS Visualize 2026, measured.** Installed at `…\SOLIDWORKS+3DEXPERIENCE R2026x\Visualize\SLDWORKSVisualize.exe`; it takes a model path as+its argument; it ships no scripting interface on disk (UI automation only); and+it demands its OWN platform sign-in even while SOLIDWORKS is signed in. No+rendering verbs in this build: Drew wants rendering intelligence only once a+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.
install.sh+9@@ -8,4 +8,13 @@ for D in "$HOME/.claude/skills" "$HOME/.codex/skills"; do # SKILL.md holds the rules that never bend; the detail it points at lives here. cp -f "$H"/references/*.md "$D/solidworks-bridge/references/" done+# A dependent skill: the measured 3DEXPERIENCE sign-in flow (no credentials).+install_skill() { # <name> <source dir>+ for D in "$HOME/.claude/skills" "$HOME/.codex/skills"; do+ [ -d "$(dirname "$D")" ] || continue+ mkdir -p "$D/$1"+ cp -f "$2"/SKILL.md "$D/$1/SKILL.md"+ done+}+install_skill solidworks-sign-in "$H/skills/solidworks-sign-in" echo "[solidworks-bridge] skills installed."
package.json+3−2@@ -1,8 +1,8 @@ { "slug": "solidworks-bridge",- "version": "0.9.14",+ "version": "0.9.15", "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 \u2014 a single native swx-bridge.exe; Adom Bridge loads that.",+ "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": [ "solidworks", "mcad",@@ -27,6 +27,7 @@ "references/launch-and-sign-in.md", "references/writes-and-dialogs.md", "references/modelling-and-export.md",+ "skills/solidworks-sign-in/SKILL.md", "package.json", "install.sh", "uninstall.sh"
references/launch-and-sign-in.md+3@@ -225,3 +225,6 @@ Read this before starting SOLIDWORKS, while waiting through a launch or a 3DEXPE (`userConfirmedNewPassword:true`, on their word only). `swx_start {signIn:"auto"}` lets the bridge do it once by itself; the default is to wait for the user.+- **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.
skills/solidworks-sign-in/SKILL.mdadded+88@@ -0,0 +1,88 @@+---+name: solidworks-sign-in+description: "How to get through the 3DEXPERIENCE ID login that SOLIDWORKS and SOLIDWORKS Visualize raise when the platform session has lapsed - where the window comes from, how to find it, the measured two-page flow, what to type with and what never to touch, and where the login comes from (never from this skill). Use when a SOLIDWORKS launch is waiting on a sign-in, when swx_status says waitingOn userSignIn, or when a License Manager notice says it failed to check a license. Trigger words - solidworks sign in, 3DEXPERIENCE login, sign me in, login window, License Manager failed to check license, session expired, Visualize login."+user-invocable: true+---++Parent skill: **solidworks-bridge**++Every line below is marked **[measured]** (seen on a real machine, 2026-10-02) or+**[unmeasured]** (not seen yet — treat as a guess and read the window first).++## When the window appears++- [measured] It appears when the 3DEXPERIENCE platform session has lapsed. It+ did not appear on about fifteen launches on 09-30 and 10-01, then appeared on+ 10-02 after licence checks failed overnight.+- [measured] A session that is already running shows the warning first: a+ "License Manager" notice, "Failed to check 1 license(s): 3DEXPERIENCE+ SOLIDWORKS Professional: Unable to connect to server. The session will end+ after the next 5 unsuccessful attempts.", OK only. Notices can queue (two+ identical ones). The bridge never answers them.+- [measured] SOLIDWORKS and SOLIDWORKS Visualize each ask separately. Visualize+ wants its own sign-in even while SOLIDWORKS is signed in.++## Finding the window++- [measured] SOLIDWORKS's launcher shows it: class+ `DSYSysLoginPanelWebView2Class`, title "Login | 3DEXPERIENCE ID | Dassault+ 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.++## The flow++1. [measured] The accessibility tree is LAZY. The first `desktop_ui_tree` read+ gave 13 nodes with one control (Button "Close"); after one+ `desktop_ui_from_point` query on a point inside the page, the same read gave+ 38 nodes with the form. Wake it before deciding anything.+2. [measured] Page 1: Edit "Email or username" (automationId `username`) and+ Button "Continue". There is NO password box on page 1.+3. [measured] `desktop_ui_set` on that Edit is refused ("control found but has+ NO ValuePattern"), even though the tree says `settable: true`. Typing needs+ real keystrokes: `desktop_click` on the box, `desktop_press_key ["ctrl+a"]`+ (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.+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.++## Rules that never bend++- [measured] NEVER send keystrokes while the user is typing. On 10-02 keystrokes+ sent while the user typed in another window left fragments of THEIR typing in+ the login box, and the intended text did not land. Check `swx_user_presence`+ (seconds since last input) first, and bring the login window to the front and+ confirm it is in front before every keystroke batch.+- Read the user-name box back after typing and compare before pressing anything.+ Never read the password box back.+- Never retry a rejected password; an account lockout is worse than a manual+ sign-in.+- A second factor (a code, a captcha, a new-device check) is the user's to+ answer. Tell them; do not try.++## Credentials++This skill carries NO login, and it never will. The login comes from the user's+own secret store on their desktop, in general terms:++- the pup vault on the user's desktop, through `swx_sign_in {reason}` — the+ bridge reads it locally, the user clicks one consent toast per use, and the+ password never reaches the AI, a reply or a log;+- growing that vault, by the user on their desktop: `pup_import_browser_logins+ {"hostFilter": "3ds.com"}` (it found nothing on one machine because the+ browser held no saved 3DEXPERIENCE login), or signing in to 3DEXPERIENCE once+ in a pup window so the vault captures it;+- or the user's own PRIVATE skill pack, if they keep one, for where their logins+ live — never this skill.++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.
src/launch.rs+14−2@@ -510,6 +510,10 @@ pub fn note_sign_in_inventory(inv: Value) { update(move |a| a.sign_in_inventory = Some(inv)); } +pub fn note_sign_in_wake(wake: Value) {+ update(move |a| a.sign_in_wake = Some(wake));+}+ pub fn note_sign_in_rejected() { update(|a| a.sign_in_rejected = true); }@@ -528,8 +532,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(inv) = crate::signin::inventory(hwnd) {- note_sign_in_inventory(crate::signin::inventory_json(&inv));+ if let Ok(r) = crate::signin::inventory(hwnd) {+ note_sign_in_inventory(crate::signin::inventory_json(&r.elements));+ note_sign_in_wake(r.wake_json()); } let auto = attempt().map(|a| a.sign_in_auto && !a.sign_in_auto_tried && !a.sign_in_rejected).unwrap_or(false); if auto {@@ -782,6 +787,9 @@ pub struct Attempt { /// reaches the sign-in (0.9.13): control types, names, automation ids, /// IsPassword, toggle states — never a value. pub sign_in_inventory: Option<Value>,+ /// How that inventory was read: ab's node count on each read (cold first)+ /// and how many wakes it took (0.9.15 — the tree is lazy).+ pub sign_in_wake: Option<Value>, /// The saved password was rejected in this launch: never retried. pub sign_in_rejected: bool, /// `swx_start {signIn:"auto"}`: the bridge may sign in by itself, ONCE.@@ -884,6 +892,7 @@ impl Attempt { "signInSeen": self.sign_in_seen, "signInWindow": self.sign_in_window, "signInInventory": self.sign_in_inventory,+ "signInWake": self.sign_in_wake, "signInRejected": self.sign_in_rejected, "signInMode": if self.sign_in_auto { "auto" } else { "wait" }, "signInAutoTried": self.sign_in_auto_tried,@@ -1030,6 +1039,7 @@ pub fn load() -> LaunchStore { sign_in_seen: a.get("signInSeen").and_then(|x| x.as_bool()).unwrap_or(false), sign_in_window: a.get("signInWindow").and_then(|x| x.as_str()).map(String::from), sign_in_inventory: a.get("signInInventory").filter(|x| !x.is_null()).cloned(),+ sign_in_wake: a.get("signInWake").filter(|x| !x.is_null()).cloned(), sign_in_rejected: a.get("signInRejected").and_then(|x| x.as_bool()).unwrap_or(false), sign_in_auto: a.get("signInMode").and_then(|x| x.as_str()) == Some("auto"), sign_in_auto_tried: a.get("signInAutoTried").and_then(|x| x.as_bool()).unwrap_or(false),@@ -1398,6 +1408,7 @@ pub fn begin( sign_in_seen: false, sign_in_window: None, sign_in_inventory: None,+ sign_in_wake: None, sign_in_rejected: false, sign_in_auto: false, sign_in_auto_tried: false,@@ -1466,6 +1477,7 @@ fn unfinished_placeholder(shortcut: &str) -> Attempt { sign_in_seen: false, sign_in_window: None, sign_in_inventory: None,+ sign_in_wake: None, sign_in_rejected: false, sign_in_auto: false, sign_in_auto_tried: false,
src/lib.rs+1@@ -1,3 +1,4 @@+#![recursion_limit = "256"] //! Adom SOLIDWORKS bridge (`swx`) — one binary, two modes. //! //! ```text
src/signin.rs+449−69@@ -147,6 +147,8 @@ pub struct Element { /// ab's `invokable` / `settable` (MEASURED 2026-10-02 on ab 2.1.163). pub invokable: Option<bool>, pub settable: Option<bool>,+ /// Screen rectangle (x, y, w, h), when ab reports one — for a real click.+ pub rect: Option<[i64; 4]>, } impl Element {@@ -205,6 +207,7 @@ pub fn parse_inventory(reply: &Value) -> Vec<Element> { 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), }); // THE PASSWORD FIELD (MEASURED 2026-10-02: ab's nodes carry no // `isPassword` key): an explicit password flag, OR an edit@@ -247,20 +250,94 @@ pub fn looks_like_password(s: &str) -> bool { s.contains("pass") || s.contains("mot de passe") || s.contains("contrase") } -/// Read the login window's inventory through ab. `Err` = could not be read.-pub fn inventory(hwnd: i64) -> Result<Vec<Element>, String> {+thread_local! {+ static WINDOW_RECT: std::cell::RefCell<Option<[i64; 4]>> = const { std::cell::RefCell::new(None) };+}++fn wake_ms() -> u64 {+ std::env::var("SWX_SIGN_IN_WAKE_MS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(700)+}++fn rect_xywh(r: &Value) -> Option<[i64; 4]> {+ let n = |k: &str| r.get(k).and_then(|v| v.as_f64());+ let (x, y) = (n("x").or(n("left"))?, n("y").or(n("top"))?);+ let w = n("w").or(n("width")).or_else(|| n("right").map(|r| r - x))?;+ let h = n("h").or(n("height")).or_else(|| n("bottom").map(|b| b - y))?;+ Some([x as i64, y as i64, w as i64, h as i64])+}++/// 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);+ if !ab_ok(&r) {+ return Err(format!("desktop_ui_tree failed: {}", ab_error(&r)));+ }+ let top = unwrap_ab(&r);+ let els = parse_inventory(&r);+ let count = top.get("nodeCount").and_then(|v| v.as_u64()).unwrap_or(els.len() as u64);+ let wr = top.pointer("/window/rect").and_then(rect_xywh).or_else(|| top.pointer("/root/rect").and_then(rect_xywh));+ let center = wr.map(|[x, y, w, h]| (x + w / 2, y + h / 2));+ WINDOW_RECT.with(|c| *c.borrow_mut() = wr);+ Ok((els, count, center))+}++/// The login window, read — woken first if it is lazy.+pub struct Read {+ pub elements: Vec<Element>,+ /// The window's screen rectangle, for turning an element's screen point+ /// into a point on a window screenshot.+ pub window_rect: Option<[i64; 4]>,+ /// ab's node count on every read, cold first.+ pub node_counts: Vec<u64>,+ pub wakes: u32,+}++impl Read {+ pub fn wake_json(&self) -> Value {+ json!({"nodeCounts": self.node_counts, "wakes": self.wakes})+ }+}++/// Does this read show a form (or a question) yet?+fn shows_something(els: &[Element]) -> bool {+ els.iter().any(|e| e.is("edit")) || has_marker(els, SECOND_STEP).is_some() || has_marker(els, REJECTED).is_some()+}++/// READ THE LOGIN WINDOW, WAKING IT IF IT IS LAZY (0.9.15). MEASURED+/// 2026-10-02 on a 3DEXPERIENCE ID login window: the first `desktop_ui_tree`+/// returned 13 nodes with ONE control (Button "Close"); after a+/// `desktop_ui_from_point` query on the page the same call returned 38 nodes+/// with the form. So a read that shows no form queries a point inside the+/// window (read-only) and reads again — up to three times — and both counts+/// are recorded.+pub fn inventory(hwnd: i64) -> Result<Read, String> { if !crate::caller::ab_available() { return Err("ab's direct API is not available to this bridge (it was not spawned by Adom Bridge)".into()); }- let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 16, "maxNodes": 400}), AB_TIMEOUT);- if !ab_ok(&r) {- return Err(format!("desktop_ui_tree failed: {}", ab_error(&r)));+ let (mut els, n, mut center) = read_once(hwnd)?;+ let mut counts = vec![n];+ let mut wakes = 0u32;+ while !shows_something(&els) && wakes < 3 {+ let mut args = json!({"hwnd": hwnd, "reason": "waking the 3DEXPERIENCE sign-in page's accessibility tree (read-only)"});+ if let (Some((x, y)), Value::Object(m)) = (center, &mut args) {+ m.insert("x".into(), json!(x));+ m.insert("y".into(), json!(y));+ }+ let _ = crate::caller::ad_callback("desktop_ui_from_point", args, AB_TIMEOUT);+ wakes += 1;+ std::thread::sleep(Duration::from_millis(wake_ms()));+ let (e, n, c) = read_once(hwnd)?;+ els = e;+ counts.push(n);+ if c.is_some() {+ center = c;+ } }- let inv = parse_inventory(&r);- if inv.is_empty() {+ if els.is_empty() { return Err("desktop_ui_tree answered, but no element with a control type could be read from it".into()); }- Ok(inv)+ let window_rect = WINDOW_RECT.with(|c| *c.borrow());+ Ok(Read { elements: els, window_rect, node_counts: counts, wakes }) } pub fn inventory_json(inv: &[Element]) -> Value {@@ -281,6 +358,18 @@ const REJECTED: &[&str] = &[ const SUBMIT_PRIMARY: &[&str] = &["log in", "login", "sign in", "sign-in", "signin", "connect", "log on"]; const SUBMIT_SECONDARY: &[&str] = &["continue", "next", "submit", "ok"]; const REMEMBER: &[&str] = &["remember", "stay signed", "keep me signed", "keep me logged"];+const USER_FIELD: &[&str] = &["email", "user", "login", "identifier", "account"];+/// The cookie banner's buttons — a consent choice that is the USER's: never+/// clicked (MEASURED 2026-10-02: "Accept All", "Manage my preferences").+const COOKIE_CHOICES: &[&str] = &["accept all", "accept all cookies", "manage my preferences", "reject all", "accept"];++/// Is a cookie banner on the page? Reported, never answered.+pub fn cookie_banner(inv: &[Element]) -> bool {+ inv.iter().any(|e| {+ let n = e.name.trim().to_lowercase();+ (e.is("button") && COOKIE_CHOICES.contains(&n.as_str())) || n.contains("cookies")+ })+} /// The recognized form. #[derive(Debug, Clone)]@@ -326,8 +415,22 @@ pub fn recognize(inv: &[Element]) -> (Reading, Option<Form>) { if passwords.is_empty() && users.is_empty() { return (Reading::Unrecognized("no text field at all".into()), None); }+ // PAGE ONE of a two-page sign-in (MEASURED 2026-10-02: "Email or username"+ // + Continue, no password field): the one field must SAY it is the user name.+ if passwords.is_empty() {+ let u = users[0];+ let says = format!("{} {}", u.name, u.automation_id).to_lowercase();+ if !USER_FIELD.iter().any(|w| says.contains(w)) {+ return (Reading::Unrecognized(format!("one text field (\"{}\") that does not say it is the user name, and no password field", u.name)), None);+ }+ } // Buttons that can be INVOKED (ab's `invokable`, when it says).- let buttons: Vec<&Element> = inv.iter().filter(|e| e.is("button") && e.invokable != Some(false)).collect();+ // (A cookie-consent button is never a candidate: that choice is the user's.)+ let buttons: Vec<&Element> = inv+ .iter()+ .filter(|e| e.is("button") && e.invokable != Some(false))+ .filter(|e| !COOKIE_CHOICES.contains(&e.name.trim().to_lowercase().as_str()))+ .collect(); let named = |list: &[&str]| -> Vec<&Element> { buttons .iter()@@ -587,88 +690,124 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value { json!({"rejectedBefore": rejected_before()}), ); }- // 1. THE INVENTORY, captured every time.- let inv = match inventory(hwnd) {- Ok(i) => i,+ // 1. THE INVENTORY, captured every time (woken first if the page is lazy).+ let read = match inventory(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(&inv));- let (reading, form) = recognize(&inv);- let inv_j = inventory_json(&inv);+ crate::launch::note_sign_in_inventory(inventory_json(&read.elements));+ crate::launch::note_sign_in_wake(read.wake_json());+ let inv_j = inventory_json(&read.elements);+ let wake_1 = read.wake_json();+ let banner = cookie_banner(&read.elements);+ let (reading, form) = recognize(&read.elements); let form = match (reading, form) { (Reading::Form, Some(f)) => f, (Reading::SecondStep(m), _) => { tell_user(&format!("The 3DEXPERIENCE sign-in is asking for a second step (\"{m}\"). Please complete it in the sign-in window — the bridge will not."));- return err("sign_in_needs_user", format!("the sign-in window asks for a second step (\"{m}\") that only the user can answer"), "The user has been notified. Nothing was typed.", json!({"inventory": inv_j}));+ return err("sign_in_needs_user", format!("the sign-in window asks for a second step (\"{m}\") that only the user can answer"), "The user has been notified. Nothing was typed.", json!({"inventory": inv_j, "wake": wake_1})); } (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}));+ 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})); } };- // 2. THE SECRET, locally.+ // 2. THE SECRET, locally — ONCE for the whole sign-in, however many pages it+ // takes (a second reveal would raise the consent toast twice). let _ = reason; // the AI's reason is ledgered; the user's toast carries CONSENT_REASON let (secret, vault) = match reveal() { Vault::Got(s, v) => (s, v),- Vault::Missing(e) => return err("sign_in_credential_missing", format!("the vault has no 3DEXPERIENCE login for {}: {e}", vault_host()), format!("Ask the user to import their saved browser login into the vault on their laptop: ab {IMPORT_COMMAND} (it asks for their consent). Nothing was typed."), json!({"inventory": inv_j, "importCommand": IMPORT_COMMAND})),+ Vault::Missing(e) => return err(+ "sign_in_credential_missing",+ format!("the vault has no 3DEXPERIENCE login for {}: {e}", vault_host()),+ format!(+ "Nothing was typed. The user's vault on their laptop needs the login, in one of three ways (each on their desktop, by them — never a password in chat): \+ (1) import it from their browser: ab {IMPORT_COMMAND} — measured 2026-10-02 on Drew's machine it answered no_data (his browser holds none); \+ (2) sign in to 3DEXPERIENCE once in a pup window, so pup captures the login; \+ (3) store it themselves with pup's credential_set on their desktop."+ ),+ json!({"inventory": inv_j, "importCommand": IMPORT_COMMAND,+ "growTheVault": ["pup_import_browser_logins {\"hostFilter\":\"3ds.com\"}", "sign in once in a pup window (pup captures it)", "credential_set on the user's desktop, by the user"]}),+ ), Vault::Denied(e) => return err("sign_in_consent_denied", format!("the user did not allow the saved login to be used: {e}"), "The user declined the consent toast. Nothing was typed; do not ask again unless they say so.", json!({"inventory": inv_j})), Vault::Pending(e) => return err("sign_in_consent_pending", format!("the consent toast was not answered within {}s: {e}", consent_secs()), "The toast \"Sign in to SOLIDWORKS with your saved 3DEXPERIENCE login\" is waiting on the user's desktop. Tell them; calling swx_sign_in again re-attaches to it without a second prompt. Nothing was typed.", json!({"inventory": inv_j, "consentReason": CONSENT_REASON})), Vault::RelayForbidden(e) => return err("sign_in_relay_forbidden", format!("ab refused the reveal as a RELAYED call: {e}"), "This is a bridge wiring bug, not a user problem: the bridge runs on the desktop and must call ab as a LOCAL caller. Report it (see _reportIssues). Nothing was typed.", json!({"inventory": inv_j})), Vault::Unavailable(e) => return err("sign_in_vault_unavailable", format!("the vault could not be asked: {e}"), "Nothing was typed. The user can sign in by hand.", json!({"inventory": inv_j})), }; let needles = secret.needles();- let fill_reason = "signing in to 3DEXPERIENCE for a SOLIDWORKS launch this bridge started"; let mut steps: Vec<Value> = Vec::new();- // 3. FILL — the user name (if the form has one and the vault has one), then- // the password.- let mut fail: Option<(String, String)> = None;- if let (Some(u_el), Some(u)) = (&form.user, &secret.user) {- let r = crate::caller::ad_callback(- "desktop_ui_set",- merge(target(u_el, "edit"), json!({"hwnd": hwnd, "text": String::from_utf8_lossy(u), "sensitive": true, "reason": fill_reason})),- AB_TIMEOUT,- );- steps.push(json!({"step": "user", "ok": ab_ok(&r)}));- if !ab_ok(&r) {- fail = Some(("user".into(), ab_error(&r)));- }+ 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) {+ drop(secret);+ return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles); }- if fail.is_none() {- if let Some(p_el) = &form.password {- let r = crate::caller::ad_callback(- "desktop_ui_set",- merge(target(p_el, "edit"), json!({"hwnd": hwnd, "text": String::from_utf8_lossy(&secret.pass), "sensitive": true, "reason": fill_reason})),- AB_TIMEOUT,- );- steps.push(json!({"step": "password", "ok": ab_ok(&r)}));- if !ab_ok(&r) {- fail = Some(("password".into(), ab_error(&r)));+ // 4. PAGE TWO, if page one asked only for the user name (MEASURED 2026-10-02:+ // "Email or username" + Continue). Wait for the page to CHANGE, read it+ // (woken), recognize the password page, fill it, submit.+ let mut two_page = false;+ if form.password.is_none() {+ two_page = true;+ 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;+ loop {+ if let Some(None) = crate::launch::sign_in_now(os.as_ref()) {+ break; // the window closed after the user name alone+ }+ if let Ok(r2) = inventory(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)}));+ crate::launch::note_sign_in_inventory(inventory_json(&r2.elements));+ page2_rect = r2.window_rect.or(page2_rect);+ page2 = Some(f2);+ break;+ }+ (Reading::SecondStep(_), _) => break,+ _ if has_marker(&r2.elements, REJECTED).is_some() => break,+ _ => {}+ }+ }+ if std::time::Instant::now() >= deadline {+ break; }+ std::thread::sleep(Duration::from_millis(500)); }- }- drop(secret); // ZEROED. Only the needles (for scrubbing) remain, below.- if let Some((step, e)) = fail {- let out = err("sign_in_fill_failed", format!("typing the {step} into the sign-in window failed: {e}"), "Nothing was submitted. The user can sign in by hand.", json!({"inventory": inv_j, "steps": steps, "vault": vault}));- return scrub(&out, &needles);- }- // 4. "REMEMBER ME", if the form has one and it is not already on.- let mut remember_ticked = Value::Null;- 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));+ match page2 {+ 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) {+ 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 => {+ drop(secret);+ let last_j = inventory_json(&last);+ let out = if let Some(m) = has_marker(&last, SECOND_STEP) {+ tell_user(&format!("The 3DEXPERIENCE sign-in is asking for a second step (\"{m}\"). Please complete it in the sign-in window."));+ err("sign_in_needs_user", format!("after the user name, the sign-in window asks for a second step (\"{m}\")"), "The user has been notified. The password was not typed.", json!({"pages": pages, "inventoryAfter": last_j, "steps": steps, "vault": vault}))+ } else if let Some(m) = has_marker(&last, REJECTED) {+ crate::launch::note_sign_in_rejected();+ record_rejection(&m);+ tell_user("The 3DEXPERIENCE sign-in did not accept the saved user name. The bridge will not try again — please sign in by hand.");+ err("sign_in_rejected", format!("the sign-in window rejected the saved user name (\"{m}\"); it is NEVER retried"), "Ask the user to sign in by hand and check the saved login in the vault.", json!({"pages": pages, "inventoryAfter": last_j, "steps": steps, "vault": vault}))+ } else {+ err("sign_in_unconfirmed", format!("the user name was submitted, but no password page appeared within {}s", verify_secs()), "Not retried, and the password was not typed. The inventory says what the window shows now; the user can finish by hand.", json!({"pages": pages, "inventoryAfter": last_j, "steps": steps, "vault": vault}))+ };+ return scrub(&out, &needles);+ } } }- // 5. SUBMIT.- let r = crate::caller::ad_callback("desktop_ui_click", merge(target(&form.submit, "button"), json!({"hwnd": hwnd, "reason": fill_reason})), AB_TIMEOUT);- if !ab_ok(&r) {- let out = err("sign_in_fill_failed", format!("pressing \"{}\" failed: {}", form.submit.name, ab_error(&r)), "The fields may be filled but nothing was submitted. The user can press it.", json!({"inventory": inv_j, "steps": steps, "vault": vault}));- return scrub(&out, &needles);- }- // 6. VERIFY BY EFFECT: the window goes away.+ drop(secret); // ZEROED. Only the needles (for scrubbing) remain, below.+ // 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()) {@@ -676,10 +815,13 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value { let out = crate::verbs::ok(json!({ "signedIn": true, "verifiedBy": "the sign-in window closed after the submit",+ "twoPage": two_page,+ "cookieBanner": banner, "rememberTicked": remember_ticked, "steps": steps, "vault": vault, "inventory": inv_j,+ "pages": pages, "_hint": "Signed in. The launch continues on its own; poll swx_status for `ready`.", })); return scrub(&out, &needles);@@ -689,22 +831,245 @@ fn sign_in_inner(reason: &str, user_confirmed_new_password: bool) -> Value { } } // STILL UP: read it again and say what it is asking.- let after = inventory(hwnd).unwrap_or_default();+ let after = inventory(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(); record_rejection(&m); tell_user("The saved 3DEXPERIENCE password was rejected. The bridge will not try it again — please sign in by hand, and update the saved login.");- err("sign_in_rejected", format!("the sign-in window rejected the saved password (\"{m}\"); it is NEVER retried"), "Ask the user to sign in by hand and update the saved login in the vault.", json!({"inventoryAfter": after_j, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))+ err("sign_in_rejected", format!("the sign-in window rejected the saved password (\"{m}\"); it is NEVER retried"), "Ask the user to sign in by hand and update the saved login in the vault.", json!({"inventoryAfter": after_j, "pages": pages, "steps": steps, "vault": vault, "rememberTicked": remember_ticked})) } else if let Some(m) = has_marker(&after, SECOND_STEP) { tell_user(&format!("The 3DEXPERIENCE sign-in is asking for a second step (\"{m}\"). Please complete it in the sign-in window."));- err("sign_in_needs_user", format!("after the password, the sign-in window asks for a second step (\"{m}\")"), "The user has been notified.", json!({"inventoryAfter": after_j, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))+ err("sign_in_needs_user", format!("after the password, the sign-in window asks for a second step (\"{m}\")"), "The user has been notified.", json!({"inventoryAfter": after_j, "pages": pages, "steps": steps, "vault": vault, "rememberTicked": remember_ticked})) } else {- err("sign_in_unconfirmed", format!("the form was submitted, but the sign-in window is still open after {}s and says nothing this bridge recognizes", verify_secs()), "Not retried. swx_status shows whether the launch advanced; the inventory says what the window shows now.", json!({"inventoryAfter": after_j, "steps": steps, "vault": vault, "rememberTicked": remember_ticked}))+ err("sign_in_unconfirmed", format!("the form was submitted, but the sign-in window is still open after {}s and says nothing this bridge recognizes", verify_secs()), "Not retried. swx_status shows whether the launch advanced; the inventory says what the window shows now.", json!({"inventoryAfter": after_j, "pages": pages, "steps": steps, "vault": vault, "rememberTicked": remember_ticked})) }; scrub(&out, &needles) } +const FILL_REASON: &str = "signing in to 3DEXPERIENCE for a SOLIDWORKS launch this bridge started";++/// Fill ONE page: the user name (if the page has the field), the password (if+/// 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> {+ 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)?;+ }+ if let Some(p_el) = &form.password {+ fill_field(hwnd, origin, 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));+ }+ }+ 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"}));+ if !ab_ok(&r) {+ return Err(FillErr::Failed { step: format!("press of \"{}\"", form.submit.name), why: ab_error(&r), observed: None, may_be_banner: true });+ }+ Ok(())+}++pub(crate) enum FillErr {+ /// The user is typing (or the idle time cannot be read): no keystroke sent.+ NeedsIdle(String),+ /// `may_be_banner`: a failure a cookie banner covering the form could cause+ /// (a set or a click that did not land) — never a focus, idle or read-back+ /// stop, which say exactly what happened.+ Failed { step: String, why: String, observed: Option<Value>, may_be_banner: bool },+}++fn idle_secs_needed() -> u64 {+ std::env::var("SWX_SIGN_IN_IDLE_SECS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(5)+}++/// IS THE USER'S KEYBOARD QUIET? MEASURED 2026-10-02: real keystrokes sent+/// while Drew was typing in another window left "ogin" — fragments of HIS+/// keystrokes — in the login box, and the bridge's text did not land.+fn user_idle() -> Result<u64, String> {+ let os = crate::launchos::os();+ match os.seconds_since_last_input() {+ Ok(n) if n >= idle_secs_needed() => Ok(n),+ Ok(n) => Err(format!("the user gave input {n}s ago (needs {}s of quiet)", idle_secs_needed())),+ Err(e) => Err(format!("the time since the user's last input could not be read ({e})")),+ }+}++/// Is the login window the foreground window, right now?+fn login_in_front(hwnd: i64) -> Result<(), String> {+ match crate::launchos::os().foreground_window() {+ Ok(Some(w)) if w.hwnd == hwnd => Ok(()),+ Ok(Some(w)) => Err(format!("the foreground moved to another window (\"{}\")", w.title)),+ Ok(None) => Err("no window has the foreground".into()),+ Err(e) => Err(format!("the foreground window could not be read ({e})")),+ }+}++/// THE USER NAME FIELD'S CONTENT, read back from the tree (the tree DOES carry+/// values). Used ONLY for the user-name field — the password field is never+/// read back.+fn field_value(hwnd: i64, el: &Element) -> Option<String> {+ let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 30, "maxNodes": 400}), AB_TIMEOUT);+ fn walk(v: &Value, el: &Element) -> Option<String> {+ match v {+ Value::Array(a) => a.iter().find_map(|x| walk(x, el)),+ Value::Object(m) => {+ let role = str_of(m, &["controlType", "control_type", "role", "type"]);+ if norm_type(&role) == "edit"+ && str_of(m, &["name"]) == el.name+ && str_of(m, &["automationId", "automation_id", "id"]) == el.automation_id+ {+ if let Some(v) = m.get("value").and_then(|v| v.as_str()) {+ return Some(v.to_string());+ }+ }+ m.iter().filter(|(k, _)| matches!(k.as_str(), "children" | "elements" | "nodes" | "tree" | "root" | "items" | "controls")).find_map(|(_, x)| walk(x, el))+ }+ _ => None,+ }+ }+ walk(&unwrap_ab(&r), el)+}++/// 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+/// reports `settable:true`. Then real keystrokes, and only safely:+/// * only while the user's keyboard has been quiet (`SWX_SIGN_IN_IDLE_SECS`,+/// 5 s) — checked before every batch;+/// * a real click on the field (it brings the login window to the front), and+/// the login window confirmed as the FOREGROUND window before every batch —+/// aborted if focus moved;+/// * select-all first (a failed attempt leaves the field dirty — measured:+/// "ogin" was still in it), then type;+/// * 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> {+ let label = if read_back { "user name" } else { "password" };+ let typed = String::from_utf8_lossy(text).to_string();+ let r = crate::caller::ad_callback(+ "desktop_ui_set",+ merge(target(el, "edit"), json!({"hwnd": hwnd, "text": typed, "sensitive": true, "reason": FILL_REASON})),+ AB_TIMEOUT,+ );+ if ab_ok(&r) {+ steps.push(json!({"step": label, "ok": true, "via": "uiaValuePattern"}));+ return Ok(());+ }+ let why = ab_error(&r);+ // The measured refusal: "control found but has NO ValuePattern".+ if !why.to_lowercase().contains("no valuepattern") {+ 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)?;+ // 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 });+ };+ 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 });+ }+ // THE CLICK TAKES FOCUS — asynchronously: give the window a moment to come+ // to the front before the first check.+ 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));+ }+ let guard = |what: &str| -> Result<(), FillErr> {+ 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 select-all")?;+ let r = crate::caller::ad_callback("desktop_press_key", json!({"keys": ["ctrl+a"], "hwnd": 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);+ if !ab_ok(&r) {+ return Err(FillErr::Failed { step: format!("{label}: typing"), why: ab_error(&r), observed: None, may_be_banner: false });+ }+ // A keystroke that went astray is evidence too: still in front?+ login_in_front(hwnd).map_err(|e| FillErr::Failed { step: format!("{label}: after typing"), why: e, observed: None, may_be_banner: false })?;+ if read_back {+ // The page updates its field asynchronously: read back for a moment.+ let until = std::time::Instant::now() + Duration::from_millis(1500);+ let mut got = field_value(hwnd, el);+ while got.as_deref() != Some(typed.as_str()) && std::time::Instant::now() < until {+ std::thread::sleep(Duration::from_millis(150));+ got = field_value(hwnd, el);+ }+ if got.as_deref() != Some(typed.as_str()) {+ steps.push(json!({"step": label, "ok": false, "via": "keystrokes", "readBack": "mismatch"}));+ return Err(FillErr::Failed {+ step: format!("{label}: read-back"),+ why: "the user-name field does not hold what was typed — keystrokes may have collided with the user's, or gone to another window".into(),+ observed: Some(json!({"fieldText": got, "length": got.as_ref().map(|g| g.chars().count())})),+ may_be_banner: false,+ });+ }+ }+ steps.push(json!({"step": label, "ok": true, "via": "keystrokes", "readBack": if read_back { json!("matched") } else { json!("notReadPassword") }}));+ Ok(())+}++fn fill_failure(fe: FillErr, banner: bool, pages: &[Value], steps: &[Value], vault: &Value) -> Value {+ let (step, e, observed, may_be_banner) = match fe {+ FillErr::NeedsIdle(why) => {+ tell_user("The bridge is ready to sign you in to 3DEXPERIENCE, but it types real keystrokes. Please take your hands off the keyboard and mouse for five seconds, then ask again.");+ return err(+ "sign_in_needs_user_idle",+ format!("no keystroke was sent: {why}. Typing while the user types collides (measured 2026-10-02: the user's keystrokes landed in the login box)"),+ "The user has been asked for five seconds hands-off; call swx_sign_in again after that (it re-attaches to the same consent).",+ json!({"pages": pages, "steps": steps, "vault": vault}),+ );+ }+ FillErr::Failed { step, why, observed, may_be_banner } => (step, why, observed, may_be_banner),+ };+ let (step, e, observed, may_be_banner) = (step, e, observed, may_be_banner);+ if banner && may_be_banner {+ // THE COOKIE BANNER MAY BE IN THE WAY — and its choice is the user's.+ tell_user("The 3DEXPERIENCE sign-in's cookie banner may be covering the form. Please choose your cookie setting in the sign-in window — the bridge will not choose it for you.");+ return err(+ "sign_in_needs_user",+ format!("the {step} failed with a cookie banner on the page ({e}); it may be covering the form, and choosing a cookie setting is the user's decision"),+ "The user has been notified to answer the cookie banner. Nothing was submitted.",+ json!({"cookieBanner": true, "pages": pages, "steps": steps, "vault": vault, "observed": observed}),+ );+ }+ err(+ "sign_in_fill_failed",+ format!("the {step} in the sign-in window failed: {e}"),+ "Nothing further was submitted and Enter was never pressed. The user can sign in by hand.",+ json!({"pages": pages, "steps": steps, "vault": vault, "observed": observed}),+ )+}+ #[cfg(test)] mod tests { use super::*;@@ -729,6 +1094,21 @@ mod tests { assert!(matches!(recognize(&code).0, Reading::SecondStep(_))); } + #[test]+ fn page_one_is_a_user_name_box_and_continue_and_nothing_else() {+ // The measured page one: "Email or username" + Continue, no password.+ let p1 = vec![el("edit", "Email or username", Some(false)), el("button", "Continue", None), el("button", "Accept All", None)];+ let (r, f) = recognize(&p1);+ assert_eq!(r, Reading::Form);+ let f = f.unwrap();+ assert!(f.password.is_none() && f.user.is_some() && f.submit.name == "Continue");+ // One box that does NOT say it is the user name is not page one.+ let other = vec![el("edit", "Search", Some(false)), el("button", "Continue", None)];+ assert!(matches!(recognize(&other).0, Reading::Unrecognized(_)));+ // The cookie banner is reported, and never the submit.+ assert!(cookie_banner(&p1));+ }+ #[test] fn scrub_removes_the_secret_everywhere() { let v = json!({"error": "could not set 'hunter2-xyz'", "value": "hunter2-xyz", "nested": [{"a": "x hunter2-xyz y"}]});
tests/signin.rs+381−1@@ -1,3 +1,4 @@+#![recursion_limit = "256"] //! UNATTENDED 3DEXPERIENCE SIGN-IN (0.9.13). //! //! The login window (class DSYSysLoginPanelWebView2Class, owned by our launcher@@ -129,7 +130,8 @@ fn everything_but_the_fill(b: &Bridge, ab: &common::AbStub, replies: &[Value]) - } walk(std::path::Path::new(&b.scratch.path()), &mut all); for c in ab.calls() {- let is_fill = c["command"] == "desktop_ui_set";+ // The fills: a UIA set, or real keystrokes (0.9.15).+ let is_fill = c["command"] == "desktop_ui_set" || c["command"] == "desktop_type"; if !is_fill { all.push_str(&c.to_string()); }@@ -555,3 +557,381 @@ fn consent_is_waited_for_and_relay_forbidden_is_a_wiring_bug() { s(&r) ); }++// ── 0.9.15: the MEASURED two-page, lazy 3DEXPERIENCE ID login ───────────────++/// The COLD read, measured 2026-10-02 (13 nodes, ONE control: Button "Close").+fn cold_tree() -> Value {+ json!({"success": true, "status": "ok", "hwnd": 777001, "maxNodes": 400, "nodeCount": 13, "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": "", "invokable": false, "settable": false,+ "rect": {"x": 100, "y": 50, "w": 968, "h": 968}, "children": [+ {"role": "Pane", "name": "", "automationId": "", "invokable": false, "settable": false, "children": [+ {"role": "Pane", "name": "", "automationId": "", "invokable": false, "settable": false},+ {"role": "Button", "name": "Close", "automationId": "", "invokable": true, "settable": false}+ ]}+ ]}})+}++/// The WARM read of page ONE, measured 2026-10-02 (38 nodes): "Email or+/// username" + Continue, the hyperlinks, and the COOKIE BANNER — no password.+fn page_one() -> Value {+ json!({"success": true, "status": "ok", "hwnd": 777001, "maxNodes": 400, "nodeCount": 38, "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": "", "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": "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},+ {"role": "Hyperlink", "name": "\u{e900} English", "automationId": "", "invokable": true, "settable": false},+ {"role": "Hyperlink", "name": "Help", "automationId": "", "invokable": true, "settable": false},+ {"role": "Hyperlink", "name": "Manage cookies", "automationId": "", "invokable": true, "settable": false},+ {"role": "Hyperlink", "name": "privacy policy", "automationId": "", "invokable": true, "settable": false},+ {"role": "Button", "name": "Accept All", "automationId": "", "invokable": true, "settable": false},+ {"role": "Button", "name": "Manage my preferences", "automationId": "", "invokable": true, "settable": false},+ {"role": "Text", "name": "Continue with only necessary cookies", "automationId": "", "invokable": false, "settable": false}+ ]},+ {"role": "Button", "name": "Close", "automationId": "", "invokable": true, "settable": false}+ ]}})+}++/// 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,+ "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": "Button", "name": "Log in", "automationId": "", "invokable": true, "settable": false},+ {"role": "Button", "name": "Close", "automationId": "", "invokable": true, "settable": false}+ ]}})+}++/// 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.+fn drive_two_pages(m: &Machine, ab: &common::AbStub, to_page_two: bool) {+ let deadline = Instant::now() + Duration::from_secs(25);+ let mut state = 0;+ let mut fp_at_continue = 0;+ while Instant::now() < deadline {+ let fp = ab.calls_to("desktop_ui_from_point").len();+ let clicks = ab.calls_to("desktop_ui_click");+ let clicked = |n: &str| clicks.iter().any(|c| c["args"]["name"] == n);+ match state {+ 0 if fp >= 1 => {+ ab.set_response("desktop_ui_tree", page_one());+ state = 1;+ }+ 1 if clicked("Continue") => {+ if !to_page_two {+ return;+ }+ ab.set_response("desktop_ui_tree", cold_tree());+ fp_at_continue = ab.calls_to("desktop_ui_from_point").len();+ state = 2;+ }+ 2 if fp > fp_at_continue => {+ ab.set_response("desktop_ui_tree", page_two());+ state = 3;+ }+ 3 if clicked("Log in") => {+ let _ = std::fs::remove_file(m.dir.join("sign-in.json"));+ return;+ }+ _ => {}+ }+ std::thread::sleep(Duration::from_millis(20));+ }+}++#[test]+fn the_measured_two_page_lazy_login_is_one_flow_with_one_consent() {+ assert!(+ swx_bridge::launch::is_sign_in_window("WindowsForms10.Window.8.app.0.141b42a_r6_ad1", "Login - 3DEXPERIENCE ID - Dassault Systemes"),+ "the Visualize-hosted login window (measured 2026-10-02) is a sign-in window"+ );+ let m = Machine::new("signin-two-page");+ std::fs::write(+ m.dir.join("sign-in.json"),+ json!({"hwnd": 777001, "className": "WindowsForms10.Window.8.app.0.141b42a_r6_ad1",+ "title": "Login - 3DEXPERIENCE ID - Dassault Systemes"}).to_string(),+ )+ .unwrap();+ let ab = ab_for(cold_tree(), vault_ok());+ let b = m.boot(TempDir::new("signin-two-page-scratch"), &ab, &[("SWX_SIGN_IN_WAKE_MS", "60"), ("SWX_SIGN_IN_VERIFY_SECS", "8")]);+ let mut replies = vec![b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}))];+ let (st, r) = std::thread::scope(|sc| {+ sc.spawn(|| drive_two_pages(&m, &ab, true));+ // The window is up; the monitor's inventory read wakes it.+ let deadline = Instant::now() + Duration::from_secs(10);+ let mut st = wait_for_sign_in_stage(&b);+ while st["launch"]["signInWake"].is_null() && Instant::now() < deadline {+ std::thread::sleep(Duration::from_millis(100));+ st = b.call("swx_status", json!({}));+ }+ let r = b.call("swx_sign_in", json!({"reason": "Drew: sign me in"}));+ (st, r)+ });+ replies.push(st);+ check!(+ b,+ "ONE flow over two pages: signed in, two-page, the cookie banner reported, verified by the window closing",+ r["signedIn"] == true && r["twoPage"] == true && r["cookieBanner"] == true && !m.sign_in_up(),+ s(&r)+ );+ check!(b, "the consent toast was raised ONCE (one reveal for both pages)", ab.calls_to("pup_credential_reveal").len() == 1, String::new());+ let clicks = ab.calls_to("desktop_ui_click");+ check!(+ b,+ "the cookie choice was never made: no click on Accept All or Manage my preferences",+ !clicks.iter().any(|c| c["args"]["name"] == "Accept All" || c["args"]["name"] == "Manage my preferences"),+ format!("{clicks:?}")+ );+ let sets = ab.calls_to("desktop_ui_set");+ check!(+ 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,+ format!("{sets:?}")+ );+ let pages = r["pages"].as_array().cloned().unwrap_or_default();+ // Whoever read page one first (the launch monitor, the moment the window+ // appeared, or swx_sign_in) woke it; the FIRST capture holds both counts.+ let woke = |w: &Value| w["nodeCounts"].as_array().map(|a| a.first() == Some(&json!(13)) && a.contains(&json!(38))).unwrap_or(false);+ let first_wake = replies[1]["launch"]["signInWake"].clone();+ check!(+ b,+ "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),+ s(&r["pages"])+ );+ replies.push(r);+ let all = everything_but_the_fill(&b, &ab, &replies);+ check!(b, "the password appears nowhere", !all.contains(PW), String::new());+ check!(b, "...nor the user name", !all.contains(USER), String::new());+}++#[test]+fn a_password_page_that_never_comes_types_no_password() {+ let m = Machine::new("signin-no-page-two");+ let ab = ab_for(page_one(), vault_ok());+ let b = m.boot(TempDir::new("signin-no-page-two-scratch"), &ab, &[("SWX_SIGN_IN_WAKE_MS", "20"), ("SWX_SIGN_IN_VERIFY_SECS", "2")]);+ 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,+ "no password page: sign_in_unconfirmed, the password never typed, one consent",+ r["errorCode"] == "sign_in_unconfirmed"+ && !ab.calls_to("desktop_ui_set").iter().any(|c| c["args"]["text"] == PW)+ && ab.calls_to("pup_credential_reveal").len() == 1,+ s(&r)+ );+}++#[test]+fn a_cookie_banner_in_the_way_is_the_users_choice() {+ let m = Machine::new("signin-cookie");+ let ab = ab_for(page_one(), vault_ok());+ ab.set_response("desktop_ui_set", json!({"success": false, "error": "element is not visible (obscured)"}));+ let b = m.boot(TempDir::new("signin-cookie-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,+ "the fill failed with the banner up: sign_in_needs_user naming the banner, the user told, the banner never answered",+ r["errorCode"] == "sign_in_needs_user" && r["cookieBanner"] == true+ && !ab.calls_to("notify_user").is_empty()+ && !ab.calls_to("desktop_ui_click").iter().any(|c| c["args"]["name"] == "Accept All" || c["args"]["name"] == "Manage my preferences"),+ s(&r)+ );+}++// ── K1–K5 (2026-10-02): the page refuses a background set; real keystrokes ──++fn no_value_pattern() -> Value {+ json!({"success": false, "error": "control found but has NO ValuePattern (can't set text via UIA — try a foregrounded desktop_type)"})+}++fn with_username_value(v: &str) -> Value {+ fn walk(n: &mut Value, v: &str) {+ match n {+ Value::Array(a) => a.iter_mut().for_each(|x| walk(x, v)),+ Value::Object(m) => {+ if m.get("automationId") == Some(&json!("username")) {+ m.insert("value".into(), json!(v));+ }+ m.values_mut().for_each(|x| walk(x, v));+ }+ _ => {}+ }+ }+ let mut t = page_one();+ walk(&mut t, v);+ t+}++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_click", json!({"success": true}));+ ab.set_response("desktop_press_key", json!({"success": true}));+ ab.set_response("desktop_type", json!({"success": true}));+ ab+}++/// The machine reacts like the measured page: a click on the field brings the+/// login window to the front; typing the user name puts `typed_lands` in the+/// field; Continue shows page two; Log in closes the window.+fn drive_keystrokes(m: &Machine, ab: &common::AbStub, typed_lands: Option<String>, focus_follows: bool) {+ 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);+ if focus_follows && !ab.calls_to("desktop_click").is_empty() {+ std::fs::write(m.dir.join("foreground.json"),+ json!({"hwnd": 777001, "pid": 1, "title": "Login - 3DEXPERIENCE ID", "className": "x"}).to_string()).unwrap();+ }+ match state {+ 0 if !ab.calls_to("desktop_type").is_empty() => {+ if let Some(t) = &typed_lands {+ ab.set_response("desktop_ui_tree", with_username_value(t));+ }+ state = 1;+ }+ 1 if clicked("Continue") => {+ ab.set_response("desktop_ui_tree", page_two());+ state = 2;+ }+ 2 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_page_that_refuses_a_background_set_is_typed_safely_and_read_back() {+ let m = Machine::new("signin-keys");+ let ab = keystroke_ab();+ 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);+ 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));+ let steps = r["steps"].to_string();+ check!(+ b,+ "the user name was typed and READ BACK (matched); the password was typed and never read back",+ steps.contains("\"readBack\":\"matched\"") && steps.contains("notReadPassword"),+ steps.clone()+ );+ let keys = ab.calls_to("desktop_press_key");+ check!(+ b,+ "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),+ format!("{keys:?} {:?}", ab.calls_to("desktop_click"))+ );+ replies.push(r);+ let all = everything_but_the_fill(&b, &ab, &replies);+ check!(b, "the password appears nowhere but the fill", !all.contains(PW), String::new());+}++#[test]+fn no_keystroke_is_sent_while_the_user_is_typing() {+ let m = Machine::new("signin-collision");+ std::fs::write(m.dir.join("last-input-secs"), "0").unwrap();+ let ab = keystroke_ab();+ let b = m.boot(TempDir::new("signin-collision-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,+ "the user typed 0 s ago: sign_in_needs_user_idle, the user asked for five seconds hands-off, NOTHING typed or clicked",+ r["errorCode"] == "sign_in_needs_user_idle"+ && !ab.calls_to("notify_user").is_empty()+ && ab.calls_to("desktop_type").is_empty()+ && ab.calls_to("desktop_click").is_empty()+ && ab.calls_to("desktop_press_key").is_empty(),+ s(&r)+ );+}++#[test]+fn a_collided_user_name_stops_before_continue_and_says_what_was_seen() {+ let m = Machine::new("signin-ogin");+ let ab = keystroke_ab();+ let b = m.boot(TempDir::new("signin-ogin-scratch"), &ab, &[]);+ b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));+ wait_for_sign_in_stage(&b);+ let r = std::thread::scope(|sc| {+ // MEASURED: the field ended up holding "ogin" — the user's keystrokes.+ sc.spawn(|| drive_keystrokes(&m, &ab, Some("ogin".to_string()), true));+ b.call("swx_sign_in", json!({"reason": "sign in"}))+ });+ check!(+ b,+ "read-back mismatch: sign_in_fill_failed with what was observed; Continue never pressed; the password never typed",+ r["errorCode"] == "sign_in_fill_failed" && r["observed"]["fieldText"] == "ogin"+ && !ab.calls_to("desktop_ui_click").iter().any(|c| c["args"]["name"] == "Continue")+ && !ab.calls_to("desktop_type").iter().any(|c| c["args"]["text"] == PW),+ s(&r)+ );+}++#[test]+fn keystrokes_stop_when_the_login_window_is_not_in_front() {+ let m = Machine::new("signin-focus");+ let ab = keystroke_ab();+ let b = m.boot(TempDir::new("signin-focus-scratch"), &ab, &[]);+ b.call("swx_start", json!({"waitSeconds": 0, "reason": "start"}));+ wait_for_sign_in_stage(&b);+ // The click does not bring the login window forward: focus stays elsewhere.+ let r = b.call("swx_sign_in", json!({"reason": "sign in"}));+ check!(+ b,+ "focus is elsewhere after the click: sign_in_fill_failed naming the foreground, NOTHING typed",+ r["errorCode"] == "sign_in_fill_failed" && r["error"].as_str().unwrap_or("").contains("foreground")+ && ab.calls_to("desktop_type").is_empty() && ab.calls_to("desktop_press_key").is_empty(),+ s(&r)+ );+}++#[test]+fn a_missing_login_names_every_way_to_grow_the_vault() {+ let m = Machine::new("signin-grow");+ let ab = ab_for(page_one(), vault_ok());+ ab.set_response("pup_credentials", json!({"success": true, "data": {"credentials": []}}));+ let b = m.boot(TempDir::new("signin-grow-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"}));+ let ways = r["growTheVault"].to_string();+ check!(+ b,+ "credential_missing names the import, a pup-window sign-in, and credential_set",+ r["errorCode"] == "sign_in_credential_missing" && ways.contains("pup_import_browser_logins")+ && ways.contains("pup window") && ways.contains("credential_set")+ && ab.calls_to("pup_credential_reveal").is_empty(),+ s(&r)+ );+}
tests/wiring.rs+27@@ -1206,3 +1206,30 @@ fn the_release_script_syncs_the_wiki_in_order_and_verifies() { assert_eq!(page["tags"], pkg["tags"], "page.json tags drifted from package.json — run tools/sync-page.py"); assert_eq!(page["hero"]["path"], "screenshots/hero.png", "page.json must keep the hero"); }++/// THE DEPENDENT SIGN-IN SKILL (0.9.15) carries the measured flow and NO+/// credential, is packaged and installed explicitly, and is governed like every+/// other file an agent follows (the never-ask scan walks every .md).+#[test]+fn the_sign_in_skill_is_shipped_and_carries_no_credential() {+ let root = Path::new(env!("CARGO_MANIFEST_DIR"));+ let skill = std::fs::read_to_string(root.join("skills/solidworks-sign-in/SKILL.md")).expect("the skill");+ assert!(skill.contains("name: solidworks-sign-in") && skill.contains("Parent skill: **solidworks-bridge**"));+ assert!(skill.contains("[measured]") && skill.contains("[unmeasured]"), "every line marked measured or unmeasured");+ assert!(skill.contains("never write a password"), "the skill must forbid writing a password into it");+ // No credential-shaped content: no e-mail addresses, no `password: x` / `password=x`.+ let lower = skill.to_lowercase();+ let at = skill.char_indices().filter(|(_, c)| *c == '@').count();+ assert_eq!(at, 0, "an address-like '@' in the sign-in skill");+ for pat in ["password:", "password=", "password =", "pwd", "passwd"] {+ assert!(!lower.contains(pat), "credential-shaped text {pat:?} in the sign-in skill");+ }+ let pkg: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(root.join("package.json")).unwrap()).unwrap();+ assert!(pkg["files"].as_array().unwrap().iter().any(|f| f == "skills/solidworks-sign-in/SKILL.md"), "listed in package.json files");+ let install = std::fs::read_to_string(root.join("install.sh")).unwrap();+ let uninstall = std::fs::read_to_string(root.join("uninstall.sh")).unwrap();+ assert!(install.contains("install_skill solidworks-sign-in") && install.contains(".claude/skills") && install.contains(".codex/skills"));+ assert!(uninstall.contains("uninstall_skill solidworks-sign-in"));+ let verify = std::fs::read_to_string(root.join("tools/verify-wiki.py")).unwrap();+ assert!(verify.contains("skill_count") && verify.contains("== 2"), "verify-wiki expects two skills");+}
tools/verify-wiki.py+2@@ -46,6 +46,8 @@ def main(): tags = json.loads(tags) if isinstance(tags, str) else tags check(sorted(tags or []) == sorted(pkg.get("tags", [])), "the tags", str(tags)) check(page.get("version") == ver, "the page's package version", str(page.get("version")))+ # Two user skills: solidworks-bridge and the dependent solidworks-sign-in.+ check(page.get("skill_count") == 2, "the package ships 2 skills", str(page.get("skill_count"))) bv = get(API + "/files/BRIDGE_VERSION", raw=True).decode().strip() check(bv == ver, "the page's BRIDGE_VERSION", bv) want_title = os.environ.get("RELEASE_TITLE")
uninstall.sh+4@@ -3,4 +3,8 @@ set -euo pipefail for D in "$HOME/.claude/skills" "$HOME/.codex/skills"; do rm -rf "$D/solidworks-bridge" done+uninstall_skill() { # <name>+ for D in "$HOME/.claude/skills" "$HOME/.codex/skills"; do rm -rf "$D/$1"; done+}+uninstall_skill solidworks-sign-in echo "[solidworks-bridge] skills removed."