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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755
"""macOS implementations of the Fusion 360 UI-automation surface.
Mirrors handlers/fusion_ui.py's public API (screenshot_fusion_window,
screenshot_hwnd, click_fusion, send_key_to_fusion, close_window,
get_fusion_window_info) plus the primitives close_fusion.py /
dismiss_recovery.py / dialog_classify.py need, using:
- CGWindowListCopyWindowInfo (via ctypes on CoreGraphics) for window
enumeration — the "hwnd" the bridge hands out on macOS is the CGWindowID.
- /usr/sbin/screencapture -l <windowid> for window capture (works while the
window is backgrounded; needs the Screen Recording TCC permission).
- CGEvent (CGEventCreateMouseEvent / CGEventCreateKeyboardEvent) for input —
synthetic input. Unicode-string key events cover arbitrary characters
without a keycode table.
- System Events (osascript/JXA) for accessibility actions: raising a window,
clicking a window's close button in the BACKGROUND (no focus steal),
and app activation by pid. Needs the Accessibility TCC permission.
Response shapes match fusion_ui.py exactly so server.py and the callers see
one contract on both platforms. Every entry point is best-effort and returns
{"success": False, "error": ...} rather than raising.
"""
import ctypes
import os
import re
import subprocess
import sys
import time
IS_MACOS = sys.platform == "darwin"
SCREENSHOT_DIR = "/tmp/conduit-screenshots"
SCREENCAPTURE = "/usr/sbin/screencapture"
SIPS = "/usr/bin/sips"
OSASCRIPT = "/usr/bin/osascript"
# Marker every Fusion-on-mac process command line contains.
FUSION_PROC_PATTERN = r"Autodesk Fusion\.app/Contents/MacOS"
# ---------------------------------------------------------------------------
# CoreFoundation / CoreGraphics via ctypes
# ---------------------------------------------------------------------------
if IS_MACOS:
_CG = ctypes.CDLL("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")
_CF = ctypes.CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")
else: # pragma: no cover - module only used on macOS
_CG = None
_CF = None
_kCFStringEncodingUTF8 = 0x08000100
_kCFNumberSInt64Type = 4
_kCFNumberFloat64Type = 6
_kCGWindowListOptionOnScreenOnly = 1 << 0
_kCGWindowListExcludeDesktopElements = 1 << 4
_kCGNullWindowID = 0
_kCGHIDEventTap = 0
_kCGEventLeftMouseDown = 1
_kCGEventLeftMouseUp = 2
_kCGEventMouseMoved = 5
_kCGMouseButtonLeft = 0
_kCGEventFlagMaskShift = 0x00020000
_kCGEventFlagMaskControl = 0x00040000
_kCGEventFlagMaskAlternate = 0x00080000 # option
_kCGEventFlagMaskCommand = 0x00100000
class _CGPoint(ctypes.Structure):
_fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double)]
class _CGRect(ctypes.Structure):
_fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double),
("w", ctypes.c_double), ("h", ctypes.c_double)]
if IS_MACOS:
_CF.CFRelease.argtypes = [ctypes.c_void_p]
_CF.CFArrayGetCount.restype = ctypes.c_long
_CF.CFArrayGetCount.argtypes = [ctypes.c_void_p]
_CF.CFArrayGetValueAtIndex.restype = ctypes.c_void_p
_CF.CFArrayGetValueAtIndex.argtypes = [ctypes.c_void_p, ctypes.c_long]
_CF.CFDictionaryGetValue.restype = ctypes.c_void_p
_CF.CFDictionaryGetValue.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
_CF.CFStringCreateWithCString.restype = ctypes.c_void_p
_CF.CFStringCreateWithCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint32]
_CF.CFStringGetCString.restype = ctypes.c_bool
_CF.CFStringGetCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long, ctypes.c_uint32]
_CF.CFNumberGetValue.restype = ctypes.c_bool
_CF.CFNumberGetValue.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p]
_CF.CFBooleanGetValue.restype = ctypes.c_bool
_CF.CFBooleanGetValue.argtypes = [ctypes.c_void_p]
_CG.CGWindowListCopyWindowInfo.restype = ctypes.c_void_p
_CG.CGWindowListCopyWindowInfo.argtypes = [ctypes.c_uint32, ctypes.c_uint32]
_CG.CGRectMakeWithDictionaryRepresentation.restype = ctypes.c_bool
_CG.CGRectMakeWithDictionaryRepresentation.argtypes = [ctypes.c_void_p, ctypes.POINTER(_CGRect)]
_CG.CGEventCreateMouseEvent.restype = ctypes.c_void_p
_CG.CGEventCreateMouseEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint32, _CGPoint, ctypes.c_uint32]
_CG.CGEventCreateKeyboardEvent.restype = ctypes.c_void_p
_CG.CGEventCreateKeyboardEvent.argtypes = [ctypes.c_void_p, ctypes.c_uint16, ctypes.c_bool]
_CG.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p]
_CG.CGEventSetFlags.argtypes = [ctypes.c_void_p, ctypes.c_uint64]
_CG.CGEventKeyboardSetUnicodeString.argtypes = [
ctypes.c_void_p, ctypes.c_long, ctypes.POINTER(ctypes.c_uint16)]
try:
_CG.CGPreflightScreenCaptureAccess.restype = ctypes.c_bool
except Exception:
pass
# Pre-built CFString keys for the CGWindow dictionaries.
def _cfstr(s: str):
return _CF.CFStringCreateWithCString(None, s.encode("utf-8"), _kCFStringEncodingUTF8)
_K_NUMBER = _cfstr("kCGWindowNumber")
_K_PID = _cfstr("kCGWindowOwnerPID")
_K_LAYER = _cfstr("kCGWindowLayer")
_K_NAME = _cfstr("kCGWindowName")
_K_OWNER = _cfstr("kCGWindowOwnerName")
_K_BOUNDS = _cfstr("kCGWindowBounds")
_K_ALPHA = _cfstr("kCGWindowAlpha")
def _cf_to_str(ref) -> str:
if not ref:
return ""
buf = ctypes.create_string_buffer(2048)
if _CF.CFStringGetCString(ref, buf, 2048, _kCFStringEncodingUTF8):
return buf.value.decode("utf-8", "replace")
return ""
def _cf_to_int(ref):
if not ref:
return None
v = ctypes.c_int64(0)
if _CF.CFNumberGetValue(ref, _kCFNumberSInt64Type, ctypes.byref(v)):
return int(v.value)
return None
def _cf_to_float(ref):
if not ref:
return None
v = ctypes.c_double(0)
if _CF.CFNumberGetValue(ref, _kCFNumberFloat64Type, ctypes.byref(v)):
return float(v.value)
return None
def list_windows() -> list:
"""Every on-screen, non-desktop window as a plain dict.
Keys: id, pid, owner, title, layer, alpha, rect {left, top, right, bottom,
width, height}. Coordinates are Quartz global points, top-left origin —
the same space CGEvent input uses, so a rect can be clicked directly.
"""
out = []
arr = _CG.CGWindowListCopyWindowInfo(
_kCGWindowListOptionOnScreenOnly | _kCGWindowListExcludeDesktopElements,
_kCGNullWindowID)
if not arr:
return out
try:
n = _CF.CFArrayGetCount(arr)
for i in range(n):
d = _CF.CFArrayGetValueAtIndex(arr, i)
if not d:
continue
rect = _CGRect()
bounds = _CF.CFDictionaryGetValue(d, _K_BOUNDS)
if not bounds or not _CG.CGRectMakeWithDictionaryRepresentation(bounds, ctypes.byref(rect)):
continue
alpha = _cf_to_float(_CF.CFDictionaryGetValue(d, _K_ALPHA))
out.append({
"id": _cf_to_int(_CF.CFDictionaryGetValue(d, _K_NUMBER)),
"pid": _cf_to_int(_CF.CFDictionaryGetValue(d, _K_PID)),
"owner": _cf_to_str(_CF.CFDictionaryGetValue(d, _K_OWNER)),
"title": _cf_to_str(_CF.CFDictionaryGetValue(d, _K_NAME)),
"layer": _cf_to_int(_CF.CFDictionaryGetValue(d, _K_LAYER)) or 0,
"alpha": 1.0 if alpha is None else alpha,
"rect": {
"left": int(rect.x), "top": int(rect.y),
"right": int(rect.x + rect.w), "bottom": int(rect.y + rect.h),
"width": int(rect.w), "height": int(rect.h),
},
})
finally:
_CF.CFRelease(arr)
return out
def _window_by_id(winid):
for w in list_windows():
if w["id"] == int(winid):
return w
return None
def window_valid(winid) -> bool:
return _window_by_id(winid) is not None
def get_window_rect(winid) -> tuple:
w = _window_by_id(winid)
if not w:
return (0, 0, 0, 0)
r = w["rect"]
return (r["left"], r["top"], r["right"], r["bottom"])
# ---------------------------------------------------------------------------
# Fusion process + window discovery
# ---------------------------------------------------------------------------
def fusion_pids() -> list:
try:
r = subprocess.run(["pgrep", "-f", FUSION_PROC_PATTERN],
stdin=subprocess.DEVNULL, capture_output=True,
text=True, timeout=5)
return [int(p) for p in r.stdout.split()] if r.returncode == 0 else []
except Exception:
return []
def has_fusion_process() -> bool:
return bool(fusion_pids())
def _fusion_windows() -> list:
pids = set(fusion_pids())
if not pids:
return []
return [w for w in list_windows()
if w["pid"] in pids and w["layer"] == 0 and w["alpha"] > 0.05]
def find_fusion_main_winid():
"""The main Fusion window's CGWindowID (or 0).
macOS window titles don't always carry the app name, so prefer a title
containing 'Autodesk Fusion' but fall back to the LARGEST normal-layer
Fusion window.
"""
wins = _fusion_windows()
if not wins:
return 0
titled = [w for w in wins if "autodesk fusion" in (w["title"] or "").lower()]
pool = titled or wins
pool.sort(key=lambda w: w["rect"]["width"] * w["rect"]["height"], reverse=True)
return pool[0]["id"]
def find_dialog_windows() -> list:
"""Fusion-owned windows that are NOT the main window — the dialog list.
Mirrors fusion_ui._find_qt_dialog_windows' shape ({hwnd, title, className,
rect}) and its small-panel filter: tiny auxiliary windows (<400x300 with no
title) are skipped, but ANY titled window is kept (recovery prompts can be
small).
"""
main_id = find_fusion_main_winid()
out = []
for w in _fusion_windows():
if w["id"] == main_id:
continue
r = w["rect"]
titled = bool((w["title"] or "").strip())
if not titled and (r["width"] < 400 or r["height"] < 300):
continue
# ASPECT GUARD (ported from the Windows fusion_ui, 2026-07-23): the size
# test alone is not enough — Fusion's docked side panels (BROWSER,
# Timeline, Comments) are TALL and NARROW and sail past 400x300, getting
# reported as blocking dialogs on an idle Fusion. Real blocking dialogs
# (startup picker, licensing, recovery) are landscape or roughly square,
# so anything markedly taller than wide is a docked panel, not a dialog.
if not titled and r["height"] > r["width"] * 1.5:
continue
out.append({
"hwnd": w["id"],
"title": w["title"],
"className": "CGWindow",
"rect": r,
})
return out
def find_windows_by_title(substring: str) -> list:
"""(winid, title) for every on-screen window whose title contains substring.
Searches ALL processes (the dismiss/classify paths need AdskIdentityManager
and launcher dialogs too, and mac titles often omit the app name).
"""
sub = (substring or "").lower()
return [(w["id"], w["title"]) for w in list_windows()
if w["layer"] == 0 and sub in (w["title"] or "").lower()]
def enumerate_all_top_level() -> list:
"""(winid, title) for every visible, titled, normal-layer window."""
return [(w["id"], w["title"]) for w in list_windows()
if w["layer"] == 0 and (w["title"] or "").strip()]
# ---------------------------------------------------------------------------
# osascript / System Events helpers (Accessibility)
# ---------------------------------------------------------------------------
def _osascript(script: str, timeout: float = 10.0):
try:
r = subprocess.run([OSASCRIPT, "-e", script], stdin=subprocess.DEVNULL,
capture_output=True, text=True, timeout=timeout)
return r.returncode == 0, (r.stdout or "").strip(), (r.stderr or "").strip()
except Exception as e:
return False, "", str(e)
def _activate_pid(pid) -> bool:
ok, _, _ = _osascript(
f'tell application "System Events" to set frontmost of '
f'(first process whose unix id is {int(pid)}) to true')
return ok
def activate_fusion() -> bool:
pids = fusion_pids()
return _activate_pid(pids[0]) if pids else False
def raise_window(winid) -> bool:
"""Best-effort AXRaise of the window (after activating its app)."""
w = _window_by_id(winid)
if not w:
return False
_activate_pid(w["pid"])
title = (w["title"] or "").replace('"', '')
if title:
_osascript(
f'tell application "System Events" to tell '
f'(first process whose unix id is {w["pid"]}) to '
f'perform action "AXRaise" of (first window whose name is "{title}")')
time.sleep(0.15)
return True
def _ax_close_window(winid) -> bool:
"""Click the window's close button via Accessibility — background-safe
(no raise, no focus steal)."""
w = _window_by_id(winid)
if not w:
return False
title = (w["title"] or "").replace('"', '')
pid = w["pid"]
if title:
ok, out, _ = _osascript(
f'tell application "System Events" to tell '
f'(first process whose unix id is {pid}) to '
f'click (first button whose subrole is "AXCloseButton") of '
f'(first window whose name is "{title}")')
if ok:
return True
# Untitled (or name mismatch): try index-matching by position.
r = w["rect"]
ok, out, _ = _osascript(
f'tell application "System Events" to tell '
f'(first process whose unix id is {pid})\n'
f'repeat with wnd in windows\n'
f' if (item 1 of (position of wnd)) is {r["left"]} and '
f'(item 2 of (position of wnd)) is {r["top"]} then\n'
f' click (first button whose subrole is "AXCloseButton") of wnd\n'
f' return "closed"\n'
f' end if\n'
f'end repeat\n'
f'return "notfound"\n'
f'end tell')
return ok and out == "closed"
def close_window_bg(winid) -> bool:
"""Background close (never raises, never steals focus)."""
try:
return _ax_close_window(winid)
except Exception:
return False
def close_and_wait(winid, timeout: float = 15.0) -> bool:
"""Close a window and wait for it to disappear from the window list."""
if not _ax_close_window(winid):
# Fall back to raise + Cmd+W.
raise_window(winid)
send_named_key("w", flags=_kCGEventFlagMaskCommand)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
time.sleep(0.3)
if not window_valid(winid):
return True
return False
def quit_fusion_native() -> bool:
"""Ask Fusion to quit like a user would (Quit menu semantics)."""
ok, _, _ = _osascript('tell application "Autodesk Fusion" to quit', timeout=20)
return ok
def kill_fusion() -> int:
"""Force-kill every Fusion process. Returns 1 if anything was signalled."""
try:
r = subprocess.run(["pkill", "-9", "-f", FUSION_PROC_PATTERN],
stdin=subprocess.DEVNULL, capture_output=True, timeout=10)
return 1 if r.returncode == 0 else 0
except Exception:
return 0
# ---------------------------------------------------------------------------
# Input synthesis (CGEvent)
# ---------------------------------------------------------------------------
# macOS virtual keycodes (kVK_*) for the same named keys VK_MAP supports.
KEYCODE_MAP = {
"enter": 36, "return": 36,
"escape": 53, "esc": 53,
"tab": 48,
"space": 49,
"up": 126, "down": 125, "left": 123, "right": 124,
"backspace": 51, "delete": 117,
"home": 115, "end": 119,
"pageup": 116, "pagedown": 121,
"f1": 122, "f2": 120, "f3": 99, "f4": 118,
"f5": 96, "f6": 97, "f7": 98, "f8": 100,
"f9": 101, "f10": 109, "f11": 103, "f12": 111,
}
_MODIFIER_FLAGS = {
"cmd": _kCGEventFlagMaskCommand, "command": _kCGEventFlagMaskCommand,
"ctrl": _kCGEventFlagMaskControl, "control": _kCGEventFlagMaskControl,
"alt": _kCGEventFlagMaskAlternate, "option": _kCGEventFlagMaskAlternate,
"shift": _kCGEventFlagMaskShift,
}
def _post_key_event(keycode: int, down: bool, flags: int = 0, unicode_char: str = None):
evt = _CG.CGEventCreateKeyboardEvent(None, keycode, down)
if not evt:
return
try:
if flags:
_CG.CGEventSetFlags(evt, flags)
if unicode_char:
units = unicode_char.encode("utf-16-le")
n = len(units) // 2
buf = (ctypes.c_uint16 * n).from_buffer_copy(units)
_CG.CGEventKeyboardSetUnicodeString(evt, n, buf)
_CG.CGEventPost(_kCGHIDEventTap, evt)
finally:
_CF.CFRelease(evt)
def send_named_key(key: str, flags: int = 0) -> bool:
keycode = KEYCODE_MAP.get(key.lower())
if keycode is None:
if len(key) != 1:
return False
_post_key_event(0, True, flags, unicode_char=key)
time.sleep(0.02)
_post_key_event(0, False, flags, unicode_char=key)
return True
_post_key_event(keycode, True, flags)
time.sleep(0.02)
_post_key_event(keycode, False, flags)
return True
def _post_mouse(evt_type: int, x: float, y: float):
pt = _CGPoint(float(x), float(y))
evt = _CG.CGEventCreateMouseEvent(None, evt_type, pt, _kCGMouseButtonLeft)
if not evt:
return
try:
_CG.CGEventPost(_kCGHIDEventTap, evt)
finally:
_CF.CFRelease(evt)
# ---------------------------------------------------------------------------
# Public API — same shapes as handlers/fusion_ui.py
# ---------------------------------------------------------------------------
def _screen_recording_hint():
try:
if not bool(_CG.CGPreflightScreenCaptureAccess()):
return ("macOS Screen Recording permission is missing for the bridge's host "
"process. Grant it in System Settings > Privacy & Security > Screen "
"Recording (for Hydrogen Desktop / the terminal that spawned the "
"bridge), then retry.")
except Exception:
pass
return None
def _img_dimensions(path: str):
try:
r = subprocess.run([SIPS, "-g", "pixelWidth", "-g", "pixelHeight", path],
stdin=subprocess.DEVNULL, capture_output=True,
text=True, timeout=10)
w = h = 0
for line in r.stdout.splitlines():
m = re.search(r"pixelWidth:\s*(\d+)", line)
if m:
w = int(m.group(1))
m = re.search(r"pixelHeight:\s*(\d+)", line)
if m:
h = int(m.group(1))
return w, h
except Exception:
return 0, 0
def _downscale(path: str, max_dim: int = 1568):
w, h = _img_dimensions(path)
if max(w, h) > max_dim:
try:
subprocess.run([SIPS, "-Z", str(max_dim), path],
stdin=subprocess.DEVNULL, capture_output=True, timeout=15)
return _img_dimensions(path)
except Exception:
pass
return w, h
def _capture(winid, label: str = "", region: dict = None) -> dict:
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
timestamp = int(time.time() * 1000)
suffix = f"-{label}" if label else ""
path = os.path.join(SCREENSHOT_DIR, f"fusion{suffix}-{timestamp}.png")
if region is not None:
cmd = [SCREENCAPTURE, "-x",
"-R", f'{region["left"]},{region["top"]},{region["width"]},{region["height"]}',
path]
else:
cmd = [SCREENCAPTURE, "-x", "-o", "-l", str(int(winid)), path]
try:
r = subprocess.run(cmd, stdin=subprocess.DEVNULL, capture_output=True,
text=True, timeout=20)
except Exception as e:
return {"success": False, "error": f"screencapture failed: {e}"}
if r.returncode != 0 or not os.path.exists(path) or os.path.getsize(path) == 0:
err = (r.stderr or "").strip() or f"screencapture exited {r.returncode}"
hint = _screen_recording_hint()
resp = {"success": False, "error": f"screencapture failed: {err}"}
if hint:
resp["_hint"] = hint
return resp
w, h = _downscale(path)
size_kb = os.path.getsize(path) / 1024
return {
"success": True,
"savedTo": path,
"sizeKB": round(size_kb, 1),
"dimensions": {"width": w, "height": h},
}
def screenshot_hwnd(hwnd, label: str = "") -> dict:
"""Capture any window by CGWindowID. Returns {success, savedTo, sizeKB}."""
winid = int(hwnd)
if not window_valid(winid):
return {"success": False, "error": f"Window id {winid} is not a valid window"}
return _capture(winid, label=label)
def screenshot_fusion_window(use_bitblt: bool = False) -> dict:
"""Capture the Fusion window.
Default: window-scoped capture (screencapture -l) — works while
backgrounded. use_bitblt=True mirrors the Windows BitBlt path: raise the
window and grab its screen REGION, which also captures anything overlapping
it (floating overlays that render as separate windows).
"""
winid = find_fusion_main_winid()
if not winid:
return {
"success": False,
"error": "Fusion 360 window not found (no on-screen Fusion window).",
"_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry.",
}
if use_bitblt:
w = _window_by_id(winid)
raise_window(winid)
time.sleep(1.0)
return _capture(winid, region=w["rect"])
return _capture(winid)
def click_fusion(x, y, relative=True, hwnd=None) -> dict:
"""Click at coordinates in a window (Fusion main window or any dialog).
Same convention as Windows: relative=True means x/y are 0.0-1.0 fractions
of the window; relative=False means pixel (point) offsets from its top-left.
"""
if hwnd:
winid = int(hwnd)
if not window_valid(winid):
return {
"success": False,
"error": f"Window id {winid} is not a valid window.",
"_hint": "Window ids change when windows close/reopen. Call fusion_get_window_info to get fresh ids, then retry.",
}
else:
winid = find_fusion_main_winid()
if not winid:
return {
"success": False,
"error": "Fusion 360 window not found.",
"_hint": "Call fusion_start to start Fusion 360, then retry.",
}
left, top, right, bottom = get_window_rect(winid)
width, height = right - left, bottom - top
raise_window(winid)
time.sleep(0.1)
if relative:
screen_x = left + int(float(x) * width)
screen_y = top + int(float(y) * height)
else:
screen_x = left + int(x)
screen_y = top + int(y)
_post_mouse(_kCGEventMouseMoved, screen_x, screen_y)
time.sleep(0.05)
_post_mouse(_kCGEventLeftMouseDown, screen_x, screen_y)
time.sleep(0.03)
_post_mouse(_kCGEventLeftMouseUp, screen_x, screen_y)
return {
"success": True,
"clickedAt": {"screenX": screen_x, "screenY": screen_y},
"windowOffset": {"x": screen_x - left, "y": screen_y - top},
"relative": relative,
}
def send_key_to_fusion(key: str, hwnd=None) -> dict:
"""Send a key to Fusion (or a specific window) via CGEvent.
Accepts the same named keys as Windows (enter/escape/tab/arrows/f1-f12/...)
or a single character. Also accepts mac-style combos like "cmd+w".
"""
if hwnd:
winid = int(hwnd)
if not window_valid(winid):
return {
"success": False,
"error": f"Window id {winid} is not a valid window.",
"_hint": "Window ids change when windows close/reopen. Call fusion_get_window_info to get fresh ids, then retry.",
}
raise_window(winid)
else:
winid = find_fusion_main_winid()
if not winid:
return {
"success": False,
"error": "Fusion 360 window not found.",
"_hint": "Call fusion_start to start Fusion 360, then retry.",
}
raise_window(winid)
time.sleep(0.1)
key_norm = key.strip()
flags = 0
while "+" in key_norm:
mod, rest = key_norm.split("+", 1)
f = _MODIFIER_FLAGS.get(mod.lower().strip())
if f is None:
break
flags |= f
key_norm = rest.strip()
key_lower = key_norm.lower()
keycode = KEYCODE_MAP.get(key_lower)
if keycode is None and len(key_norm) != 1:
return {"success": False,
"error": f"Unknown key: '{key}'. Use a named key or single character."}
ok = send_named_key(key_norm if keycode is None else key_lower, flags=flags)
if not ok:
return {"success": False, "error": f"Cannot send key '{key}'."}
return {"success": True, "key": key_lower, "keycode": keycode,
"modifiers": flags != 0}
def close_window(hwnd) -> dict:
"""Close a window via its Accessibility close button (background-safe).
Background-safe: does not raise or focus the window. The window can still
prompt for confirmation — this is equivalent to clicking the red close
button.
"""
winid = int(hwnd)
if not window_valid(winid):
return {"success": False, "error": f"Window id {winid} is not a valid window."}
if _ax_close_window(winid):
return {"success": True, "hwnd": winid, "action": "AX close-button clicked"}
# Fall back to raise + Cmd+W.
raise_window(winid)
time.sleep(0.1)
send_named_key("w", flags=_kCGEventFlagMaskCommand)
return {"success": True, "hwnd": winid, "action": "Cmd+W sent (AX close button not found)"}
def _main_enabled(pid, dialog_count: int) -> bool:
"""A modal AX window means the main window is blocked — the mac analog of
a disabled main window under a modal. Best-effort; defaults to True."""
try:
ok, out, _ = _osascript(
f'tell application "System Events" to tell '
f'(first process whose unix id is {int(pid)}) to '
f'return count of (windows whose value of attribute "AXModal" is true)',
timeout=6)
if ok and out.isdigit():
return int(out) == 0
except Exception:
pass
return True
def get_fusion_window_info() -> dict:
"""Main Fusion window info + dialog windows. Same shape as Windows."""
winid = find_fusion_main_winid()
if not winid:
return {
"success": False,
"error": "Fusion 360 window not found.",
"_hint": "Call fusion_start to start Fusion 360, then retry.",
}
w = _window_by_id(winid)
dialogs = find_dialog_windows()
return {
"success": True,
"hwnd": winid,
"title": w["title"],
"mainEnabled": _main_enabled(w["pid"], len(dialogs)),
"rect": w["rect"],
"dialogs": dialogs,
}