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
v0.9.155 EMERGENCY: ownership ledger - the bridge only parks/bounces/badges windows from process trees it spawned; user-launched KiCad is untouchable. Sentinel now honors taskbar/Start clicks as user intent (it was bouncing John's own launches)
12 files changed
+294−27
BRIDGE_VERSION+1−1adom-bridge-kicad-manifest.json+5−5adom-bridge-kicad-v0.9.155.zipbridge.json+1−1handlers/launch.py+14−4handlers/open_files.py+7−1handlers/open_footprint_editor.py+14−2handlers/open_symbol_editor.py+14−2handlers/place_footprint.py+7−1handlers/win_focus.py+218−2handlers/win_taskbar.py+7−6server.py+6−2BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.154+0.9.155
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.154",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.154/adom-bridge-kicad-v0.9.154.zip",- "sha256": "d0e13a16b607b1060970b76abb4fb7b56afe854bcae5477f7c8d3794b42ac052",- "size": 434420,+ "version": "0.9.155",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.155/adom-bridge-kicad-v0.9.155.zip",+ "sha256": "abd0989f6d447c3b3dac9b8f444442880cf5a8a4c1b4f4970e25b3e11f98b003",+ "size": 436974, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-17T20:52:21Z",+ "released_at": "2026-08-17T21:13:28Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
adom-bridge-kicad-v0.9.155.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.154",+ "version": "0.9.155", "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/launch.py+14−4@@ -51,7 +51,12 @@ def handle_launch(kicad_info: dict, args: dict) -> dict: if not IS_WINDOWS: try: cmd = ["open", "-a", "KiCad"] + ([project] if project else [])- subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)+ _p = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)+ try:+ from handlers.win_focus import register_owned_pid+ register_owned_pid(_p.pid)+ except Exception:+ pass return {"success": True, "launched": True, "output": "KiCad launch requested via `open -a KiCad`."} except Exception as e:@@ -73,9 +78,14 @@ def handle_launch(kicad_info: dict, args: dict) -> dict: except Exception: pass try:- subprocess.Popen([exe] + ([project] if project else []),- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,- startupinfo=_si)+ _p = subprocess.Popen([exe] + ([project] if project else []),+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,+ startupinfo=_si)+ try:+ from handlers.win_focus import register_owned_pid+ register_owned_pid(_p.pid)+ except Exception:+ pass except Exception as e: return {"success": False, "error": f"Failed to launch KiCad: {e}"}
handlers/open_files.py+7−1@@ -43,11 +43,17 @@ def _open_file(kicad_info: dict, args: dict, exe_key: str, file_type_label: str) } try:- subprocess.Popen(+ _p = subprocess.Popen( [exe, str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, )++ try:+ from handlers.win_focus import register_owned_pid+ register_owned_pid(_p.pid)+ except Exception:+ pass # v1.7.12+: hint nudging the AI toward kicad_lint_board / # kicad_lint_schematic for unfamiliar files. The lint verbs are # ~1-3s via kicad-cli and surface DRC/ERC violations + file-format
handlers/open_footprint_editor.py+14−2@@ -418,10 +418,16 @@ def _mac_open_footprint_editor(kicad_info: dict, args: dict) -> dict: "_hint": "Reinstall KiCad — the Footprint Editor app bundle is missing.", } try:- subprocess.Popen(+ _p = subprocess.Popen( ["open", "-a", str(fp_app)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, )++ try:+ from handlers.win_focus import register_owned_pid+ register_owned_pid(_p.pid)+ except Exception:+ pass except Exception as e: return {"success": False, "error": f"Failed to launch Footprint Editor: {e}"} fp_name = args.get("footprintName", "") or args.get("footprint", "")@@ -589,12 +595,18 @@ def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict: _si = win_focus.background_startupinfo() # open the manager behind the user except Exception: _si = None- subprocess.Popen(+ _p = subprocess.Popen( [kicad_exe], startupinfo=_si, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, )++ try:+ from handlers.win_focus import register_owned_pid+ register_owned_pid(_p.pid)+ except Exception:+ pass except Exception as e: return { "success": False,
handlers/open_symbol_editor.py+14−2@@ -341,10 +341,16 @@ def _mac_open_symbol_editor(kicad_info: dict, args: dict) -> dict: } try:- subprocess.Popen(+ _p = subprocess.Popen( ["open", "-a", str(sym_app)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, )++ try:+ from handlers.win_focus import register_owned_pid+ register_owned_pid(_p.pid)+ except Exception:+ pass except Exception as e: return {"success": False, "error": f"Failed to launch Symbol Editor: {e}"} @@ -498,12 +504,18 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict: _si = win_focus.background_startupinfo() # open the manager behind the user except Exception: _si = None- subprocess.Popen(+ _p = subprocess.Popen( [kicad_exe], startupinfo=_si, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, )++ try:+ from handlers.win_focus import register_owned_pid+ register_owned_pid(_p.pid)+ except Exception:+ pass except Exception as e: return { "success": False,
handlers/place_footprint.py+7−1@@ -105,7 +105,7 @@ def handle_place_footprint(kicad_info: dict, args: dict) -> dict: _si = win_focus.background_startupinfo() # SW_SHOWNOACTIVATE — open behind the user except Exception: _si = None- subprocess.Popen(+ _p = subprocess.Popen( [pcbnew_exe, str(out_path)], creationflags=flags, startupinfo=_si,@@ -114,6 +114,12 @@ def handle_place_footprint(kicad_info: dict, args: dict) -> dict: stderr=subprocess.DEVNULL, close_fds=True, )++ try:+ from handlers.win_focus import register_owned_pid+ register_owned_pid(_p.pid)+ except Exception:+ pass opened = True except OSError as e: return {"success": True, "boardPath": str(out_path), "footprintName": fp_name,
handlers/win_focus.py+218−2@@ -324,6 +324,7 @@ def get_focus_debug() -> dict: "parkAttempts": _park_attempts, "parkLastError": _park_last_error, "sweepTicks": _sweep_ticks,+ "ownedPids": sorted(_owned_pids), } @@ -371,6 +372,182 @@ _park_baseline: set = set() _bridge_windows: set = set() # windows the BRIDGE created (persists across verbs) _park_sweep_started = False +# ---- Ownership ledger (John, 2026-08-17: "what happens if the user opens their+# own kicad? i don't want us ruining that") -------------------------------------+# The bridge may only park/bounce/badge windows it OWNS. Ownership is decided by+# PROCESS: every kicad-family pid the bridge spawns is registered here (children+# count via parent-chain walk — the PM spawns eeschema.exe/pcbnew.exe). The set+# persists to %USERPROFILE%/.adom/kicad-bridge-owned.json so a bridge restart+# does not orphan (or worse, mis-adopt) windows. A KiCad the USER launched is in+# no spawned pid tree -> _is_owned_window() is False -> every focus/park/badge+# path leaves it completely alone. There is deliberately NO "adopt what exists"+# seeding: unknown windows default to user-owned.+_owned_pids: set = set()+_KICAD_EXES = ("kicad", "eeschema", "pcbnew", "gerbview", "pl_editor",+ "pcb_calculator", "bitmap2component")+++def _ledger_path():+ import os as _os+ from pathlib import Path as _P+ base = _os.environ.get("USERPROFILE") or str(_P.home())+ d = _P(base) / ".adom"+ try:+ d.mkdir(parents=True, exist_ok=True)+ except Exception:+ pass+ return d / "kicad-bridge-owned.json"+++def _pid_of_hwnd(hwnd) -> int:+ pid = ctypes.wintypes.DWORD()+ _user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))+ return int(pid.value)+++def _pid_exe_base(pid) -> str:+ try:+ h = _kernel32.OpenProcess(0x1000, False, int(pid))+ if not h:+ return ""+ try:+ buf = ctypes.create_unicode_buffer(512)+ size = ctypes.wintypes.DWORD(512)+ if _kernel32.QueryFullProcessImageNameW(h, 0, buf, ctypes.byref(size)):+ import os as _os+ return _os.path.basename(buf.value).lower()+ finally:+ _kernel32.CloseHandle(h)+ except Exception:+ pass+ return ""+++def _pid_is_kicad(pid) -> bool:+ base = _pid_exe_base(pid)+ return any(base.startswith(e) for e in _KICAD_EXES)+++def _save_ledger() -> None:+ try:+ import json as _json+ live = {int(p) for p in _owned_pids if _pid_is_kicad(p)}+ wins = []+ for h in list(_bridge_windows):+ try:+ if _user32.IsWindow(h):+ wins.append({"hwnd": int(h), "pid": _pid_of_hwnd(h)})+ except Exception:+ pass+ _ledger_path().write_text(_json.dumps({"pids": sorted(live), "windows": wins}))+ except Exception:+ pass+++def load_owned_ledger() -> None:+ """Boot: re-own only what provably survived — a ledger hwnd whose pid still+ matches, and ledger pids still running a kicad-family exe. Everything else+ (including anything the user launched while the bridge was down) stays+ user-owned."""+ try:+ import json as _json+ data = _json.loads(_ledger_path().read_text())+ except Exception:+ return+ for e in data.get("windows", []):+ try:+ h, p = int(e.get("hwnd") or 0), int(e.get("pid") or 0)+ if h and _user32.IsWindow(h) and _pid_of_hwnd(h) == p and is_kicad_window(h):+ _bridge_windows.add(h)+ _owned_pids.add(p)+ except Exception:+ pass+ for p in data.get("pids", []):+ try:+ if _pid_is_kicad(p):+ _owned_pids.add(int(p))+ except Exception:+ pass+++def register_owned_pid(pid) -> None:+ """Spawn sites call this for every kicad-family process the bridge starts."""+ try:+ if pid:+ _owned_pids.add(int(pid))+ _save_ledger()+ except Exception:+ pass+++def _parent_pid_map() -> dict:+ """{pid: ppid} via toolhelp snapshot (cheap, ~ms)."""+ out = {}+ try:+ TH32CS_SNAPPROCESS = 0x02++ class PE32(ctypes.Structure):+ _fields_ = [("dwSize", ctypes.wintypes.DWORD),+ ("cntUsage", ctypes.wintypes.DWORD),+ ("th32ProcessID", ctypes.wintypes.DWORD),+ ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)),+ ("th32ModuleID", ctypes.wintypes.DWORD),+ ("cntThreads", ctypes.wintypes.DWORD),+ ("th32ParentProcessID", ctypes.wintypes.DWORD),+ ("pcPriClassBase", ctypes.c_long),+ ("dwFlags", ctypes.wintypes.DWORD),+ ("szExeFile", ctypes.c_wchar * 260)]++ snap = _kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)+ if snap == ctypes.wintypes.HANDLE(-1).value:+ return out+ try:+ pe = PE32()+ pe.dwSize = ctypes.sizeof(PE32)+ if _kernel32.Process32FirstW(snap, ctypes.byref(pe)):+ while True:+ out[int(pe.th32ProcessID)] = int(pe.th32ParentProcessID)+ if not _kernel32.Process32NextW(snap, ctypes.byref(pe)):+ break+ finally:+ _kernel32.CloseHandle(snap)+ except Exception:+ pass+ return out+++def _is_owned_window(hwnd) -> bool:+ """Is this window the bridge's (spawned by us, or child of our spawn)?"""+ if hwnd in _bridge_windows:+ return True+ try:+ pid = _pid_of_hwnd(hwnd)+ except Exception:+ return False+ if pid in _owned_pids:+ return True+ try:+ pm = _parent_pid_map()+ p, depth = pid, 0+ while p in pm and depth < 6:+ p = pm[p]+ depth += 1+ if p in _owned_pids:+ _owned_pids.add(pid) # child of our spawn: ours too+ _save_ledger()+ return True+ except Exception:+ pass+ return False+++def is_bridge_window(hwnd) -> bool:+ """Public ownership probe (kicad_state, taskbar badges)."""+ try:+ return _is_owned_window(hwnd)+ except Exception:+ return False+ def _enum_kicad_hwnds() -> set: out = set()@@ -413,7 +590,14 @@ def _start_park_sweep() -> None: quiet_since = None if _t.monotonic() >= _expected_fg_until: for h in _enum_kicad_hwnds() - _park_baseline:- _bridge_windows.add(h)+ # Ownership gate: a new window from a process tree we+ # did NOT spawn is the USER's (e.g. they launched+ # KiCad mid-verb) — never adopt, park, or badge it.+ if h not in _bridge_windows and not _is_owned_window(h):+ continue+ if h not in _bridge_windows:+ _bridge_windows.add(h)+ _save_ledger() suppress_activation(h) _park_offscreen(h) try:@@ -671,6 +855,8 @@ def start_focus_event_hook() -> None: return # sanctioned foreground in progress if not is_kicad_window(hwnd): return+ if not _is_owned_window(hwnd):+ return # the user's own KiCad: not ours to park or bounce print(f"[focus-hook] event=0x{event:04x} hwnd={hwnd} inflight={_verb_inflight} guards={_guard_count}", file=sys.stderr) if event == EVENT_OBJECT_SHOW: suppress_activation(hwnd)@@ -755,6 +941,31 @@ def start_focus_sentinel() -> None: except Exception: return False + # 0.9.155 (caught live: John launched his own KiCad and the sentinel bounced+ # him 8 times, including a File Open Warning he never saw): a click on the+ # TASKBAR / Start is the canonical "bring this window up" gesture, but the+ # cursor is over the shell — not the raised window — when the transition+ # lands, so click-on-window missed it and the raise read as a self-raise.+ _SHELL_CLASSES = ("Shell_TrayWnd", "Shell_SecondaryTrayWnd",+ "Windows.UI.Core.CoreWindow", "XamlExplorerHostIslandWindow",+ "TopLevelWindowForOverflowXamlIsland", "Progman", "WorkerW")++ def _cursor_over_shell() -> bool:+ try:+ import ctypes as _ct+ pt = _ct.wintypes.POINT()+ _user32.GetCursorPos(_ct.byref(pt))+ under = _user32.WindowFromPoint(pt)+ GA_ROOT = 2+ root = _user32.GetAncestor(under, GA_ROOT) if under else None+ if not root:+ return False+ buf = _ct.create_unicode_buffer(64)+ _user32.GetClassNameW(root, buf, 64)+ return buf.value in _SHELL_CLASSES+ except Exception:+ return False+ def _loop(): # v0.9.115: "recent user input" was the wrong bless test — the user # typing in ANOTHER app is exactly when a self-raise is most disruptive,@@ -781,12 +992,17 @@ def start_focus_sentinel() -> None: if fg != last_fg: if fg and is_kicad_window(fg): ago = _seconds_since_user_input()- clicked_here = (now - last_click_t) <= 1.2 and _cursor_over(fg)+ clicked_here = (now - last_click_t) <= 1.2 and \+ (_cursor_over(fg) or _cursor_over_shell()) alt_tabbed = (now - last_alt_t) <= 2.0 if now < _expected_fg_until: _log("sanctioned-foreground (allowed)", fg, ago) elif clicked_here or alt_tabbed: _log("user-foregrounded (left alone)", fg, ago)+ elif not _is_owned_window(fg):+ # Not our spawn tree: the USER's own KiCad. The+ # bridge has no authority over it — ever.+ _log("user-owned window (left alone)", fg, ago) elif _guard_count > 0: _log("steal-during-guard (guardian handles)", fg, ago) else:
handlers/win_taskbar.py+7−6@@ -176,13 +176,14 @@ def clear_adom_badge(hwnd) -> None: def _all_kicad_hwnds() -> set:- """Every live KiCad top-level window: bridge-tracked PLUS enumerated.- Windows opened before the last bridge restart drop out of _bridge_windows- (fresh process, empty set) but were still bridge-driven — badging only the- tracked set left the taskbar button bare after every bridge upgrade."""+ """Only windows the bridge OWNS (tracked live + revived from the persisted+ ownership ledger at boot). 0.9.154 briefly badged EVERY enumerated KiCad+ window to survive restarts — wrong the moment the user opens their own+ KiCad (John, 2026-08-17). The ledger solves restart amnesia the right way;+ user-owned windows are never touched.""" try:- from handlers.win_focus import _bridge_windows, _enum_kicad_hwnds- return _enum_kicad_hwnds() | set(_bridge_windows)+ from handlers.win_focus import _bridge_windows+ return set(_bridge_windows) except Exception: return set()
server.py+6−2@@ -1081,8 +1081,12 @@ def main(): except Exception: pass - # 0.9.153: KiCad windows outlive bridge restarts but _bridge_windows does- # not, so overlay badges vanished after every upgrade — re-stamp survivors.+ # 0.9.155: revive the ownership ledger FIRST (which windows/pids are OURS —+ # never the user's own KiCad), then re-stamp badges on the survivors only.+ try:+ win_focus.load_owned_ledger()+ except Exception:+ pass try: from handlers.win_taskbar import badge_existing_on_boot badge_existing_on_boot()