app
Adom Desktop - KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672
"""Handlers for close_symbol_editor, close_footprint_editor, close_3d_viewer, and close_kicad.
Cross-platform: Windows uses Win32 (WM_CLOSE + taskkill), macOS uses
osascript (graceful quit) and pkill (force).
Window enumeration is process-based on Windows (find PIDs running from KiCad's
bin_dir, then enumerate top-level windows belonging to those PIDs). On macOS
we can't enumerate windows without Accessibility-framework permissions, so
window_info returns the running editor processes as a flat list instead.
"""
import ctypes
import ctypes.wintypes
import os
import subprocess
import sys
import time
IS_WINDOWS = sys.platform == "win32"
IS_MACOS = sys.platform == "darwin"
WM_CLOSE = 0x0010
GW_OWNER = 4
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║ WINDOWS ║
# ╚═══════════════════════════════════════════════════════════════════════════╝
if IS_WINDOWS:
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
else:
user32 = None
kernel32 = None
if IS_WINDOWS:
# KiCad sub-process names that may survive WM_CLOSE
WIN_KICAD_PROCESS_NAMES = [
"eeschema.exe",
"pcbnew.exe",
"bitmap2component.exe",
"pcb_calculator.exe",
"pl_editor.exe",
"kicad.exe",
]
_callbacks = []
def _win_get_kicad_pids(kicad_info: dict) -> set:
"""Find PIDs of all running processes whose exe lives under KiCad's bin_dir."""
bin_dir = kicad_info.get("bin_dir", "")
if not bin_dir:
return set()
bin_dir_norm = os.path.normcase(os.path.normpath(bin_dir))
pids = set()
max_pids = 4096
arr = (ctypes.wintypes.DWORD * max_pids)()
bytes_returned = ctypes.wintypes.DWORD()
psapi = ctypes.windll.psapi
if not psapi.EnumProcesses(ctypes.byref(arr), ctypes.sizeof(arr), ctypes.byref(bytes_returned)):
return pids
count = bytes_returned.value // ctypes.sizeof(ctypes.wintypes.DWORD)
buf = ctypes.create_unicode_buffer(1024)
for i in range(count):
pid = arr[i]
if pid == 0:
continue
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
continue
try:
size = ctypes.wintypes.DWORD(1024)
if kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)):
exe_path = os.path.normcase(os.path.normpath(buf.value))
if exe_path.startswith(bin_dir_norm):
pids.add(pid)
finally:
kernel32.CloseHandle(handle)
return pids
def _win_find_windows_by_pid(pids: set) -> list:
"""Find all visible top-level windows belonging to any of the given PIDs."""
results = []
WNDENUMPROC = ctypes.WINFUNCTYPE(
ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
)
def callback(hwnd, _lparam):
if not user32.IsWindowVisible(hwnd):
return True
pid = ctypes.wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if pid.value in pids:
length = user32.GetWindowTextLengthW(hwnd)
title = ""
if length > 0:
buf = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buf, length + 1)
title = buf.value
owner = user32.GetWindow(hwnd, GW_OWNER)
results.append((hwnd, title, owner))
return True
cb = WNDENUMPROC(callback)
_callbacks.append(cb)
user32.EnumWindows(cb, 0)
_callbacks.remove(cb)
return results
def _win_find_all_kicad_windows(kicad_info: dict) -> list:
pids = _win_get_kicad_pids(kicad_info)
if not pids:
return []
return _win_find_windows_by_pid(pids)
def _win_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:
owner = user32.GetWindow(hwnd, GW_OWNER)
results.append((hwnd, buf.value, owner))
return True
cb = WNDENUMPROC(callback)
_callbacks.append(cb)
user32.EnumWindows(cb, 0)
_callbacks.remove(cb)
return results
def _win_get_dialog_body_text(hwnd: int) -> str:
"""Read a dialog's message text from its Static (label) child controls.
Returns the concatenated body (e.g. "Insufficient permissions to save…")
so the AI can read + handle an error dialog WITHOUT a screenshot round-trip.
Only Static class (skips buttons), skips icon-only statics + consecutive
dups. Best-effort: returns "" on any failure.
"""
parts: list = []
try:
WNDENUMPROC = ctypes.WINFUNCTYPE(
ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
)
def child_cb(child, _lparam):
cls = ctypes.create_unicode_buffer(64)
user32.GetClassNameW(child, cls, 64)
if cls.value == "Static":
length = user32.GetWindowTextLengthW(child)
if length > 0:
b = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(child, b, length + 1)
t = b.value.strip()
if t and any(ord(c) >= 32 for c in t):
parts.append(t)
return True
cb = WNDENUMPROC(child_cb)
_callbacks.append(cb)
user32.EnumChildWindows(hwnd, cb, 0)
_callbacks.remove(cb)
except Exception:
return ""
out: list = []
for p in parts:
if not out or out[-1] != p:
out.append(p)
return " ".join(out).strip()
def _win_close_and_wait(hwnd: int, title: str, timeout: float = 5.0) -> bool:
"""Send WM_CLOSE and wait for the window to disappear."""
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 _win_force_kill_kicad_processes() -> list:
"""Force-kill all KiCad sub-processes via taskkill. Returns list of killed names."""
killed = []
for name in WIN_KICAD_PROCESS_NAMES:
try:
result = subprocess.run(
["taskkill", "/F", "/IM", name],
capture_output=True, text=True, timeout=5,
creationflags=0x08000000, # CREATE_NO_WINDOW
)
if result.returncode == 0:
killed.append(name)
except Exception:
pass
return killed
# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║ macOS ║
# ╚═══════════════════════════════════════════════════════════════════════════╝
# Bundle ids / app names for AppleScript "tell application X to quit".
# Display names (left side) match the on-disk .app bundle name; AppleScript
# accepts these directly.
MAC_APP_NAMES = ["KiCad", "PCB Editor", "Schematic Editor", "Footprint Editor",
"Symbol Editor", "GerbView", "Image Converter", "PCB Calculator",
"Page Layout Editor"]
# Process-name patterns for pgrep / pkill — covers both binary names and the
# editor bundles whose Mach-O is named after the editor.
MAC_KICAD_PROCESS_NAMES = ["kicad", "pcbnew", "eeschema", "gerbview",
"bitmap2component", "pcb_calculator", "pl_editor"]
def _mac_pgrep(pattern: str) -> list:
"""Run `pgrep -f <pattern>` and return list of matching PIDs."""
try:
result = subprocess.run(
["pgrep", "-f", pattern],
capture_output=True, text=True, timeout=3,
)
if result.returncode != 0:
return []
return [int(line) for line in result.stdout.split() if line.strip().isdigit()]
except Exception:
return []
def _mac_get_kicad_pids(kicad_info: dict) -> set:
"""Find PIDs of running KiCad processes by matching binary names.
Filters by the install's base_dir so we don't hit a different KiCad install."""
base_dir = kicad_info.get("base_dir", "")
pids: set = set()
# ps -A -o pid,comm,args is the most reliable way to filter by exe path
try:
result = subprocess.run(
["ps", "-A", "-o", "pid=,comm="],
capture_output=True, text=True, timeout=3,
)
if result.returncode != 0:
return pids
for line in result.stdout.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) != 2:
continue
pid_str, comm = parts
if not pid_str.isdigit():
continue
# comm is the full path to the executable on macOS
# Filter to KiCad install if we have a base_dir; otherwise match by name
if base_dir and base_dir in comm:
pids.add(int(pid_str))
elif not base_dir:
bin_name = os.path.basename(comm).lower()
if any(name in bin_name for name in MAC_KICAD_PROCESS_NAMES):
pids.add(int(pid_str))
except Exception:
pass
return pids
def _mac_running_editors(kicad_info: dict) -> list:
"""Return a list of dicts describing currently running KiCad editor processes.
Used as a stand-in for window enumeration on macOS (where we'd need
Accessibility-framework permission to enumerate windows of other apps)."""
pids = _mac_get_kicad_pids(kicad_info)
out = []
for pid in pids:
try:
result = subprocess.run(
["ps", "-o", "comm=", "-p", str(pid)],
capture_output=True, text=True, timeout=2,
)
comm = result.stdout.strip()
if not comm:
continue
bin_name = os.path.basename(comm)
# Title is best-effort: use the binary name as the title surrogate
out.append({
"pid": pid,
"title": bin_name,
"binary": comm,
})
except Exception:
pass
return out
def _mac_quit_app(app_name: str, timeout: float = 5.0) -> bool:
"""Ask an app to quit gracefully via AppleScript.
Returns True if the quit message was delivered (the app may still
show a save-prompt dialog before fully quitting)."""
try:
subprocess.run(
["osascript", "-e", f'tell application "{app_name}" to quit'],
capture_output=True, text=True, timeout=timeout,
)
# osascript returns 0 even if the app isn't running, so we always
# claim success — caller should re-check process state.
return True
except Exception:
return False
def _mac_force_kill_kicad_processes() -> list:
"""Force-kill all KiCad sub-processes via pkill. Returns list of killed names."""
killed = []
for name in MAC_KICAD_PROCESS_NAMES:
try:
result = subprocess.run(
["pkill", "-9", "-x", name],
capture_output=True, text=True, timeout=3,
)
if result.returncode == 0:
killed.append(name)
except Exception:
pass
return killed
def _mac_wait_for_quit(name_substr: str, timeout: float = 5.0) -> bool:
"""Wait up to `timeout` for processes whose binary name contains `name_substr` to disappear."""
deadline = time.monotonic() + timeout
name_substr = name_substr.lower()
while time.monotonic() < deadline:
try:
result = subprocess.run(
["ps", "-A", "-o", "comm="],
capture_output=True, text=True, timeout=2,
)
running = any(name_substr in line.lower()
for line in result.stdout.splitlines())
if not running:
return True
except Exception:
pass
time.sleep(0.3)
return False
# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║ PUBLIC API (cross-platform) ║
# ╚═══════════════════════════════════════════════════════════════════════════╝
def _is_manager_window(title: str) -> bool:
"""The KiCad project manager's title is just 'KiCad <version>' or 'KiCad'
— it doesn't contain 'Editor', 'Viewer', etc."""
lower = title.lower()
if lower.startswith("kicad") and "editor" not in lower and "viewer" not in lower:
return True
return False
def handle_window_info(kicad_info: dict, args: dict) -> dict:
"""Return all KiCad windows (editors, project manager, modal dialogs).
Windows: enumerates real windows via Win32. Bundles modal dialogs separately.
macOS: returns a flat list of running editor processes. Modal-dialog
enumeration requires Accessibility-framework permission, which we don't
request — handlers that need to dismiss dialogs should ask the user.
"""
if IS_WINDOWS:
all_found = _win_find_all_kicad_windows(kicad_info)
editors = []
dialogs = []
manager = None
for hwnd, title, owner in all_found:
if owner != 0:
# `body` = the dialog's message text (read from its Static child
# controls) so the AI can read+handle the error WITHOUT a screenshot
# round-trip (e.g. "Insufficient permissions to save...").
dialogs.append({
"hwnd": hwnd,
"title": title,
"ownerHwnd": owner,
"body": _win_get_dialog_body_text(hwnd),
})
elif _is_manager_window(title):
manager = {"hwnd": hwnd, "title": title}
else:
editors.append({"hwnd": hwnd, "title": title})
data = {
"projectManager": manager,
"editors": editors,
"modalDialogs": dialogs,
"hasModalDialogs": len(dialogs) > 0,
}
if dialogs:
# In-`data` hint (siblings the flag, so it's not missed): tells the AI
# a blocking dialog is up and how to read + dismiss it.
data["_dialogHint"] = (
"hasModalDialogs is true — KiCad has a BLOCKING modal dialog. Read each "
"modalDialogs[].body for the message text; if body is empty, screenshot "
"modalDialogs[].hwnd via desktop_screenshot_window. Dismiss with "
"kicad_send_key {hwnd, key:'return'} (OK/default) or 'escape' (cancel)."
)
return {
"success": True,
"output": f"{len(editors)} editor(s), {len(dialogs)} dialog(s)",
"data": data,
}
if IS_MACOS:
editors = _mac_running_editors(kicad_info)
manager = None
editor_list = []
for e in editors:
if _is_manager_window(e["title"]):
manager = {"pid": e["pid"], "title": e["title"]}
else:
editor_list.append({"pid": e["pid"], "title": e["title"]})
return {
"success": True,
"output": f"{len(editor_list)} editor(s) running",
"data": {
"projectManager": manager,
"editors": editor_list,
"modalDialogs": [],
"hasModalDialogs": False,
"_hint": ("macOS reports editors as running processes; modal dialog "
"enumeration requires Accessibility permission and is not "
"implemented. If a modal dialog is blocking, ask the user "
"to dismiss it manually."),
},
}
return {
"success": False,
"error": f"window_info not implemented on platform {sys.platform!r}",
}
def handle_close_symbol_editor(kicad_info: dict, args: dict) -> dict:
"""Close the KiCad Symbol Editor window."""
if IS_WINDOWS:
windows = _win_find_windows_by_title("Symbol Editor")
sym_windows = [(h, t) for h, t, _ in windows if "symbol editor" in t.lower()]
if not sym_windows:
return {"success": True, "output": "Symbol Editor is not open."}
closed, failed = [], []
for hwnd, title in sym_windows:
(closed if _win_close_and_wait(hwnd, title) else failed).append(title)
if failed:
return {
"success": False,
"error": f"Failed to close: {', '.join(failed)}",
"_hint": "Call close_kicad with {\"force\": true} to force-kill the window, then retry.",
}
return {
"success": True,
"output": f"Symbol Editor closed: {closed[0]}" if len(closed) == 1
else f"Closed {len(closed)} Symbol Editor window(s).",
}
if IS_MACOS:
if not _mac_pgrep("Symbol Editor"):
return {"success": True, "output": "Symbol Editor is not open."}
_mac_quit_app("Symbol Editor")
if _mac_wait_for_quit("Symbol Editor"):
return {"success": True, "output": "Symbol Editor closed."}
return {
"success": False,
"error": "Symbol Editor did not close within 5s",
"_hint": "Call close_kicad with {\"force\": true} to force-kill the editor, then retry.",
}
return {"success": False, "error": f"unsupported platform {sys.platform!r}"}
def handle_close_footprint_editor(kicad_info: dict, args: dict) -> dict:
"""Close the KiCad Footprint Editor window."""
if IS_WINDOWS:
windows = _win_find_windows_by_title("Footprint Editor")
fp_windows = [(h, t) for h, t, _ in windows if "footprint editor" in t.lower()]
if not fp_windows:
return {"success": True, "output": "Footprint Editor is not open."}
closed, failed = [], []
for hwnd, title in fp_windows:
(closed if _win_close_and_wait(hwnd, title) else failed).append(title)
if failed:
return {
"success": False,
"error": f"Failed to close: {', '.join(failed)}",
"_hint": "Call close_kicad with {\"force\": true} to force-kill the window, then retry.",
}
return {
"success": True,
"output": f"Footprint Editor closed: {closed[0]}" if len(closed) == 1
else f"Closed {len(closed)} Footprint Editor window(s).",
}
if IS_MACOS:
if not _mac_pgrep("Footprint Editor"):
return {"success": True, "output": "Footprint Editor is not open."}
_mac_quit_app("Footprint Editor")
if _mac_wait_for_quit("Footprint Editor"):
return {"success": True, "output": "Footprint Editor closed."}
return {
"success": False,
"error": "Footprint Editor did not close within 5s",
"_hint": "Call close_kicad with {\"force\": true} to force-kill the editor, then retry.",
}
return {"success": False, "error": f"unsupported platform {sys.platform!r}"}
def handle_close_3d_viewer(kicad_info: dict, args: dict) -> dict:
"""Close the KiCad 3D Viewer window."""
if IS_WINDOWS:
windows = _win_find_windows_by_title("3D Viewer")
viewer_windows = [(h, t) for h, t, _ in windows if "3d viewer" in t.lower()]
if not viewer_windows:
return {"success": True, "output": "3D Viewer is not open."}
closed, failed = [], []
for hwnd, title in viewer_windows:
(closed if _win_close_and_wait(hwnd, title) else failed).append(title)
if failed:
return {
"success": False,
"error": f"Failed to close: {', '.join(failed)}",
"_hint": "Call close_kicad with {\"force\": true} to force-kill the window, then retry.",
}
return {"success": True, "output": "3D Viewer closed."}
if IS_MACOS:
# The 3D Viewer on macOS is a sub-window of PCB Editor; closing it
# requires either Accessibility-driven UI scripting or a force-kill of
# PCB Editor (which would also close the board). We surface the
# limitation rather than killing the whole editor.
return {
"success": False,
"error": "close_3d_viewer is not implemented on macOS",
"_hint": ("On macOS the 3D Viewer is a child window of PCB Editor; "
"closing it requires Accessibility permission. Ask the user "
"to close it manually, or use close_kicad to close the whole editor."),
}
return {"success": False, "error": f"unsupported platform {sys.platform!r}"}
def handle_close_kicad(kicad_info: dict, args: dict) -> dict:
"""Close all KiCad windows (editors first, then the project manager).
Args:
kicad_info: KiCad detection info dict.
args: Optional dict with:
force (bool): If True, skip graceful close and go straight to kill.
Default False.
"""
force = bool(args.get("force", False)) if args else False
if IS_WINDOWS:
if force:
killed = _win_force_kill_kicad_processes()
if killed:
return {
"success": True,
"output": f"Force-killed: {', '.join(killed)}",
"data": {"forceKilled": killed},
}
return {"success": True, "output": "KiCad is not running (nothing to kill)."}
all_windows = _win_find_all_kicad_windows(kicad_info)
editors, manager = [], None
for hwnd, title, owner in all_windows:
if owner != 0:
continue
if _is_manager_window(title):
manager = (hwnd, title)
else:
editors.append((hwnd, title))
if not editors and not manager:
return {"success": True, "output": "KiCad is not running."}
closed, failed = [], []
for hwnd, title in editors:
(closed if _win_close_and_wait(hwnd, title) else failed).append(title)
if manager:
hwnd, title = manager
(closed if _win_close_and_wait(hwnd, title) else failed).append(title)
force_killed = []
if failed:
time.sleep(1)
force_killed = _win_force_kill_kicad_processes()
if force_killed:
closed.extend(f"{name} (force-killed)" for name in force_killed)
failed = []
if failed:
return {
"success": False,
"error": f"Failed to close: {', '.join(failed)}",
"output": f"Closed: {', '.join(closed)}" if closed else "",
"_hint": "Call close_kicad with {\"force\": true} to force-kill stuck windows.",
}
return {
"success": True,
"output": f"KiCad closed ({len(closed)} window{'s' if len(closed) != 1 else ''}).",
"data": {"forceKilled": force_killed} if force_killed else {},
}
if IS_MACOS:
if force:
killed = _mac_force_kill_kicad_processes()
if killed:
return {
"success": True,
"output": f"Force-killed: {', '.join(killed)}",
"data": {"forceKilled": killed},
}
return {"success": True, "output": "KiCad is not running (nothing to kill)."}
# Graceful: ask each editor app to quit
editors = _mac_running_editors(kicad_info)
if not editors:
return {"success": True, "output": "KiCad is not running."}
for app_name in MAC_APP_NAMES:
_mac_quit_app(app_name)
# Wait for each known process to disappear
still_running = []
for proc_name in MAC_KICAD_PROCESS_NAMES:
if not _mac_wait_for_quit(proc_name, timeout=3.0):
still_running.append(proc_name)
force_killed = []
if still_running:
force_killed = _mac_force_kill_kicad_processes()
if still_running and not force_killed:
return {
"success": False,
"error": f"Failed to close: {', '.join(still_running)}",
"_hint": "Call close_kicad with {\"force\": true} to force-kill stuck processes.",
}
return {
"success": True,
"output": f"KiCad closed ({len(editors)} process{'es' if len(editors) != 1 else ''}).",
"data": {"forceKilled": force_killed} if force_killed else {},
}
return {"success": False, "error": f"unsupported platform {sys.platform!r}"}