← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.353\ No newline at end of file+1.9.354\ No newline at end of file
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());+    }+}