app
KiCad - the 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.
← Commit history
0.9.347: kicad_close honours unsaved work (#84); verified footprint rescan (#51)
5 files changed
+129−21
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.343+0.9.347
SKILL.md+2−1@@ -89,7 +89,7 @@ If you pass a version that's not installed, the bridge returns a hint listing th | `kicad_close_symbol_editor` | `close_symbol_editor` | Close Symbol Editor | — | | `kicad_close_footprint_editor` | `close_footprint_editor` | Close Footprint Editor | — | | `kicad_close_3d_viewer` | `close_3d_viewer` | Close 3D viewer | — |-| `kicad_close` | `close` | Close KiCad (all editors) | — |+| `kicad_close` | `close` | Close KiCad (all editors). From 0.9.347 an editor with unsaved work is NOT killed: the reply says errorCode unsaved_changes with the dialog text. `discardChanges:true` presses Discard for the caller; `force:true` is the only kill | optional `discardChanges`, `force` | | `kicad_window_info` | `window_info` | Enumerate open KiCad windows (HWND, title, bounds, editor type) | — | | `kicad_install_library` | `install_library` | Register a `.kicad_sym` + `.pretty/` pair in sym-lib-table + fp-lib-table | `libraryPath`, `libraryType`, `libraryName`, optional `kicadVersion` | | `kicad_install_symbol` | `install_symbol` | Add a single symbol to an existing library | `fileName`, `fileContent`, optional `kicadVersion` |@@ -101,6 +101,7 @@ If you pass a version that's not installed, the bridge returns a hint listing th | `kicad_click` | `click` | Send a click at (x, y) in a KiCad window | `hwnd`, `x`, `y` | | `kicad_adom_library_status` | `adom_library_status` | Report whether the Adom shared library is registered | — | | `kicad_adom_library_heal` | `adom_library_heal` | Reconcile the Adom library: `mode` merge (default) / repoint / prune; `dryRun` previews. From 0.9.279 `prune` drops symbols KiCad refuses | `mode`, `dryRun` |+| `kicad_rescan_libraries` | `rescan_libraries` | Make parts installed while an editor was open visible: refreshes the editor library tree in place (seconds, no restart). Pass `footprints:["Adom:NAME"]` to have each loaded in the Footprint Editor as proof (`verified`). `force:true` is the KiCad process restart, last resort | optional `footprints`, `force` | ## The `show_*` family — START HERE for "show me X"
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.343",+ "version": "0.9.347", "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.", "homepage": "https://wiki.adom.inc/adom/adom-bridge", "author": "Adom Inc.",
handlers/close_windows.py+114−5@@ -333,6 +333,51 @@ if IS_WINDOWS: return " ".join(out).strip() + _SAVE_PROMPT_PATTERNS = ("save changes", "save the changes", "unsaved", "changes to", "before closing")++ def _win_find_save_prompt(kicad_info: dict) -> dict | None:+ """The 'Save changes?' box KiCad raises on WM_CLOSE of a dirty editor."""+ try:+ for w in _win_scan_kicad_dialogs(kicad_info):+ if not w.get("isDialog"):+ continue+ text = f"{w.get('title', '')} {w.get('body', '')}".lower()+ if any(p in text for p in _SAVE_PROMPT_PATTERNS):+ return {"hwnd": w["hwnd"], "title": w.get("title", ""), "body": w.get("body", "")}+ except Exception: # pylint: disable=broad-except+ return None+ return None++ def _win_click_button(dialog_hwnd: int, labels: tuple) -> bool:+ """Press one named button on a dialog, time-boxed (see #83), no focus."""+ BM_CLICK, SMTO_ABORTIFHUNG = 0x00F5, 0x0002+ found = [0]+ try:+ WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)+ def cb(child, _l):+ cls = ctypes.create_unicode_buffer(32)+ user32.GetClassNameW(child, cls, 32)+ if cls.value == "Button":+ n = user32.GetWindowTextLengthW(child)+ b = ctypes.create_unicode_buffer(n + 1)+ user32.GetWindowTextW(child, b, n + 1)+ label = b.value.replace("&", "").strip().lower()+ if label in labels and user32.IsWindowVisible(child):+ found[0] = child+ return False+ return True+ c = WNDENUMPROC(cb)+ _callbacks.append(c)+ user32.EnumChildWindows(dialog_hwnd, c, 0)+ _callbacks.remove(c)+ if found[0]:+ res = ctypes.wintypes.DWORD(0)+ user32.SendMessageTimeoutW(found[0], BM_CLICK, 0, 0, SMTO_ABORTIFHUNG, 1500, ctypes.byref(res))+ return True+ except Exception: # pylint: disable=broad-except+ return False+ return False+ 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)@@ -1111,12 +1156,73 @@ def handle_close_kicad(kicad_info: dict, args: dict) -> dict: if not editors and not manager: return {"success": True, "output": "KiCad is not running."} - closed, failed = [], []+ # Issue #84 (winvm 2026-09-07): a board with unsaved edits answers WM_CLOSE+ # with KiCad's "Save changes?" box, the 5 s wait expired, and the handler+ # force-killed pcbnew while reporting success. A forced kill IS a discard,+ # just an unannounced one. Now: the save prompt is read; with+ # discardChanges:true its Discard button is pressed and the close stays+ # graceful; otherwise the window is left open and the reply says+ # unsavedChanges with the dialog text. force:true remains the only kill.+ discard = bool(args.get("discardChanges", False)) if args else False+ closed, failed, unsaved, discard_log = [], [], [], []+ def _close_one(hwnd, title):+ if _win_close_and_wait(hwnd, title, timeout=8.0):+ return True+ prompt = _win_find_save_prompt(kicad_info)+ if prompt:+ if discard:+ # KiCad's "Save Changes?" is a TaskDialog on Windows: its buttons are not+ # Win32 Button children, so press Discard through UIA (no focus, no cursor).+ pressed, attempts = False, []+ # Measured on arav-rog (0.9.346): the Win32 BM_CLICK lands on KiCad's+ # Discard button; UIA finds the control but reports not_invokable.+ pressed = _win_click_button(prompt["hwnd"], ("discard changes", "discard", "don't save", "do not save", "no"))+ attempts.append({"win32Button": pressed})+ if not pressed:+ try:+ from handlers.uia import uia_invoke+ for label in ("Discard Changes", "Discard", "Don't Save"):+ res = uia_invoke(prompt["hwnd"], contains=label) or {}+ attempts.append({"label": label, "uia": res})+ if res.get("ok") or res.get("success") or res.get("invoked"):+ pressed = True+ break+ except Exception as exc: # pylint: disable=broad-except+ attempts.append({"error": str(exc)[:120]})+ discard_log.append({"window": title, "dialog": prompt["title"], "attempts": attempts})+ if _win_close_and_wait(hwnd, title, timeout=8.0):+ closed.append(f"{title} (changes discarded)")+ return None+ unsaved.append({"window": title, "dialog": prompt["title"], "body": prompt["body"]})+ return False+ return False for hwnd, title in editors:- (closed if _win_close_and_wait(hwnd, title) else failed).append(title)- if manager:+ r = _close_one(hwnd, title)+ if r is True:+ closed.append(title)+ elif r is False:+ failed.append(title)+ if manager and not unsaved: hwnd, title = manager- (closed if _win_close_and_wait(hwnd, title) else failed).append(title)+ r = _close_one(hwnd, title)+ if r is True:+ closed.append(title)+ elif r is False:+ failed.append(title)++ if unsaved:+ return {+ "success": False,+ "errorCode": "unsaved_changes",+ "unsavedChanges": unsaved,+ "error": "KiCad is asking whether to save changes; nothing was killed: "+ + "; ".join(f"{u['window']}: {u['body'][:80]}" for u in unsaved),+ "output": f"Closed: {', '.join(closed)}" if closed else "",+ "_hint": ("The user's unsaved work is still open. Ask them, then either save in KiCad "+ "(Ctrl+S via desktop_press_key) and call kicad_close again, or call kicad_close "+ "{\"discardChanges\": true} to press Discard on their behalf. force:true kills "+ "the process, which discards silently."),+ } force_killed = [] if failed:@@ -1134,11 +1240,14 @@ def handle_close_kicad(kicad_info: dict, args: dict) -> dict: "_hint": "Call close_kicad with {\"force\": true} to force-kill stuck windows.", } - return {+ out = { "success": True, "output": f"KiCad closed ({len(closed)} window{'s' if len(closed) != 1 else ''}).", "data": {"forceKilled": force_killed} if force_killed else {}, }+ if discard_log:+ out["discard"] = discard_log+ return out if IS_MACOS: if force:
handlers/install_footprint.py+11−13@@ -237,13 +237,11 @@ def handle_install_footprint(kicad_info: dict, args: dict) -> dict: elif open_editor: output_parts.append(f"Footprint Editor: {editor_output}") - # Wiki #47 (Colby): the symbol install flags this exact situation and the- # footprint install stayed silent, so a caller had no way to pre-warn a user.- # Parity with install_symbol's staleEditorOpen - except the footprint case is- # WORSE, because KiCad caches footprint libraries process-wide rather than- # per-frame. Measured 2026-08-20: closing and reopening the Footprint Editor- # does NOT pick up a footprint installed while it was open; only restarting- # the KiCad process does.+ # Wiki #47 / #51: flag an open Footprint Editor so the caller knows the next+ # show may take a second pass. Measured 2026-09-07 on 0.9.343 (KiCad 10.0.3):+ # a footprint installed while the editor was open loaded in 18 s on the same+ # pcbnew process (View > Refresh reloads the tree, then the load succeeds on+ # the second pass). The process restart is the LAST resort now, not the rule. stale_editor = False if not editor_opened: try:@@ -266,13 +264,13 @@ def handle_install_footprint(kicad_info: dict, args: dict) -> dict: if stale_editor: out["staleEditorOpen"] = True out["output"] += (" | NOTE: a Footprint Editor was already open when this footprint "- "was added, so KiCad cannot see it until its PROCESS restarts")+ "was added; kicad_show_footprint refreshes its library tree in place "+ "(about 18 s, no KiCad restart)") out["_staleHint"] = (- "KiCad caches footprint libraries process-wide, so unlike the symbol case a "- "frame reopen is not enough (measured). kicad_show_footprint will report "- "errorCode library_cache_stale and, when the bridge owns every running KiCad, "- "restart it automatically. If the USER launched KiCad, the bridge will not "- "close it and the caller should offer them the restart."+ "Call kicad_show_footprint as usual: it reloads the editor's library tree "+ "(View > Refresh) and loads the part on its second pass, same KiCad process. "+ "A KiCad restart happens only if that fails, and only when the bridge owns "+ "every running KiCad. Batch importers: install every part, then show." ) return out