← Commit history

v0.9.97: background-first hard rule — keystroke fallbacks gated behind allowFocusSteal (were leaking keys to the user's window via neutered _force_foreground); foreground:true user opt-in to bring a window to the user; principle in dev skill

John Lauer ·52370b9725 ·2mo ago ·parent ab22a1d
9 files changed +127−8
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.96+0.9.97
adom-bridge-kicad-manifest.json+5−5
@@ -1,15 +1,15 @@ {   "manifest_version": 1,   "name": "kicad",-  "version": "0.9.96",-  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.96/adom-bridge-kicad-v0.9.96.zip",-  "sha256": "d26cca2b9dadc9472d4a10f24e73b4dc343cdd9442acd8fc5036ae3ce442f6d5",-  "size": 388470,+  "version": "0.9.97",+  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.97/adom-bridge-kicad-v0.9.97.zip",+  "sha256": "cb5b58d558700f4947c851f12d9926e7b3a74d1e90cf58410384ed1a02dbdf3d",+  "size": 389789,   "verbPrefixes": [     "kicad_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-08-15T02:01:57Z",+  "released_at": "2026-08-15T02:19:49Z",   "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png",   "languages": [     "Python",
adom-bridge-kicad-v0.9.97.zipadded
⋯ 1 unchanged line ⋯
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "kicad",   "displayName": "KiCad EDA",-  "version": "0.9.96",+  "version": "0.9.97",   "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/open_footprint_editor.py+21−1
@@ -257,7 +257,13 @@ def _send_ctrl_shift_e(hwnd: int):     VK_CONTROL = 0x11     VK_SHIFT = 0x10     VK_E = 0x45-    _force_foreground(hwnd)+    # Only reachable via allowFocusSteal now — so a REAL foreground is correct+    # here (keys must land on KiCad, never the user's window).+    try:+        from handlers import win_focus+        win_focus.bring_to_user(hwnd)+    except Exception:+        pass     time.sleep(0.3)     user32.keybd_event(VK_CONTROL, 0, 0, 0)     user32.keybd_event(VK_SHIFT, 0, 0, 0)@@ -721,6 +727,20 @@ def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict:             "via": "win32-background",         } +    # BACKGROUND-FIRST (John, 2026-08-14): the win32 tier above is the background+    # path; the SendKeys/Ctrl+Shift+E fallback needs a foregrounded window (rude,+    # and leaks keys to the user's window if we DON'T foreground). Opt-in only.+    if not args.get("allowFocusSteal"):+        return {+            "success": False, "editorOpened": True, "footprintLoaded": False,+            "error": f"Footprint Editor open but '{search_text}' didn't load in the background "+                     f"(title: {_get_footprint_editor_title() or 'unknown'})",+            "_hint": ("Background load unconfirmed. Retry once (the win32 tier usually lands warm). "+                      "The keystroke fallback is off by default (it must foreground the window — "+                      "rude while you type); pass allowFocusSteal:true to accept a focus grab, or "+                      "foreground:true to bring the editor to you."),+        }+     nav_result = _navigate_to_footprint(fp_hwnd, search_text)      if nav_result["status"] == "ok":
handlers/open_symbol_editor.py+19
@@ -590,6 +590,25 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict:             "via": "win32-background",         } +    # BACKGROUND-FIRST CONTRACT (John, 2026-08-14): the win32 tier above is the+    # background path. The PowerShell SendKeys fallback below fires GLOBAL+    # keystrokes that need the window foregrounded — which would type into the+    # USER's active window if we don't steal focus, and stealing focus is rude.+    # So it is OPT-IN only ({"allowFocusSteal":true}); by default we return the+    # honest failure and let the caller retry (the win32 tier usually lands on a+    # 2nd try once eeschema's tree is warm).+    if not args.get("allowFocusSteal"):+        return {+            "success": False, "editorOpened": True, "symbolLoaded": False,+            "error": f"Symbol Editor open but '{search_text}' didn't load in the background "+                     f"(title: {_get_symbol_editor_title() or 'unknown'})",+            "_hint": ("The background load didn't confirm. Retry this same call (the win32 tier "+                      "usually lands 2nd try once the library tree is warm). The keystroke fallback "+                      "is disabled by default because it needs to foreground the window (rude while "+                      "you're typing) — pass allowFocusSteal:true ONLY if you accept a focus grab, "+                      "or foreground:true to bring the editor to you and load it yourself."),+        }+     nav_result = _navigate_to_symbol(sym_hwnd, search_text)      if nav_result["status"] == "ok":
handlers/win_focus.py+22
@@ -189,3 +189,25 @@ def restore_foreground_persistent(hwnd, seconds: float = 6.0, interval: float =         threading.Thread(target=_loop, daemon=True).start()     except Exception:         pass+++def user_wants_foreground(args) -> bool:+    """True only when the CALLER explicitly asked to SEE the window — the user's+    own choice to foreground (John: 'always let the user choose to fg a window').+    Never inferred; must be an explicit foreground/showToUser/bringToFront flag."""+    if not isinstance(args, dict):+        return False+    return bool(args.get("foreground") or args.get("showToUser") or args.get("bringToFront"))+++def bring_to_user(hwnd) -> bool:+    """Explicit, user-requested foreground of a window (the ONLY sanctioned way a+    KiCad window is brought to the front for the user — everything else stays+    background). Returns True if it took."""+    if not IS_WINDOWS or not hwnd:+        return False+    try:+        _user32.ShowWindow(hwnd, 9)  # SW_RESTORE (in case minimized)+        return _set_foreground_robust(hwnd)+    except Exception:+        return False
server.py+24
@@ -770,6 +770,30 @@ def dispatch_command(command: str, args: dict) -> dict:     # restoring the user's window here means the editor opened BEHIND their work —     # it never stole their focus/keyboard. Done LAST, after the dialog sweep (which     # may itself have surfaced windows). Reports whether it stuck.+    # USER CHOICE (John, 2026-08-14): a caller may explicitly want to SEE the+    # window — foreground:true / showToUser:true. That is the ONE sanctioned way a+    # KiCad window comes to the front. When set, we SKIP the background-restore and+    # bring the freshly-opened KiCad window to the user instead.+    try:+        from handlers import win_focus as _wf+        if _wf.user_wants_foreground(args) and command in _SPAWNS_WINDOW:+            import time as _t+            _t.sleep(1.0)  # let the window exist+            _shown = None+            try:+                from handlers.kicad_ui import _find_all_kicad_windows+                wins = _find_all_kicad_windows()+                if wins:+                    _shown = wins[-1].get("hwnd")  # the most-recently surfaced KiCad window+                    _wf.bring_to_user(_shown)+            except Exception:+                pass+            if isinstance(result, dict):+                result["_broughtToUser"] = bool(_shown)+            _user_fg = None  # don't also restore-away what the user just asked to see+    except Exception:+        pass+     if _user_fg is not None:         try:             from handlers import win_focus
skills/kicad-bridge-background/SKILL.md+34
@@ -13,6 +13,40 @@ clicks, key sends, demo beats — happens in the background while the user keeps working.** This is a hard product requirement for the public release, not a politeness preference. +## The principle, in full (John, 2026-08-14, verbatim intent)++"ALL of your verbs should do their work in the background if they can. It's rude+to force-foreground a KiCad window, because a user driving your bridge is doing+OTHER AI-thread prompts and typing while you work. Try SUPER hard to do it in the+background. Foreground is only for UI that can ONLY be done in the fg, and even+then it's the worst case, last resort. And ALWAYS let the user CHOOSE to+foreground a window if they want to look at what you're doing."++Three obligations this puts on every windowed verb:++1. **Background-first, and try hard.** Exhaust the background paths (plugin RPC,+   the win32 tier: WM_SETTEXT/PostMessage to a specific hwnd, UIA background+   Invoke — none need focus) BEFORE any focus-dependent path.+2. **Foreground = opt-in last resort, and it must ACTUALLY foreground.** Global+   keystrokes (`keybd_event`, SendKeys, Alt+3) go to whatever window has focus —+   so if you fire them WITHOUT foregrounding, they leak into the user's window+   (found live 2026-08-14: `_send_ctrl_shift_e` did exactly this because+   `_force_foreground` is neutered). Rule: a keystroke fallback is gated behind+   `allowFocusSteal:true` AND, when it runs, does a real `win_focus.bring_to_user`+   first. Off by default → return the honest `success:false` + retry hint instead+   of leaking keys.+3. **The user may say "show me."** `foreground:true` (or `showToUser` /+   `bringToFront`) on any window-spawning verb is the ONE sanctioned foreground+   path: the dispatcher then brings the freshly-opened KiCad window TO the user+   (`win_focus.bring_to_user`) instead of restoring their window, and reports+   `_broughtToUser`. `win_focus.user_wants_foreground(args)` is the only place+   that intent is read — never infer it.++Audit rule when you touch window code: `grep` for `SetForegroundWindow`,+`keybd_event`, `SendInput`, `SendKeys`. Every hit must be EITHER inside an+`allowFocusSteal`/`user_wants_foreground` branch that truly foregrounds, or gone.+An ungated one is a focus-steal bug.+ ## Why this is hard (the three failure modes)  1. **KiCad activates windows asynchronously.** `SW_SHOWNOACTIVATE` on the