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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
"""Handler for close_fusion command — macOS.
Closes all open documents cleanly via the add-in (avoiding the recovered
documents dialog on next launch), then closes every Fusion window (native
Quit for the main window, AX close for dialogs), with optional force-kill.
IMPORTANT: Always close documents via the add-in BEFORE killing Fusion.
A force-kill causes unsaved document recovery on next launch, which shows a
blocking modal dialog that prevents automation.
"""
import json
import time
import urllib.request
import urllib.error
from handlers import mac_ui as _mac
def _find_windows_by_title(substring: str) -> list:
"""(winid, title) pairs of visible windows whose title contains the substring.
mac window titles often omit the app name, so ALSO include every normal
window owned by a Fusion process when searching for Fusion."""
found = dict(_mac.find_windows_by_title(substring))
if "autodesk fusion" in substring.lower():
for w in _mac._fusion_windows():
found.setdefault(w["id"], w["title"])
return list(found.items())
def _close_and_wait(winid: int, title: str, timeout: float = 15.0) -> bool:
"""Close a window and wait for it to disappear.
Prefer the app-level native quit for a MAIN Fusion window (macOS apps
close cleanly via Quit, not per-window close); fall back to the AX close
button for dialogs/secondary windows. Fusion may take longer to close
than typical apps, so the default timeout is 15s."""
if winid == _mac.find_fusion_main_winid():
_mac.quit_fusion_native()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
time.sleep(0.3)
if not _mac.window_valid(winid):
return True
return False
return _mac.close_and_wait(winid, timeout)
def _force_kill_fusion() -> int:
"""Force kill all Fusion processes. Returns number of processes killed."""
return _mac.kill_fusion()
def _has_fusion_process() -> bool:
"""Check if any Fusion process is still running."""
return _mac.has_fusion_process()
ADDIN_PORT = 8774
def _close_all_documents_via_addin() -> dict:
"""Ask the add-in to close all open documents without saving.
This prevents the 'Recovered Documents' dialog on next launch.
Returns {"closed": [...], "errors": [...], "count": N}.
"""
result = {"closed": [], "errors": [], "count": 0}
# First, get list of open documents
try:
body = json.dumps({"command": "document_info", "args": {}}).encode("utf-8")
req = urllib.request.Request(
f"http://127.0.0.1:{ADDIN_PORT}/command",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
resp_data = json.loads(resp.read())
except Exception:
return result # Add-in not reachable, skip clean close
if not resp_data.get("success"):
return result
docs = resp_data.get("data", {}).get("openDocuments", [])
result["count"] = len(docs)
if not docs:
return result
# Close each document (close active last to avoid switching issues)
# Reverse order so we close non-active docs first
for doc in reversed(docs):
doc_name = doc.get("name", "")
if not doc_name:
continue
try:
body = json.dumps({
"command": "close_document",
"args": {"name": doc_name, "save": False},
}).encode("utf-8")
req = urllib.request.Request(
f"http://127.0.0.1:{ADDIN_PORT}/command",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
close_resp = json.loads(resp.read())
if close_resp.get("success"):
result["closed"].append(doc_name)
else:
result["errors"].append(f"{doc_name}: {close_resp.get('error', '?')}")
except Exception as e:
result["errors"].append(f"{doc_name}: {e}")
return result
def handle_close_fusion(fusion_info: dict, args: dict) -> dict:
"""Close all Fusion 360 windows, with optional force-kill.
Always attempts to close documents cleanly via the add-in first,
to prevent the 'Recovered Documents' dialog on next launch.
Args:
fusion_info: Fusion 360 detection info dict (unused but required by interface).
args: Dict with:
- force: If true, force-kill all Fusion processes (default: false).
- skipCleanClose: If true, skip document close via add-in (default: false).
"""
force = args.get("force", False)
skip_clean = args.get("skipCleanClose", False)
# Always try to close documents cleanly first (unless explicitly skipped)
close_report = {"closed": [], "errors": [], "count": 0}
if not skip_clean:
close_report = _close_all_documents_via_addin()
if close_report["closed"]:
time.sleep(0.5) # Brief pause after closing docs
if force:
# Force kill all Fusion processes immediately
killed = _force_kill_fusion()
time.sleep(1)
if _has_fusion_process():
return {
"success": False,
"error": "Some Fusion processes survived force-kill.",
"_hint": "Report to the user and ask them to manually kill remaining Fusion processes via Activity Monitor, then retry.",
}
return {
"success": True,
"output": f"Force-killed Fusion 360 ({killed} process group{'s' if killed != 1 else ''}). "
f"Closed {len(close_report['closed'])} document(s) cleanly first.",
"data": {"documentsClosed": close_report["closed"]},
}
# Graceful close
windows = _find_windows_by_title("Autodesk Fusion")
if not windows:
# No windows but maybe processes are hanging
if _has_fusion_process():
return {
"success": True,
"output": "No Fusion windows found but processes are running. Use force=true to kill them.",
}
return {"success": True, "output": "Fusion 360 is not running."}
closed = []
failed = []
for winid, title in windows:
if _close_and_wait(winid, title):
closed.append(title)
else:
failed.append(title)
if failed:
# Try force-killing since graceful close failed
_force_kill_fusion()
time.sleep(1)
if not _has_fusion_process():
return {
"success": True,
"output": "Fusion 360 force-killed after graceful close failed.",
}
return {
"success": False,
"error": f"Failed to close: {', '.join(failed)}",
"output": f"Closed: {', '.join(closed)}" if closed else "",
"_hint": "Call fusion_close with {\"force\": true} to force-kill stuck windows, then retry.",
}
# Wait for lingering processes
for _ in range(10):
if not _has_fusion_process():
break
time.sleep(0.5)
return {
"success": True,
"output": f"Fusion 360 closed ({len(closed)} window{'s' if len(closed) != 1 else ''}).",
}
def handle_fusion_stop(fusion_info: dict, args: dict) -> dict:
"""Gracefully STOP Fusion 360 - the clean opposite of fusion_start.
Closes all docs cleanly via the add-in (avoids the 'Recovered Documents' dialog),
then closes every Fusion window and waits. Does NOT force-kill: if a window will
not close (usually a modal dialog blocking the close), it returns failure and tells
you to use fusion_kill. No surprise escalation - that's the whole point.
Args: skipCleanClose (bool, default false) - skip the add-in document close.
"""
skip_clean = args.get("skipCleanClose", False)
close_report = {"closed": [], "errors": [], "count": 0}
if not skip_clean:
close_report = _close_all_documents_via_addin()
if close_report["closed"]:
time.sleep(0.5)
windows = _find_windows_by_title("Autodesk Fusion")
if not windows:
if _has_fusion_process():
return {
"success": False,
"error": "No Fusion windows found, but Fusion processes are still running.",
"_hint": "A graceful stop cannot reach them - use fusion_kill to force-kill.",
}
return {"success": True, "output": "Fusion 360 is not running."}
closed, failed = [], []
for winid, title in windows:
(closed if _close_and_wait(winid, title) else failed).append(title)
if failed:
return {
"success": False,
"error": f"Graceful stop failed for: {', '.join(failed)}",
"output": f"Closed: {', '.join(closed)}" if closed else "",
"_hint": "A window would not close gracefully (a modal dialog often blocks it). "
"Use fusion_kill to force-kill, then retry.",
}
for _ in range(10):
if not _has_fusion_process():
break
time.sleep(0.5)
return {
"success": True,
"output": f"Fusion 360 stopped gracefully ({len(closed)} window{'s' if len(closed) != 1 else ''}); "
f"{len(close_report['closed'])} document(s) closed cleanly first.",
"data": {"documentsClosed": close_report["closed"]},
}
def handle_fusion_kill(fusion_info: dict, args: dict) -> dict:
"""Force-KILL Fusion 360 - the 'desperate' path for when fusion_stop
can't close it (a stuck modal, a wedged process).
Still closes docs cleanly via the add-in FIRST when reachable, to avoid the
'Recovered Documents' dialog on next launch.
Args: skipCleanClose (bool, default false) - skip the add-in document close.
"""
skip_clean = args.get("skipCleanClose", False)
close_report = {"closed": [], "errors": [], "count": 0}
if not skip_clean:
close_report = _close_all_documents_via_addin()
if close_report["closed"]:
time.sleep(0.5)
killed = _force_kill_fusion()
time.sleep(1)
if _has_fusion_process():
return {
"success": False,
"error": "Some Fusion processes survived force-kill.",
"_hint": "Ask the user to kill remaining Fusion processes via Activity Monitor, then retry.",
}
return {
"success": True,
"output": f"Fusion 360 force-killed ({killed} process group{'s' if killed != 1 else ''}); "
f"{len(close_report['closed'])} document(s) closed cleanly first.",
"data": {"documentsClosed": close_report["closed"]},
}