app
fusion - the Fusion 360 bridge (macOS)
Public Made by Adomby adom
This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Desktop loads the bridge runtime itself from the release zip.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
"""Auto-detect the Fusion 360 installation on macOS.
This bridge is macOS-only (Kyle, 2026-07-31): the Windows detection —
Fusion360.exe / FusionLauncher.exe markers, tasklist walks, Win32 focus
guards — is gone. The hard-won webdeploy lessons (John, 2026-07-14) carry
over with mac markers:
* Fusion streams into ~/Library/Application Support/Autodesk/webdeploy/
production/<hash>/, one hash dir per content build; old builds are never
pruned, so several stale dirs are NORMAL.
* "Installed" is gated on the real app binary
(<hash>/Autodesk Fusion.app/Contents/MacOS/Autodesk Fusion, ~1.6 MB
measured) existing at full size — the bundle dir itself appears early
in a stream and must never be the completeness marker.
"""
from __future__ import annotations # PEP-604 hints must parse on py3.9 (mac stock)
import json
import os
import subprocess
import time
import urllib.error
import urllib.request
from pathlib import Path
from handlers import mac_ui
ADDIN_PORT = 8774
# Fusion's webdeploy root on macOS. Kept as a list for parity with callers
# that iterate (and in case Autodesk ever adds a second root).
_WEBDEPLOY_BASES = [
Path.home() / "Library" / "Application Support" / "Autodesk" / "webdeploy" / "production",
]
WEBDEPLOY_BASE = _WEBDEPLOY_BASES[0]
# EVERY known per-user add-in dir, newest convention first. Fusion MOVED this
# across versions and silently ignores the others (issue #63: an add-in in the
# legacy API/AddIns dir never loads on 2025+ Fusion, which scans FusionAddins -
# proven live on a fresh install). Track ALL of them; never assume one path.
ADDINS_DIR_CANDIDATES = [
Path.home() / "Library" / "Application Support" / "Autodesk" / "FusionAddins",
Path.home() / "Library" / "Application Support" / "Autodesk" / "Autodesk Fusion" / "API" / "AddIns",
Path.home() / "Library" / "Application Support" / "Autodesk" / "Autodesk Fusion 360" / "API" / "AddIns",
]
def detect_fusion() -> dict:
"""Detect the Fusion 360 installation. Returns a dict with paths and status."""
app_path = _find_fusion_app()
addins_dir = _find_addins_dir()
addin_installed = _check_addin_installed(addins_dir) if addins_dir else False
running = _is_fusion_running()
if app_path:
return {
"installed": True,
"exe_path": str(app_path),
"addins_dir": str(addins_dir) if addins_dir else None,
"addin_installed": addin_installed,
"running": running,
}
return {
"installed": False,
"exe_path": None,
"addins_dir": str(addins_dir) if addins_dir else None,
"addin_installed": addin_installed,
"running": running,
"error": "Fusion 360 not found",
}
# ---------------------------------------------------------------------------------------------------
# INSTALL-COMPLETENESS DETECTION
#
# The reliable signal is the real app binary inside the bundle. It only exists at full size once the
# payload finished streaming. Gate "installed" on it, NEVER on the bundle dir appearing (that lands
# early, mid-stream — the mac analog of the FusionLauncher.exe trap that broke the fresh VM,
# John 2026-07-14).
# ---------------------------------------------------------------------------------------------------
_FUSION_BUNDLE = "Autodesk Fusion.app"
_FUSION_APP_EXE = os.path.join(_FUSION_BUNDLE, "Contents", "MacOS", "Autodesk Fusion")
_MIN_APP_EXE_BYTES = 200_000 # guards against a placeholder the streamer writes early
def _app_dir_complete(subdir: Path) -> bool:
"""A production hash dir is a COMPLETE Fusion install ONLY when it contains the bundle's real
binary at full size. Process-independent and version-stable."""
exe = subdir / _FUSION_APP_EXE
try:
return exe.exists() and exe.stat().st_size >= _MIN_APP_EXE_BYTES
except OSError:
return False
def _any_app_complete() -> bool:
"""True if ANY webdeploy hash dir holds a complete Fusion. This is the single source of truth
for 'Fusion is installed'."""
for base in _WEBDEPLOY_BASES:
try:
if base.exists():
for subdir in base.iterdir():
if subdir.is_dir() and _app_dir_complete(subdir):
return True
except OSError:
continue
return False
def _find_fusion_app() -> Path | None:
"""The newest COMPLETE 'Autodesk Fusion.app' bundle to launch (via `open`), or None.
Completeness is gated per-dir on the bundle binary — returning a bundle whose
binary hasn't finished streaming reproduces the launch-into-half-written-install
failure."""
candidates = []
for base in _WEBDEPLOY_BASES:
if not base.exists():
continue
try:
subdirs = [d for d in base.iterdir() if d.is_dir()]
except OSError:
continue
for subdir in subdirs:
# The bundle usually sits inside the hash dir; tolerate it directly
# under production/ as well.
if subdir.name == _FUSION_BUNDLE and (subdir / "Contents").exists():
app = subdir
else:
app = subdir / _FUSION_BUNDLE
binary = app / "Contents" / "MacOS" / "Autodesk Fusion"
try:
if not (binary.exists() and binary.stat().st_size >= _MIN_APP_EXE_BYTES):
continue
candidates.append((subdir.stat().st_mtime, app))
except OSError:
continue
if not candidates:
return None
candidates.sort(key=lambda c: c[0], reverse=True)
return candidates[0][1]
def _incomplete_webdeploy_present() -> bool:
"""Disk-state (process-INDEPENDENT) 'a fresh install is actively streaming' signal: some hash dir
has the bundle but NO complete binary exists ANYWHERE yet. Why disk-state and not a process
check: the Autodesk streamer spawns short-lived per-chunk workers, so a process poll reads False
for most of a live multi-GB stream. Why gate on 'no complete app anywhere': once the app IS
complete, leftover partial dirs are NORMAL and must NOT read as 'still installing'. Never
raises."""
if _any_app_complete():
return False # a complete install exists -> not installing
for base in _WEBDEPLOY_BASES:
try:
if not base.exists():
continue
for subdir in base.iterdir():
if not subdir.is_dir():
continue
if (subdir / _FUSION_BUNDLE).exists() or subdir.name == _FUSION_BUNDLE:
return True # payload started, no complete app -> streaming
except OSError:
continue
return False
def _clean_incomplete_webdeploy() -> list:
"""Remove the residue of an INTERRUPTED stream so the next install re-streams clean.
Only ever called AFTER a launch failure (we already know something is corrupt).
SAFETY GUARD: if a COMPLETE install exists anywhere, do NOTHING and return [] — partial-looking
dirs beside a complete app can be a legit part of the install, not corruption. Never raises."""
import shutil
if _any_app_complete():
return [] # healthy install present - never touch it
removed = []
for base in _WEBDEPLOY_BASES:
if not base.exists():
continue
try:
subdirs = [d for d in base.iterdir() if d.is_dir()]
except OSError:
continue
for subdir in subdirs:
has_bundle = (subdir / _FUSION_BUNDLE).exists() or subdir.name == _FUSION_BUNDLE
if not has_bundle:
continue
if not _app_dir_complete(subdir if subdir.name != _FUSION_BUNDLE else subdir.parent):
try:
shutil.rmtree(subdir, ignore_errors=True)
removed.append(subdir.name)
except Exception:
pass
return removed
def fusion_update_in_progress() -> bool:
"""Best-effort: is Fusion ACTIVELY applying an auto-update right now? While it is, Fusion
crash-restarts ITSELF every ~30-60s, so verbs intermittently see 'not running'. Callers surface
a distinct non-fatal `updating` status instead of a fatal error so a driving AI waits/retries.
A bare "2+ production dirs" count is NOT an update — Fusion never prunes old webdeploy builds,
so a normal machine accumulates many stale dirs (regression caught live 2026-07-06). Require
ACTIVE streaming: 2+ bundle-bearing builds AND at least one build dir TOUCHED in the last
~20 min (the streamer is writing into it). Never raises."""
now = time.time()
RECENT_SECS = 20 * 60
bundle_mtimes = []
for base in _WEBDEPLOY_BASES:
try:
if not base.exists():
continue
for d in base.iterdir():
try:
if not d.is_dir():
continue
if (d / _FUSION_BUNDLE).exists() or d.name == _FUSION_BUNDLE:
bundle_mtimes.append(d.stat().st_mtime)
except OSError:
continue
except OSError:
continue
if len(bundle_mtimes) < 2:
return False
return any((now - mt) < RECENT_SECS for mt in bundle_mtimes)
def _find_addins_dir() -> Path | None:
"""Find the Fusion 360 add-ins directory."""
for candidate in ADDINS_DIR_CANDIDATES:
if candidate.exists():
return candidate
# Return the first candidate even if it doesn't exist yet (for install_addin)
return ADDINS_DIR_CANDIDATES[0] if ADDINS_DIR_CANDIDATES else None
def _check_addin_installed(addins_dir: Path | None) -> bool:
"""Check if the AdomBridge add-in is installed in ANY known add-in dir.
Scans every ADDINS_DIR_CANDIDATES entry (not just the preferred one) - the
copy that counts is whichever dir THIS Fusion version scans, and we can't
know that a priori, so presence anywhere counts as installed."""
for candidate in ADDINS_DIR_CANDIDATES:
if (candidate / "AdomBridge" / "AdomBridge.py").exists():
return True
if addins_dir and (Path(addins_dir) / "AdomBridge" / "AdomBridge.py").exists():
return True
return False
def _has_fusion_process() -> bool:
"""Is any Fusion 360 process running? (pgrep on the bundle binary path —
process check is authoritative; window-title matching false-positives on
browser tabs that mention 'Autodesk Fusion')."""
return mac_ui.has_fusion_process()
def _is_fusion_running() -> bool:
return _has_fusion_process()
def is_fusion_running() -> bool:
"""Public API: check if Fusion 360 is currently running (live check)."""
return _has_fusion_process()
def family_windows() -> list:
"""Visible normal-layer windows owned by Fusion processes, in the shape the
launch state machine consumes: {hwnd, title, pid, image, width, height,
left, top, owner}. 'hwnd' is the CGWindowID; CGWindowList has no owner-popup
concept, so 'owner' is always 0 and find_licensing_dialog relies on the
shape gate. Never raises."""
out = []
try:
for w in mac_ui._fusion_windows():
r = w["rect"]
if r["width"] <= 0 or r["height"] <= 0:
continue
out.append({
"hwnd": int(w["id"]), "title": w["title"] or "", "pid": int(w["pid"]),
"image": w.get("owner") or "",
"width": r["width"], "height": r["height"],
"left": r["left"], "top": r["top"],
"owner": 0,
})
except Exception:
pass
return out
def find_licensing_dialog():
"""The seat/licensing or sign-in modal ("Active Sessions Exceeded" / "Suspend Remote Session" /
a sign-in prompt), or None.
CGWindowList exposes no owned-popup relationship, so detection is the SHAPE gate proven on the
Windows line: a seat/sign-in modal is always WIDE (823x262 'Suspend Remote Session', 1235x527
'Active Sessions Exceeded') — far wider than any docked side panel — and never the LARGEST
Fusion window (a dialog is never bigger than the app that owns it; matching the main window is
the false-fire that hung readiness, John 2026-07-14). Require width >= 480 (excludes narrow
panels), 180 <= height <= 900 (excludes toolbar strips), and not the main window."""
wins = family_windows()
if not wins:
return None
main_hwnd = max(wins, key=lambda w: w["width"] * w["height"])["hwnd"]
for w in wins:
if w["hwnd"] == main_hwnd:
continue
ww, hh = w["width"], w["height"]
if 480 <= ww <= 1500 and 180 <= hh <= 900:
return w
return None
def wait_for_addin(timeout: int = 90, dismiss_dialogs: bool = False) -> bool:
"""Wait for the AdomBridge add-in HTTP server to become responsive.
Polls the add-in health endpoint once per second up to `timeout` seconds.
Returns True if the add-in responded, False on timeout.
If dismiss_dialogs is True, also checks for and dismisses blocking dialogs
(startup picker, recovery dialog) every 5 seconds while waiting. This is
critical at startup: dialogs block the add-in's main thread, so dismissing
them is required for the add-in to become responsive.
"""
for i in range(timeout):
try:
req = urllib.request.Request(
f"http://127.0.0.1:{ADDIN_PORT}/health", method="GET"
)
with urllib.request.urlopen(req, timeout=1) as resp:
return True
except Exception:
# Every 5 seconds during the wait, try to dismiss blocking dialogs.
# Dialogs are the #1 reason the add-in can't respond at startup.
if dismiss_dialogs and i >= 5 and i % 5 == 0:
_dismiss_startup_dialogs()
time.sleep(1)
return False
def _dismiss_startup_dialogs():
"""Dismiss common Fusion startup dialogs (recovery, startup picker) so the
add-in's main thread unblocks and it can become responsive."""
try:
from handlers.dismiss_recovery import dismiss_recovery_dialog
dismiss_recovery_dialog(max_wait=2.0)
except Exception:
pass
# Dismiss the startup picker ("What do you want to design?") and other
# Fusion dialogs by sending Escape to the Fusion main window.
try:
from handlers.fusion_ui import send_key_to_fusion
send_key_to_fusion("escape")
except Exception:
pass
def _popen_fusion_background(app_path: str):
"""Launch the Fusion bundle in the BACKGROUND: `open -g` starts it without
stealing the user's focus (John, 2026-07-06: "new windows ALWAYS only open
in the background"). The add-in still loads fully unfocused; a verb
explicitly foregrounds Fusion later when the user should see it."""
return subprocess.Popen(
["open", "-g", str(app_path)],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def ensure_fusion_running(fusion_info: dict, wait_addin: bool = False) -> dict | None:
"""Launch Fusion 360 if not already running.
Uses a live process check (not the stale fusion_info["running"] flag).
Returns None on success, or an error dict on failure.
If wait_addin is True, also waits for the add-in to be responsive.
"""
# Live check — ignore stale fusion_info["running"]
if _has_fusion_process():
if wait_addin:
# Fusion is running but add-in might not be ready yet
if not wait_for_addin(timeout=10):
return {
"success": False,
"error": "Fusion 360 is running but AdomBridge add-in not responding.",
"errorCode": "fusion_addin_not_responding",
"_hint": "Fix it YOURSELF - never ask the user: (1) run install_addin() so the "
"add-in lands in EVERY Fusion add-in dir (Fusion moved it to "
"FusionAddins on 2025+ builds; an add-in in the legacy API/AddIns dir "
"is silently ignored - issue #63), then (2) restart Fusion via "
"fusion_stop + fusion_start so it rescans.",
}
return None # Already running (and add-in ready if requested)
exe_path = fusion_info.get("exe_path")
if not exe_path or not Path(exe_path).exists():
return {"success": False, "error": "Fusion 360 executable not found"}
try:
_popen_fusion_background(exe_path)
except Exception as e:
return {"success": False, "error": f"Failed to launch Fusion 360: {e}"}
# Wait for the Fusion process to appear (up to 30s)
for _ in range(60):
time.sleep(0.5)
if _has_fusion_process():
break
else:
# LAUNCH FAILED - do NOT return a blind "process not found". READ the dialog that may be on
# screen, and DISTINGUISH the two causes (conflating them mis-blamed a healthy install,
# John 2026-07-14):
# (a) app is COMPLETE but launch failed -> the install is FINE; do NOT re-stream.
# (b) app is NOT complete -> a genuinely half-streamed install; clean the partial dirs so
# the next install re-streams clean.
launch_err = None
try:
from handlers.dialog_classify import classify_launch_dialogs, close_dialog_bg
for d in classify_launch_dialogs():
if d.get("category") == "launch_error" or "streamed application" in (d.get("title") or "").lower():
launch_err = d
close_dialog_bg(d.get("hwnd"))
except Exception:
pass
if _any_app_complete():
return {
"success": False,
"error": "Fusion is INSTALLED (complete bundle present) but did not start within 30s"
+ (" - a launch-error dialog was dismissed" if launch_err else "") + ".",
"errorCode": "fusion_launch_failed",
"installed": True,
"dialog": (launch_err or {}).get("title"),
"_hint": "The install is COMPLETE - do NOT re-stream. Retry fusion_start (a stale "
"lock from a just-killed instance clears on a retry). If it keeps failing, "
"fusion_kill then wait a few seconds before fusion_start.",
}
removed = _clean_incomplete_webdeploy()
return {
"success": False,
"error": "Fusion launch failed: the streamed install is INCOMPLETE (no complete bundle yet).",
"errorCode": "fusion_incomplete_install",
"cleanedWebdeployDirs": removed,
"dialog": (launch_err or {}).get("title"),
"_hint": "AUTO-RECOVERED: dismissed the launch-error dialog and removed the partial "
"webdeploy dir(s). Re-run fusion_install_fusion, then WAIT for fusion_readiness "
"to report installed:true BEFORE fusion_start - starting mid-stream is what "
"corrupts the install.",
}
# Give it time to finish initializing UI
time.sleep(5.0)
if wait_addin:
# dismiss_dialogs=True: while waiting for the add-in, also check for
# and dismiss blocking dialogs every 5s. This is critical because
# startup dialogs (picker, recovery) block the add-in's main thread.
if not wait_for_addin(timeout=60, dismiss_dialogs=True):
return {
"success": False,
"error": "addin_timeout_after_launch",
"message": "Fusion 360 launched but the AdomBridge add-in didn't respond "
"within 60s. A modal dialog (e.g. Document Recovery, update prompt) "
"may be blocking startup. Check Fusion and dismiss any dialogs, "
"then retry.",
}
return None
# Back-compat alias: callers that predate the mac-only rewrite import
# _find_fusion_launcher; on macOS the "launcher" IS the app bundle path.
_find_fusion_launcher = _find_fusion_app
if __name__ == "__main__":
info = detect_fusion()
print(json.dumps(info, indent=2))