← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.237\ No newline at end of file+1.9.238\ No newline at end of file
addin/AdomBridge/AdomBridge.py+32−4
@@ -12,6 +12,8 @@ import traceback # Module-level references kept alive for the add-in lifetime _app = None _ui = None+# One start-failure modal per Fusion session; repeats go to the log (#69).+_start_error_shown = False _custom_event = None _event_handler = None _http_thread = None@@ -76,15 +78,36 @@ def run(context):          # Start the HTTP server on a background daemon thread         from . import http_server-        _http_thread = http_server.start(CUSTOM_EVENT_ID, _app)+        try:+            _http_thread = http_server.start(CUSTOM_EVENT_ID, _app)+        except OSError:+            # Port 8774 already bound: a SIBLING copy of this add-in is serving. install()+            # writes every known AddIns location and newer Fusion builds scan more than one+            # (seen live on #69: FusionAddins AND API/AddIns both loaded), so a second copy+            # binding the port is normal operation, not an error the user can act on.+            # Stand down quietly; the serving copy has the job. (Colby, PR #62)+            adsk.core.Application.log(+                "Adom Bridge add-in: port 8774 already served by a sibling copy; this copy is standing down")+            return          # Log to the Text Commands palette (non-blocking)         adsk.core.Application.log(f"Adom Bridge add-in started (port 8774)")      except Exception:         # Only use messageBox for errors — worth blocking to show the user-        if _ui:+        # ONE modal, total. The first failure is worth blocking to show the user; repeats+        # (Fusion can retry the lifecycle, and several installed copies can each fail the+        # same way) turned one broken deploy into a dialog storm - twice per launch, then+        # once per user action (issue #69, Colby, 2026-08-28). After the first box, log.+        global _start_error_shown+        if _ui and not _start_error_shown:+            _start_error_shown = True             _ui.messageBox(f"Adom Bridge failed to start:\n{traceback.format_exc()}")+        else:+            try:+                adsk.core.Application.log(f"Adom Bridge failed to start:\n{traceback.format_exc()}")+            except Exception:+                pass   def stop(context):@@ -110,5 +133,10 @@ def stop(context):         _http_thread = None      except Exception:-        if _ui:-            _ui.messageBox(f"Adom Bridge stop error:\n{traceback.format_exc()}")+        # NEVER a modal from stop. Fusion re-fires the lifecycle on ordinary events, so+        # when the add-in is broken a stop-time messageBox costs the user one dialog PER+        # ACTION they take (seen live 2026-08-28, #69). run() already told them once.+        try:+            adsk.core.Application.log(f"Adom Bridge stop error:\n{traceback.format_exc()}")+        except Exception:+            pass
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.9.237",-  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.237/adom-bridge-fusion-v1.9.237.zip",-  "sha256": "8180d60b5add20106e2fbb11afddbc8f583159c6ff50dc6526a8ed960f2c1723",-  "size": 528977,+  "version": "1.9.238",+  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.238/adom-bridge-fusion-v1.9.238.zip",+  "sha256": "eabd567eadb9e17c29fa16400539b4b7697ff5484b530ab7237ce305f124be5e",+  "size": 534015,   "verbPrefixes": [     "fusion_"   ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.237",+  "version": "1.9.238",   "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.",
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.9.237"+version = "1.9.238" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.237"+version = "1.9.238" edition = "2021"  [dependencies]
describe.py+1−1
@@ -186,7 +186,7 @@ _T = [     ("fusion_open_cloud_file", "Open a CLOUD file by name (the front door to every design flow). PCB/ELECTRONICS NAV RULE: open the electronics DESIGN document (EcadDesignProductType), never a child .brd/.sch/3D directly - then switch views with fusion_show_schematic / fusion_show_2d_board / fusion_show_3d_board. LONG-RUNNING: fire it, then poll fusion_get_app_state until activeDocument matches (cloud opens on electronics designs take 1-3 min). A crash-recovery modal can block the open - fusion_dismiss_blocking_dialogs.", {"fileName": "required str", "projectName": "optional str (defaults to active project)", "folderPath": "optional str"}, {"success": "bool", "documentName": "str"}, 240, "fusion_get_app_state", True, {"fileName": "BQ25792", "projectName": "Main"}),     ("fusion_open_design", "Open a LOCAL design file (routes through the add-in import). Prefer cloud flows (fusion_open_cloud_file) for team designs; this is for local .f3d/.step style files.", {"filePath": "required str (WINDOWS path)"}, {"success": "bool"}, 240, None, True, {"filePath": "C:/Temp/part.f3d"}),     ("fusion_close_document", "Close ONE document WITHOUT the modal save dialog (save:false discards silently - the automation-safe close). Omit name to close the active document.", {"name": "optional str (default: active document)", "save": "optional bool default false"}, {"success": "bool", "closed": "str"}, 60, None, False, {"save": False}),-    ("fusion_close_all_documents", "Close ALL open documents without save dialogs. DESTRUCTIVE to unsaved user work - confirm with the human before calling on a box you do not own. By default it SKIPS scratch 'Untitled' docs; pass closeUntitled:true to close the UNMODIFIED ones too (page issue #59: the library/bind flow left ~5 per import, 16 after three, and no caller could clear them), and force:true to also discard MODIFIED Untitled docs, which may hold real unsaved work.", {"save": "optional bool default false", "closeUntitled": "optional bool default false - also close UNMODIFIED Untitled scratch docs", "force": "optional bool default false - with closeUntitled, also discard MODIFIED Untitled docs"}, 60, None, False, {"closeUntitled": True}),+    ("fusion_close_all_documents", "Close ALL open documents without save dialogs. DESTRUCTIVE to unsaved user work - confirm with the human before calling on a box you do not own. By default it SKIPS scratch 'Untitled' docs; pass closeUntitled:true to close the UNMODIFIED ones too (page issue #59: the library/bind flow left ~5 per import, 16 after three, and no caller could clear them), and force:true to also discard MODIFIED Untitled docs, which may hold real unsaved work.", {"save": "optional bool default false", "closeUntitled": "optional bool default false - also close UNMODIFIED Untitled scratch docs", "force": "optional bool default false - with closeUntitled, also discard MODIFIED Untitled docs"}, {"success": "bool", "closed": "[str]", "wouldPrompt": "[str]|null"}, 60, None, False, {"closeUntitled": True}),     ("fusion_reset_workspace", "Bring Fusion back to a clean slate between runs: closes named documents AND the Untitled scratch the library/bind flow leaves behind (page issue #59). Fusion always keeps ONE blank document alive, so a single remaining Untitled is correct rather than a failure. MODIFIED Untitled docs are left untouched unless force:true, because those can hold real unsaved work.", {"force": "optional bool default false - also discard MODIFIED Untitled docs"}, {"success": "bool", "closedByAddin": "list", "untitledScratchClosed": "int", "remaining": "list"}, 90, None, False, {}),     ("fusion_document_info", "Metadata for the ACTIVE document: name, product type, save state, version, parent project.", {}, {"success": "bool", "name": "str", "productType": "str", "isSaved": "bool"}, 30, None, False, {}),     ("fusion_execute_text_command", "Run a raw Fusion TEXT COMMAND (app.executeTextCommand) - the escape hatch to Fusion internals (Electron.* electronics commands included). Electron.* commands need an ELECTRONICS document context (schematic/board view active). Guard risky calls with expectDocument.", {"command": "required str", "expectDocument": "optional str (refuses wrong_document if the active doc does not match)"}, {"success": "bool", "output": "str"}, 60, None, False, {"command": "Electron.ImmediateRepaint /full"}),
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.237"+DASH_VERSION="1.9.238" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
install_addin.py+46
@@ -1,3 +1,4 @@+import os #!/usr/bin/env python3 """Install the AdomBridge add-in into EVERY Fusion add-in location. @@ -87,7 +88,52 @@ def find_addins_dir() -> Path | None:     return TARGET_CANDIDATES[0]  +def _check_relative_imports(src_root):+    """Refuse a payload whose relative imports do not all resolve INSIDE the payload.++    Issue #69 (Colby, 2026-08-28): the sync delivered a new commands/__init__.py that imports+    .activity_palette, without activity_palette.py, and Fusion's add-in bricked with a+    ModuleNotFoundError dialog storm. The half that landed referenced the half that did not.+    This is the transactional guarantee made cheap: check the payload before touching the+    installed tree, and if it is incomplete leave the installed add-in exactly as it was -+    which also means the mirror step can never delete a hand-restored file to re-brick a box.+    Returns a list of "file: imports X" problems; empty means clean.+    """+    import os, re+    problems = []+    py = []+    for d, _, fs in os.walk(src_root):+        for f in fs:+            if f.endswith(".py"):+                py.append(os.path.join(d, f))+    have = set(os.path.normpath(p) for p in py)+    have_dirs = set(os.path.normpath(os.path.dirname(p)) for p in py)+    pat = re.compile(r"^\s*from\s+(\.+)([\w.]*)\s+import", re.M)+    for f in py:+        try:+            txt = open(f, encoding="utf-8", errors="ignore").read()+        except Exception:+            continue+        for m in pat.finditer(txt):+            dots, mod = m.group(1), m.group(2)+            base = os.path.dirname(f)+            for _ in range(len(dots) - 1):+                base = os.path.dirname(base)+            if not mod:+                continue+            cand = os.path.normpath(os.path.join(base, *mod.split(".")))+            if not (cand + ".py" in have or os.path.join(cand, "__init__.py") in have or cand in have_dirs):+                problems.append("%s imports %s%s" % (os.path.relpath(f, src_root), dots, mod))+    return problems++ def install():+    _src = os.path.join(os.path.dirname(os.path.abspath(__file__)), "addin", "AdomBridge")+    _bad = _check_relative_imports(_src)+    if _bad:+        print("[install_addin] REFUSING: payload has unresolved relative imports - installing it "+              "would brick the add-in (issue #69): " + "; ".join(_bad))+        return None     """Install the AdomBridge add-in into EVERY known Fusion add-in location.      ALL of TARGET_CANDIDATES get the add-in (see module docstring - Fusion moved
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.237",+  "version": "1.9.238",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+11−8
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.237",+  "version": "1.9.238",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -11,6 +11,10 @@     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -75,7 +79,10 @@     "build a basic parts library",     "add a 3d model to a component",     "make a fusion 360 library with 3d",-    "set up the fusion bridge"+    "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page"   ],   "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",   "brief": "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.",@@ -104,9 +111,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }-}\ No newline at end of file+  ]+}
tools/release.sh+29
@@ -192,6 +192,35 @@ zp, listf = sys.argv[1], sys.argv[2] files = [l.strip() for l in open(listf) if l.strip()] missing = [f for f in files if not os.path.exists(f)] if missing: raise SystemExit("missing release files: %s" % missing)+# THE ADD-IN PAYLOAD COMES FROM THE TREE, NOT THE HAND LIST (issue #69, Colby, 2026-08-28).+# activity_palette.py was added to the tree and imported from commands/__init__.py, but never+# added to release-files.txt, so 1.9.233-1.9.237 shipped the importer without the imported+# module. Every fresh install bricked Fusion's add-in with a ModuleNotFoundError dialog storm+# (twice per launch, then once per user action). A hand list is a second source of truth+# that nothing keeps honest; the tree is the only one. Same family as kicad-bridge#63.+import glob+tree = sorted(set(glob.glob("addin/AdomBridge/**/*.py", recursive=True)+                  + glob.glob("addin/AdomBridge/**/*.manifest", recursive=True)))+extra = [f for f in tree if f not in files]+if extra:+    print("  +%d add-in file(s) from the tree the hand list lacked: %s" % (len(extra), extra))+files = files + extra+# And REFUSE to ship a payload whose relative imports do not all resolve inside it (the+# transactional guarantee Colby asked for, done at the cheapest possible point: before+# the zip exists at all).+import re+payload = set(files)+for f in [x for x in files if x.startswith("addin/AdomBridge/") and x.endswith(".py")]:+    d = os.path.dirname(f)+    for m in re.finditer(r"^\s*from\s+(\.+)([\w.]*)\s+import", open(f, encoding="utf-8").read(), re.M):+        dots, mod = m.group(1), m.group(2)+        base = d+        for _ in range(len(dots) - 1): base = os.path.dirname(base)+        if not mod: continue+        cand = os.path.join(base, *mod.split(".")) +        if not (cand + ".py" in payload or os.path.join(cand, "__init__.py") in payload):+            raise SystemExit("REFUSING to build: %s imports %s%s which is not in the payload" % (f, dots, mod))+print("  add-in payload: %d files, all relative imports resolve" % len([x for x in files if x.startswith("addin/")])) with zipfile.ZipFile(zp, "w", zipfile.ZIP_DEFLATED) as z:     for f in files: z.write(f, f) print("  zip", os.path.getsize(zp), "bytes,", len(files), "files")