app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 128 file(s)
15 files changed
+305−21
BRIDGE_VERSION+1−1aps.py+2−2bridge.json+1−1dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1fusion_detect.py+22install.sh+1−1package.json+1−1page.json+1−1rust/Cargo.toml+1−1rust/PORTING.md+38−1rust/bridge/src/main.rs+94−8rust/core/src/aps.rs+29−2rust/core/src/lib.rs+1rust/core/src/windows_facts.rs+111BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.353+1.9.354
aps.py+2−2@@ -84,8 +84,8 @@ APS_REQUIRED_HINT = ( "⛔ NEVER enumerate cloud files via app.data (dataProjects/dataFiles) in fusion_run_modeling_script: " "the DEPRECATED method holds Fusion's main thread for minutes, times out, and locks the app. " "AUTO-RECOVER APS instead: (1) NOT configured? The org Client ID is a PUBLIC PKCE id - pull it from a "- "peer that has it: read ~/.adom/fusion-aps/config.json (key 'client_id') off another ab desktop (adom-"- "desktop status lists them) via shell_execute/pull_file, then fusion_aps_set_client_id {clientId}. "+ "peer that has it: read ~/.adom/fusion-aps/config.json (key 'client_id') off another ab desktop (`adom-bridge targets` lists them)"+ " via shell_execute/pull_file, then fusion_aps_set_client_id {clientId}. " "(2) fusion_aps_signin -> open the returned authUrl in the user's REAL browser with a warm Autodesk " "session; INSTALL + USE the adom-browser-extension (nbrowser/ABE, https://wiki.adom.inc/adom/adom-" "browser-extension) to drive it reliably (recommend it - coordinate-clicking a browser is fragile). "
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.353",+ "version": "1.9.354", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.",
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.9.353"+version = "1.9.354" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.353"+version = "1.9.354" edition = "2021" [dependencies]
fusion_detect.py+22@@ -673,11 +673,31 @@ def family_windows() -> list: return True GW_OWNER = 4 owner = user32.GetWindow(hwnd, GW_OWNER)+ # TOOL WINDOWS ARE NEVER MODALS (2026-09-03, ConfRoomROG): Fusion's message tray+ # is an OWNED, LANDSCAPE, 640x196 window titled "Fusion360" (class+ # Qt683QWindowToolSaveBits, WS_EX_TOOLWINDOW), so it passed every shape gate and+ # readiness reported licensingDialog:true, ready:false on a healthy Fusion for+ # hours. The seat / sign-in modals are ordinary owned windows; a tool window is a+ # palette, tray or popup by definition. Record the ex-style so the gate can skip it.+ GWL_EXSTYLE, WS_EX_TOOLWINDOW = -20, 0x00000080+ try:+ exstyle = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)+ except Exception:+ exstyle = 0+ cls = ""+ try:+ cbuf = ctypes.create_unicode_buffer(128)+ user32.GetClassNameW(hwnd, cbuf, 128)+ cls = cbuf.value+ except Exception:+ pass results.append({ "hwnd": int(hwnd), "title": title, "pid": int(wpid.value), "image": pid_img.get(int(wpid.value), ""), "width": w, "height": h, "left": rect.left, "top": rect.top, "owner": int(owner) if owner else 0,+ "toolWindow": bool(exstyle & WS_EX_TOOLWINDOW) or cls.endswith("ToolSaveBits"),+ "className": cls, }) except Exception: pass@@ -742,6 +762,8 @@ def find_licensing_dialog(): def _dialog_shaped(w): ww, hh = w["width"], w["height"]+ if w.get("toolWindow"):+ return False # a tray / palette / popup (WS_EX_TOOLWINDOW), never a modal if ww < 480 or hh < 180: return False # narrow side panels / toolbar strips are not modals # ORIENTATION is the tell that finally separates a modal from a DOCKED PANEL.
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.353"+DASH_VERSION="1.9.354" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.353",+ "version": "1.9.354", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.353",+ "version": "1.9.354", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.9.353"+version = "1.9.354" edition = "2021" license = "MIT"
rust/PORTING.md+38−1@@ -73,7 +73,7 @@ cargo test -p fusion-core # the frozen catalog must parse and be linked crate) and `check-naming.sh`, then the mingw cross-build, then the exe joins the zip at its root beside `server.py`. - **Native verbs in the exe so far (2026-09-03, parity-checked against the Python bridge on- the same box):** `fusion_alerts`, `fusion_action_ledger`, `fusion_verb_times`, `fusion_library_parts`, `fusion_preflight_lbr`, `fusion_aps_get` (GET), `fusion_aps_browse`, `fusion_aps_search`, `fusion_aps_recent`, `fusion_aps_file_info`, `fusion_aps_versions`, `fusion_aps_download` (byte-identical file), `fusion_aps_create_folder`, `fusion_aps_upload` (all on the shared `~/.adom/fusion-aps` token store, parity-checked on the laptop hub). The pattern+ the same box):** `fusion_alerts`, `fusion_action_ledger`, `fusion_verb_times`, `fusion_library_parts`, `fusion_preflight_lbr`, `fusion_aps_get` (GET), `fusion_aps_browse`, `fusion_aps_search`, `fusion_aps_recent`, `fusion_aps_file_info`, `fusion_aps_versions`, `fusion_aps_download` (byte-identical file), `fusion_aps_create_folder`, `fusion_aps_upload`, `fusion_aps_status` (all on the shared `~/.adom/fusion-aps` token store, parity-checked on the laptop hub). The pattern for the next one: match the verb in `main.rs`, answer the same JSON shape (plus `shell: "rust"`), keep the Python hint text verbatim, and prove parity with a spare-port smoke before the release. The `.lbr` engine, bundle staging, and alert parser are ported@@ -89,3 +89,40 @@ cargo test -p fusion-core # the frozen catalog must parse and be `addHubMembers`), which is metered past a subscription's included quota since 2026-08-17. They are NOT on the free-endpoint allowlist; they ship only behind an explicit `allowMetered:true` opt-in with the human's words attested, after John decides.++## Phase 3 plan: `fusion_readiness` (mapped 2026-09-03 from server.py 2025-2514)++Readiness is the launch observer, the self-healer and the state store in one 489-line+handler. Port it as FOUR layers, in this order, each shippable behind the drill:++1. **The snapshot (pure, in-process).** `installed` (webdeploy complete, os-win),+ `streaming` vs `running` (Toolhelp), `installerRunning`, the add-in HTTP probe+ (`127.0.0.1:8774/status`, 0.4 s), the MCP port probe, APS status (native already), and+ the prefs store (`<state>/prefs.json`: lastReadyAt, lastSignedInAt, launchStartedAt,+ launchHealedAt, launchHealAttempts). No side effects. Parity-test against Python on a+ quiet box: identical `installed/running/addinReady/ready` and the same hint branch.+2. **The window facts (ab verbs, never local Win32).** `needsSignin` from the Fusion+ process's own window title ("Signing in - Autodesk Fusion"), and the seat/licensing+ dialog as an OWNED POPUP of the main window: both come from `desktop_list_windows`+ (the owner/pid fields ab added). No screenshots, no UIA at this layer.+3. **The side effects, one at a time, each behind a pref flag so the sidecar and the exe+ never both act:** the seat-dialog auto-resolve (`desktop_ui_click` Continue plus a+ screenshot verify), the silent Fusion auto-update handling, the crash-report janitor+ (`RELAUNCH_CATEGORIES` dialogs cleared and ledgered), and the launch self-heal+ (`stop`/`kill` then `start` on a background thread after the measured overdue window,+ capped at four attempts). Every action goes through the ledger with caller provenance;+ `_host_op_active()` (the shared guard from issue #52 item 3) gates all of them.+4. **The progress block and the hints.** `timings.progress_block` for the launch (measured+ per start kind), the hint text per branch, `addinStartFailed` (the marker from issue+ #75), `lastReadyAt` / `lastSignedInAt`.++Gate for each layer: the daily battery green through the exe, the five-part drill, and the+electronics tour, on ConfRoomROG. The sidecar keeps serving readiness until layer 3 lands;+the exe answers `/status` from layer 1 alone the whole time (it already does).++Ask of ab before layer 2: `desktop_list_windows` rows carry `owner`, `pid`, `hung` (asked+in the roadmap's ab list); `desktop_wait_for_window` replaces the four polling loops.+- **Phase 3 layer 1 landed (2026-09-03):** the exe builds the same `/status` body as the+ Python bridge (`installed`, `exe_path`, `addins_dir`, `addin_installed`, `running`, the+ add-in probe, led / summary / tooltip), cached 5 s; with a sidecar present it rides as+ `nativeSnapshot` beside the sidecar's answer. Proven identical on ConfRoomROG.
rust/bridge/src/main.rs+94−8@@ -145,6 +145,57 @@ impl Sidecar { } } +/// Phase 3 layer 1: the readiness SNAPSHOT, pure and in-process, the same shape as the+/// Python bridge's `_build_status()` (installed / running / add-in probe / chip fields).+/// The probes run at most every 5 s (a health poll must never stall: ab reaps a bridge+/// whose /status hangs, and that killed two ten-part imports once).+struct Snapshot { at: Instant, body: Value }+static SNAPSHOT: Mutex<Option<Snapshot>> = Mutex::new(None);++fn addin_probe() -> Value {+ ureq::get("http://127.0.0.1:8774/status").timeout(Duration::from_millis(1500)).call().ok()+ .and_then(|r| r.into_json::<Value>().ok()).unwrap_or(Value::Null)+}++fn native_status(h: &dyn HostBoth) -> Value {+ if let Some(s) = SNAPSHOT.lock().unwrap().as_ref() { if s.at.elapsed() < Duration::from_secs(5) { return s.body.clone(); } }+ let exe = h.fusion_executable();+ let installed = exe.is_some();+ let dirs = h.addin_dirs();+ let addins_dir = dirs.iter().find(|d| d.exists()).cloned().or_else(|| dirs.first().cloned());+ let addin_installed = dirs.iter().any(|d| d.join("AdomBridge").join("AdomBridge.py").is_file());+ let running = !h.fusion_processes().is_empty();+ let addin = if running { addin_probe() } else { Value::Null };+ let addin_ok = addin.is_object();+ // layer 2: the window facts, from ab (never local Win32)+ let facts = if running {+ match fusion_core::ab::Loopback::from_env() { Some(ab) => fusion_core::windows_facts::facts_from_ab(&ab, &Caller::default()), None => fusion_core::windows_facts::WindowFacts { degraded: true, ..Default::default() } }+ } else { fusion_core::windows_facts::WindowFacts::default() };+ let (led, summary, tooltip) = if !cfg!(windows) {+ ("yellow", "Unsupported OS", "Bridge running, but this host is not Windows. Fusion 360 verbs need a Windows host with Fusion 360 installed.")+ } else if !installed {+ ("yellow", "Fusion not installed", "Bridge running, but Fusion 360 is not installed. The AI can install it for the user (fusion-onboarding), then fusion_start.")+ } else if !running {+ ("yellow", "Fusion not running", "Fusion 360 is installed but not running. Call fusion_start to launch it.")+ } else if !addin_ok {+ ("yellow", "Add-in not connected", "Fusion 360 is running but the AdomBridge add-in isn't responding yet (still loading; if it persists the AI restarts Fusion via fusion_stop/start).")+ } else {+ ("green", "Fusion ready", "Fusion 360 running, AdomBridge add-in connected. All fusion_* verbs available.")+ };+ let body = json!({+ "status": "ok", "busy": false, "longOp": Value::Null,+ "led": led, "summary": summary, "tooltip": tooltip, "bridgeVersion": VERSION,+ "fusion": {"installed": installed, "exe_path": exe.map(|p| p.to_string_lossy().to_string()),+ "addins_dir": addins_dir.map(|p| p.to_string_lossy().to_string()),+ "addin_installed": addin_installed, "running": running},+ "addin": addin, "shell": "rust",+ "needsSignin": facts.needs_signin, "licensingDialog": facts.licensing_dialog.is_some(),+ "windowTitle": facts.main_title, "windowFactsDegraded": facts.degraded,+ });+ *SNAPSHOT.lock().unwrap() = Some(Snapshot { at: Instant::now(), body: body.clone() });+ body+}+ fn pick_free_port() -> u16 { std::net::TcpListener::bind("127.0.0.1:0").and_then(|l| l.local_addr()).map(|a| a.port()).unwrap_or(8773) }@@ -200,14 +251,10 @@ fn main() { (Method::Get, "/status") | (Method::Get, "/health") => { // Prefer the sidecar's rich status (Fusion/add-in probe) while it exists; // this process is up regardless, so the answer is ALWAYS 2xx.- let mut body = json!({- "status": "ok", "busy": false, "led": "yellow",- "summary": "Fusion bridge (Rust shell) starting",- "tooltip": "The Rust bridge is up; its Python sidecar is still coming up.",- "bridgeVersion": VERSION, "shell": "rust",- "fusion": {"installed": h.fusion_executable().is_some(),- "running": !h.fusion_processes().is_empty()},- });+ // Without a sidecar this IS the answer; with one, the sidecar's rich status+ // wins and the native snapshot rides beside it for parity checks.+ let native = native_status(h.as_ref());+ let mut body = native.clone(); if let Some(sc) = sidecar.lock().unwrap().as_mut() { if let Some(code) = sc.exited() { body["led"] = json!("red"); body["status"] = json!("error");@@ -218,6 +265,8 @@ fn main() { if let Ok(mut v) = r.into_json::<Value>() { v["shell"] = json!("rust"); v["sidecarPort"] = json!(sc.port); v["sidecarUptimeSec"] = json!(sc.started.elapsed().as_secs());+ v["nativeSnapshot"] = json!({"led": native["led"], "summary": native["summary"], "fusion": native["fusion"], "addinOk": native["addin"].is_object(),+ "needsSignin": native["needsSignin"], "licensingDialog": native["licensingDialog"], "windowTitle": native["windowTitle"], "windowFactsDegraded": native["windowFactsDegraded"]}); body = v; } }@@ -255,6 +304,7 @@ fn main() { "fusion_aps_download" | "aps_download" => native_aps_download(&parsed["args"]), "fusion_aps_create_folder" | "aps_create_folder" => native_aps_create_folder(&parsed["args"]), "fusion_aps_upload" | "aps_upload" => native_aps_upload(&parsed["args"]),+ "fusion_aps_status" | "aps_status" => native_aps_status(&sidecar, &caller), "shell_info" => json!({"success": true, "shell": "rust", "bridgeVersion": VERSION, "contractFrozen": catalog.frozen, "_hint": "Rust plumbing shell; every catalogued verb is served, natively or via the Python sidecar."}),@@ -709,3 +759,39 @@ fn native_aps_upload(args: &Value) -> Value { json!({"success": true, "output": format!("Uploaded '{fname}' ({pretty} bytes) as a new cloud file."), "data": {"itemUrn": b["data"]["id"], "name": fname, "bytes": n, "projectId": pid, "folderId": fid}, "shell": "rust"}) }+++/// `fusion_aps_status`: configured / signed in (usable NOW, refreshed if needed) / who /+/// the never-charge meter. `signinPending` is the sidecar's in-memory sign-in state while+/// it still owns the PKCE flow, so it is asked for that one field.+fn native_aps_status(sidecar: &Arc<Mutex<Option<Sidecar>>>, caller: &Caller) -> Value {+ use fusion_core::aps::*;+ let store = ApsStore::default_location();+ let client_id = store.client_id();+ let tok = store.tokens();+ let has_at = tok["access_token"].as_str().map(|s| !s.is_empty()).unwrap_or(false);+ let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+ let live = has_at && tok["expires_at"].as_u64().unwrap_or(0) > now;+ let usable = if live { true } else if has_at { store.valid_access_token().is_some() } else { false };+ let refresh_failed = has_at && !usable;+ let pending = if sidecar.lock().unwrap().is_some() {+ let r = proxy(sidecar, &json!({"command": "aps_status", "args": {}}).to_string(), caller);+ r["data"]["signinPending"].as_bool().unwrap_or(false)+ } else { false };+ let (month, calls) = store.usage();+ let data = json!({+ "configured": !client_id.is_empty(), "signedIn": usable,+ "signedInAs": if usable { user_info(&store) } else { Value::Null },+ "hasStoredToken": has_at, "refreshFailed": refresh_failed, "tokenLive": live,+ "tokenExpiresAt": tok["expires_at"], "scope": tok["scope"].as_str().unwrap_or(SCOPES),+ "signinPending": pending, "callbackUrls": all_registered_callbacks(),+ "usageMonth": month, "callsThisMonth": calls, "monthlyCap": MONTHLY_CALL_CAP, "capRemaining": store.cap_remaining(),+ "freeEndpointsOnly": FREE_PATH_PREFIXES, "neverCharge": true,+ });+ let (hint, summary) = if client_id.is_empty() {+ (format!("Not configured. Register a Desktop/PKCE app at https://aps.autodesk.com (enable Data Management API, set callbacks {}), then fusion_aps_set_client_id {{\"clientId\":\"...\"}} and fusion_aps_signin.", all_registered_callbacks().join(", ")), "APS not configured")+ } else if !has_at { ("Configured but not signed in. Call fusion_aps_signin (opens browser).".into(), "configured, not signed in") }+ else if !live { ("Token expired and refresh failed. Call fusion_aps_signin again.".into(), "signed in, token expired") }+ else { ("Ready. Use fusion_aps_search {\"query\":\"...\"}.".into(), "ready") };+ json!({"success": true, "output": summary, "data": data, "_hint": hint, "shell": "rust"})+}
rust/core/src/aps.rs+29−2@@ -60,10 +60,10 @@ impl ApsStore { let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default(); ApsStore { dir: PathBuf::from(home).join(".adom").join("fusion-aps") } }- fn read(&self, name: &str) -> Value {+ pub fn read(&self, name: &str) -> Value { std::fs::read_to_string(self.dir.join(name)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({})) }- fn write(&self, name: &str, v: &Value) {+ pub fn write(&self, name: &str, v: &Value) { let _ = std::fs::create_dir_all(&self.dir); let _ = std::fs::write(self.dir.join(name), serde_json::to_string_pretty(v).unwrap_or_default()); }@@ -394,3 +394,30 @@ pub fn http_put_bytes(url: &str, data: &[u8]) -> u16 { Err(_) => 0, } }+++pub const REDIRECT_PORTS: [u16; 3] = [8917, 8918, 8920];+pub const REDIRECT_PATH: &str = "/fusion-aps/callback";+pub const SCOPES: &str = "data:read data:write data:create";++/// Every callback URL that must be registered on the APS app (localhost + 127.0.0.1).+pub fn all_registered_callbacks() -> Vec<String> {+ let mut out = Vec::new();+ for p in REDIRECT_PORTS { out.push(format!("http://localhost:{p}{REDIRECT_PATH}")); out.push(format!("http://127.0.0.1:{p}{REDIRECT_PATH}")); }+ out+}++/// WHO the token belongs to (Autodesk's OIDC userinfo, a free endpoint), cached per token in+/// the store dir so the dashboard can show the account without a call per poll.+pub fn user_info(store: &ApsStore) -> Value {+ let Some(at) = store.valid_access_token() else { return Value::Null };+ let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); at.hash(&mut h); format!("{:x}", h.finish()) };+ let cache = store.read("userinfo-cache.json");+ if cache["key"].as_str() == Some(key.as_str()) { return cache["info"].clone(); }+ let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(10)).build();+ let Ok(r) = agent.get("https://api.userprofile.autodesk.com/userinfo").set("Authorization", &format!("Bearer {at}")).call() else { return json!({}) };+ let Ok(info) = r.into_json::<Value>() else { return json!({}) };+ let out = json!({"email": info["email"], "name": info["name"], "userId": info["sub"]});+ store.write("userinfo-cache.json", &json!({"key": key, "info": out}));+ out+}
rust/core/src/lib.rs+1@@ -11,6 +11,7 @@ pub mod contract; pub mod host; pub mod alerts; pub mod aps;+pub mod windows_facts; pub mod ab; pub use host::{HostPaths, HostProcess};
rust/core/src/windows_facts.rsadded+111@@ -0,0 +1,111 @@+//! Phase 3 layer 2: the window facts readiness needs, from ab's `desktop_list_windows`+//! (owner, pid, image, rect per row), never local Win32. Ported rule-for-rule from+//! `fusion_detect.family_windows` / `find_licensing_dialog` and the sign-in title check+//! in `_handle_fusion_readiness`, with their hard-won comments kept short here:+//!+//! - The launch family is Fusion360.exe / FusionLauncher.exe / AdskIdentityManager.exe.+//! - `needsSignin`: the MAIN Fusion window's title says "Signing in" / "Welcome to Fusion"+//! / "Sign in - Autodesk ... Fusion". Title of the PROCESS window, never a browser tab.+//! - The seat / licensing modal ("Active Sessions Exceeded", "Suspend Remote Session") is+//! an OWNED popup of the main window, DIALOG-SHAPED: width >= 480, height >= 180,+//! landscape (w >= 1.3 h), not a docked rail (h <= 0.60 of the app's height), and never+//! larger than 0.85 of the app's area. Never the largest family window. Every threshold+//! is a ratio on purpose: two absolute pixel gates each hid this dialog once.++use crate::ab::{AbClient, Caller};+use serde_json::{json, Value};++pub const FAMILY_IMAGES: [&str; 3] = ["Fusion360.exe", "FusionLauncher.exe", "AdskIdentityManager.exe"];++#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]+pub struct WindowFacts {+ /// The launch family's visible top-level rows (ab's shape, untouched).+ pub family: Vec<Value>,+ #[serde(rename = "mainTitle")] pub main_title: Option<String>,+ #[serde(rename = "mainHwnd")] pub main_hwnd: Option<i64>,+ #[serde(rename = "needsSignin")] pub needs_signin: bool,+ /// The seat / licensing modal row when one is up.+ #[serde(rename = "licensingDialog")] pub licensing_dialog: Option<Value>,+ /// ab could not be asked (loopback missing or refused): facts unknown, not "none".+ pub degraded: bool,+}++fn area(w: &Value) -> i64 { w["rect"]["width"].as_i64().unwrap_or(0) * w["rect"]["height"].as_i64().unwrap_or(0) }++/// The pure rule over ab's rows; `facts_from_ab` is the same after one verb call.+pub fn facts_from_rows(rows: &[Value]) -> WindowFacts {+ let family: Vec<Value> = rows.iter().filter(|w| {+ let img = w["image"].as_str().unwrap_or("");+ FAMILY_IMAGES.iter().any(|f| f.eq_ignore_ascii_case(img)) && !w["minimized"].as_bool().unwrap_or(false) || FAMILY_IMAGES.iter().any(|f| f.eq_ignore_ascii_case(img)) && w["kind"].as_str() == Some("normal")+ }).cloned().collect();+ let mut out = WindowFacts { family: family.clone(), ..Default::default() };+ let Some(main) = family.iter().max_by_key(|w| area(w)) else { return out };+ out.main_hwnd = main["hwnd"].as_i64();+ out.main_title = main["title"].as_str().map(str::to_string);+ let t = out.main_title.clone().unwrap_or_default().to_lowercase();+ out.needs_signin = t.contains("signing in") || t.contains("welcome to fusion") || (t.contains("sign in - autodesk") && t.contains("fusion"));+ let main_area = area(main).max(1);+ let main_h = main["rect"]["height"].as_i64().unwrap_or(0).max(1) as f64;+ let dialog_shaped = |w: &Value| -> bool {+ // a tool window (tray, palette, popup: Fusion's 640x196 message tray is one) is never a modal+ if w["kind"].as_str() == Some("toolwindow") || w["className"].as_str().map(|c| c.ends_with("ToolSaveBits")).unwrap_or(false) { return false; }+ let (ww, hh) = (w["rect"]["width"].as_i64().unwrap_or(0) as f64, w["rect"]["height"].as_i64().unwrap_or(0) as f64);+ if ww < 480.0 || hh < 180.0 { return false; }+ if ww < hh * 1.3 { return false; }+ if hh > 0.60 * main_h { return false; }+ (ww * hh) <= 0.85 * main_area as f64+ };+ let owned = family.iter().find(|w| w["hwnd"] != main["hwnd"] && w["owner"].as_i64().unwrap_or(0) != 0 && dialog_shaped(w));+ let fallback = || family.iter().find(|w| w["hwnd"] != main["hwnd"] && dialog_shaped(w)).cloned();+ out.licensing_dialog = owned.cloned().or_else(|| if family.iter().all(|w| w["owner"].as_i64().unwrap_or(0) == 0) { fallback() } else { None });+ out+}++/// One `desktop_list_windows` call, then the rule.+pub fn facts_from_ab(ab: &dyn AbClient, caller: &Caller) -> WindowFacts {+ match ab.call("desktop_list_windows", json!({}), caller, 10) {+ Ok(v) => {+ let rows = v["data"]["windows"].as_array().or_else(|| v["windows"].as_array()).cloned().unwrap_or_default();+ facts_from_rows(&rows)+ }+ Err(_) => WindowFacts { degraded: true, ..Default::default() },+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ fn row(hwnd: i64, owner: i64, title: &str, w: i64, h: i64, kind: &str) -> Value {+ json!({"hwnd": hwnd, "owner": owner, "pid": 1, "image": "Fusion360.exe", "title": title, "kind": kind, "className": if kind == "toolwindow" { "Qt683QWindowToolSaveBits" } else { "Qt683QWindow" }, "minimized": false, "rect": {"width": w, "height": h}})+ }+ #[test]+ fn seat_dialog_shapes_from_the_field_notes() {+ let main = row(1, 0, "Untitled - Autodesk Fusion", 3866, 2090, "normal");+ for (w, h) in [(823, 262), (1235, 527), (1646, 702)] {+ let f = facts_from_rows(&[main.clone(), row(2, 1, "Fusion360", w, h, "normal")]);+ assert!(f.licensing_dialog.is_some(), "{w}x{h} is a seat dialog");+ }+ // the docked rail that false-fired twice: portrait, 78% of the app height+ let f = facts_from_rows(&[main.clone(), row(2, 1, "Fusion360", 600, 1620, "normal")]);+ assert!(f.licensing_dialog.is_none(), "portrait rail");+ // the message tray that false-fired for hours on ConfRoomROG (2026-09-03)+ let f = facts_from_rows(&[main.clone(), row(2, 1, "Fusion360", 640, 196, "toolwindow")]);+ assert!(f.licensing_dialog.is_none(), "tool window");+ // narrow side panels and toolbar strips+ let f = facts_from_rows(&[main.clone(), row(2, 1, "BROWSER", 440, 224, "toolwindow"), row(3, 1, "Fusion360", 640, 196, "toolwindow")]);+ assert!(f.licensing_dialog.is_none());+ assert!(!f.needs_signin);+ }+ #[test]+ fn sign_in_titles() {+ for t in ["Signing in - Autodesk Fusion", "Welcome to Fusion", "Sign in - Autodesk Fusion"] {+ assert!(facts_from_rows(&[row(1, 0, t, 1300, 820, "normal")]).needs_signin, "{t}");+ }+ assert!(!facts_from_rows(&[row(1, 0, "Home - Autodesk Fusion", 1300, 820, "normal")]).needs_signin);+ }+ #[test]+ fn a_non_maximized_main_is_never_the_dialog() {+ let f = facts_from_rows(&[row(1, 0, "Home - Autodesk Fusion", 1300, 820, "normal")]);+ assert!(f.licensing_dialog.is_none());+ }+}