← Commit history

v1.6.34: focus-guard covers Autodesk family + seat-conflict auto-reclaim (background)

John Lauer ·1d40e24253 ·3mo ago ·parent aa89fbe
7 files changed +124−27
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.6.33\ No newline at end of file+1.6.34\ No newline at end of file
CHANGELOG.md+13
@@ -3,6 +3,19 @@ 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.34 - 2026-07-06++- **Focus-guard now covers the whole Autodesk launch FAMILY** (fix for the 1.6.33 guard, caught live).+  The guard only demoted `Fusion360.exe` windows, but the splash "Loading additional modules"+  (`FusionLauncher.exe`) and the "Signing in" / **"Active Sessions Exceeded"** dialogs+  (`AdskIdentityManager.exe`) slipped past and yanked the user's foreground. Now `_fusion_family_pids()`+  matches every family process image and the guard (150s) minimizes any of them that steals focus.+- **Seat conflict ("Active Sessions Exceeded") now AUTO-RECLAIMS the license instead of asking**+  (John: "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 the launch handler frees the seat at the+  SOURCE via `_reclaim_seat_from_peers()` (cross-AD `fusion_stop` to peer ADs), kills the local instance+  stuck on the dialog, and relaunches clean - no conflict dialog, no user prompt, fully background.+ ## 1.6.33 - 2026-07-06  - **Fusion now launches in the BACKGROUND - never steals the user's foreground** (John's directive).
adom-bridge-fusion-manifest.json+5−5
@@ -1,13 +1,13 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.6.33",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.33/adom-bridge-fusion-v1.6.33.zip",-  "sha256": "70b10a6c7eabfa5116fa749a1d02b76fc5b918787fcf64b5387da567f317bc65",-  "size": 332961,+  "version": "1.6.34",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.34/adom-bridge-fusion-v1.6.34.zip",+  "sha256": "da0ae88c943bab901db9509c800359459a20507baf6e469cbb2c287afafa3a5d",+  "size": 334972,   "verbPrefixes": [     "fusion_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-07-06T11:01:29Z"+  "released_at": "2026-07-06T11:20:43Z" }
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.6.33",+  "version": "1.6.34",   "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+42−11
@@ -299,6 +299,35 @@ def _dismiss_startup_dialogs(): _SW_SHOWMINNOACTIVE = 7  # show minimized, do NOT activate/steal focus  +# Every process in the Autodesk launch family whose windows must NEVER steal the+# user's foreground: the main app (Fusion360.exe), the streamed-app launcher ++# splash ("Loading additional modules" - FusionLauncher.exe), and the identity/seat+# manager (the "Signing in" + "Active Sessions Exceeded" dialogs - AdskIdentityManager+# .exe). The 1.6.33 guard only knew Fusion360.exe, so the splash + seat dialog sailed+# straight past it and yanked focus (John, 2026-07-06). Match the whole family.+_FUSION_FAMILY_IMAGES = {+    "fusion360.exe", "fusionlauncher.exe", "adskidentitymanager.exe",+    "adsso.exe", "adcefwebbrowser.exe", "fusion360bootstrap.exe",+}+++def _fusion_family_pids() -> set:+    """PIDs of every running Autodesk-launch-family process (via one tasklist CSV)."""+    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 set()+    pids = set()+    for m in re.finditer(r'"([^"]+\.exe)","(\d+)"', out):+        if m.group(1).lower() in _FUSION_FAMILY_IMAGES:+            pids.add(int(m.group(2)))+    return pids++ def _fusion_pids() -> set:     """PIDs of running Fusion360.exe processes (via tasklist CSV). Empty on error."""     try:@@ -332,14 +361,16 @@ def _popen_fusion_background(exe_path: str):     )  -def _focus_guard(seconds: float = 120.0):-    """For `seconds`, demote any Fusion window that steals the foreground.+def _focus_guard(seconds: float = 150.0):+    """For `seconds`, demote any Autodesk-FAMILY window that steals the foreground. -    SURGICAL: only acts when the CURRENT foreground window belongs to a Fusion-    process (i.e. Fusion just grabbed focus). It minimizes that window without-    activating anything, so focus falls back to whatever the user was using. It-    does NOT touch Fusion when the user is in another app, so it never fights a-    user who deliberately opened Fusion. Best-effort; never raises."""+    SURGICAL: only acts when the CURRENT foreground window belongs to an Autodesk+    launch-family process (Fusion / launcher-splash / identity-seat manager) - i.e.+    something just grabbed focus off the user. It minimizes that window without+    activating anything, so focus falls back to whatever the user was in. It does+    NOT touch the family when the user is working in another app, so it never+    fights a user who deliberately foregrounds Fusion themselves. Best-effort;+    never raises."""     try:         user32 = ctypes.windll.user32     except Exception:@@ -349,8 +380,8 @@ def _focus_guard(seconds: float = 120.0):     last_refresh = 0.0     while time.time() < end:         now = time.time()-        if now - last_refresh > 3.0:-            pids = _fusion_pids()+        if now - last_refresh > 2.0:+            pids = _fusion_family_pids()  # whole family, not just Fusion360.exe             last_refresh = now         try:             fg = user32.GetForegroundWindow()@@ -358,11 +389,11 @@ def _focus_guard(seconds: float = 120.0):                 wpid = ctypes.wintypes.DWORD(0)                 user32.GetWindowThreadProcessId(fg, ctypes.byref(wpid))                 if wpid.value in pids:-                    # minimize the focus-stealing Fusion window, no activation+                    # minimize the focus-stealing family window, no activation                     user32.ShowWindowAsync(fg, _SW_SHOWMINNOACTIVE)         except Exception:             pass-        time.sleep(0.2)+        time.sleep(0.15)   def _start_focus_guard(seconds: float = 120.0):
server.py+45−4
@@ -92,6 +92,30 @@ def _addin_staleness(reported_version) -> dict: fusion_info = None  +def _reclaim_seat_from_peers() -> int:+    """Free the Autodesk seat held by another machine by stopping Fusion on peer ADs.++    John (2026-07-06): "if the user asked you to do something with fusion you should+    just grab the license back. the user can always grab it back on the other machine,+    so there's no harm." So on a seat conflict we do NOT ask - we free the seat at the+    SOURCE via the cross-AD direct API (fully background + reliable, no CEF-dialog+    clicking), then relaunch locally with no conflict. Best-effort; returns the number+    of peers signalled (0 if the direct API/peers are unavailable)."""+    n = 0+    try:+        if not ad_client.available():+            return 0+        for peer in ad_client.peers():+            try:+                if ad_client.call("fusion_stop", {}, target=peer) is not None:+                    n += 1+            except Exception:+                pass+    except Exception:+        pass+    return n++ def _handle_launch(fusion_info: dict, args: dict) -> dict:     """Launch Fusion 360 and optionally wait for the AdomBridge add-in."""     # LIVE detect (not the stale bridge-start fusion_info snapshot) so a launch right@@ -142,14 +166,31 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict:     # WATCH for launch/licensing dialogs the moment the process is up - IN CODE, so     # a launch is never blind to them (the seat-conflict + streamed-app-error dialogs     # are owned by AdskIdentityManager/FusionLauncher, invisible to the Fusion-scoped-    # classifier). Auto-dismiss BENIGN errors; a DECISION dialog (seat conflict) is-    # returned for the AI to notify_user + ASK - never auto-picked.+    # classifier). Auto-dismiss BENIGN errors.     launch_dialogs = classify_launch_dialogs()     for _dlg in launch_dialogs:         if _dlg.get("category") in AUTO_DISMISS_CATEGORIES:             close_dialog_bg(_dlg.get("hwnd"))  # benign ack (WM_CLOSE == Cancel/OK)-    # Surface anything needing the USER: seat DECISIONS + first-run SIGN-IN. Never-    # auto-close these (a WM_CLOSE would kill the sign-in window).++    # 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"):+        _reclaim_seat_from_peers()+        try:+            handle_fusion_kill(fusion_info, {})+        except Exception:+            pass+        _t.sleep(2.0)+        return _handle_launch(fusion_info, {**(args or {}), "_seatReclaimAttempted": True})++    # Surface anything still needing the USER: remaining seat DECISIONS (only if the+    # auto-reclaim above couldn't free it - e.g. the seat holder isn't a reachable AD)+    # + first-run SIGN-IN. Never auto-close these (a WM_CLOSE would kill the window).     _needs_user = [d for d in launch_dialogs                    if d.get("decision") or d.get("category") == "signin_required"]     if _needs_user:
skills/fusion-bridge-dev/SKILL.md+17−5
@@ -16,11 +16,23 @@ opening path you add MUST follow it: - **Launch minimized + not-activated.** Start GUI processes with `STARTUPINFO.wShowWindow =   SW_SHOWMINNOACTIVE (7)` + `CREATE_NO_WINDOW`. See `_popen_fusion_background()` in `fusion_detect.py`   — copy that, never a bare `subprocess.Popen([exe])` (which opens Fusion in the foreground).-- **Guard the startup window.** `STARTUPINFO` alone is not enough — Fusion re-foregrounds itself-  several times as its splash/main window initialize. `ensure_fusion_running()` fires-  `_start_focus_guard()`: a daemon thread that, for ~120s, minimizes (no-activate) **only** a Fusion-  window that is **currently the foreground** (it detects focus-theft via `GetForegroundWindow` +-  PID match, so it never fights a user who deliberately opened Fusion). Reuse it for any new launch.+- **Guard the startup window — match the whole Autodesk FAMILY, not just `Fusion360.exe`.**+  `STARTUPINFO` alone is not enough: Fusion re-foregrounds itself several times as it initializes, and+  the noisiest offenders are NOT `Fusion360.exe` — the splash "Loading additional modules" is+  `FusionLauncher.exe`, and the "Signing in" + **"Active Sessions Exceeded"** dialogs are+  `AdskIdentityManager.exe`. A guard scoped to only `Fusion360.exe` lets those yank focus (the exact+  bug John hit 2026-07-06). `ensure_fusion_running()` fires `_start_focus_guard()`: a daemon that, for+  ~150s, minimizes (no-activate) **only** the foreground window when it belongs to the Autodesk launch+  family (`_FUSION_FAMILY_IMAGES` = Fusion360 / FusionLauncher / AdskIdentityManager / …, matched by+  process image via `_fusion_family_pids()`). It acts ONLY on focus-theft, so it never fights a user+  who deliberately foregrounds Fusion. Any window-family you add MUST be in that set.+- **Seat conflict ("Active Sessions Exceeded") = AUTO-RECLAIM, never ask.** John (2026-07-06): "just+  grab the license back… the user can always grab it back on the other machine, so there's no harm."+  The dialog is a CEF modal that ignores background `SendInput`, so do NOT try to click it. Instead+  `_handle_launch` frees the seat at the SOURCE — `_reclaim_seat_from_peers()` stops Fusion on peer ADs+  via the cross-AD direct API (fully background, reliable) — then kills the local instance stuck on the+  dialog and relaunches clean (guarded by a one-shot `_seatReclaimAttempted` arg). Do not reintroduce a+  notify+ask path for the seat dialog. - **The add-in still loads while minimized** — background operation (open/model/export) needs no   visible window. So minimized costs you nothing. - **Foreground is opt-in, only to SHOW the user something.** The ONLY time you may foreground/restore