← Commit history

v1.9.49: save-prompt hardening

John Lauer ·72415ba136 ·1mo ago ·parent cac5a2d
6 files changed +97−30
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.48\ No newline at end of file+1.9.49\ No newline at end of file
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():