← Commit history

v0.9.182: deterministic user-intent detection (WH_MOUSE_LL + WH_KEYBOARD_LL - a taskbar click or alt-tab can no longer fall between polls) and issue #34's cached false no_kicad_installed

John Lauer ·28800f0f43 ·1mo ago ·parent 051d894
7 files changed +177−13
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.181+0.9.182
adom-bridge-kicad-manifest.json+5−5
@@ -1,15 +1,15 @@ {   "manifest_version": 1,   "name": "kicad",-  "version": "0.9.181",-  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.181/adom-bridge-kicad-v0.9.181.zip",-  "sha256": "5d507ca02975d9470e9b3e2ee7e846fab6fa54087f0f7d54eca862e9eff80f55",-  "size": 452249,+  "version": "0.9.182",+  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.182/adom-bridge-kicad-v0.9.182.zip",+  "sha256": "4933a1adfd550d7ed30460d35e76c97ba2d968f28949bf7c8f68ceb0422553c6",+  "size": 454594,   "verbPrefixes": [     "kicad_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-08-19T18:17:15Z",+  "released_at": "2026-08-19T18:26:38Z",   "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png",   "languages": [     "Python",
adom-bridge-kicad-v0.9.182.zipadded
⋯ 1 unchanged line ⋯
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "kicad",   "displayName": "KiCad EDA",-  "version": "0.9.181",+  "version": "0.9.182",   "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/win_focus.py+135
@@ -266,6 +266,135 @@ except Exception:     pass  +# ── USER-INTENT HOOKS (0.9.182) ──────────────────────────────────────────────+# John's requirement, verbatim: "if you need NOACTIVATE then you HAVE to detect+# if the human is asking for the window to be foregrounded, meaning if they+# click the windows taskbar icon or alt_tab to the window, you MUST know that+# and allow them to see it or you have broken the entire user experience."+#+# The old detector POLLED GetAsyncKeyState every 120ms, so a click that began+# and ended between two polls was invisible - which is exactly how a taskbar+# click ended up treated as a self-raise and bounced. Polling cannot fix that;+# the gap is inherent.+#+# These are LOW-LEVEL HOOKS instead (WH_MOUSE_LL / WH_KEYBOARD_LL). Windows+# delivers every mouse button and every keystroke to them synchronously, before+# any application sees it. There is no sampling window and nothing to miss. On+# any of:+#     * a mouse button pressed anywhere on the shell (taskbar / Start / tray)+#     * a mouse button pressed on one of OUR KiCad windows+#     * Alt+Tab+# we IMMEDIATELY drop every activation block, so the user's click or alt-tab+# lands on a window that can take focus normally. Release is instant: there is+# no hold-off, because a user may want the window up the very next second.+_user_intent_until = 0.0      # monotonic deadline: "the user is driving"+_intent_hooks_started = False+_hook_refs = []               # keep callbacks alive for the process lifetime+++def user_wants_foreground() -> bool:+    """True if the user just asked for a window (click or alt-tab)."""+    try:+        import time as _t+        return _t.monotonic() < _user_intent_until+    except Exception:+        return False+++def _note_user_intent(source: str) -> None:+    """Release EVERY activation block, right now."""+    global _user_intent_until+    try:+        import time as _t+        _user_intent_until = _t.monotonic() + 5.0+        n = allow_activation_all()+        if n:+            print(f"[intent] {source}: released activation on {n} window(s)",+                  file=sys.stderr, flush=True)+        if _focus_events is not None:+            _focus_events.append({"t": _t.time(), "action": f"user intent ({source})",+                                  "title": "", "secondsSinceUserInput": 0.0})+    except Exception:+        pass+++def start_user_intent_hooks() -> None:+    """Low-level mouse + keyboard hooks on a dedicated pumped thread."""+    global _intent_hooks_started+    if not IS_WINDOWS or _intent_hooks_started:+        return+    _intent_hooks_started = True+    import threading++    WH_MOUSE_LL, WH_KEYBOARD_LL = 14, 13+    WM_LBUTTONDOWN, WM_RBUTTONDOWN, WM_MBUTTONDOWN = 0x0201, 0x0204, 0x0207+    WM_KEYDOWN, WM_SYSKEYDOWN = 0x0100, 0x0104+    VK_TAB, VK_MENU = 0x09, 0x12+    SHELL_CLASSES = {"Shell_TrayWnd", "Shell_SecondaryTrayWnd", "TaskListThumbnailWnd",+                     "Windows.UI.Core.CoreWindow", "XamlExplorerHostIslandWindow",+                     "MultitaskingViewFrame", "TaskSwitcherWnd", "TaskSwitcherOverlayWnd"}++    HOOKPROC = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_int,+                                  ctypes.wintypes.WPARAM, ctypes.wintypes.LPARAM)++    def _class_of(hwnd) -> str:+        try:+            buf = ctypes.create_unicode_buffer(96)+            _user32.GetClassNameW(hwnd, buf, 96)+            return buf.value or ""+        except Exception:+            return ""++    def _on_mouse(code, wparam, lparam):+        try:+            if code >= 0 and wparam in (WM_LBUTTONDOWN, WM_RBUTTONDOWN, WM_MBUTTONDOWN):+                pt = ctypes.wintypes.POINT()+                _user32.GetCursorPos(ctypes.byref(pt))+                under = _user32.WindowFromPoint(pt)+                root = _user32.GetAncestor(under, 2) if under else None   # GA_ROOT+                cls = _class_of(root) if root else ""+                if cls in SHELL_CLASSES:+                    _note_user_intent(f"click on the shell ({cls})")+                elif root and is_kicad_window(root):+                    _note_user_intent("click on a KiCad window")+        except Exception:+            pass+        return _user32.CallNextHookEx(None, code, wparam, lparam)++    def _on_key(code, wparam, lparam):+        try:+            if code >= 0 and wparam in (WM_KEYDOWN, WM_SYSKEYDOWN):+                vk = ctypes.cast(lparam, ctypes.POINTER(ctypes.wintypes.DWORD))[0]+                if vk == VK_TAB and (_user32.GetAsyncKeyState(VK_MENU) & 0x8000):+                    _note_user_intent("alt-tab")+        except Exception:+            pass+        return _user32.CallNextHookEx(None, code, wparam, lparam)++    def _pump():+        try:+            mp, kp = HOOKPROC(_on_mouse), HOOKPROC(_on_key)+            _hook_refs.extend([mp, kp])+            _user32.SetWindowsHookExW.restype = ctypes.wintypes.HANDLE+            hm = _user32.SetWindowsHookExW(WH_MOUSE_LL, mp, None, 0)+            hk = _user32.SetWindowsHookExW(WH_KEYBOARD_LL, kp, None, 0)+            print(f"[intent] hooks armed mouse={bool(hm)} keyboard={bool(hk)}",+                  file=sys.stderr, flush=True)+            if not (hm or hk):+                return+            msg = ctypes.wintypes.MSG()+            while _user32.GetMessageW(ctypes.byref(msg), None, 0, 0) != 0:+                _user32.TranslateMessage(ctypes.byref(msg))+                _user32.DispatchMessageW(ctypes.byref(msg))+        except Exception as e:+            print(f"[intent] hook thread died: {e}", file=sys.stderr, flush=True)++    try:+        threading.Thread(target=_pump, daemon=True, name="kicad-user-intent").start()+    except Exception:+        pass++ def _push_kicad_to_background(h) -> None:     """THE ENTIRE BACKGROUNDING ALGORITHM (0.9.181). John's rules, verbatim: @@ -301,6 +430,12 @@ def _push_kicad_to_background(h) -> None:         else:             _backgrounded_once.add(h) +        if user_wants_foreground():+            # The human just clicked or alt-tabbed. Their intent outranks any+            # background policy - do nothing at all.+            print(f"[bg] {h}: user is driving; leaving it exactly where it is",+                  file=sys.stderr, flush=True)+            return         HWND_BOTTOM = 1         SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE = 0x0010, 0x0002, 0x0001         _user32.SetWindowPos(h, HWND_BOTTOM, 0, 0, 0, 0,
plugin_install.py+32−6
@@ -155,7 +155,6 @@ has finished setting Python up. Everything is logged to %TEMP%/adom-kicad-plugin-loader.log so a failure is one file away. \"\"\" import os-import sys import tempfile import threading import time@@ -637,10 +636,23 @@ def ensure_plugin_installed_once(all_kicad_versions: list[dict]) -> dict:     global _install_result  # pylint: disable=global-statement     with _install_lock:         if _install_result is not None:-            cached = dict(_install_result)-            cached["triggered"] = False-            cached["cached"] = True-            return cached+            # WIKI ISSUE #34 (Colby Knox): a FAILED result must never be cached+            # for the life of the bridge. On his box the auto-install's detect+            # returned zero installs on a machine with KiCad 10 plainly+            # installed - the explicit kicad_install_plugin found it moments+            # later - and because the false negative was cached, the reverse+            # bridge never came up again until a bridge restart, silently+            # degrading every plugin-dependent verb. A negative is now retried,+            # and re-detected from scratch rather than trusting the list we were+            # handed at boot (which is empty if detection had not finished yet).+            failed = (_install_result.get("errorCode") or+                      _install_result.get("summary") in ("no-kicad", "failed"))+            if not failed:+                cached = dict(_install_result)+                cached["triggered"] = False+                cached["cached"] = True+                return cached+            _install_result = None   # retry below, with a fresh detection         # Honor disable env var. Useful for development/diagnostic.         if os.environ.get("ADOM_KICAD_PLUGIN_AUTO_INSTALL_DISABLE") == "1":             _install_result = {@@ -654,7 +666,21 @@ def ensure_plugin_installed_once(all_kicad_versions: list[dict]) -> dict:                 ),             }             return dict(_install_result)-        _install_result = _do_install(all_kicad_versions)+        # Re-detect if we were handed nothing: the boot list can legitimately be+        # empty when detection has not completed, and trusting it is precisely+        # how a "no KiCad installed" verdict got minted on a box with KiCad 10.+        versions = all_kicad_versions+        if not versions:+            try:+                from kicad_detect import detect_all_kicad_versions+                versions = detect_all_kicad_versions() or []+                if versions:+                    print(f"[plugin] auto-install was handed an EMPTY version list; "+                          f"re-detected {len(versions)} install(s) (issue #34)",+                          file=sys.stderr, flush=True)+            except Exception:+                versions = all_kicad_versions+        _install_result = _do_install(versions)         return _install_result  
server.py+3
@@ -1158,6 +1158,9 @@ def main():     # transition (kicad_state.focusEvents + stderr) so quietness is provable.     try:         from handlers import win_focus+        win_focus.start_user_intent_hooks()   # 0.9.182: BEFORE anything else -+        # the user's click must be detectable from the first moment the bridge+        # is alive, not only once a verb happens to run.         win_focus.start_focus_sentinel()         win_focus.start_focus_event_hook()     except Exception: