← Commit history

v1.9.6: background envelope + verb-suite round-one fixes + both dev skills

John Lauer ·cabeaae868 ·1mo ago ·parent 1378697
8 files changed +222−17
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.5\ No newline at end of file+1.9.6\ No newline at end of file
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {     "manifest_version": 1,     "name": "fusion360",-    "version": "1.9.5",-    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.5/adom-bridge-fusion-v1.9.5.zip",-    "sha256": "09a4b57947d638c9a8f2e87308dd9ec4635e49d8d5779c1c6286ffb29f35cfd0",-    "size": 302863,+    "version": "1.9.6",+    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.6/adom-bridge-fusion-v1.9.6.zip",+    "sha256": "b0a03bf579dd46e26267e248285fe0cd381771a6793bcf58a80b9ed4209b36e3",+    "size": 304432,     "verbPrefixes": [         "fusion_"     ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.5",+  "version": "1.9.6",   "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.",
describe.py+2−2
@@ -20,8 +20,8 @@ _T = [      {"skipCleanClose": "optional bool"}, {"success": "bool", "output": "str"}, 60, None, False, {}),     ("fusion_kill", "Force-KILL Fusion 360 (taskkill /F) - the desperate path for when fusion_stop cannot close it (stuck modal / wedged process). Closes docs cleanly first when reachable.",      {"skipCleanClose": "optional bool"}, {"success": "bool", "output": "str"}, 30, None, False, {}),-    ("fusion_close_window", "Close the active Fusion document/window.",-     {"name": "optional str (default: active)"}, {"success": "bool"}, 30, None, False, {}),+    ("fusion_close_window", "Close a DIALOG window by hwnd (WM_CLOSE, like clicking X) - for dialogs without Cancel/Escape handling (e.g. Recovered Documents). NOT a document closer; refuses without hwnd. Get the hwnd from a screenshot's dialogs array or desktop_list_windows. (Doc drift caught by the verb test suite 2026-08-14: this was described as 'close the active document' while the handler required hwnd.)",+     {"hwnd": "required int (the dialog window)"}, {"success": "bool"}, 30, None, False, {"hwnd": 123456}),     ("fusion_get_app_state", "Current app state: running, active document, workspace, isElectronics. The poll/status verb for long-running calls.",      {}, {"activeDocument": "str", "activeWorkspace": "str", "isElectronics": "bool"}, 20, None, False, {}),     ("fusion_readiness", "FAST readiness check (does NOT launch Fusion): is the Fusion host app installed + running + the bridge ready to drive? AD DETECTS Fusion (never installs it) via bridge.json 'detect', and auto-installs Python if missing (AD >=1.9.47). Call this before driving Fusion; if installed-but-not-running, fusion_start. SELF-HEALS the seat/licensing dialog (background UIA + screenshot-verify) whenever it detects one. Pairs with AD's bridge_readiness.",
dev-skills/fusion-background-etiquette/SKILL.mdadded+66
@@ -0,0 +1,66 @@+---+name: fusion-background-etiquette+description: The invariant every code change must preserve - Fusion windows NEVER steal the user's foreground; every window-raising verb runs inside the refcounted background envelope, and the daily verb tests assert focus invariance+---++# fusion-background-etiquette - Fusion never steals the foreground++**The invariant (John, 2026-08-14):** a user is typing in their AI threads while this+bridge drives Fusion. Any window this bridge causes to appear - the app itself, a+document, a picker, a recovery dialog - must arrive in the BACKGROUND. Screenshots,+exports, view switches, and builds all work on background windows (PrintWindow renders+occluded windows fine), so there is no functional excuse for a focus steal. A stolen+foreground mid-keystroke is the single most user-hostile thing this bridge can do.++## The mechanism: the background envelope++`fusion_detect.begin_background_envelope(seconds, minimize=False)` - refcounted, so+overlapping verbs nest and only the last drain restores the user's settings. It does+three things for its duration:++1. Raises the Windows foreground-lock timeout, so focus steals are DENIED up front and+   become taskbar flashes (this kills the visible flicker; the watcher alone cannot).+2. Disables the minimize animation, so any demotion is an instant vanish.+3. Runs a watcher: any Autodesk-FAMILY window that still reaches the foreground is+   demoted. Two modes:+   - `minimize=True` (launch only): splash / seat / launcher windows are minimized away.+   - `minimize=False` (everything else): the window is pushed to the BOTTOM of the+     z-order WITHOUT minimizing - a minimized window renders stale in PrintWindow, and+     background captures must keep working mid-operation.++The watcher is surgical: it acts only when the CURRENT foreground window belongs to the+Fusion family. A user who deliberately clicks into Fusion is never fought.++## Where it is armed++`dispatch_command` in `server.py` holds `_WINDOW_RAISING` - the set of verbs that can+make a window appear (opens, view switches, imports, builds, exports-with-dialogs,+`start`). Every dispatch of one of those arms the envelope for the verb's timeout + 20s.++**Deliberate-foreground verbs are excluded on purpose** and must stay excluded:+`prefs_open`, `mcp_enable`, `demo` - they need real clicks on screen, and they announce+themselves with an on-screen caption first (see the fusion-driving skill's etiquette).++## The rule for every future code change++- New verb that can raise a window? **Add it to `_WINDOW_RAISING` in the same commit.**+- New verb that deliberately takes the foreground? It must (a) be a conscious exception+  documented here, (b) announce itself with a caption before touching the screen, and+  (c) take a `foregroundReason`-style justification if a caller can trigger it remotely.+- Never "fix" a focus problem by foregrounding Fusion. Fix it by making the operation+  work in the background - that is always possible with hwnd-targeted capture and UIA.++## How regressions get caught++The daily verb test suite (dev-skills/fusion-verb-tests) includes a focus-invariance+check: it records the foreground window before the document-flow stage and fails the+run if a Fusion-family window holds the foreground afterwards. A focus steal is a+failing test from now on, not an anecdote a user has to report.++## History, so the lesson keeps its teeth++The launch path got this treatment first (guard built for `fusion_start`). It then sat+with exactly ONE caller while every cloud open and dialog ran bare - and users kept+losing their foreground to document opens for weeks until John called it out during a+test run. The envelope exists so protection is the DEFAULT at the dispatcher, not a+per-call courtesy someone has to remember.
fusion_detect.py+88−4
@@ -715,6 +715,87 @@ def _popen_fusion_background(exe_path: str):     )  ++# ── The background envelope (John, 2026-08-14: "ALWAYS open in the background") ──────────────+# The launch guard above only protected fusion_start. But the focus thefts users actually feel+# happen DURING operations on a running Fusion: a cloud open activates the document window, a+# picker/recovery dialog pops, an import raises a chooser - all while the user is typing in+# another app. The envelope generalizes the guard so ANY window-raising verb runs inside it:+#   - foreground-lock raised for the duration (steals become taskbar flashes)+#   - a watcher demotes any Autodesk-family window that still grabs focus+#   - refcounted, so overlapping verbs nest and only the LAST one restores the user's settings+# Two demotion modes: minimize=True (launch: splash/seat windows vanish) and minimize=False+# (running ops: the main window is pushed to the BOTTOM of the z-order WITHOUT minimizing, so+# PrintWindow captures keep rendering it correctly).+_ENVELOPE_LOCK = threading.Lock()+_ENVELOPE_COUNT = 0+_ENVELOPE_SAVED = {}+++def begin_background_envelope(seconds: float, minimize: bool = False):+    """Guard the next `seconds` against Fusion-family focus theft. Fire-and-forget."""+    global _ENVELOPE_COUNT+    try:+        with _ENVELOPE_LOCK:+            _ENVELOPE_COUNT += 1+            if _ENVELOPE_COUNT == 1:+                _ENVELOPE_SAVED["fg"] = _get_fg_lock_timeout()+                _ENVELOPE_SAVED["anim"] = _get_min_animate()+                _set_fg_lock_timeout(300000)+                _set_min_animate(0)+        threading.Thread(target=_envelope_watch, args=(seconds, minimize), daemon=True).start()+    except Exception:+        pass+++def _envelope_watch(seconds: float, minimize: bool):+    try:+        user32 = ctypes.windll.user32+        HWND = ctypes.wintypes.HWND+        user32.GetForegroundWindow.restype = HWND+        user32.GetForegroundWindow.argtypes = []+        user32.GetWindowThreadProcessId.argtypes = [HWND, ctypes.POINTER(ctypes.wintypes.DWORD)]+        user32.GetWindowThreadProcessId.restype = ctypes.wintypes.DWORD+        user32.ShowWindowAsync.argtypes = [HWND, ctypes.c_int]+        user32.ShowWindowAsync.restype = ctypes.wintypes.BOOL+        user32.SetWindowPos.argtypes = [HWND, HWND, ctypes.c_int, ctypes.c_int,+                                        ctypes.c_int, ctypes.c_int, ctypes.c_uint]+        user32.SetWindowPos.restype = ctypes.wintypes.BOOL+        HWND_BOTTOM = HWND(1)+        SWP_FLAGS = 0x0001 | 0x0002 | 0x0010  # NOSIZE | NOMOVE | NOACTIVATE+        end = time.time() + seconds+        pids, last_refresh = set(), 0.0+        while time.time() < end:+            now = time.time()+            if now - last_refresh > 2.0:+                pids = _fusion_family_pids()+                last_refresh = now+            try:+                fg = user32.GetForegroundWindow()+                if fg and pids:+                    wpid = ctypes.wintypes.DWORD(0)+                    user32.GetWindowThreadProcessId(fg, ctypes.byref(wpid))+                    if wpid.value in pids:+                        if minimize:+                            user32.ShowWindowAsync(fg, _SW_SHOWMINNOACTIVE)+                        else:+                            user32.SetWindowPos(fg, HWND_BOTTOM, 0, 0, 0, 0, SWP_FLAGS)+            except Exception:+                pass+            time.sleep(0.1)+    except Exception:+        pass  # non-Windows / ctypes unavailable: envelope is a no-op, refcount still drains+    finally:+        global _ENVELOPE_COUNT+        with _ENVELOPE_LOCK:+            _ENVELOPE_COUNT -= 1+            if _ENVELOPE_COUNT == 0:+                if _ENVELOPE_SAVED.get("fg") is not None:+                    _set_fg_lock_timeout(_ENVELOPE_SAVED["fg"])+                if _ENVELOPE_SAVED.get("anim") is not None:+                    _set_min_animate(_ENVELOPE_SAVED["anim"])++ def _focus_guard(seconds: float = 150.0, restore_fg_lock=None, restore_min_anim=None):     """For `seconds`, demote any Autodesk-FAMILY window that steals the foreground. @@ -822,10 +903,13 @@ def ensure_fusion_running(fusion_info: dict, wait_addin: bool = False) -> dict |         # streams + CREATE_NO_WINDOW: the bridge is a detached, console-less child of         # AD, so inherited handles are invalid.         _popen_fusion_background(exe_path)-        # Reactive backstop: minimize any family window that still slips to foreground,-        # and restore the user's original foreground-lock + animation when done.-        _start_focus_guard(seconds=150.0, restore_fg_lock=_orig_fg_lock,-                           restore_min_anim=_orig_min_anim)+        # Reactive backstop via the refcounted envelope (minimize mode: splash/seat+        # windows vanish rather than being demoted). The envelope restores the user's+        # foreground-lock + animation when the LAST active guard drains, so it composes+        # with per-verb envelopes armed by the dispatcher.+        _set_fg_lock_timeout(_orig_fg_lock)   # envelope snapshots + re-raises itself+        _set_min_animate(_orig_min_anim)+        begin_background_envelope(150.0, minimize=True)     except Exception as e:         return {"success": False, "error": f"Failed to launch Fusion 360: {e}"} 
server.py+19−1
@@ -24,7 +24,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, find_licensing_dialog, family_windows, _incomplete_webdeploy_present+from fusion_detect import detect_fusion, ensure_fusion_running, wait_for_addin, _is_fusion_running, fusion_update_in_progress, find_licensing_dialog, family_windows, _incomplete_webdeploy_present, begin_background_envelope 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@@ -5751,6 +5751,24 @@ def dispatch_command(command: str, args: dict, caller_identity: dict = None) ->         if _guard is not None:             return _guard         args = {k: v for k, v in args.items() if k != "expectDocument"}++    # Background envelope (John 2026-08-14): ANY verb that can raise a Fusion window runs guarded,+    # so a cloud open / view switch / import NEVER steals the user's foreground mid-typing. The+    # deliberate-foreground verbs (prefs_open, mcp_enable, demo - which announce themselves with+    # captions) are excluded on purpose. Refcounted, so overlapping verbs nest cleanly.+    _WINDOW_RAISING = {+        "open_cloud_file", "open_by_urn", "aps_open", "open_design", "open_lbr",+        "open_schematic", "open_board", "import_electronics", "import_step",+        "new_electronics_from_eagle", "show_schematic", "show_2d_board", "show_3d_board",+        "run_modeling_script", "execute_text_command", "electron_run",+        "attach_3d_package", "make_3d_package", "build_library_3d",+        "capture_library_views", "generate_package", "export_optimized_glb",+        "board_stackup", "save_to_cloud", "start",+    }+    if command in _WINDOW_RAISING:+        _sec = ADDIN_COMMAND_TIMEOUTS.get(command, 60) + 20+        begin_background_envelope(float(_sec), minimize=(command == "start"))+     # Direct handlers (don't need the add-in)     handler = COMMAND_HANDLERS.get(command)     if handler is not None:
tests/run_verb_tests.py+41−4
@@ -67,9 +67,26 @@ def expect(d, spec):         got = {v["name"] if isinstance(v, dict) else v for v in (d.get("verbs") or [])}         planned = set(PLAN.keys())         if got and (got - planned): errs.append("verbs described but UNTRIAGED in test plan: %s" % sorted(got - planned)[:6])+    if spec.get("pred") == "must_refuse" and not (d.get("success") is False or d.get("error")):+        errs.append("expected a refusal, got success")     if d.get("_testTimeout"): errs.append("timed out")     return errs ++def call_polled(target, verb, args, doc, poll_sec):+    """Fire a long open, then poll get_app_state until the doc is active (the bridge's own+    _timeoutHint prescribes exactly this instead of trusting the relay timeout)."""+    args = {k: v for k, v in args.items() if not k.startswith("_")}+    d, dt = call(target, verb, args, timeout=70)+    ok = (d.get("success") is True or d.get("status") == "ok")+    t0 = time.time()+    while time.time() - t0 < poll_sec:+        st, _ = call(target, "fusion_get_app_state", {}, timeout=45)+        if st.get("activeDocument") == doc:+            return {"success": True, "activeDocument": doc}, dt + (time.time() - t0)+        time.sleep(8)+    return (d if not ok else {"success": False, "error": "document never became active"}), dt + (time.time() - t0)+ # tier, args, expectation, [cleanup verb, cleanup args] P = {} def t(verb, tier, args=None, exp=None, note=""):@@ -88,7 +105,7 @@ t("fusion_aps_status", "daily", {}, {"ok": True}) t("fusion_aps_get_browser", "daily", {}, {"ok": True}) t("fusion_aps_search", "daily", {"query": "BQ25792", "limit": 5}, {"ok": True, "pred": "count_gt_0"}) t("fusion_aps_recent", "daily", {"limit": 5}, {"ok": True, "pred": "count_gt_0"})-t("fusion_aps_browse", "daily", {}, {"ok": True, "fields": ["items"]})+t("fusion_aps_browse", "daily", {}, {"ok": True, "fields": ["hubs"]}) t("fusion_aps_file_info", "daily", {"query": "BQ25792"}, {"ok": True, "fields": ["versionCount"]}) t("fusion_aps_versions", "weekly", {"query": "BQ25792"}, {"ok": True, "fields": ["versionCount"]}) t("fusion_aps_download", "weekly", {"query": "BQ25792", "saveDir": SCRATCH}, {"ok": True})@@ -113,10 +130,14 @@ t("fusion_mcp_enable", "manual", note="drives the prefs UI, takes foreground") t("fusion_prefs_open", "manual", note="opens a dialog on the user's screen") t("fusion_prefs_close", "manual") t("fusion_set_auto_update", "daily", {}, {"ok": True}, "no args = read-only")+t("fusion_dismiss_blocking_dialogs", "daily", {}, {"ok": True}, "no-op when nothing is blocking")+t("fusion_detect_layers", "weekly", {}, {"ok": True}, "board view active")+t("fusion_fetch_optimized_glb", "weekly", {}, {"ok": True}, "runs the stackup GLB fetch")+t("fusion_load_design_rules", "manual", note="mutates the board's DRC rules")  # ── stage 3: document flows on the canonical board ────────────────────────── OPEN = {"fileName": "BQ25792", "projectName": "Main", "folderPath": "Molecules/XRP/Power"}-t("fusion_open_cloud_file", "daily", OPEN, {"ok": True}, "the canonical demo board")+t("fusion_open_cloud_file", "daily", dict(OPEN, **{"_pollDoc": "BQ25792", "_pollSec": 180}), {"ok": True}, "fire-and-poll: cloud opens outlive the relay timeout") t("fusion_show_schematic", "daily", {}, {"ok": True}) t("fusion_show_2d_board", "daily", {}, {"ok": True}) t("fusion_show_3d_board", "daily", {}, {"ok": True})@@ -144,7 +165,7 @@ t("fusion_electron_select", "manual", note="selection state is user-visible") t("fusion_open_by_urn", "weekly", note="URN fetched from aps_search at runtime", args={"_dynamic": "urn_from_search"}) t("fusion_aps_open", "weekly", {"query": "BQ25792", "wait": False}, {"ok": True}) t("fusion_open_design", "manual", note="local-file open; superseded by cloud flows")-t("fusion_close_window", "daily", {}, {"ok": True}, "closes the test board tab, restores tab state")+t("fusion_close_window#noargs", "negative", {}, {"pred": "must_refuse"}, "dialog closer; hwnd required - refusal IS the pass") t("fusion_close_document", "manual"); t("fusion_close_all_documents", "manual", note="would close the user's own tabs")  # ── stage 4: exports (local writes to scratch) ──────────────────────────────@@ -205,7 +226,10 @@ def main():             results.append({"verb": name, "status": "SKIP", "why": "dynamic fixture not staged this run"})             continue         verb = name.split("#")[0]-        d, dt = call(a.target, verb, spec["args"])+        if "_pollDoc" in spec["args"]:+            d, dt = call_polled(a.target, verb, spec["args"], spec["args"]["_pollDoc"], spec["args"]["_pollSec"])+        else:+            d, dt = call(a.target, verb, spec["args"])         errs = expect(d, spec["exp"])         if name.startswith("fusion_search_cloud_files") or name.startswith("fusion_walk_cloud_tree"):             # must refuse: success:true here is the FAILURE@@ -215,6 +239,19 @@ def main():                         "why": "; ".join(errs), "sec": round(dt, 1)})         print("  %-42s %s %s" % (name, "PASS" if not errs else "FAIL", ("- " + "; ".join(errs)) if errs else "")) +    # Focus invariance (fusion-background-etiquette): the whole run must leave the user's+    # foreground alone. z=0 belonging to a Fusion-family window at the end = a steal happened.+    fg, _ = call(a.target, "desktop_list_windows", {}, timeout=45)+    try:+        wins = fg.get("windows") or []+        top = min(wins, key=lambda w: w.get("z", 999)) if wins else {}+        stolen = "Autodesk Fusion" in str(top.get("title", "")) or str(top.get("title", "")).strip() == "Fusion360"+        results.append({"verb": "focus_invariance", "status": "FAIL" if stolen else "PASS",+                        "why": ("foreground is %r after the run" % top.get("title")) if stolen else ""})+        print("  %-42s %s" % ("focus_invariance", "FAIL" if stolen else "PASS"))+    except Exception as e:+        results.append({"verb": "focus_invariance", "status": "SKIP", "why": str(e)})+     passed = sum(1 for r in results if r["status"] == "PASS")     failed = [r for r in results if r["status"] == "FAIL"]     stamp = datetime.datetime.now().strftime("%Y-%m-%d_%H%M")