app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167
//! UNATTENDED 3DEXPERIENCE SIGN-IN (0.9.13).
//!
//! Drew, 2026-10-02: *"can we please figure out how to sign me in? … you can't
//! launch the thing without me manually typing in my password every time."*
//!
//! MEASURED: the sign-in is a native window, class
//! `DSYSysLoginPanelWebView2Class`, title "Login | 3DEXPERIENCE ID | Dassault
//! Systèmes", owned by SWXDesktopLauncher.exe — WebView2 content, so its fields
//! are reachable through UI Automation, which ab drives (`desktop_ui_tree`,
//! `desktop_ui_set`, `desktop_ui_click`). It appears when the 3DEXPERIENCE
//! platform session has EXPIRED: not on ~15 launches on 09-30/10-01, then on
//! 10-02 after licence checks failed overnight.
//!
//! NOBODY HAS SEEN THAT WINDOW'S ACCESSIBLE TREE YET. So:
//! * every time a launch reaches the sign-in, its INVENTORY is captured —
//! control types, names, automation ids, IsPassword, checkbox states, NEVER
//! a field's value — into the launch state and the reply;
//! * nothing here assumes field names: the password field is the one edit
//! whose IsPassword is true, the user field the one other edit, the submit
//! the one button named like a sign-in; anything else is
//! `sign_in_window_unrecognized`, with the inventory.
//!
//! THE SECRET (Drew's standing rules): it lives ONLY in the pup vault on his
//! laptop. This bridge runs on that laptop and asks ab for it LOCALLY
//! (`pup_credential_reveal`), types it through UIA, and zeroes its own copy.
//! It never appears in a reply, a log line, the ledger, an error text or a
//! command line: every value that leaves this module is scrubbed of it.
//! A rejected password is NEVER retried (lockout risk).
use serde_json::{json, Value};
use std::time::Duration;
const AB_TIMEOUT: Duration = Duration::from_secs(60);
pub const IMPORT_COMMAND: &str = "pup_import_browser_logins {\"hostFilter\":\"3ds.com\"}";
/// The vault entry asked for. `SWX_SIGN_IN_HOST` overrides.
pub fn vault_host() -> String {
std::env::var("SWX_SIGN_IN_HOST").ok().filter(|h| !h.trim().is_empty()).unwrap_or_else(|| "3ds.com".into())
}
fn verify_secs() -> u64 {
std::env::var("SWX_SIGN_IN_VERIFY_SECS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(30)
}
// ── the secret ──────────────────────────────────────────────────────────────
/// The credential, held only as long as the fill takes, and zeroed on drop.
/// Best effort: the HTTP buffers that carried it are dropped, not wiped.
struct Secret {
user: Option<Vec<u8>>,
pass: Vec<u8>,
}
impl Secret {
fn needles(&self) -> Vec<String> {
let mut v = vec![String::from_utf8_lossy(&self.pass).to_string()];
// The username is not a secret, but it is personal: kept out too.
if let Some(u) = &self.user {
v.push(String::from_utf8_lossy(u).to_string());
}
v.into_iter().filter(|s| !s.is_empty()).collect()
}
}
fn wipe(b: &mut [u8]) {
for x in b.iter_mut() {
// SAFETY: a plain write to memory we own; volatile so it is not elided.
unsafe { std::ptr::write_volatile(x, 0) };
}
}
impl Drop for Secret {
fn drop(&mut self) {
wipe(&mut self.pass);
if let Some(u) = self.user.as_mut() {
wipe(u);
}
}
}
/// SCRUB: every string in `v` with every occurrence of every needle replaced,
/// and every key that could carry a credential removed.
pub fn scrub(v: &Value, needles: &[String]) -> Value {
match v {
Value::String(s) => {
let mut s = s.clone();
for n in needles {
if !n.is_empty() {
s = s.replace(n.as_str(), "[redacted]");
}
}
Value::String(s)
}
Value::Array(a) => Value::Array(a.iter().map(|x| scrub(x, needles)).collect()),
Value::Object(m) => Value::Object(
m.iter()
.filter(|(k, _)| {
let k = k.to_ascii_lowercase();
!matches!(k.as_str(), "password" | "secret" | "value" | "text" | "credential" | "username")
})
.map(|(k, x)| (k.clone(), scrub(x, needles)))
.collect(),
),
other => other.clone(),
}
}
// ── ab replies ──────────────────────────────────────────────────────────────
/// ab answers either with the data inline or as `output` holding a JSON string
/// (the shape the rest of this bridge reads); `data` is unwrapped if present.
fn unwrap_ab(v: &Value) -> Value {
let mut cur = v.clone();
if let Some(s) = cur.get("output").and_then(|o| o.as_str()) {
if let Ok(p) = serde_json::from_str::<Value>(s) {
cur = p;
}
}
if let Some(d) = cur.get("data").cloned() {
if !d.is_null() {
cur = d;
}
}
cur
}
/// MEASURED 2026-10-12: pup verbs (`pup_credentials`, `pup_credential_reveal`)
/// answer `{"ok":true,"status":"ok","error":"", …}` — an EMPTY `error` string on
/// success. Treating any non-null `error` as a failure made the vault look
/// unavailable with an empty message (0.9.23). An error is a non-empty string,
/// or `success:false` / `ok:false`.
fn ab_ok(v: &Value) -> bool {
let flagged_false = |k: &str| v.get(k).and_then(|s| s.as_bool()) == Some(false);
let error_text = v.get("error").and_then(|e| e.as_str()).map(|t| !t.trim().is_empty()).unwrap_or(false);
let error_object = v.get("error").map(|e| !e.is_null() && !e.is_string()).unwrap_or(false);
!flagged_false("success") && !flagged_false("ok") && !error_text && !error_object
}
fn ab_error(v: &Value) -> String {
match v.get("error") {
Some(Value::String(t)) if !t.trim().is_empty() => t.clone(),
Some(Value::Null) | None | Some(Value::String(_)) => "ab reported a failure".to_string(),
Some(other) => other.to_string(),
}
}
// ── the inventory ───────────────────────────────────────────────────────────
/// One accessible element — its SHAPE, never its value.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Element {
pub control_type: String,
pub name: String,
pub automation_id: String,
pub is_password: Option<bool>,
pub toggle_state: Option<String>,
pub enabled: Option<bool>,
/// 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]>,
/// Screen centre — ab's own `rect.centerX/centerY` when it gives them
/// (MEASURED 2026-10-02: click there, in screen px).
pub center: Option<(i64, i64)>,
/// LOCATED BY `desktop_find_control`, not read from the tree (0.9.23): the
/// SWXDesktopUpdate-hosted login's tree holds only the frame (measured
/// 2026-10-12, 14 nodes), so its controls are found one by one by name /
/// automation id. Such an element is targeted by its automation id.
pub by_find: bool,
/// Does the field already hold text? (A non-empty `value` in ab's node —
/// the value itself is NEVER copied.) MEASURED 2026-10-12: on page 2 the
/// user-name edit is prefilled and READ-ONLY.
pub has_value: Option<bool>,
}
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)
}
/// Off-screen, or a zero-size rectangle (a hidden input a find can still
/// match). Never a page's field.
pub fn hidden(&self) -> bool {
self.offscreen() || self.rect.map(|[_, _, w, h]| w <= 0 || h <= 0).unwrap_or(false)
}
}
impl Element {
pub fn to_json(&self) -> Value {
let mut v = json!({
"controlType": self.control_type,
"name": self.name,
"automationId": self.automation_id,
"isPassword": self.is_password,
"toggleState": self.toggle_state,
"enabled": self.enabled,
"invokable": self.invokable,
"settable": self.settable,
});
if self.by_find {
v["locatedBy"] = json!("desktop_find_control");
}
if let Some(h) = self.has_value {
v["hasValue"] = json!(h);
}
v
}
fn is(&self, t: &str) -> bool {
self.control_type == t
}
}
fn norm_type(t: &str) -> String {
let t = t.to_ascii_lowercase();
let t = t.trim_start_matches("controltype.").trim_end_matches("control").trim();
match t {
"edit" | "textbox" | "text box" | "input" | "textfield" => "edit".into(),
"button" | "pushbutton" | "push button" => "button".into(),
"checkbox" | "check box" => "checkbox".into(),
other => other.to_string(),
}
}
fn str_of(m: &serde_json::Map<String, Value>, keys: &[&str]) -> String {
keys.iter().find_map(|k| m.get(*k).and_then(|v| v.as_str())).unwrap_or("").to_string()
}
/// One element from one ab node (a tree node or a find_control match) — its
/// SHAPE, never its value.
fn element_of(m: &serde_json::Map<String, Value>) -> Option<Element> {
let ty = str_of(m, &["controlType", "control_type", "role", "type", "localizedControlType"]);
if ty.is_empty() || !(m.contains_key("name") || m.contains_key("automationId") || m.contains_key("automation_id")) {
return None;
}
let mut e = Element {
control_type: norm_type(&ty),
name: str_of(m, &["name"]),
automation_id: str_of(m, &["automationId", "automation_id", "id"]),
is_password: m.get("isPassword").or_else(|| m.get("is_password")).and_then(|x| x.as_bool()),
toggle_state: m
.get("toggleState")
.or_else(|| m.get("toggle_state"))
.or_else(|| m.get("checked"))
.map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())),
enabled: m.get("isEnabled").or_else(|| m.get("enabled")).and_then(|x| x.as_bool()),
invokable: m.get("invokable").and_then(|x| x.as_bool()),
settable: m.get("settable").and_then(|x| x.as_bool()),
rect: m.get("rect").and_then(rect_xywh),
center: m.get("rect").and_then(rect_center),
by_find: false,
has_value: m.get("value").and_then(|x| x.as_str()).map(|v| !v.is_empty()),
};
// THE PASSWORD FIELD (MEASURED 2026-10-02: ab's nodes carry no
// `isPassword` key): an explicit password flag, OR an edit whose name /
// automation id says password.
if e.control_type == "edit" && e.is_password.is_none() {
let flag = m.get("password").and_then(|x| x.as_bool());
let named = looks_like_password(&e.name) || looks_like_password(&e.automation_id);
e.is_password = Some(flag == Some(true) || named);
}
Some(e)
}
/// Every element in ab's tree reply, depth first. Lenient about the reply's
/// shape (it has not been measured) and STRICT about what is copied: type,
/// name, automation id, IsPassword, toggle state, enabled — never a value.
pub fn parse_inventory(reply: &Value) -> Vec<Element> {
fn walk(v: &Value, out: &mut Vec<Element>) {
match v {
Value::Array(a) => a.iter().for_each(|x| walk(x, out)),
Value::Object(m) => {
if let Some(e) = element_of(m) {
out.push(e);
}
for (k, x) in m {
if matches!(k.as_str(), "children" | "elements" | "nodes" | "tree" | "root" | "items" | "controls") {
walk(x, out);
}
}
}
_ => {}
}
}
let mut out = Vec::new();
let top = unwrap_ab(reply);
walk(&top, &mut out);
// A TRUNCATED TREE IS NEVER A GUESS: marked, and recognize() refuses it.
if top.get("truncated").and_then(|t| t.as_bool()) == Some(true) || reply.get("truncated").and_then(|t| t.as_bool()) == Some(true) {
out.push(Element {
control_type: "truncated".into(),
name: "ab truncated the accessible tree; not every element was read".into(),
..Default::default()
});
}
out
}
/// /pass(word)?|mot de passe|contrase/i — the measured rule for a password edit
/// whose node carries no password flag.
pub fn looks_like_password(s: &str) -> bool {
let s = s.to_lowercase();
s.contains("pass") || s.contains("mot de passe") || s.contains("contrase")
}
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])
}
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);
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 (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;
}
}
if els.is_empty() {
return Err("desktop_ui_tree answered, but no element with a control type could be read from it".into());
}
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 {
json!(inv.iter().map(|e| e.to_json()).collect::<Vec<_>>())
}
// ── the page the tree does not show: desktop_find_control (0.9.23) ──────────
//
// MEASURED 2026-10-12 on adom_drew2: a THIRD login host — a window titled just
// "3DEXPERIENCE", class `WindowsForms10.Window.8.app.0.30495d1_r6_ad1`, UIA
// automationId `SWXDesktopUpdateForm`, owned by `SWXDesktopUpdate.exe` running
// from %TEMP%\SWXDesktopUpdate\ (started the moment the platform shortcut was
// invoked). Its content is the same 3DEXPERIENCE ID sign-in, but
// `desktop_ui_tree` returns only the frame (14 nodes: Window, MenuBar,
// Minimize/Maximize/Close) even at maxNodes 600 / depth 40 and after a
// `desktop_ui_from_point` wake. `desktop_find_control` DOES reach the page:
// page 1: Edit automationId "username" (name "Email or username",
// settable:true), Button "Continue" (invokable);
// page 2: Edit "username" (prefilled), Edit automationId "password" whose
// NAME is "Password " + a private-use icon glyph (an exact-name query
// does NOT match it; `{automationId:"password"}` does), Text
// "Password", Text "Remember me" (no CheckBox found by name),
// Button "Log in", Button "Back".
// `{role:"Edit"}` with no name matched "Address and search bar" (view_1012)
// somewhere else entirely — so a query NEVER goes out with a role alone.
// So a control is located by automation id first, then by role + exact name,
// then by role + name-contains, and a match is accepted only if its role and
// its id or (cleaned) name say it is the control asked for.
/// One control to look for: its role, its automation id (if known), its name.
pub struct Want {
pub role: &'static str,
pub automation_id: Option<&'static str>,
pub name: &'static str,
}
const PAGE_WANTS: &[Want] = &[
Want { role: "Edit", automation_id: Some("username"), name: "Email or username" },
Want { role: "Edit", automation_id: Some("password"), name: "Password" },
Want { role: "Button", automation_id: None, name: "Continue" },
Want { role: "Button", automation_id: None, name: "Log in" },
Want { role: "Button", automation_id: None, name: "Back" },
Want { role: "CheckBox", automation_id: None, name: "Remember me" },
Want { role: "Text", automation_id: None, name: "Remember me" },
Want { role: "Button", automation_id: None, name: "Accept All" },
Want { role: "Button", automation_id: None, name: "Manage my preferences" },
];
/// Lower case, letters/digits/spaces only, trimmed — "Password \u{e9a0}" and
/// "Remember me…" compare as "password" and "remember me".
fn clean(s: &str) -> String {
let t: String = s.chars().filter(|c| c.is_ascii_alphanumeric() || *c == ' ').collect::<String>().to_lowercase();
t.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Every element-like object anywhere in a find_control reply.
fn found_elements(reply: &Value) -> Vec<Element> {
fn walk(v: &Value, out: &mut Vec<Element>) {
match v {
Value::Array(a) => a.iter().for_each(|x| walk(x, out)),
Value::Object(m) => {
if let Some(e) = element_of(m) {
out.push(e);
}
m.values().for_each(|x| walk(x, out));
}
_ => {}
}
}
let mut out = Vec::new();
if !ab_ok(reply) {
return out;
}
let top = unwrap_ab(reply);
if top.get("found").and_then(|f| f.as_bool()) == Some(false) {
return out;
}
walk(&top, &mut out);
out
}
/// Is `e` the control `w` asks for?
fn is_wanted(e: &Element, w: &Want) -> bool {
if e.control_type != norm_type(w.role) {
return false;
}
if let Some(id) = w.automation_id {
if e.automation_id.eq_ignore_ascii_case(id) {
return true;
}
}
let (n, want) = (clean(&e.name), clean(w.name));
!want.is_empty() && (n == want || n.starts_with(&want))
}
/// LOCATE ONE CONTROL by `desktop_find_control`: automation id, then role +
/// name, then role + name-contains. Never a role alone. Each attempt is
/// returned (the query and what came back), for the reply.
pub fn find_one(hwnd: i64, w: &Want) -> (Option<Element>, Vec<Value>) {
let mut queries: Vec<Value> = Vec::new();
if let Some(id) = w.automation_id {
queries.push(json!({"hwnd": hwnd, "automationId": id}));
}
queries.push(json!({"hwnd": hwnd, "role": w.role, "name": w.name}));
queries.push(json!({"hwnd": hwnd, "role": w.role, "contains": w.name}));
let mut tried = Vec::new();
for q in queries {
let r = crate::caller::ad_callback("desktop_find_control", q.clone(), Duration::from_secs(20));
let hit = found_elements(&r).into_iter().find(|e| is_wanted(e, w));
let mut q_report = q.clone();
if let Value::Object(m) = &mut q_report {
m.remove("hwnd");
}
tried.push(json!({"query": q_report, "matched": hit.as_ref().map(|e| e.to_json())}));
if let Some(mut e) = hit {
e.by_find = true;
if e.control_type == "edit" && w.automation_id == Some("password") {
e.is_password = Some(true);
}
return (Some(e), tried);
}
}
(None, tried)
}
/// The page, control by control. Off-screen edits (a hidden input) are not
/// the page's fields.
pub fn read_by_find(hwnd: i64) -> (Vec<Element>, Value) {
let mut els: Vec<Element> = Vec::new();
let mut report = serde_json::Map::new();
for w in PAGE_WANTS {
let (hit, _tried) = find_one(hwnd, w);
let key = format!("{} \"{}\"", w.role, w.name);
match hit {
Some(e) if !(e.control_type == "edit" && e.hidden()) => {
report.insert(key, json!({"found": true, "name": e.name, "automationId": e.automation_id,
"center": e.center.map(|(x, y)| json!([x, y])), "settable": e.settable, "invokable": e.invokable}));
if !els.iter().any(|x| x.control_type == e.control_type && x.name == e.name && x.automation_id == e.automation_id) {
els.push(e);
}
}
_ => {
report.insert(key, json!({"found": false}));
}
}
}
(els, Value::Object(report))
}
/// IS THIS WINDOW A 3DEXPERIENCE ID LOGIN, BY ITS CONTENT? An Edit
/// "Email or username" (page 1) or "Password" (page 2) found in it. Some(1|2)
/// = the page; None = neither field. Needs ab.
pub fn probe_login(hwnd: i64) -> Option<u8> {
if !crate::caller::ab_available() {
return None;
}
let (pw, _) = find_one(hwnd, &PAGE_WANTS[1]);
if pw.filter(|e| !e.hidden()).is_some() {
return Some(2);
}
let (user, _) = find_one(hwnd, &PAGE_WANTS[0]);
user.filter(|e| !e.hidden()).map(|_| 1)
}
static PROBES: std::sync::Mutex<Option<std::collections::HashMap<i64, (std::time::Instant, Option<u8>)>>> = std::sync::Mutex::new(None);
fn probe_ttl() -> Duration {
Duration::from_millis(std::env::var("SWX_SIGN_IN_PROBE_TTL_MS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(3000))
}
/// `probe_login`, remembered for a few seconds per window (a census runs on
/// every swx_status and every launch poll).
pub fn probe_login_cached(hwnd: i64) -> Option<u8> {
if let Some((at, v)) = PROBES.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|m| m.get(&hwnd).cloned()) {
if at.elapsed() < probe_ttl() {
return v;
}
}
let v = probe_login(hwnd);
PROBES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(hwnd, (std::time::Instant::now(), v));
v
}
/// The platform's updater, which hosts the third login (measured 2026-10-12).
pub const DESKTOP_UPDATE_IMAGE: &str = "SWXDesktopUpdate.exe";
/// Worth a content probe? Visible, titled, and not a window the probe would be
/// wasted on: SOLIDWORKS's own (its login is hosted by the launcher) and
/// Visualize's main window (its dialogs are classified by their words).
pub fn probe_eligible(image: &str, w: &crate::launchos::WindowInfo) -> bool {
w.visible
&& !w.title.trim().is_empty()
&& !image.eq_ignore_ascii_case("SLDWORKS.exe")
&& !SHELLS.iter().any(|s| image.eq_ignore_ascii_case(s))
&& !w.class_name.to_ascii_lowercase().starts_with("hwndwrapper[sldworksvisualize.exe;")
}
/// Is this window a login: by its class / title (the fast path) or by its
/// CONTENT? `Some("titleOrClass" | "content")`.
pub fn window_is_login(image: &str, w: &crate::launchos::WindowInfo) -> Option<(&'static str, Option<u8>)> {
if !w.visible {
return None;
}
if crate::launch::is_sign_in_window(&w.class_name, &w.title) {
return Some(("titleOrClass", None));
}
if probe_eligible(image, w) {
if let Some(page) = probe_login_cached(w.hwnd) {
return Some(("content", Some(page)));
}
}
None
}
/// A login window hosted by SWXDesktopUpdate.exe, anywhere on the machine —
/// the platform's own sign-in, which parks every app start behind it.
pub fn desktop_update_login(os: &dyn crate::launchos::LaunchOs) -> Option<(i64, String)> {
let census = os.processes().ok()?;
for p in census.iter().filter(|p| p.image.eq_ignore_ascii_case(DESKTOP_UPDATE_IMAGE)) {
if let Ok(ws) = os.windows_of_pid(p.pid) {
if let Some(w) = ws.iter().find(|w| window_is_login(&p.image, w).is_some()) {
return Some((w.hwnd, w.title.clone()));
}
}
}
None
}
/// The tree holds no page for this window (remembered so the next read skips
/// the wakes that never help on this host).
static TREE_BLIND: std::sync::Mutex<Vec<i64>> = std::sync::Mutex::new(Vec::new());
/// A page, read: from the tree when the tree shows it, otherwise control by
/// control through desktop_find_control.
pub struct Page {
pub elements: Vec<Element>,
pub source: &'static str,
pub wake: Value,
pub found: Value,
}
impl Page {
pub fn report(&self) -> Value {
json!({"source": self.source, "wake": self.wake, "findControl": self.found})
}
}
/// READ THE LOGIN PAGE — the tree first; when the tree has no text field (the
/// SWXDesktopUpdate host, measured), `desktop_find_control` by name.
pub fn read_page(hwnd: i64) -> Result<Page, String> {
let blind = TREE_BLIND.lock().unwrap_or_else(|e| e.into_inner()).contains(&hwnd);
let tree: Result<(Vec<Element>, Value), String> = if blind {
if !crate::caller::ab_available() {
Err("ab's direct API is not available to this bridge (it was not spawned by Adom Bridge)".into())
} else {
read_once(hwnd).map(|(e, n, _)| (e, json!({"nodeCounts": [n], "wakes": 0, "treeBlind": true})))
}
} else {
inventory(hwnd).map(|r| (r.elements.clone(), r.wake_json()))
};
if let Ok((els, wake)) = &tree {
if els.iter().any(|e| e.is("edit")) || has_marker(els, SECOND_STEP).is_some() || has_marker(els, REJECTED).is_some() {
return Ok(Page { elements: els.clone(), source: "tree", wake: wake.clone(), found: Value::Null });
}
}
if !crate::caller::ab_available() {
return tree.map(|(e, w)| Page { elements: e, source: "tree", wake: w, found: Value::Null });
}
let (found, report) = read_by_find(hwnd);
if found.iter().any(|e| e.is("edit")) {
let mut b = TREE_BLIND.lock().unwrap_or_else(|e| e.into_inner());
if !b.contains(&hwnd) {
b.push(hwnd);
}
let wake = tree.as_ref().map(|(_, w)| w.clone()).unwrap_or(Value::Null);
return Ok(Page { elements: found, source: "desktop_find_control", wake, found: report });
}
match tree {
Ok((e, w)) => Ok(Page { elements: e, source: "tree", wake: w, found: report }),
Err(e) if !found.is_empty() => Ok(Page { elements: found, source: "desktop_find_control", wake: json!({"treeError": e}), found: report }),
Err(e) => Err(e),
}
}
// ── what the window is asking ───────────────────────────────────────────────
const SECOND_STEP: &[&str] = &[
"verification code", "security code", "one-time", "one time", "two-step", "2-step", "two-factor",
"2fa", "authenticator", "captcha", "not a robot", "new device", "verify your identity",
"confirm your identity", "enter the code",
];
const REJECTED: &[&str] = &[
"incorrect", "invalid", "wrong password", "does not match", "do not match", "not recognized",
"authentication failed", "login failed", "sign in failed", "couldn't sign", "could not sign",
];
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", "reject", "accept", "allow all"];
/// Is a cookie banner on the page? Reported, never answered. A banner is an
/// accept / reject / preferences BUTTON (measured 2026-10-02 on the Visualize
/// host: "Accept All", "Manage my preferences"). A lone "Manage cookies"
/// HYPERLINK is page furniture, not a banner — MEASURED 2026-10-12 on the
/// SWXDesktopUpdate host, where 0.9.25 called that link a banner and toasted
/// the user to answer a banner that did not exist.
pub fn cookie_banner(inv: &[Element]) -> bool {
inv.iter().any(|e| {
let n = e.name.trim().trim_end_matches(['.', '…']).to_lowercase();
e.is("button") && (COOKIE_CHOICES.contains(&n.as_str()) || (n.contains("cookie") && (n.contains("accept") || n.contains("reject") || n.contains("allow"))))
})
}
/// The recognized form.
#[derive(Debug, Clone)]
pub struct Form {
pub user: Option<Element>,
pub password: Option<Element>,
pub submit: Element,
pub remember: Option<Element>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Reading {
Form,
SecondStep(String),
Rejected(String),
Unrecognized(String),
}
fn has_marker(inv: &[Element], markers: &[&str]) -> Option<String> {
inv.iter()
.map(|e| format!("{} {}", e.name, e.automation_id).to_lowercase())
.find_map(|s| markers.iter().find(|m| s.contains(*m)).map(|m| m.to_string()))
}
/// What the window is asking for, from its inventory alone.
pub fn recognize(inv: &[Element]) -> (Reading, Option<Form>) {
if inv.iter().any(|e| e.is("truncated")) {
return (Reading::Unrecognized("the accessible tree was truncated, so the form cannot be read whole".into()), None);
}
if let Some(m) = has_marker(inv, SECOND_STEP) {
return (Reading::SecondStep(m), None);
}
// Only edits that can be SET (ab's `settable`, when it says) count.
let editable = |e: &&Element| e.is("edit") && e.settable != Some(false);
let passwords: Vec<&Element> = inv.iter().filter(editable).filter(|e| e.is_password == Some(true)).collect();
let users: Vec<&Element> = inv.iter().filter(editable).filter(|e| e.is_password != Some(true)).collect();
if passwords.len() > 1 {
return (Reading::Unrecognized(format!("{} password fields", passwords.len())), None);
}
if users.len() > 1 {
return (Reading::Unrecognized(format!("{} non-password text fields (expected at most one: the user name)", users.len())), None);
}
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).
// (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()
.filter(|b| {
let n = b.name.trim().trim_end_matches(['.', '…']).to_lowercase();
list.contains(&n.as_str())
})
.copied()
.collect()
};
let primary = named(SUBMIT_PRIMARY);
let submit = match (primary.len(), named(SUBMIT_SECONDARY)) {
(1, _) => primary[0].clone(),
(0, s) if s.len() == 1 => s[0].clone(),
(0, _) => return (Reading::Unrecognized("no single button named like a sign-in (Log in / Sign in / Connect / Continue / Next)".into()), None),
(n, _) => return (Reading::Unrecognized(format!("{n} buttons named like a sign-in")), None),
};
let remember = inv
.iter()
.find(|e| e.is("checkbox") && REMEMBER.iter().any(|r| e.name.to_lowercase().contains(r)))
.cloned();
(
Reading::Form,
Some(Form {
user: users.first().map(|e| (*e).clone()),
password: passwords.first().map(|e| (*e).clone()),
submit,
remember,
}),
)
}
/// ab targets a control by `name` / `contains` (MEASURED 2026-10-02: a click
/// with no name answers "control found but has NO InvokePattern"). The
/// automation id is sent too, as a reported field — never relied on.
fn target(e: &Element, role: &str) -> Value {
// LOCATED BY find_control (0.9.23): by automation id when it has one —
// MEASURED 2026-10-12: `desktop_ui_set {hwnd, automationId:"username",
// text}` works on the SWXDesktopUpdate host, and the password edit's NAME
// ("Password " + an icon glyph) does not match an exact-name query.
if e.by_find && !e.automation_id.is_empty() {
return json!({"automationId": e.automation_id});
}
if e.by_find {
let _ = role;
return json!({"name": e.name});
}
json!({
"name": e.name,
"contains": e.name,
"automationId": if e.automation_id.is_empty() { Value::Null } else { json!(e.automation_id) },
"role": role,
})
}
fn merge(mut a: Value, b: Value) -> Value {
if let (Value::Object(x), Value::Object(y)) = (&mut a, b) {
for (k, v) in y {
x.insert(k, v);
}
}
a
}
// ── the vault ───────────────────────────────────────────────────────────────
enum Vault {
Got(Secret, Value),
Missing(String),
Denied(String),
Pending(String),
RelayForbidden(String),
/// ab's POLICY refused the reveal (0.9.25): the secret goes only to a
/// VERIFIED LOCAL caller, and a bridge ab hosts calls back with origin
/// "bridge". Not a bug, and never worked around.
Policy(String),
Unavailable(String),
}
/// What the user sees on pup's consent toast (it is shown to them — MEASURED
/// 2026-10-02: "reason 8+ chars; it is shown to the user").
pub const CONSENT_REASON: &str = "Sign in to SOLIDWORKS with your saved 3DEXPERIENCE login";
fn consent_secs() -> u64 {
std::env::var("SWX_SIGN_IN_CONSENT_SECS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(120)
}
/// The hosts the vault holds a login for, from `pup_credentials {}` — host and
/// username only, never a secret, and NO consent toast. `Err` = could not ask.
/// The vault's host list — each host with the user name pup lists beside it
/// (host + username only: no secret, no toast).
fn vault_hosts() -> Result<Vec<(String, Option<String>)>, String> {
let r = crate::caller::ad_callback("pup_credentials", json!({}), AB_TIMEOUT);
if !ab_ok(&r) {
return Err(ab_error(&r));
}
fn walk(v: &Value, out: &mut Vec<(String, Option<String>)>) {
match v {
Value::Array(a) => a.iter().for_each(|x| walk(x, out)),
Value::Object(m) => {
if let Some(h) = m.get("host").and_then(|h| h.as_str()) {
let user = ["username", "user", "login", "email"]
.iter()
.find_map(|k| m.get(*k).and_then(|u| u.as_str()))
.filter(|u| !u.is_empty())
.map(String::from);
out.push((h.to_lowercase(), user));
}
for (k, x) in m {
if k != "host" {
walk(x, out);
}
}
}
_ => {}
}
}
let mut out = Vec::new();
walk(&unwrap_ab(&r), &mut out);
out.sort();
out.dedup_by(|a, b| a.0 == b.0);
Ok(out)
}
/// The vault host for the 3DEXPERIENCE login: an exact `SWX_SIGN_IN_HOST`, or
/// any host on its registrable domain — preferring the identity service.
fn pick_host(hosts: &[String], want: &str) -> Option<String> {
let want = want.to_lowercase();
let on_domain: Vec<&String> = hosts.iter().filter(|h| *h == &want || h.ends_with(&format!(".{want}"))).collect();
on_domain
.iter()
.find(|h| h.contains("iam."))
.or_else(|| on_domain.iter().find(|h| h.contains("3dexperience")))
.or_else(|| on_domain.first())
.map(|h| h.to_string())
}
fn error_code_of(r: &Value) -> String {
let d = unwrap_ab(r);
[r.get("errorCode"), r.get("code"), r.get("status"), d.get("errorCode"), d.get("code"), d.get("status")]
.iter()
.flatten()
.filter_map(|v| v.as_str())
.map(|s| s.to_lowercase())
.collect::<Vec<_>>()
.join(" ")
+ " "
+ &ab_error(r).to_lowercase()
}
/// EXISTENCE FIRST, without a toast (M5): a missing login never asks the user
/// to consent to anything. The vault host, and the user name listed with it.
fn lookup() -> Result<(String, Option<String>), Vault> {
let hosts = match vault_hosts() {
Ok(h) => h,
Err(e) => return Err(Vault::Unavailable(format!("the vault's host list could not be read: {e}"))),
};
let names: Vec<String> = hosts.iter().map(|(h, _)| h.clone()).collect();
let Some(host) = pick_host(&names, &vault_host()) else {
return Err(Vault::Missing(format!("no saved login on {} ({} host(s) in the vault)", vault_host(), hosts.len())));
};
let user = hosts.iter().find(|(h, _)| *h == host).and_then(|(_, u)| u.clone());
Ok((host, user))
}
/// Is this reveal refusal ab's VERIFIED-LOCAL-ORIGIN policy? MEASURED
/// 2026-10-12 (hosted 0.9.24): "pup_credential_reveal requires a VERIFIED local
/// transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived
/// with origin \"bridge\"".
pub fn is_origin_policy(message: &str) -> bool {
let m = message.to_lowercase();
m.contains("verified-origin") || (m.contains("verified") && (m.contains("local") || m.contains("origin"))) || m.contains("arrived with origin")
}
fn reveal_at(host: &str) -> Vault {
// THE REVEAL: gated by a STICKY CONSENT TOAST the user must click (measured:
// pup's own text — consent is PER USE). A `consent_pending` answer
// re-attaches when called again, with no second prompt.
let deadline = std::time::Instant::now() + Duration::from_secs(consent_secs());
loop {
let r = crate::caller::ad_callback(
"pup_credential_reveal",
json!({"host": host, "reason": CONSENT_REASON}),
Duration::from_secs(120),
);
let d = unwrap_ab(&r);
let pass = ["password", "secret"].iter().find_map(|k| d.get(*k).and_then(|v| v.as_str()).map(|s| s.as_bytes().to_vec()));
let user = ["username", "user", "login", "email"].iter().find_map(|k| d.get(*k).and_then(|v| v.as_str()).map(|s| s.as_bytes().to_vec()));
let consent = d.get("consent").or_else(|| r.get("consent")).cloned().unwrap_or(json!("perUse"));
if let Some(p) = pass.filter(|p| !p.is_empty()) {
if ab_ok(&r) {
return Vault::Got(
Secret { user, pass: p },
json!({"host": host, "consent": consent,
"consentBasis": "pup_credential_reveal's own hint: \"gated by a sticky consent toast the user must click\""}),
);
}
}
let code = error_code_of(&r);
let err = scrub(&json!(ab_error(&r)), &[]).as_str().unwrap_or("").to_string();
if code.contains("relay_forbidden") {
return Vault::RelayForbidden(err);
}
// ab's POLICY: never retried, never worked around.
if is_origin_policy(&code) {
return Vault::Policy(err);
}
if code.contains("consent_pending") || code.contains("pending") {
if std::time::Instant::now() >= deadline {
return Vault::Pending(err);
}
std::thread::sleep(Duration::from_millis(1500));
continue;
}
let e = err.to_lowercase();
return if e.contains("not found") || e.contains("no credential") || e.contains("no entry") || e.contains("no saved") || e.contains("no login") || e.contains("no match") {
Vault::Missing(err)
} else if e.contains("consent") || e.contains("denied") || e.contains("declined") || e.contains("cancel") {
Vault::Denied(err)
} else {
Vault::Unavailable(err)
};
}
}
// ── the rejection record: never retried ─────────────────────────────────────
fn rejected_path() -> std::path::PathBuf {
crate::scratch::root().join("sign-in-rejected.json")
}
pub fn rejected_before() -> Option<Value> {
std::fs::read_to_string(rejected_path()).ok().and_then(|t| serde_json::from_str(&t).ok())
}
fn record_rejection(why: &str) {
let _ = crate::launch::write_atomic(
&rejected_path(),
&json!({"atMs": crate::launchos::epoch_ms() as u64, "why": why, "host": vault_host()}).to_string(),
);
}
// ── the sign-in ─────────────────────────────────────────────────────────────
/// A vault answer that is not the login, as the reply. `prepared` = what the
/// bridge had already done to the page (`None`: nothing typed).
fn vault_refusal(v: Vault, extra: Value, prepared: Option<Value>) -> Value {
let typed_user = prepared.as_ref().and_then(|p| p.get("userNameFilled")).and_then(|b| b.as_bool()) == Some(true);
let nothing = if typed_user { "The password was not typed; the user name was filled (see pagePrepared)." } else { "Nothing was typed." };
let extra = merge(extra, json!({"pagePrepared": prepared}));
match v {
Vault::Got(..) => err("sign_in_vault_unavailable", "internal: a revealed login reached the refusal path", nothing, extra),
Vault::Missing(e) => err(
"sign_in_credential_missing",
format!("the vault has no 3DEXPERIENCE login for {}: {e}", vault_host()),
format!(
"{nothing} 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."
),
merge(extra, json!({"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) => err("sign_in_consent_denied", format!("the user did not allow the saved login to be used: {e}"), format!("The user declined the consent toast. {nothing} Do not ask again unless they say so."), extra),
Vault::Pending(e) => err("sign_in_consent_pending", format!("the consent toast was not answered within {}s: {e}", consent_secs()), format!("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}"), merge(extra, json!({"consentReason": CONSENT_REASON}))),
Vault::RelayForbidden(e) => err("sign_in_relay_forbidden", format!("ab refused the reveal as a RELAYED call: {e}"), format!("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}"), extra),
Vault::Policy(e) => {
tell_user(if typed_user {
"Please type your 3DEXPERIENCE password in the sign-in window. The bridge filled your user name; Adom Bridge does not hand saved passwords to bridges."
} else {
"Please sign in to 3DEXPERIENCE in the sign-in window. Adom Bridge does not hand saved passwords to bridges."
});
err(
"sign_in_vault_policy",
format!("ab's policy keeps the saved password from this bridge: {e}"),
format!("ab gives a saved password only to a verified local caller, and a bridge is not one; the bridge prepared the page (pagePrepared) and the USER types the password — until ab issue #237 (a fill verb that keeps the secret inside ab) lands. {nothing} Not retried."),
merge(extra, json!({
"abSaid": e,
"waysOut": [
if typed_user {
"the user types the password in the window (the bridge has filled the user name and advanced to page 2)"
} else {
"the user signs in by hand in the window (the vault lists no user name, so the bridge could not prepare the page)"
},
"ab issue #237: a fill verb that keeps the secret inside ab"
],
})),
)
}
Vault::Unavailable(e) => err("sign_in_vault_unavailable", format!("the vault could not be asked: {e}"), format!("{nothing} The user can sign in by hand."), extra),
}
}
fn err(code: &str, msg: impl Into<String>, hint: impl Into<String>, extra: Value) -> Value {
merge(crate::verbs::err_code(code, msg, hint), extra)
}
fn tell_user(what: &str) {
let _ = crate::caller::ad_callback(
"notify_user",
json!({"title": "SOLIDWORKS sign-in needs you", "message": what}),
Duration::from_secs(10),
);
}
/// SIGN IN, unattended. `who` is the caller's identity for the ledger.
/// 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, &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",
json!({
"caller": who,
"reason": reason,
"outcome": outcome,
"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
}
/// 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,
/// How it was recognised: "titleOrClass" (the fast path) or "content" (an
/// Edit "Email or username" / "Password" found in it — 0.9.23).
pub found_by: &'static str,
/// The page a content probe saw (1 = user name, 2 = password).
pub page: Option<u8>,
}
impl LoginWindow {
pub fn host(&self) -> &'static str {
if self.owner_image.eq_ignore_ascii_case("SLDWORKSVisualize.exe") {
"visualize"
} else if self.owner_image.eq_ignore_ascii_case(DESKTOP_UPDATE_IMAGE) {
"desktopUpdate"
} else if self.owner_image.is_empty() {
"unknown"
} else {
"solidworks"
}
}
pub fn to_json(&self) -> Value {
json!({"hwnd": self.hwnd, "title": self.title, "ownerPid": self.owner_pid,
"ownerImage": self.owner_image, "ownedByThisBridgesLaunch": self.ours,
"host": self.host(), "recognizedBy": self.found_by, "page": self.page})
}
}
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));
}
// THE PLATFORM UPDATER (0.9.23): SWXDesktopUpdate.exe starts the moment a
// platform shortcut is invoked (measured 2026-10-12) and is not a child the
// chain census links. One started at or after THIS bridge's invoke, while
// its launch is in flight, is that launch's.
if let (Some(a), Ok(c)) = (crate::launch::attempt().filter(|a| !a.finished), &census) {
for p in c.iter().filter(|p| p.image.eq_ignore_ascii_case(DESKTOP_UPDATE_IMAGE)) {
let since_invoke = p.created_at.map(|t| t + 2 >= a.invoked_at).unwrap_or(false);
if since_invoke && !candidates.iter().any(|(q, _, _)| *q == p.pid) {
candidates.push((p.pid, p.image.clone(), true));
}
}
}
if all {
match &census {
Ok(c) => {
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() {
if out.iter().any(|o| o.hwnd == w.hwnd) {
continue;
}
if let Some((how, page)) = window_is_login(&image, w) {
out.push(LoginWindow { hwnd: w.hwnd, title: w.title.clone(), owner_pid: pid, owner_image: image.clone(), ours: is_ours, found_by: how, page });
}
}
}
}
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> {
if lw.owner_pid == 0 {
// A window the census does not hold (named by the caller): ab's list.
return crate::abwin::list_windows(Duration::from_secs(10))
.ok()
.map(|ws| ws.iter().any(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(lw.hwnd)));
}
os.windows_of_pid(lw.owner_pid).ok().map(|ws| ws.iter().any(|w| w.hwnd == lw.hwnd && w.visible))
}
/// THE WINDOW THE CALLER NAMED, when no census holds it as a login (0.9.23).
/// MEASURED 2026-10-12: `swx_sign_in {userAuthorized:true, window:857684}`
/// was refused "no 3DEXPERIENCE ID login window matching the request" while
/// that window WAS the sign-in (SWXDesktopUpdate.exe, title "3DEXPERIENCE").
/// A named window is judged by its CONTENT; refused only when neither field is
/// in it, with what was found.
fn named_window(os: &dyn crate::launchos::LaunchOs, hwnd: i64) -> Result<LoginWindow, Value> {
let (found, report) = read_by_find(hwnd);
let user = found.iter().any(|e| e.is("edit") && is_wanted(e, &PAGE_WANTS[0]));
let pass = found.iter().any(|e| e.is("edit") && is_wanted(e, &PAGE_WANTS[1]));
// Who owns it, if the census can say.
let mut owner: Option<(u32, String, String)> = None;
if let Ok(c) = os.processes() {
for p in &c {
if let Ok(ws) = os.windows_of_pid(p.pid) {
if let Some(w) = ws.iter().find(|w| w.hwnd == hwnd) {
owner = Some((p.pid, p.image.clone(), w.title.clone()));
break;
}
}
}
}
let title = owner.as_ref().map(|o| o.2.clone()).or_else(|| {
crate::abwin::list_windows(Duration::from_secs(10)).ok().and_then(|ws| {
ws.iter().find(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(hwnd)).and_then(|w| w.get("title").and_then(|t| t.as_str()).map(String::from))
})
});
if !user && !pass {
return Err(err(
"sign_in_window_not_a_login",
format!("window {hwnd} shows neither an \"Email or username\" nor a \"Password\" field"),
"Nothing was typed. `found` lists every control looked for and what desktop_find_control answered.",
json!({"hwnd": hwnd, "title": title, "found": report}),
));
}
let (pid, image) = owner.map(|o| (o.0, o.1)).unwrap_or((0, String::new()));
Ok(LoginWindow { hwnd, title: title.unwrap_or_default(), owner_pid: pid, owner_image: image, ours: false, found_by: "content", page: Some(if pass { 2 } else { 1 }) })
}
fn sign_in_inner(reason: &str, opts: &Opts) -> Value {
let os = crate::launchos::os();
let user_confirmed_new_password = opts.user_confirmed_new_password;
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 named_hwnd = opts.window.as_deref().and_then(|w| w.trim().parse::<i64>().ok()).filter(|_| opts.user_authorized);
let found = match login_windows(os.as_ref(), opts.user_authorized) {
Ok(f) => f,
// A window the CALLER NAMED is judged by its content, not refused
// because a census could not be taken (0.9.23).
Err(_) if named_hwnd.is_some() => Vec::new(),
Err(e) => return err("sign_in_not_waiting", format!("the login windows could not be read: {e}"), "Nothing was typed anywhere.", json!({})),
};
let pool: Vec<LoginWindow> = if opts.user_authorized { found } else { found.into_iter().filter(|w| w.ours).collect() };
let picked: Vec<LoginWindow> = match opts.window.as_deref().map(|w| w.trim().to_lowercase()) {
None => pool.clone(),
Some(w) if w == "visualize" => pool.iter().filter(|l| l.owner_image.eq_ignore_ascii_case("SLDWORKSVisualize.exe")).cloned().collect(),
Some(w) if w == "solidworks" => pool.iter().filter(|l| l.host() == "solidworks").cloned().collect(),
Some(w) if w == "desktopupdate" => pool.iter().filter(|l| l.host() == "desktopUpdate").cloned().collect(),
Some(w) => match w.parse::<i64>() {
Ok(h) => {
let hit: Vec<LoginWindow> = pool.iter().filter(|l| l.hwnd == h).cloned().collect();
if hit.is_empty() && opts.user_authorized {
// NAMED BY THE CALLER: never refused on census grounds.
match named_window(os.as_ref(), h) {
Ok(lw) => vec![lw],
Err(e) => return e,
}
} else {
hit
}
}
Err(_) => return err("sign_in_bad_window", format!("window {w:?} is not \"visualize\", \"solidworks\", \"desktopUpdate\" or an hwnd"), "Pass window:\"visualize\", \"solidworks\", \"desktopUpdate\" or a window handle.", json!({})),
},
};
let listed = json!(pool.iter().map(|l| l.to_json()).collect::<Vec<_>>());
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())}),
)
}
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 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)",
"Ask the user to sign in by hand, or to update the saved 3DEXPERIENCE login in the vault; only after THEY say it has changed may swx_sign_in be called with userConfirmedNewPassword:true.",
json!({"rejectedBefore": rejected_before()}),
);
}
// 1. THE INVENTORY, captured every time (woken first if the page is lazy).
// When the tree holds only the frame (the SWXDesktopUpdate host,
// measured 2026-10-12), the page is located control by control.
let read = match read_page(hwnd) {
Ok(r) => r,
Err(e) => return err("sign_in_inventory_unreadable", e, "Nothing was typed. The user can sign in by hand.", json!({"hwnd": hwnd})),
};
crate::launch::note_sign_in_inventory(inventory_json(&read.elements));
crate::launch::note_sign_in_wake(read.wake.clone());
let inv_j = inventory_json(&read.elements);
let wake_1 = read.wake.clone();
let located_1 = read.report();
let banner = cookie_banner(&read.elements);
let (reading, form) = recognize(&read.elements);
let form = match (reading, form) {
(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, "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, "wake": wake_1, "located": located_1, "cookieBanner": banner, "window": window_j}));
}
};
// 2. THE LOGIN'S HOST AND USER NAME — no toast, no secret: pup_credentials
// lists host + username. 0.9.25: THE PAGE IS PREPARED BEFORE THE
// PASSWORD IS ASKED FOR. MEASURED 2026-10-12 (hosted 0.9.24): ab refused
// the reveal to this bridge by POLICY ("requires a VERIFIED local
// transport … arrived with origin \"bridge\"") — so the user name is
// filled and Continue pressed first, and a refused reveal leaves the
// window on the password page with the name in place, for the user.
let _ = reason; // the AI's reason is ledgered; the user's toast carries CONSENT_REASON
let (host, listed_user) = match lookup() {
Ok(x) => x,
Err(v) => return vault_refusal(v, json!({"inventory": inv_j}), None),
};
let mut needles: Vec<String> = listed_user.iter().cloned().collect();
let mut steps: Vec<Value> = Vec::new();
// A page with the PASSWORD edit is page 2 (or a one-page form), whatever
// else it holds (0.9.26: measured, page 2 also shows the user-name edit and
// was called page 1).
let first_page: u8 = if form.password.is_some() { 2 } else { 1 };
let mut pages: Vec<Value> = vec![json!({"page": first_page, "inventory": inv_j, "wake": wake_1, "located": located_1, "cookieBanner": banner})];
let mut remember_ticked = Value::Null;
let mut vault = json!({"host": host});
// No user name listed beside the host: the reveal is the only source of
// it, so it comes first (the old order).
let mut secret: Option<Secret> = None;
let mut user_name: Option<Vec<u8>> = listed_user.as_ref().map(|u| u.as_bytes().to_vec());
if user_name.is_none() {
match reveal_at(&host) {
Vault::Got(sec, v) => {
needles.extend(sec.needles());
user_name = sec.user.clone();
vault = v;
secret = Some(sec);
}
other => return vault_refusal(other, json!({"inventory": inv_j}), Some(json!({"page": 1, "userNameFilled": false}))),
}
}
let mut user_filled = false;
let mut two_page = false;
let mut closed_early = false;
let mut pw_els: Vec<Element> = read.elements.clone();
let mut pw_form: Form = form.clone();
let mut pw_page: u8 = if lw.page == Some(2) { 2 } else { first_page };
// 3. PAGE ONE when it asks only for the user name (MEASURED 2026-10-02:
// "Email or username" + Continue): the name, Continue, then wait for the
// page to CHANGE and recognize the password page.
if form.password.is_none() {
two_page = true;
if let Err(fe) = fill_page(hwnd, &read.elements, &form, user_name.as_deref(), None, &mut steps, &mut remember_ticked) {
drop(secret);
return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles);
}
user_filled = 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_els: Vec<Element> = Vec::new();
loop {
if still_up(os.as_ref(), &lw) == Some(false) {
break; // the window closed after the user name alone
}
if let Ok(r2) = read_page(hwnd) {
last = r2.elements.clone();
match recognize(&r2.elements) {
(Reading::Form, Some(f2)) if f2.password.is_some() => {
pages.push(json!({"page": 2, "inventory": inventory_json(&r2.elements), "wake": r2.wake.clone(),
"located": r2.report(), "cookieBanner": cookie_banner(&r2.elements)}));
crate::launch::note_sign_in_inventory(inventory_json(&r2.elements));
page2_els = r2.elements.clone();
page2 = Some(f2);
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));
}
match page2 {
Some(f2) => {
// The user name was typed on page one; page two takes the password.
pw_form = Form { user: None, ..f2 };
pw_els = page2_els;
pw_page = 2;
}
None if still_up(os.as_ref(), &lw) == Some(false) => closed_early = true,
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);
}
}
} else if let Some(u_el) = &form.user {
// A password page that also holds the user-name box (a one-page form,
// or page two with the name prefilled): the name goes in first.
let Some(u) = user_name.as_deref() else {
drop(secret);
return scrub(&fill_failure(FillErr::Failed { step: "user name".into(), why: "the saved login has no user name to type".into(), observed: None, may_be_banner: false }, banner, &pages, &steps, &vault), &needles);
};
// PAGE 2 (MEASURED 2026-10-12): the name is PREFILLED and READ-ONLY —
// a set answers "Cannot call SetValue on read only value". A prefilled
// name is left alone; a read-only refusal means prefilled; and the name
// step never blocks the password step.
if u_el.has_value == Some(true) {
steps.push(json!({"step": "user name", "ok": true, "nameField": "prefilled", "via": "skipped (the field already holds a name)"}));
user_filled = true;
} else {
match fill_field(hwnd, u_el, u, true, &mut steps) {
Ok(()) => user_filled = true,
Err(FillErr::ReadOnly(why)) => {
steps.push(json!({"step": "user name", "ok": true, "nameField": "prefilled", "via": "read-only", "abSaid": why}));
user_filled = true;
}
Err(FillErr::NeedsIdle(why)) => {
drop(secret);
return scrub(&fill_failure(FillErr::NeedsIdle(why), banner, &pages, &steps, &vault), &needles);
}
Err(FillErr::Failed { step, why, .. }) => {
steps.push(json!({"step": "user name", "ok": false, "nameField": "notSet", "failedAt": step, "why": why,
"effect": "not blocking: the password page goes on"}));
}
}
}
pw_form = Form { user: None, ..form.clone() };
}
// 4. THE PASSWORD: the reveal, ONCE, only now — the page is prepared.
if !closed_early {
let sec = match secret.take() {
Some(sec) => sec,
None => match reveal_at(&host) {
Vault::Got(sec, v) => {
needles.extend(sec.needles());
vault = v;
sec
}
other => {
let prepared = json!({"page": pw_page, "userNameFilled": user_filled});
let out = vault_refusal(other, json!({"pages": pages, "steps": steps, "vault": vault, "cookieBanner": banner}), Some(prepared));
return scrub(&out, &needles);
}
},
};
let filled = fill_page(hwnd, &pw_els, &pw_form, None, Some(&sec.pass), &mut steps, &mut remember_ticked);
drop(sec); // ZEROED. Only the needles (for scrubbing) remain, below.
if let Err(fe) = filled {
return scrub(&fill_failure(fe, banner, &pages, &steps, &vault), &needles);
}
}
drop(secret);
// 5. VERIFY BY EFFECT: the window goes away.
let deadline = std::time::Instant::now() + Duration::from_secs(verify_secs());
loop {
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,
"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);
}
_ if std::time::Instant::now() >= deadline => break,
_ => std::thread::sleep(Duration::from_millis(500)),
}
}
// STILL UP: read it again and say what it is asking.
let after = read_page(hwnd).map(|r| r.elements).unwrap_or_default();
let after_j = inventory_json(&after);
let out = if let Some(m) = has_marker(&after, REJECTED) {
crate::launch::note_sign_in_rejected();
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, "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, "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, "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, page: &[Element], form: &Form, user: Option<&[u8]>, pass: Option<&[u8]>, steps: &mut Vec<Value>, remember_ticked: &mut Value) -> Result<(), FillErr> {
if let Some(u_el) = &form.user {
let Some(u) = 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, u_el, u, true, steps)?;
}
if let Some(p_el) = &form.password {
let Some(p) = pass else {
return Err(FillErr::Failed { step: "password".into(), why: "no password was revealed for this page".into(), observed: None, may_be_banner: false });
};
fill_field(hwnd, p_el, p, false, steps)?;
}
if let Some(rm) = &form.remember {
*remember_ticked = tick_remember(hwnd, page, rm);
} else if form.password.is_some() {
// THE SWXDesktopUpdate HOST (MEASURED 2026-10-12): page 2 shows a Text
// "Remember me" and no CheckBox could be found by name. The label is
// clicked (as on the Visualize host) only while the user is idle and the
// login window is in front; its state is NOT readable, so
// `rememberTicked` stays null and the step says what was done.
if let Some(label) = page.iter().find(|e| e.by_find && e.is("text") && clean(&e.name).starts_with("remember me") && e.center.is_some() && !e.offscreen()) {
steps.push(click_remember_label(hwnd, label));
}
}
let r = crate::caller::ad_callback("desktop_ui_click", merge(target(&form.submit, "button"), json!({"hwnd": hwnd, "reason": FILL_REASON})), AB_TIMEOUT);
steps.push(json!({"step": format!("press \"{}\"", form.submit.name), "ok": ab_ok(&r), "via": "uiaInvoke"}));
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(())
}
/// "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");
}
// READ THE TOGGLE BACK: a tick is claimed only if the box says so.
let read_toggle = || {
read_page(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 settle_on = |first: Option<String>| {
let until = std::time::Instant::now() + Duration::from_millis(1500);
let mut after = first;
while !is_on(after.as_deref()) && std::time::Instant::now() < until {
std::thread::sleep(Duration::from_millis(150));
after = read_toggle();
}
after
};
// AN ON-SCREEN CHECKBOX (MEASURED 2026-10-12 on the SWXDesktopUpdate host:
// a real CheckBox "Remember me ", settable, with a toggle pattern): the
// Toggle pattern in the background first, then an Invoke; the label click
// below only if neither ticks it.
if !rm.offscreen() {
let args = merge(target(rm, "checkbox"), json!({"hwnd": hwnd, "reason": FILL_REASON}));
let mut via = "uiaToggle";
let mut r = crate::caller::ad_callback("desktop_ui_toggle", args.clone(), AB_TIMEOUT);
if !ab_ok(&r) && rm.invokable != Some(false) {
via = "uiaInvoke";
r = crate::caller::ad_callback("desktop_ui_click", args, AB_TIMEOUT);
}
if ab_ok(&r) {
let after = settle_on(read_toggle());
if is_on(after.as_deref()) {
return json!({"ticked": true, "via": via, "toggleStateAfter": after});
}
if after.is_none() {
return json!({"ticked": Value::Null, "via": via, "why": "the toggle state could not be read back"});
}
}
}
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 after = settle_on(read_toggle());
json!({"ticked": ab_ok(&r) && is_on(after.as_deref()), "via": "labelClick", "label": label.name, "toggleStateAfter": after})
}
/// A real click on the "Remember me" label, guarded like every real input.
fn click_remember_label(hwnd: i64, label: &Element) -> Value {
if let Err(why) = user_idle() {
return json!({"step": "remember me (label)", "ok": false, "why": format!("skipped: {why}"), "state": "unreadable"});
}
let _ = crate::caller::ad_callback("desktop_raise_window", json!({"hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);
let settle = std::time::Instant::now() + Duration::from_millis(1500);
while login_in_front(hwnd).is_err() && std::time::Instant::now() < settle {
std::thread::sleep(Duration::from_millis(100));
}
if let Err(why) = login_in_front(hwnd) {
return json!({"step": "remember me (label)", "ok": false, "why": format!("skipped: {why}"), "state": "unreadable"});
}
let (x, y) = label.center.unwrap_or_default();
let r = crate::caller::ad_callback("desktop_click", json!({"x": x, "y": y, "hwnd": hwnd, "reason": FILL_REASON}), AB_TIMEOUT);
json!({"step": "remember me (label)", "ok": ab_ok(&r), "via": "labelClick", "label": label.name,
"state": "unreadable — no CheckBox was found on this host, so whether it is ticked cannot be read"})
}
/// "Cannot call SetValue on read only value" (measured), and its spellings.
fn is_read_only(why: &str) -> bool {
let w = why.to_lowercase();
w.contains("read only") || w.contains("readonly") || w.contains("read-only")
}
pub(crate) enum FillErr {
/// The field refused a set as READ-ONLY (MEASURED 2026-10-12: page 2's
/// prefilled user name — "UIA SetValue failed: Cannot call SetValue on read
/// only value"). Evidence of a prefilled field, never of a banner.
ReadOnly(String),
/// 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,
}
}
if let Some(v) = walk(&unwrap_ab(&r), el) {
return Some(v);
}
if !el.by_find {
return None;
}
// Located by find_control: its reply's `value`, IF ab ever carries one
// (measured 2026-10-12: it does not).
let q = if el.automation_id.is_empty() { json!({"hwnd": hwnd, "role": "Edit", "name": el.name}) } else { json!({"hwnd": hwnd, "automationId": el.automation_id}) };
let r = crate::caller::ad_callback("desktop_find_control", q, AB_TIMEOUT);
fn val(v: &Value) -> Option<String> {
match v {
Value::Array(a) => a.iter().find_map(val),
Value::Object(m) => m.get("value").and_then(|x| x.as_str()).map(String::from).or_else(|| m.values().find_map(val)),
_ => None,
}
}
if ab_ok(&r) { val(&unwrap_ab(&r)) } else { None }
}
/// Where a field is NOW: its centre (ab's own), whether a cookie-banner
/// 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> {
if el.by_find {
// Fresh geometry by find_control; the banner's buttons too.
let q = if el.automation_id.is_empty() { json!({"hwnd": hwnd, "role": "Edit", "name": el.name}) } else { json!({"hwnd": hwnd, "automationId": el.automation_id}) };
let r = crate::caller::ad_callback("desktop_find_control", q, AB_TIMEOUT);
let field = found_elements(&r).into_iter().find(|e| e.is("edit") && (e.automation_id == el.automation_id || e.name == el.name))?;
let center = field.center?;
let covered = match field.rect {
Some(fr) => ["Accept All", "Manage my preferences"].iter().any(|n| {
let (b, _) = find_one(hwnd, &Want { role: "Button", automation_id: None, name: n });
b.and_then(|b| b.rect).map(|br| overlaps(fr, br)).unwrap_or(false)
}),
None => false,
};
return Some(Spot { center, covered, render_widget: None });
}
let r = crate::caller::ad_callback("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 30, "maxNodes": 400}), AB_TIMEOUT);
if !ab_ok(&r) {
return None;
}
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)) })
}
/// CLEAR, THEN SET, a find_control-located field (0.9.23). MEASURED
/// 2026-10-12 on the SWXDesktopUpdate host: the first `desktop_ui_set` of the
/// user name left the name plus a stray trailing "s" (a leftover keystroke
/// or SetValue inserting, origin unknown); setting "" first and then the value
/// fixed it. So: set "", read back (when readable) that it is empty, set the
/// value, read the user name back (when readable). `desktop_find_control`'s
/// reply carries NO `value` (measured), and this host's tree holds no page, so
/// the read-back is usually UNREADABLE there — said in the step, never
/// assumed. The password is never read back. `Ok(false)` = no ValuePattern
/// (fall back to keystrokes).
fn set_cleared(hwnd: i64, el: &Element, typed: &str, read_back: bool, label: &str, steps: &mut Vec<Value>) -> Result<bool, FillErr> {
let set = |text: &str| {
crate::caller::ad_callback(
"desktop_ui_set",
merge(target(el, "edit"), json!({"hwnd": hwnd, "text": text, "sensitive": true, "reason": FILL_REASON})),
AB_TIMEOUT,
)
};
let r = set("");
if !ab_ok(&r) {
let why = ab_error(&r);
if why.to_lowercase().contains("no valuepattern") {
return Ok(false);
}
steps.push(json!({"step": format!("{label}: clear"), "ok": false, "via": "uiaValuePattern"}));
if is_read_only(&why) {
return Err(FillErr::ReadOnly(why));
}
return Err(FillErr::Failed { step: format!("{label} (UIA clear)"), why, observed: None, may_be_banner: false });
}
let cleared = field_value(hwnd, el);
if read_back {
if let Some(v) = cleared.as_deref().filter(|v| !v.is_empty()) {
return Err(FillErr::Failed {
step: format!("{label}: clear"),
why: "the field was set to empty but still holds text".into(),
observed: Some(json!({"fieldText": v, "length": v.chars().count()})),
may_be_banner: false,
});
}
}
let r = set(typed);
if !ab_ok(&r) {
steps.push(json!({"step": label, "ok": false, "via": "uiaValuePattern", "cleared": true}));
let why = ab_error(&r);
if is_read_only(&why) {
return Err(FillErr::ReadOnly(why));
}
return Err(FillErr::Failed { step: format!("{label} (UIA set)"), why, observed: None, may_be_banner: false });
}
if !read_back {
steps.push(json!({"step": label, "ok": true, "via": "uiaValuePattern", "cleared": true, "readBack": "notReadPassword"}));
return Ok(true);
}
let got = field_value(hwnd, el);
match got {
Some(g) if g == typed => {
steps.push(json!({"step": label, "ok": true, "via": "uiaValuePattern", "cleared": true, "readBack": "matched"}));
Ok(true)
}
Some(g) => {
steps.push(json!({"step": label, "ok": false, "via": "uiaValuePattern", "cleared": true, "readBack": "mismatch"}));
Err(FillErr::Failed {
step: format!("{label}: read-back"),
why: "the user-name field does not hold what was set".into(),
observed: Some(json!({"fieldText": g, "length": g.chars().count()})),
may_be_banner: false,
})
}
None => {
steps.push(json!({"step": label, "ok": true, "via": "uiaValuePattern", "cleared": true,
"readBack": "unreadable (desktop_find_control carries no value; the tree holds no page on this host)"}));
Ok(true)
}
}
}
/// FILL ONE FIELD. First the background UIA ValuePattern set; MEASURED
/// 2026-10-02 the 3DEXPERIENCE ID page refuses it ("control found but has NO
/// ValuePattern … try a foregrounded desktop_type") even on an edit the tree
/// 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, el: &Element, text: &[u8], read_back: bool, steps: &mut Vec<Value>) -> Result<(), FillErr> {
let label = if read_back { "user name" } else { "password" };
let typed = String::from_utf8_lossy(text).to_string();
if el.by_find {
match set_cleared(hwnd, el, &typed, read_back, label, steps)? {
true => return Ok(()),
false => {} // no ValuePattern: real keystrokes, below
}
} else {
let r = crate::caller::ad_callback(
"desktop_ui_set",
merge(target(el, "edit"), json!({"hwnd": hwnd, "text": typed, "sensitive": true, "reason": FILL_REASON})),
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"}));
// A FAILED SET IS NEVER EVIDENCE OF A COOKIE BANNER (0.9.26): measured
// 2026-10-12, "Cannot call SetValue on read only value" on a page with
// no banner was reported as one, and the user was told to answer it.
if is_read_only(&why) {
return Err(FillErr::ReadOnly(why));
}
return Err(FillErr::Failed { step: format!("{label} (UIA set)"), why, observed: None, may_be_banner: false });
}
}
// ── REAL KEYSTROKES ──────────────────────────────────────────────────────
user_idle().map_err(FillErr::NeedsIdle)?;
// 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 2026-10-02 on ab 2.1.163: desktop_set_window_bounds
// {hwnd, x:300, y:30, w:1100, h:1450, reason} replied "ok" and the
// email box then sat at y=787, the banner at y=1020 (keys are w/h).
let _ = crate::caller::ad_callback(
"desktop_set_window_bounds",
json!({"hwnd": hwnd, "x": 300, "y": 30, "w": 1100, "h": 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 });
};
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 });
}
// 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));
}
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 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": 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": 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 });
}
// 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.is_none() && el.by_find {
steps.push(json!({"step": label, "ok": true, "via": "keystrokes", "readBack": "unreadable (desktop_find_control carries no value; the tree holds no page on this host)"}));
return Ok(());
}
if got.as_deref() != Some(typed.as_str()) {
steps.push(json!({"step": label, "ok": false, "via": "keystrokes", "readBack": "mismatch"}));
return Err(FillErr::Failed {
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),
FillErr::ReadOnly(why) => ("set of a read-only field".to_string(), why, None, false),
};
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, "cookieBanner": banner}),
)
}
#[cfg(test)]
mod ab_envelope_tests {
use super::*;
#[test]
fn an_empty_error_string_is_success_measured_pup_envelope() {
let r = json!({"ok": true, "status": "ok", "error": "", "credentials": [{"host": "iam.3ds.com", "username": "u"}]});
assert!(ab_ok(&r), "pup_credentials answers error:\"\" on success (measured 2026-10-12)");
assert!(ab_ok(&json!({"success": true})));
assert!(!ab_ok(&json!({"success": false})));
assert!(!ab_ok(&json!({"ok": false, "error": ""})));
assert!(!ab_ok(&json!({"error": "relay_forbidden"})));
assert!(!ab_ok(&json!({"error": {"code": 7}})));
assert_eq!(ab_error(&json!({"error": ""})), "ab reported a failure");
assert_eq!(ab_error(&json!({"error": "nope"})), "nope");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn el(t: &str, name: &str, pw: Option<bool>) -> Element {
Element { control_type: t.into(), name: name.into(), is_password: pw, ..Default::default() }
}
#[test]
fn a_plain_form_is_recognized_and_extra_fields_are_not() {
let inv = vec![el("edit", "Email", Some(false)), el("edit", "Password", Some(true)), el("checkbox", "Remember me", None), el("button", "Log in", None), el("button", "Forgot password?", None)];
let (r, f) = recognize(&inv);
assert_eq!(r, Reading::Form);
let f = f.unwrap();
assert_eq!(f.submit.name, "Log in");
assert!(f.remember.is_some());
let mut extra = inv.clone();
extra.push(el("edit", "Company", Some(false)));
assert!(matches!(recognize(&extra).0, Reading::Unrecognized(_)));
let mut code = inv.clone();
code.push(el("edit", "Verification code", Some(false)));
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"}]});
let s = scrub(&v, &["hunter2-xyz".to_string()]).to_string();
assert!(!s.contains("hunter2-xyz"), "{s}");
}
#[test]
fn the_inventory_never_copies_a_value() {
let reply = json!({"success": true, "data": {"controlType": "Window", "name": "Login", "children": [
{"controlType": "Edit", "name": "Password", "isPassword": true, "value": "SHOULD-NOT-COPY"}]}});
let inv = parse_inventory(&reply);
assert_eq!(inv.len(), 2);
assert!(!inventory_json(&inv).to_string().contains("SHOULD-NOT-COPY"));
}
}