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.49: save-prompt hardening
6 files changed
+97−30
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.48+1.9.49
CHANGELOG.md+11@@ -3,6 +3,17 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## 1.9.49 - 2026-08-16++- **Save-prompt hardening after two live tour failures (ConfRoomROG).** A bare Qt "Save" modal+ from a killed session blocked the add-in main thread invisibly: no UIA tree, PrintWindow black,+ classified "unknown". Now: (1) title "save" classifies save_changes; (2) the sentinel detects+ the UIA-less variant by title and falls back to posting ESCAPE (= Cancel, non-destructive,+ needs no UIA and no ab input grant) when the Don't-Save click is unavailable; (3) the+ bridge-doc registry persists to prefs.json AND carries built-in conventions (adom-test-*,+ AdomDemoSample) so a respawn can't orphan the bridge's own docs; (4) the 20s janitor sweep+ now clears standing bridge-doc save prompts, not just seat dialogs and update nags.+ ## 1.9.48 - 2026-08-16 - **stepShot frames actually deliver on PIL-less bridges.** AD's provisioned python has no PIL,
adom-bridge-fusion-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.9.48",- "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.48/adom-bridge-fusion-v1.9.48.zip",- "sha256": "94fc6edeb64a8366928d736bdc574282a3d2e3448ff4d2ca5e42b6fdbb0e7fc4",- "size": 396880,+ "version": "1.9.49",+ "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.49/adom-bridge-fusion-v1.9.49.zip",+ "sha256": "42a4b004a7c906418f9d6461a09bd1f5a90aea3b9ee809bba5cb64414f2c2ac4",+ "size": 397899, "verbPrefixes": [ "fusion_" ],
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.48",+ "version": "1.9.49", "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.",
handlers/dialog_classify.py+4−1@@ -56,7 +56,10 @@ _RULES = [ "Document Recovery prompt. Call fusion_dismiss_recovery, or fusion_relocate_recovery " "BEFORE fusion_start to prevent it."), - (("save changes", "save document", "do you want to save", "unsaved changes"),+ # "save" alone: ConfRoomROG's Fusion build titles the unsaved-doc prompt with the bare+ # word (2026-08-16 - it classified "unknown" and blocked the main thread invisibly).+ # Substring "save" is safe HERE because the more specific save rules above run first.+ (("save changes", "save document", "do you want to save", "unsaved changes", "save"), "save_changes", "Unsaved-changes prompt. Use fusion_close_document (closes without the save modal), " "or fusion_send_key {\"key\":\"tab\"} then {\"key\":\"enter\"}."),
server.py+76−23@@ -4231,33 +4231,57 @@ COMMAND_HANDLERS["set_auto_update"] = _handle_set_auto_update _BRIDGE_DOCS: set = set() # lowercase doc-name fragments the bridge created _DONT_SAVE_LABELS = ("Don't Save", "Don’t Save")+# Names the bridge generates by CONVENTION - always ours, even after a respawn wiped the+# session registry (caught live 2026-08-16: respawn emptied _BRIDGE_DOCS and the sentinel+# refused its own ADOM-TEST doc's prompt, wedging two tours in a row).+_BRIDGE_DOC_BUILTINS = ("adom-test-", "adomdemosample", "adom-fusion-wrap") def _note_bridge_doc(name: str):- """Register a document/library name as BRIDGE-CREATED (safe to auto-discard)."""+ """Register a document/library name as BRIDGE-CREATED (safe to auto-discard).+ Write-through to prefs.json so the registry SURVIVES bridge respawns.""" n = str(name or "").rsplit("/", 1)[-1].rsplit("\\", 1)[-1] n = n.rsplit(".", 1)[0].strip().lower() if len(n) >= 3: _BRIDGE_DOCS.add(n) while len(_BRIDGE_DOCS) > 24: _BRIDGE_DOCS.pop()+ try:+ _set_bridge_pref("bridgeDocs", sorted(_BRIDGE_DOCS))+ except Exception:+ pass+++try: # reload the persisted registry at import (respawn survival)+ _BRIDGE_DOCS.update(x for x in (_get_bridge_pref("bridgeDocs", []) or [])+ if isinstance(x, str) and len(x) >= 3)+except Exception:+ pass+++def _is_bridge_doc_text(text: str):+ t = (text or "").lower()+ return (next((n for n in _BRIDGE_DOCS if n in t), None)+ or next((p for p in _BRIDGE_DOC_BUILTINS if p in t), None)) def _find_save_prompt() -> dict:- """Is Fusion's unsaved-doc Save prompt up? Precise: it is the ONLY Fusion dialog- with a "Don't Save" button. Returns {found, hwnd, button, text} - never raises."""+ """Is Fusion's unsaved-doc Save prompt up? Two shapes seen live: a CEF modal with a+ UIA-reachable "Don't Save" button, and a bare Qt dialog titled just "Save" with NO+ UIA tree at all (ConfRoomROG, 2026-08-16 - it blocked the main thread invisibly).+ Returns {found, hwnd, button|None, text} - never raises.""" try: info = get_fusion_window_info() or {} for d in info.get("dialogs") or []: h = d.get("hwnd") if not h: continue+ title = str(d.get("title") or "").strip().lower() for label in _DONT_SAVE_LABELS: r = _find_controls_any(_ad_call("desktop_find_control", {"hwnd": int(h), "name": label}, timeout=10)) best = r.get("best") or {} if best.get("name") and best.get("invokable"):- # Pull the dialog's static text so the caller can see WHICH doc it names. text = "" try: t = _find_controls_any(_ad_call("desktop_find_control",@@ -4269,34 +4293,54 @@ def _find_save_prompt() -> dict: except Exception: pass return {"found": True, "hwnd": int(h), "button": best.get("name"), "text": text}+ if title in ("save", "save changes", "unsaved changes"):+ # UIA-less Qt variant: identified by title alone; no clickable button handle.+ return {"found": True, "hwnd": int(h), "button": None, "text": ""} except Exception: pass return {"found": False} def _dismiss_save_prompt_if_bridge_doc() -> dict:- """Auto-click "Don't Save" ONLY when the prompt names a bridge-created doc.- Returns {handled, why, ...} - never raises."""+ """Auto-dismiss the Save prompt ONLY for bridge-created docs. Ladder:+ 1. Prompt text names a bridge doc -> UIA-click "Don't Save" (background, discards OUR+ throwaway edits). 2. Text unreadable (UIA-less Qt variant) but the MAIN WINDOW+ title names a bridge doc -> post ESCAPE (= Cancel: non-destructive for ANY doc,+ just unblocks the main thread; the doc stays open unsaved). UIA click falling+ back to Escape also covers ab's per-command permission gate blocking desktop_ui_click.+ A prompt for a USER's document is never touched. Returns {handled, why} - never raises.""" prompt = _find_save_prompt() if not prompt.get("found"): return {"handled": False, "why": "no save prompt"}- text = (prompt.get("text") or "").lower()- matched = next((n for n in _BRIDGE_DOCS if n in text), None)- if not matched and text:- return {"handled": False, "why": "prompt names a non-bridge document - NOT auto-discarding",- "text": prompt["text"], "hwnd": prompt["hwnd"]}- if not matched and not text:- # No readable text (CEF-opaque build): only trust it if EVERY doc this session is ours- # is unknowable, so stay conservative.- return {"handled": False, "why": "prompt text unreadable - NOT auto-discarding", "hwnd": prompt["hwnd"]}- r = _unwrap(_ad_call("desktop_ui_click",- {"hwnd": prompt["hwnd"], "name": prompt["button"]}, timeout=15))- ok = bool(r.get("invoked") or r.get("success"))- if ok:- print("[Fusion Bridge] save-prompt sentinel: clicked %r for bridge doc %r (background)"- % (prompt["button"], matched))- return {"handled": ok, "why": "clicked '%s' for bridge doc '%s'" % (prompt["button"], matched),- "doc": matched}+ text = prompt.get("text") or ""+ matched = _is_bridge_doc_text(text)+ main_title = ""+ if not matched:+ try:+ main_title = str((get_fusion_window_info() or {}).get("title") or "")+ except Exception:+ pass+ matched = _is_bridge_doc_text(main_title)+ if not matched:+ return {"handled": False, "why": "prompt/main title name no bridge document - NOT auto-dismissing",+ "hwnd": prompt["hwnd"]}+ if prompt.get("button"):+ r = _unwrap(_ad_call("desktop_ui_click",+ {"hwnd": prompt["hwnd"], "name": prompt["button"]}, timeout=15))+ if r.get("invoked") or r.get("success"):+ print("[Fusion Bridge] save-prompt sentinel: clicked %r for bridge doc %r (background)"+ % (prompt["button"], matched))+ return {"handled": True, "why": "clicked '%s' for bridge doc '%s'" % (prompt["button"], matched)}+ # Escape = Cancel: always non-destructive, needs no UIA and no ab input grant.+ try:+ esc = send_key_background("escape", hwnd=prompt["hwnd"])+ if esc.get("success"):+ print("[Fusion Bridge] save-prompt sentinel: ESCAPED the prompt for bridge doc %r "+ "(Cancel - doc stays open unsaved, main thread freed)" % matched)+ return {"handled": True, "why": "escaped (Cancel) for bridge doc '%s'" % matched}+ except Exception:+ pass+ return {"handled": False, "why": "dismiss attempts did not land", "hwnd": prompt["hwnd"]} class _SavePromptSentinel:@@ -5092,6 +5136,15 @@ def _seat_sweep_tick(): % (r.get("clicks"), r.get("verified"))) except Exception: pass+ # Same tick also sweeps a standing Save prompt for BRIDGE-CREATED docs (2026-08-16:+ # a bare "Save" Qt modal from a killed session blocked the main thread invisibly and+ # failed two tours in a row - the sentinel only ran during doc-switch calls).+ try:+ info = get_fusion_window_info() or {}+ if info.get("dialogs"):+ _dismiss_save_prompt_if_bridge_doc()+ except Exception:+ pass def _signin_janitor_loop():