app
Adom Desktop - Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Desktop: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
"""Handler for close_fusion command.
Closes all open documents cleanly via the add-in (avoiding recovered documents
dialog on next launch), then sends WM_CLOSE to all Fusion 360 windows, with
optional force-kill.
IMPORTANT: Always close documents via the add-in BEFORE killing Fusion.
taskkill //f causes unsaved document recovery on next launch, which shows a
blocking modal dialog that prevents automation.
"""
import ctypes
import ctypes.wintypes
import json
import subprocess
import time
import urllib.request
import urllib.error
WM_CLOSE = 0x0010
# Guard windll so the module imports on non-Windows hosts (the bridge must boot
# + serve /status anywhere); these Win32 paths are only reached on Windows.
user32 = ctypes.windll.user32 if hasattr(ctypes, "windll") else None
_callbacks = []
# Fusion process names to kill when force-stopping
FUSION_PROCESS_NAMES = [
"Fusion360.exe", "FusionLauncher.exe", "Fusion.exe",
"FusionService.exe", "FusionCEF.exe", "Fusion360Helper.exe",
]
def _find_windows_by_title(substring: str) -> list:
"""Find all visible windows whose title contains the given substring."""
results = []
WNDENUMPROC = ctypes.WINFUNCTYPE(
ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
)
def callback(hwnd, _lparam):
if user32.IsWindowVisible(hwnd):
length = user32.GetWindowTextLengthW(hwnd)
if length > 0:
buf = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buf, length + 1)
if substring in buf.value:
results.append((hwnd, buf.value))
return True
cb = WNDENUMPROC(callback)
_callbacks.append(cb)
user32.EnumWindows(cb, 0)
_callbacks.remove(cb)
return results
def _close_and_wait(hwnd: int, title: str, timeout: float = 15.0) -> bool:
"""Send WM_CLOSE and wait for the window to disappear.
Fusion 360 may take longer to close than typical apps, so default timeout is 15s.
"""
user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
time.sleep(0.3)
if not user32.IsWindow(hwnd) or not user32.IsWindowVisible(hwnd):
return True
return False
def _force_kill_fusion() -> int:
"""Force kill all Fusion processes. Returns number of processes killed."""
killed = 0
for name in FUSION_PROCESS_NAMES:
try:
result = subprocess.run(
["taskkill", "/F", "/IM", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if result.returncode == 0:
killed += 1
except Exception:
pass
return killed
def _has_fusion_process() -> bool:
"""Check if any Fusion process is still running."""
try:
output = subprocess.check_output(
["tasklist", "/FI", "IMAGENAME eq Fusion360.exe", "/FO", "CSV", "/NH"],
stderr=subprocess.DEVNULL,
text=True,
timeout=5,
)
return "Fusion360.exe" in output
except Exception:
return False
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 Task Manager, 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: send WM_CLOSE to windows
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 hwnd, title in windows:
if _close_and_wait(hwnd, 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": f"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 WM_CLOSE every Fusion window and waits. Does NOT force-kill: if a window will
not close (usually a modal dialog blocking WM_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 hwnd, title in windows:
(closed if _close_and_wait(hwnd, 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 WM_CLOSE). "
"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 (taskkill /F) - 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 Task Manager, 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"]},
}