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
v1.9.6: background envelope + verb-suite round-one fixes + both dev skills
8 files changed
+222−17
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.5+1.9.6
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")