← Commit history

0.9.347: kicad_close honours unsaved work (#84); verified footprint rescan (#51)

John Lauer ·3276ed65ed ·1mo ago ·parent 12dbb65
5 files changed +129−21
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.343\ No newline at end of file+0.9.347\ No newline at end of file
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