← Commit history

v1.6.38: deterministic process-based seat/licensing dialog detection

John Lauer ·dd0feba0ef ·3mo ago ·parent d4b1418
6 files changed +146−20
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.6.37\ No newline at end of file+1.6.38\ No newline at end of file
CHANGELOG.md+11
@@ -3,6 +3,17 @@ All notable changes to the Adom Desktop Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/adom-desktop-fusion-bridge ยท GitHub backup: `adom-inc/adom-desktop-fusion-bridge` +## 1.6.38 - 2026-07-06++- **Deterministic, process-based detection of the seat/licensing dialog** (John: track state in CODE,+  not via the AI following skills). The "Active Sessions Exceeded" dialog's window TITLE is literally+  just "Fusion360" (the text is CEF body content), so the title-based classifier never caught it - it+  silently blocked Fusion's main thread for a whole session. New `family_windows()` enumerates visible+  top-level windows by OWNING PROCESS (Fusion360 / FusionLauncher / AdskIdentityManager), and+  `find_licensing_dialog()` finds the licensing modal by process + dialog size. `fusion_start` now+  auto-reclaims on it deterministically, and `fusion_readiness` reports `licensingDialog:true` with a+  clear hint instead of a vague "add-in not responding" hang.+ ## 1.6.37 - 2026-07-06  - **`fusion_readiness` no longer lies `ready:true` for a stuck/dead Fusion** (John caught it). It only
adom-bridge-fusion-manifest.json+5−5
@@ -1,13 +1,13 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.6.37",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.37/adom-bridge-fusion-v1.6.37.zip",-  "sha256": "3274f36e47192bb2a6ca48cb8262f6aba386e3dd9e9f4c031b73ed29339f4ca3",-  "size": 337727,+  "version": "1.6.38",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.38/adom-bridge-fusion-v1.6.38.zip",+  "sha256": "9d224d1ce06beffb8962bcb69df6a6389e60b16f2b2bbf9e73c1a357311a0840",+  "size": 339225,   "verbPrefixes": [     "fusion_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-07-06T11:44:57Z"+  "released_at": "2026-07-06T12:09:09Z" }
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.6.37",+  "version": "1.6.38",   "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/adom-desktop-fusion-bridge",   "author": "Adom Inc.",
fusion_detect.py+96
@@ -328,6 +328,102 @@ def _fusion_family_pids() -> set:     return pids  +def _family_pid_images() -> dict:+    """{pid: image_name} for every running Autodesk-launch-family process."""+    try:+        cnw = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0+        out = subprocess.check_output(+            ["tasklist", "/FO", "CSV", "/NH"],+            stdin=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=cnw,+        ).decode("utf-8", "replace")+    except Exception:+        return {}+    m = {}+    for mo in re.finditer(r'"([^"]+\.exe)","(\d+)"', out):+        img = mo.group(1).lower()+        if img in _FUSION_FAMILY_IMAGES:+            m[int(mo.group(2))] = img+    return m+++def family_windows() -> list:+    """Visible top-level windows owned by the Autodesk launch family (Fusion360 /+    FusionLauncher / AdskIdentityManager). Each dict: {hwnd, title, pid, image, width,+    height, left, top}.++    This is how the launch state machine detects the SEAT/licensing dialog+    DETERMINISTICALLY - by OWNING PROCESS + size, NOT the "Fusion360" window TITLE that+    fooled the title-based classifier for a whole day (the "Active Sessions Exceeded"+    dialog's title is literally just "Fusion360"; the real text is CEF body content).+    Never raises."""+    results = []+    try:+        user32 = ctypes.windll.user32+        HWND = ctypes.wintypes.HWND+        user32.GetWindowThreadProcessId.argtypes = [HWND, ctypes.POINTER(ctypes.wintypes.DWORD)]+        user32.IsWindowVisible.argtypes = [HWND]+        fam = _fusion_family_pids()+        if not fam:+            return results+        pid_img = _family_pid_images()+    except Exception:+        return results+    WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)++    def cb(hwnd, _l):+        try:+            if not user32.IsWindowVisible(hwnd):+                return True+            wpid = ctypes.wintypes.DWORD(0)+            user32.GetWindowThreadProcessId(hwnd, ctypes.byref(wpid))+            if wpid.value not in fam:+                return True+            n = user32.GetWindowTextLengthW(hwnd)+            title = ""+            if n:+                buf = ctypes.create_unicode_buffer(n + 1)+                user32.GetWindowTextW(hwnd, buf, n + 1)+                title = buf.value+            rect = ctypes.wintypes.RECT()+            user32.GetWindowRect(hwnd, ctypes.byref(rect))+            w = rect.right - rect.left+            h = rect.bottom - rect.top+            if w <= 0 or h <= 0:+                return True+            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,+            })+        except Exception:+            pass+        return True++    cb_c = WNDENUMPROC(cb)+    _callbacks.append(cb_c)+    try:+        user32.EnumWindows(cb_c, 0)+    except Exception:+        pass+    try:+        _callbacks.remove(cb_c)+    except ValueError:+        pass+    return results+++def find_licensing_dialog():+    """The seat/licensing modal ("Active Sessions Exceeded", or a sign-in prompt): a+    family window that is DIALOG-SIZED (the maximized main app is far bigger; tooltips+    far smaller). Detected by owning process + size, so the "Fusion360" title never+    matters. Returns the window dict, or None. The seat dialog is ~1235x527."""+    for w in family_windows():+        ww, hh = w["width"], w["height"]+        if 640 <= ww <= 1500 and 320 <= hh <= 820:+            return w+    return None++ def _fusion_pids() -> set:     """PIDs of running Fusion360.exe processes (via tasklist CSV). Empty on error."""     try:
server.py+32−13
@@ -21,7 +21,7 @@ from pathlib import Path # Ensure the plugin root is on the path sys.path.insert(0, str(Path(__file__).parent)) -from fusion_detect import detect_fusion, ensure_fusion_running, wait_for_addin, _is_fusion_running, fusion_update_in_progress+from fusion_detect import detect_fusion, ensure_fusion_running, wait_for_addin, _is_fusion_running, fusion_update_in_progress, find_licensing_dialog, family_windows from handlers.open_design import handle_open_design from handlers.close_fusion import handle_close_fusion, handle_fusion_stop, handle_fusion_kill from handlers.dismiss_recovery import dismiss_recovery_dialog@@ -172,20 +172,21 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict:         if _dlg.get("category") in AUTO_DISMISS_CATEGORIES:             close_dialog_bg(_dlg.get("hwnd"))  # benign ack (WM_CLOSE == Cancel/OK) -    # SEAT CONFLICT ("Active Sessions Exceeded") -> AUTO-RECLAIM the license, never ask-    # (John 2026-07-06: "just grab the license back... no harm, the user can reclaim on-    # the other machine"). The dialog is a CEF modal that ignores background SendInput,-    # so instead of fighting it we free the seat at the SOURCE (stop Fusion on peer ADs-    # via cross-AD), kill the local instance stuck on the dialog, and relaunch clean --    # no conflict dialog reappears. Guarded against infinite recursion by a one-shot arg.-    _seat = [d for d in launch_dialogs if d.get("category") == "session_conflict_decision"]-    if _seat and not (args or {}).get("_seatReclaimAttempted"):+    # SEAT/LICENSING dialog ("Active Sessions Exceeded") -> AUTO-RECLAIM the license,+    # never ask (John 2026-07-06: "just grab the license back... no harm"). DETECT IT+    # DETERMINISTICALLY by owning PROCESS + dialog size (find_licensing_dialog), NOT the+    # "Fusion360" window title that fooled the title-based classifier for a whole day.+    # The dialog is a CEF modal that ignores background SendInput, so instead of fighting+    # it we free the seat at the SOURCE (stop Fusion on peer ADs) + let the Autodesk+    # server release it, kill the local instance, and relaunch clean. One-shot guarded.+    _lic = find_licensing_dialog()+    if _lic and not (args or {}).get("_seatReclaimAttempted"):         _reclaim_seat_from_peers()         try:             handle_fusion_kill(fusion_info, {})         except Exception:             pass-        _t.sleep(2.0)+        _t.sleep(4.0)  # give the Autodesk licensing server a moment to release the seat         return _handle_launch(fusion_info, {**(args or {}), "_seatReclaimAttempted": True})      # Surface anything still needing the USER: remaining seat DECISIONS (only if the@@ -852,6 +853,16 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict:     # free; requiring it stops a half-launched or stuck Fusion from reading as ready.     ready = installed and running and addin_ok and not needs_signin +    # SEAT/LICENSING dialog blocking the launch - detected DETERMINISTICALLY by owning+    # process + dialog size (not the "Fusion360" title). Reported so a caller knows the+    # block is a seat conflict (fusion_start auto-reclaims it), not a generic hang.+    licensing = False+    if running and not addin_ok:+        try:+            licensing = find_licensing_dialog() is not None+        except Exception:+            licensing = False+     # Fusion applying an AUTO-UPDATE (two+ webdeploy builds) crash-restarts itself every     # ~30-60s, so a verb intermittently sees running:false. That is NOT a crash or a     # bridge failure - surface it as a distinct non-fatal `updating` flag so a driving AI@@ -889,11 +900,16 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict:         hint = stale_info["_staleHint"]     elif ready:         hint = "READY - drive Fusion (fusion_open_lbr / build_library_3d / etc.)."+    elif installed and running and licensing:+        hint = ("Fusion is blocked by a SEAT/LICENSING dialog (e.g. 'Active Sessions Exceeded') - "+                "detected by owning process + dialog size, NOT by title. Call fusion_start: it "+                "AUTO-RECLAIMS the seat (frees it from peer machines, then relaunches clean). No user "+                "action needed - this is not a crash.")     elif installed and running:         hint = ("Fusion is RUNNING but its add-in is NOT responding yet - NOT ready to drive. Either it "-                "is still finishing launch (poll fusion_readiness a few more times), or the add-in did "-                "not load (fusion_stop then fusion_start to re-deploy + re-load it). Do NOT fire "-                "modeling/export verbs until ready:true.")+                "is still finishing launch / signing in (poll fusion_readiness a few more times), or the "+                "add-in did not load (fusion_stop then fusion_start to re-deploy + re-load it). Do NOT "+                "fire modeling/export verbs until ready:true.")     elif installed:         hint = "Fusion is INSTALLED but not running - call fusion_start (blocks until the add-in is ready), then retry."     else:@@ -920,6 +936,9 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict:     if needs_signin:         result["needsSignin"] = True         result["statusVerb"] = "fusion_readiness"+    if licensing:+        result["licensingDialog"] = True+        result["statusVerb"] = "fusion_readiness"     return result