← Commit history

v0.9.101: PREVENTION guardian — WS_EX_NOACTIVATE on misbehaving KiCad windows (no more fg flashes/churn), cleared on guard-exit/bring_to_user/close; 100ms poll; runner w-shadowing crash fix

John Lauer ·1415d5d1dc ·2mo ago ·parent 0e830a1
8 files changed +130−27
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.100+0.9.101
adom-bridge-kicad-manifest.json+5−5
@@ -1,15 +1,15 @@ {   "manifest_version": 1,   "name": "kicad",-  "version": "0.9.100",-  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.100/adom-bridge-kicad-v0.9.100.zip",-  "sha256": "9b1e10da5e79e8f24c8a1317602fd7af790f5c2484b4a8c37fd0fd779cd37b6b",-  "size": 391976,+  "version": "0.9.101",+  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.101/adom-bridge-kicad-v0.9.101.zip",+  "sha256": "54488db05f7494046db51ae454cf333861dcc2502adc7c44652647fdd7e2027e",+  "size": 392971,   "verbPrefixes": [     "kicad_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-08-15T09:44:48Z",+  "released_at": "2026-08-15T09:53:07Z",   "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png",   "languages": [     "Python",
adom-bridge-kicad-v0.9.101.zipadded
⋯ 1 unchanged line ⋯
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "kicad",   "displayName": "KiCad EDA",-  "version": "0.9.100",+  "version": "0.9.101",   "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+7
@@ -1054,6 +1054,13 @@ def handle_close_3d_viewer(kicad_info: dict, args: dict) -> dict:   def handle_close_kicad(kicad_info: dict, args: dict) -> dict:+    # Safety: clear any WS_EX_NOACTIVATE the focus guardian applied, so nothing+    # un-clickable lingers regardless of how the guard threads ended.+    try:+        from handlers import win_focus+        win_focus.allow_activation_all()+    except Exception:  # pylint: disable=broad-except+        pass     """Close all KiCad windows (editors first, then the project manager).      Args:
handlers/win_focus.py+98−16
@@ -133,6 +133,69 @@ def is_kicad_window(hwnd) -> bool:     return _hwnd_exe_name(hwnd) in _KICAD_EXES  +# ── PREVENTION, not reaction (v0.9.101, John: "i sure see a lot of windows+# coming to fg"). The polling guardian REACTED: KiCad took focus, we yanked it+# back 250ms later — the user saw every steal as a flash and lost keystrokes.+# WS_EX_NOACTIVATE makes Windows REFUSE the window's self-activation outright:+# no flash, no focus churn. We apply it to a KiCad window the first time it+# misbehaves (or when a guardian arms over it), and CLEAR it when the last+# guardian exits / on bring_to_user / on close — so after bridge work ends the+# user can click into KiCad normally again.+WS_EX_NOACTIVATE = 0x08000000+GWL_EXSTYLE = -20++_noactivated: set = set()+_guard_count = 0+_guard_lock = None+try:+    import threading as _thr+    _guard_lock = _thr.Lock()+except Exception:+    pass+++def _get_exstyle(hwnd):+    fn = getattr(_user32, "GetWindowLongPtrW", None) or _user32.GetWindowLongW+    return fn(hwnd, GWL_EXSTYLE)+++def _set_exstyle(hwnd, style):+    fn = getattr(_user32, "SetWindowLongPtrW", None) or _user32.SetWindowLongW+    return fn(hwnd, GWL_EXSTYLE, style)+++def suppress_activation(hwnd) -> None:+    """Make a KiCad window unable to grab the foreground (WS_EX_NOACTIVATE)."""+    try:+        cur = _get_exstyle(hwnd)+        if not (cur & WS_EX_NOACTIVATE):+            _set_exstyle(hwnd, cur | WS_EX_NOACTIVATE)+        _noactivated.add(hwnd)+    except Exception:+        pass+++def allow_activation(hwnd) -> None:+    """Undo suppress_activation for one window (user wants it / bridge done)."""+    try:+        if _user32.IsWindow(hwnd):+            cur = _get_exstyle(hwnd)+            if cur & WS_EX_NOACTIVATE:+                _set_exstyle(hwnd, cur & ~WS_EX_NOACTIVATE)+    except Exception:+        pass+    _noactivated.discard(hwnd)+++def allow_activation_all() -> int:+    """Clear NOACTIVATE from every window we altered. Returns count cleared."""+    n = 0+    for h in list(_noactivated):+        allow_activation(h)+        n += 1+    return n++ def _demote_below(kicad_hwnd, user_hwnd) -> None:     """Slide a KiCad window directly UNDER the user's window without activating     either — the window stays visible in the taskbar and fully screenshotable,@@ -145,7 +208,7 @@ def _demote_below(kicad_hwnd, user_hwnd) -> None:         pass  -def restore_foreground_persistent(hwnd, seconds: float = 6.0, interval: float = 0.25) -> None:+def restore_foreground_persistent(hwnd, seconds: float = 6.0, interval: float = 0.1) -> None:     """Focus guardian: for `seconds` after a window-spawning verb, make sure NO     KiCad window holds the user's foreground. KiCad activates windows     ASYNCHRONOUSLY (a 3D viewer self-raises when its render completes — sometimes@@ -167,23 +230,41 @@ def restore_foreground_persistent(hwnd, seconds: float = 6.0, interval: float =     import time as _t      def _loop():+        global _guard_count+        if _guard_lock:+            with _guard_lock:+                _guard_count += 1         protect = hwnd         deadline = _t.monotonic() + seconds-        while _t.monotonic() < deadline:-            try:-                fg = _user32.GetForegroundWindow()-                if fg and fg != protect:-                    if is_kicad_window(fg):-                        # KiCad stole the foreground: user back on top, thief tucked under.-                        if _user32.IsWindow(protect):-                            _set_foreground_robust(protect)-                            _demote_below(fg, protect)-                    else:-                        # The USER moved to another window — protect that instead.-                        protect = fg-            except Exception:-                pass-            _t.sleep(interval)+        try:+            while _t.monotonic() < deadline:+                try:+                    fg = _user32.GetForegroundWindow()+                    if fg and fg != protect:+                        if is_kicad_window(fg):+                            # PREVENT the next steal (NOACTIVATE), then repair this+                            # one: user back on top, thief tucked under.+                            suppress_activation(fg)+                            if _user32.IsWindow(protect):+                                _set_foreground_robust(protect)+                                _demote_below(fg, protect)+                        else:+                            # The USER moved to another window — protect that instead.+                            protect = fg+                except Exception:+                    pass+                _t.sleep(interval)+        finally:+            # last guardian out clears the NOACTIVATE styles so the user can+            # click into KiCad normally once bridge work has quieted down.+            if _guard_lock:+                with _guard_lock:+                    _guard_count -= 1+                    last = _guard_count <= 0+            else:+                last = True+            if last:+                allow_activation_all()      try:         threading.Thread(target=_loop, daemon=True).start()@@ -207,6 +288,7 @@ def bring_to_user(hwnd) -> bool:     if not IS_WINDOWS or not hwnd:         return False     try:+        allow_activation(hwnd)       # a suppressed window can't be activated         _user32.ShowWindow(hwnd, 9)  # SW_RESTORE (in case minimized)         return _set_foreground_robust(hwnd)     except Exception:
skills/kicad-bridge-background/SKILL.md+14
@@ -63,6 +63,20 @@ An ungated one is a focus-steal bug.    dialog auto-dismiss that pokes a window, `_force_foreground` calls left in    old handlers. New code copies old code; old code steals focus. +## The mechanism (v0.9.101: PREVENT, don't react)++v0.9.79's guardian only REACTED — poll 250ms, yank focus back after KiCad had+already taken it. John watched a suite run: "i sure see a lot of windows coming+to fg." Every steal was a visible flash + lost keystrokes, and the+SetForegroundWindow restore can silently fail from a background process. The+v0.9.101 guardian PREVENTS: the first time a KiCad window grabs the foreground+(or is caught by a guard), it gets **WS_EX_NOACTIVATE** — Windows then refuses+its self-activation outright (no flash, no churn; wx Raise() becomes a no-op for+focus). The style is CLEARED when the last guardian exits, on+`bring_to_user` (foreground:true — a suppressed window can't be activated), and+by `kicad_close` — so the user can always click into KiCad once bridge work+quiets down. Poll tightened to 100ms for the first-steal repair.+ ## The mechanism (v0.9.79)  `handlers/win_focus.py`:
skills/kicad-bridge-test/run_verb_tests.py+4−4
@@ -300,10 +300,10 @@ def main():            "Footprint Editor", "3D Viewer")     _user_win = {}     if not a.skip_gui and not a.no_stealth:-        w = _current_top()-        # only adopt a NON-KiCad window as the one to protect-        if w and not any(k in (w.get("title") or "") for k in _kw):-            _user_win = w+        _tw = _current_top()   # NB: do NOT name this `w` — it shadowed the paths+        # dict and crashed phase 4 with KeyError 'templib' (2026-08-15 run).+        if _tw and not any(k in (_tw.get("title") or "") for k in _kw):+            _user_win = _tw             print(f"[stealth] keeping KiCad behind your window: {(_user_win.get('title') or '')[:50]}")      def _keep_background():