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.73: plugin v0.9.0 background_activate — canvas-load with NO keyboard simulation and NO focus: dvc.GetSelection() (no model walk, no segfault) + ITEM_ACTIVATED + native VK_RETURN posted to the tree hwnd
6 files changed
+595−548
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.72+0.9.73
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.72",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.72/adom-bridge-kicad-v0.9.72.zip",- "sha256": "c821d251d83ffa480741edb2e78cf736e7f47aeb1eadcccd9b9bace54273485d",- "size": 366463,+ "version": "0.9.73",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.73/adom-bridge-kicad-v0.9.73.zip",+ "sha256": "c07a4fbefafb177b893231d84f6c58ff36639a6e73fa28defbbc6f2c8f17d276",+ "size": 366949, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-09T00:06:13Z",+ "released_at": "2026-08-09T01:40:12Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
adom-bridge-kicad-v0.9.73.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.72",+ "version": "0.9.73", "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.",
plugin_install.py+528−528@@ -1,528 +1,528 @@-"""plugin_install.py — auto-install adom_bridge into KiCad USER_SITE.--The Phase 2 reverse bridge plugin (`adom_bridge.py`) needs to live at-KiCad's USER_SITE directory so that KiCad's bundled Python loads it via-`usercustomize.py` at every GUI process startup. See PLAN.md v4 for the-architectural story.--This module does the deployment, with extensive feedback for the AI that-triggered the install. The pattern is:-- On every bridge boot, the first incoming kicad_* command triggers- `ensure_plugin_installed_once()`.-- The result (whether install was needed, what was copied, what failed)- is cached in module state and attached as `pluginAutoInstall` on that- first response.-- Subsequent commands do nothing (cached short-circuit) so steady-state- command flow has zero overhead.--Failure modes the AI needs to know about-========================================-* `appdata_missing` — %USERPROFILE% (or $HOME on Mac) unresolved.-* `no_kicad_installed` — kicad_detect found zero installs, nothing to deploy to.-* `payload_missing` — plugins/kicad/plugin_payload/ wasn't bundled into- the Tauri app (Tauri bundling regression, like the v1.3.30..v1.3.37- recorder.html bug — see verify_bundle.py).-* `file_locked` — the destination .py was open (rare; might happen if- KiCad already has it imported and OS locks the file on Windows).-* `permission_denied` — user-site dir uncreatable (very rare).-* `kicad_running` — KiCad was running when we installed; the running- process WON'T pick up the new plugin until restarted.--Every error case returns a `_hint` field telling the calling AI exactly-what action it should take to recover.-"""--from __future__ import annotations--import hashlib-import json-import os-import platform-import shutil-import sys-import threading-import traceback-from datetime import datetime-from pathlib import Path-from typing import Optional--# Bridge version we're shipping. Bumped together with adom_bridge.py's-# __version__ — version drift between this file and the payload would-# cause an infinite reinstall loop; the runtime asserts they match.-PAYLOAD_VERSION = "0.7.0"--# Module-level cache: install runs at most ONCE per bridge process boot.-# Multiple concurrent kicad_* commands hitting the bridge during startup-# might all check `ensure_plugin_installed_once()` — the lock guarantees-# only the first one actually does the file copy.-_install_lock = threading.Lock()-_install_result: Optional[dict] = None-_response_attached = False---def _payload_dir() -> Path:- """Find plugin_payload/ relative to this file. Works in dev (`plugins/kicad/`)- AND in the bundled Tauri release (`<resources>/plugins/kicad/`)."""- return Path(__file__).parent / "plugin_payload"---def _user_site_dir_windows(kicad_version: str) -> Optional[Path]:- """Compute the KiCad USER_SITE path on Windows.-- KiCad's bundled sitecustomize.py builds this path from `CSIDL_PERSONAL`- (== `%USERPROFILE%\\Documents`) joined with `KiCad\\<version>\\3rdparty\\Python<XY>\\site-packages`.- We're matching that exactly so adom_bridge.py lands where KiCad's- embedded interpreter will look for usercustomize.py.-- Returns None if USERPROFILE isn't set (extremely unusual on Windows).- """- userprofile = os.environ.get("USERPROFILE")- if not userprofile:- return None- # KiCad 10 uses Python 3.11 — the path includes the no-dot version- # ("311"). KiCad 9 also used 3.x; we hardcode 311 for now and add a- # version detection step in Phase 2.x.- return Path(userprofile) / "Documents" / "KiCad" / kicad_version / "3rdparty" / "Python311" / "site-packages"---def _user_site_dir_mac(kicad_version: str) -> Optional[Path]:- """Best-effort macOS USER_SITE. NEEDS EMPIRICAL VERIFICATION- (Phase 1 macOS parity follow-up). Documented here so future Kyle-- session work can confirm/correct."""- home = os.environ.get("HOME")- if not home:- return None- return Path(home) / "Library" / "Preferences" / "kicad" / kicad_version / "3rdparty" / "Python311" / "site-packages"---def _user_site_dir(kicad_version: str) -> Optional[Path]:- if sys.platform == "win32":- return _user_site_dir_windows(kicad_version)- if sys.platform == "darwin":- return _user_site_dir_mac(kicad_version)- # Linux KiCad install paths vary wildly (flatpak vs apt vs build-- # from-source). Caller will get "skipped_unsupported_platform".- return None---def _file_hash(path: Path) -> Optional[str]:- try:- with open(path, "rb") as f:- return hashlib.sha256(f.read()).hexdigest()- except OSError:- return None---def _file_state(dest: Path) -> dict:- """Inspect a destination file and return state for the feedback report.-- Returns a dict with `exists`, `size`, `sha256`, `mtime` (or `_error`).- """- if not dest.exists():- return {"exists": False}- try:- st = dest.stat()- return {- "exists": True,- "size": st.st_size,- "sha256": _file_hash(dest),- "mtime": datetime.utcfromtimestamp(st.st_mtime).isoformat() + "Z",- }- except OSError as e:- return {"exists": True, "_error": f"stat failed: {e}"}---def _is_kicad_running() -> list[dict]:- """Best-effort: return PIDs of running KiCad GUI exes. So the AI can- warn the user that the freshly-installed plugin won't be active until- they restart KiCad."""- if sys.platform != "win32":- return []- try:- import subprocess- out = subprocess.run(- ["tasklist", "/FO", "CSV", "/NH"],- capture_output=True, text=True, timeout=10,- )- running: list[dict] = []- targets = {"kicad.exe", "pcbnew.exe", "eeschema.exe", "kicad-cli.exe"}- for line in out.stdout.splitlines():- parts = [p.strip('"') for p in line.split(",")]- if len(parts) < 2:- continue- name = parts[0].lower()- if name in targets:- try:- running.append({"exeName": name, "pid": int(parts[1])})- except ValueError:- pass- return running- except (OSError, subprocess.SubprocessError):- return []---def _install_one_version(version: str, version_info: dict) -> dict:- """Install / refresh the plugin for ONE KiCad version. Returns a- dict describing what happened."""- report: dict = {- "kicadVersion": version,- "installPath": version_info.get("base_dir"),- }- payload = _payload_dir()- if not payload.exists():- report["action"] = "failed"- report["errorCode"] = "payload_missing"- report["error"] = f"plugin payload directory not found at {payload}"- report["_hint"] = (- "The plugin_payload/ directory wasn't shipped with the bundle. This is a "- "build regression — see verify_bundle.py in src-tauri/. Report to the user "- "and ask them to upgrade adom-desktop to a build that includes the payload "- "(verify with: adom-desktop --version, expecting v1.7.5 or newer)."- )- return report-- user_site = _user_site_dir(version)- if user_site is None:- report["action"] = "skipped"- report["errorCode"] = "user_site_unavailable"- report["error"] = (- f"could not compute USER_SITE path on {sys.platform} for KiCad {version} "- f"(missing USERPROFILE / HOME?)"- )- report["_hint"] = (- f"Plugin auto-install is only supported on Windows in v1.7.x; "- f"this is {sys.platform}. macOS parity is Phase 1 follow-up. "- "Other kicad_* commands still work via the legacy UI-automation path; "- "you just won't have the in-process reverse bridge."- )- return report-- report["userSitePath"] = str(user_site)- src_bridge = payload / "adom_bridge.py"- src_loader = payload / "usercustomize.py"- dest_bridge = user_site / "adom_bridge.py"- dest_loader = user_site / "usercustomize.py"-- # Pre-flight: inspect current state so feedback shows before/after- pre_state = {- "adom_bridge.py": _file_state(dest_bridge),- "usercustomize.py": _file_state(dest_loader),- }- report["preState"] = pre_state-- # Compare hashes — short-circuit if already up to date- src_bridge_hash = _file_hash(src_bridge)- src_loader_hash = _file_hash(src_loader)- dest_bridge_hash = pre_state["adom_bridge.py"].get("sha256")- dest_loader_hash = pre_state["usercustomize.py"].get("sha256")-- if dest_bridge_hash == src_bridge_hash and dest_loader_hash == src_loader_hash:- report["action"] = "already-current"- report["payloadVersion"] = PAYLOAD_VERSION- report["message"] = (- f"plugin already deployed at {user_site} and content matches the shipped "- f"payload (sha256 identical for both files). No write performed."- )- return report-- # Mkdir the user-site if missing. The KiCad sitecustomize.py creates- # this on first launch too, so we're paving the same path it would.- try:- user_site.mkdir(parents=True, exist_ok=True)- except OSError as e:- report["action"] = "failed"- report["errorCode"] = "permission_denied"- report["error"] = f"could not create {user_site}: {e}"- report["_hint"] = (- "Filesystem permission issue creating the KiCad user-site directory. "- "This is rare on Windows. Possible causes: (1) Documents folder is on "- "OneDrive with sync errors; (2) antivirus blocking. Report exact error "- "to the user and have them try: `mkdir -p '<userSitePath>'` manually, "- "then retry the kicad_* command. If that also fails, the user has a "- "broken filesystem state."- )- return report-- # Copy. shutil.copy2 preserves timestamps which is harmless. We do- # one file at a time so a half-failure still leaves a coherent state.- written: list[dict] = []- for src, dest in ((src_bridge, dest_bridge), (src_loader, dest_loader)):- try:- shutil.copy2(src, dest)- written.append({- "name": src.name,- "src": str(src),- "dest": str(dest),- "size": dest.stat().st_size,- "sha256": _file_hash(dest),- })- except OSError as e:- report["action"] = "failed"- report["errorCode"] = "copy_failed"- report["error"] = f"copying {src.name} -> {dest}: {e}"- report["filesWritten"] = written- report["_hint"] = (- "Failed mid-copy. Most likely cause on Windows: the destination .py "- "is currently imported by a running KiCad process which has Windows-"- "level locks on the file. Fix: ask the user to close ALL KiCad windows "- "(Project Manager, PCB Editor, Schematic Editor), then retry the "- "kicad_* command. If the file isn't held, this could also be an "- "antivirus quarantine — check Windows Defender's protection history."- )- return report-- report["action"] = "installed" if not pre_state["adom_bridge.py"]["exists"] else "updated"- report["payloadVersion"] = PAYLOAD_VERSION- report["filesWritten"] = written- report["message"] = (- f"plugin {'installed' if report['action'] == 'installed' else 'updated to v' + PAYLOAD_VERSION} "- f"at {user_site}. KiCad GUI processes launched AFTER this point will auto-load it via "- f"sitecustomize.py + usercustomize.py."- )- return report---def _do_install(all_kicad_versions: list[dict]) -> dict:- """Internal entry. Iterates over every detected KiCad version and- installs into each. Returns a top-level summary dict."""- started_at = datetime.utcnow().isoformat() + "Z"-- if not all_kicad_versions:- return {- "triggered": True,- "startedAt": started_at,- "completedAt": datetime.utcnow().isoformat() + "Z",- "summary": "no-kicad",- "errorCode": "no_kicad_installed",- "perVersion": [],- "_hint": (- "Auto-install skipped because kicad_detect found zero KiCad installs. "- "Report this to the user; recommend installing KiCad from "- "https://www.kicad.org/download/ (or via `adom-desktop desktop_install_kicad` "- "which uses winget). After install, the next kicad_* command will retry "- "the auto-install for you."- ),- }-- running = _is_kicad_running()- per_version = []- overall_action = "already-current"- overall_error = None- for v in all_kicad_versions:- version = v.get("version") or "10.0"- try:- per_version.append(_install_one_version(version, v))- except Exception as e: # pylint: disable=broad-except- per_version.append({- "kicadVersion": version,- "action": "failed",- "errorCode": "unexpected_error",- "error": f"{type(e).__name__}: {e}",- "_traceback": traceback.format_exc(),- "_hint": (- "Unexpected exception during plugin install for this version. "- "Full traceback in `_traceback`. Report to John on the adom-desktop "- "side; this should not happen and is a real bug, not an environment "- "issue."- ),- })-- # Pick the most-severe action across all versions as the overall summary.- actions = [pv.get("action") for pv in per_version]- if any(a == "failed" for a in actions):- overall_action = "partial-failure"- overall_error = next(pv for pv in per_version if pv.get("action") == "failed").get("errorCode")- elif any(a == "installed" for a in actions):- overall_action = "installed"- elif any(a == "updated" for a in actions):- overall_action = "updated"- elif any(a == "skipped" for a in actions):- overall_action = "skipped"-- result: dict = {- "triggered": True,- "startedAt": started_at,- "completedAt": datetime.utcnow().isoformat() + "Z",- "summary": overall_action,- "payloadVersion": PAYLOAD_VERSION,- "perVersion": per_version,- "runningKiCad": running,- }-- if overall_error:- result["errorCode"] = overall_error-- # Top-level hint depends on what happened + whether KiCad was running- if overall_action == "installed":- if running:- running_summary = ", ".join(f"{r['exeName']} pid={r['pid']}" for r in running)- result["_hint"] = (- f"Plugin successfully INSTALLED for the first time. HOWEVER, KiCad is "- f"currently running ({running_summary}). Those running processes will "- f"NOT pick up the new plugin (usercustomize.py only fires at Python "- f"interpreter init, which happens at KiCad startup). "- f"\n\nTo use the new reverse bridge, ask the user to: (1) Save any work "- f"in their open KiCad windows; (2) Close ALL KiCad windows; (3) Reopen "- f"KiCad. After that, run kicad_bridge_status to verify the plugin came "- f"online.\n\nIf the user doesn't want to restart KiCad right now, the "- f"existing non-bridge commands (open_board, install_symbol, etc.) keep "- f"working through the legacy UI-automation path. The bridge is purely "- f"additive."- )- else:- result["_hint"] = (- "Plugin successfully INSTALLED. No KiCad processes were running at "- "install time, so the next time the user (or your next kicad_* command) "- "launches KiCad, the reverse-bridge plugin will auto-load. Verify with "- "kicad_bridge_status once KiCad is up."- )- elif overall_action == "updated":- if running:- result["_hint"] = (- "Plugin successfully UPDATED to v" + PAYLOAD_VERSION + ". Running "- "KiCad processes still have the OLD plugin loaded in-memory. They'll "- "switch to the new version on next restart. Not urgent — current "- "session still works."- )- else:- result["_hint"] = (- "Plugin successfully UPDATED to v" + PAYLOAD_VERSION + ". Next KiCad "- "launch picks it up automatically."- )- elif overall_action == "already-current":- result["_hint"] = (- "Plugin already current at v" + PAYLOAD_VERSION + ". No-op. "- "Future kicad_* commands won't re-check; this status is cached for the "- "bridge process lifetime."- )- elif overall_action == "partial-failure":- result["_hint"] = (- "AT LEAST ONE KiCad version's plugin install failed. Inspect perVersion[] "- "for which exact version's errorCode applies. The bridge still works for "- "the versions that succeeded; the failed ones fall back to legacy UI "- "automation. Report the specific errorCode to the user — each one has its "- "own recovery path documented in the per-version `_hint` field."- )- elif overall_action == "skipped":- result["_hint"] = (- "Plugin install skipped — see per-version errorCodes. Most common reason: "- "running on macOS/Linux where Phase 2 hasn't been ported yet. Legacy "- "kicad_* commands keep working; you just don't have the reverse bridge."- )-- return result---def ensure_plugin_installed_once(all_kicad_versions: list[dict]) -> dict:- """Install the plugin if needed; cache the result.-- Idempotent: every call after the first returns the cached dict, marked- with `triggered: False` and `cached: True` so consumers can tell whether- this call actually did anything.- """- 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- # Honor disable env var. Useful for development/diagnostic.- if os.environ.get("ADOM_KICAD_PLUGIN_AUTO_INSTALL_DISABLE") == "1":- _install_result = {- "triggered": False,- "summary": "disabled",- "errorCode": "disabled_via_env",- "_hint": (- "ADOM_KICAD_PLUGIN_AUTO_INSTALL_DISABLE=1 in the kicad bridge's "- "environment. To install manually, call: "- "adom-desktop kicad_install_plugin (Phase 2.1)."- ),- }- return dict(_install_result)- _install_result = _do_install(all_kicad_versions)- return _install_result---def attach_to_response_if_first(result: dict) -> dict:- """Attach `pluginAutoInstall` to a response dict EXACTLY ONCE per- bridge boot. After the AI has been notified on first command, future- responses skip the field to keep payloads small.-- bridge_status / bridge_call ALWAYS get the field attached regardless,- since those verbs explicitly ask about plugin state.- """- global _response_attached # pylint: disable=global-statement- if not isinstance(result, dict):- return result- if _response_attached and _install_result is not None:- return result- if _install_result is None:- return result- result["pluginAutoInstall"] = dict(_install_result)- _response_attached = True- return result---def force_attach(result: dict) -> dict:- """For bridge_status / bridge_call — always attach the current install- state, regardless of `_response_attached`. Useful when the AI is- explicitly inspecting plugin state."""- if not isinstance(result, dict) or _install_result is None:- return result- result["pluginAutoInstall"] = dict(_install_result)- return result---def get_cached_result() -> Optional[dict]:- return dict(_install_result) if _install_result is not None else None---def force_install(all_kicad_versions: list[dict]) -> dict:- """Bypass the once-per-boot cache and re-run the install. The new- result replaces the cache so subsequent attach_to_response_if_first()- calls see the fresh state.-- Used by the explicit `kicad_install_plugin` verb."""- global _install_result, _response_attached # pylint: disable=global-statement- with _install_lock:- _install_result = _do_install(all_kicad_versions)- # Reset the "attached on first response" flag so future calls- # surface the fresh install state once more.- _response_attached = False- return dict(_install_result)---def handle_install_plugin(kicad_info: dict, args: dict) -> dict:- """Explicit kicad_install_plugin verb. Bypasses the auto-install- cache and forces a fresh deploy + status check.-- Args:- force: default true. If false, behaves like the auto-install path- (returns cached result if it's already run this boot).-- Returns the full install report. Useful for: debugging when the- plugin isn't loading, updating the payload between releases without- restarting the bridge, recovering from a partial-failure install.- """- # Need the full all_kicad_versions list from server.py — caller- # passed kicad_info (the default version). Re-detect to get all.- from kicad_detect import detect_all_kicad_versions- all_versions = detect_all_kicad_versions()-- if args.get("force") is False:- # Honor explicit force=false by behaving like the auto path- return {"success": True, "result": ensure_plugin_installed_once(all_versions)}-- result = force_install(all_versions)- return {- "success": result.get("summary") not in ("partial-failure",),- "result": result,- "_hint": (- "force-reinstall complete. Future kicad_* commands will show "- "this fresh install state in their pluginAutoInstall fields. "- "If KiCad was running during install, ask the user to restart "- "those processes — the in-memory Python interpreter still has "- "the OLD plugin loaded until next launch."- ),- }+"""plugin_install.py — auto-install adom_bridge into KiCad USER_SITE. + +The Phase 2 reverse bridge plugin (`adom_bridge.py`) needs to live at +KiCad's USER_SITE directory so that KiCad's bundled Python loads it via +`usercustomize.py` at every GUI process startup. See PLAN.md v4 for the +architectural story. + +This module does the deployment, with extensive feedback for the AI that +triggered the install. The pattern is: +- On every bridge boot, the first incoming kicad_* command triggers + `ensure_plugin_installed_once()`. +- The result (whether install was needed, what was copied, what failed) + is cached in module state and attached as `pluginAutoInstall` on that + first response. +- Subsequent commands do nothing (cached short-circuit) so steady-state + command flow has zero overhead. + +Failure modes the AI needs to know about +======================================== +* `appdata_missing` — %USERPROFILE% (or $HOME on Mac) unresolved. +* `no_kicad_installed` — kicad_detect found zero installs, nothing to deploy to. +* `payload_missing` — plugins/kicad/plugin_payload/ wasn't bundled into + the Tauri app (Tauri bundling regression, like the v1.3.30..v1.3.37 + recorder.html bug — see verify_bundle.py). +* `file_locked` — the destination .py was open (rare; might happen if + KiCad already has it imported and OS locks the file on Windows). +* `permission_denied` — user-site dir uncreatable (very rare). +* `kicad_running` — KiCad was running when we installed; the running + process WON'T pick up the new plugin until restarted. + +Every error case returns a `_hint` field telling the calling AI exactly +what action it should take to recover. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import shutil +import sys +import threading +import traceback +from datetime import datetime +from pathlib import Path +from typing import Optional + +# Bridge version we're shipping. Bumped together with adom_bridge.py's +# __version__ — version drift between this file and the payload would +# cause an infinite reinstall loop; the runtime asserts they match. +PAYLOAD_VERSION = "0.9.0" + +# Module-level cache: install runs at most ONCE per bridge process boot. +# Multiple concurrent kicad_* commands hitting the bridge during startup +# might all check `ensure_plugin_installed_once()` — the lock guarantees +# only the first one actually does the file copy. +_install_lock = threading.Lock() +_install_result: Optional[dict] = None +_response_attached = False + + +def _payload_dir() -> Path: + """Find plugin_payload/ relative to this file. Works in dev (`plugins/kicad/`) + AND in the bundled Tauri release (`<resources>/plugins/kicad/`).""" + return Path(__file__).parent / "plugin_payload" + + +def _user_site_dir_windows(kicad_version: str) -> Optional[Path]: + """Compute the KiCad USER_SITE path on Windows. + + KiCad's bundled sitecustomize.py builds this path from `CSIDL_PERSONAL` + (== `%USERPROFILE%\\Documents`) joined with `KiCad\\<version>\\3rdparty\\Python<XY>\\site-packages`. + We're matching that exactly so adom_bridge.py lands where KiCad's + embedded interpreter will look for usercustomize.py. + + Returns None if USERPROFILE isn't set (extremely unusual on Windows). + """ + userprofile = os.environ.get("USERPROFILE") + if not userprofile: + return None + # KiCad 10 uses Python 3.11 — the path includes the no-dot version + # ("311"). KiCad 9 also used 3.x; we hardcode 311 for now and add a + # version detection step in Phase 2.x. + return Path(userprofile) / "Documents" / "KiCad" / kicad_version / "3rdparty" / "Python311" / "site-packages" + + +def _user_site_dir_mac(kicad_version: str) -> Optional[Path]: + """Best-effort macOS USER_SITE. NEEDS EMPIRICAL VERIFICATION + (Phase 1 macOS parity follow-up). Documented here so future Kyle- + session work can confirm/correct.""" + home = os.environ.get("HOME") + if not home: + return None + return Path(home) / "Library" / "Preferences" / "kicad" / kicad_version / "3rdparty" / "Python311" / "site-packages" + + +def _user_site_dir(kicad_version: str) -> Optional[Path]: + if sys.platform == "win32": + return _user_site_dir_windows(kicad_version) + if sys.platform == "darwin": + return _user_site_dir_mac(kicad_version) + # Linux KiCad install paths vary wildly (flatpak vs apt vs build- + # from-source). Caller will get "skipped_unsupported_platform". + return None + + +def _file_hash(path: Path) -> Optional[str]: + try: + with open(path, "rb") as f: + return hashlib.sha256(f.read()).hexdigest() + except OSError: + return None + + +def _file_state(dest: Path) -> dict: + """Inspect a destination file and return state for the feedback report. + + Returns a dict with `exists`, `size`, `sha256`, `mtime` (or `_error`). + """ + if not dest.exists(): + return {"exists": False} + try: + st = dest.stat() + return { + "exists": True, + "size": st.st_size, + "sha256": _file_hash(dest), + "mtime": datetime.utcfromtimestamp(st.st_mtime).isoformat() + "Z", + } + except OSError as e: + return {"exists": True, "_error": f"stat failed: {e}"} + + +def _is_kicad_running() -> list[dict]: + """Best-effort: return PIDs of running KiCad GUI exes. So the AI can + warn the user that the freshly-installed plugin won't be active until + they restart KiCad.""" + if sys.platform != "win32": + return [] + try: + import subprocess + out = subprocess.run( + ["tasklist", "/FO", "CSV", "/NH"], + capture_output=True, text=True, timeout=10, + ) + running: list[dict] = [] + targets = {"kicad.exe", "pcbnew.exe", "eeschema.exe", "kicad-cli.exe"} + for line in out.stdout.splitlines(): + parts = [p.strip('"') for p in line.split(",")] + if len(parts) < 2: + continue + name = parts[0].lower() + if name in targets: + try: + running.append({"exeName": name, "pid": int(parts[1])}) + except ValueError: + pass + return running + except (OSError, subprocess.SubprocessError): + return [] + + +def _install_one_version(version: str, version_info: dict) -> dict: + """Install / refresh the plugin for ONE KiCad version. Returns a + dict describing what happened.""" + report: dict = { + "kicadVersion": version, + "installPath": version_info.get("base_dir"), + } + payload = _payload_dir() + if not payload.exists(): + report["action"] = "failed" + report["errorCode"] = "payload_missing" + report["error"] = f"plugin payload directory not found at {payload}" + report["_hint"] = ( + "The plugin_payload/ directory wasn't shipped with the bundle. This is a " + "build regression — see verify_bundle.py in src-tauri/. Report to the user " + "and ask them to upgrade adom-desktop to a build that includes the payload " + "(verify with: adom-desktop --version, expecting v1.7.5 or newer)." + ) + return report + + user_site = _user_site_dir(version) + if user_site is None: + report["action"] = "skipped" + report["errorCode"] = "user_site_unavailable" + report["error"] = ( + f"could not compute USER_SITE path on {sys.platform} for KiCad {version} " + f"(missing USERPROFILE / HOME?)" + ) + report["_hint"] = ( + f"Plugin auto-install is only supported on Windows in v1.7.x; " + f"this is {sys.platform}. macOS parity is Phase 1 follow-up. " + "Other kicad_* commands still work via the legacy UI-automation path; " + "you just won't have the in-process reverse bridge." + ) + return report + + report["userSitePath"] = str(user_site) + src_bridge = payload / "adom_bridge.py" + src_loader = payload / "usercustomize.py" + dest_bridge = user_site / "adom_bridge.py" + dest_loader = user_site / "usercustomize.py" + + # Pre-flight: inspect current state so feedback shows before/after + pre_state = { + "adom_bridge.py": _file_state(dest_bridge), + "usercustomize.py": _file_state(dest_loader), + } + report["preState"] = pre_state + + # Compare hashes — short-circuit if already up to date + src_bridge_hash = _file_hash(src_bridge) + src_loader_hash = _file_hash(src_loader) + dest_bridge_hash = pre_state["adom_bridge.py"].get("sha256") + dest_loader_hash = pre_state["usercustomize.py"].get("sha256") + + if dest_bridge_hash == src_bridge_hash and dest_loader_hash == src_loader_hash: + report["action"] = "already-current" + report["payloadVersion"] = PAYLOAD_VERSION + report["message"] = ( + f"plugin already deployed at {user_site} and content matches the shipped " + f"payload (sha256 identical for both files). No write performed." + ) + return report + + # Mkdir the user-site if missing. The KiCad sitecustomize.py creates + # this on first launch too, so we're paving the same path it would. + try: + user_site.mkdir(parents=True, exist_ok=True) + except OSError as e: + report["action"] = "failed" + report["errorCode"] = "permission_denied" + report["error"] = f"could not create {user_site}: {e}" + report["_hint"] = ( + "Filesystem permission issue creating the KiCad user-site directory. " + "This is rare on Windows. Possible causes: (1) Documents folder is on " + "OneDrive with sync errors; (2) antivirus blocking. Report exact error " + "to the user and have them try: `mkdir -p '<userSitePath>'` manually, " + "then retry the kicad_* command. If that also fails, the user has a " + "broken filesystem state." + ) + return report + + # Copy. shutil.copy2 preserves timestamps which is harmless. We do + # one file at a time so a half-failure still leaves a coherent state. + written: list[dict] = [] + for src, dest in ((src_bridge, dest_bridge), (src_loader, dest_loader)): + try: + shutil.copy2(src, dest) + written.append({ + "name": src.name, + "src": str(src), + "dest": str(dest), + "size": dest.stat().st_size, + "sha256": _file_hash(dest), + }) + except OSError as e: + report["action"] = "failed" + report["errorCode"] = "copy_failed" + report["error"] = f"copying {src.name} -> {dest}: {e}" + report["filesWritten"] = written + report["_hint"] = ( + "Failed mid-copy. Most likely cause on Windows: the destination .py " + "is currently imported by a running KiCad process which has Windows-" + "level locks on the file. Fix: ask the user to close ALL KiCad windows " + "(Project Manager, PCB Editor, Schematic Editor), then retry the " + "kicad_* command. If the file isn't held, this could also be an " + "antivirus quarantine — check Windows Defender's protection history." + ) + return report + + report["action"] = "installed" if not pre_state["adom_bridge.py"]["exists"] else "updated" + report["payloadVersion"] = PAYLOAD_VERSION + report["filesWritten"] = written + report["message"] = ( + f"plugin {'installed' if report['action'] == 'installed' else 'updated to v' + PAYLOAD_VERSION} " + f"at {user_site}. KiCad GUI processes launched AFTER this point will auto-load it via " + f"sitecustomize.py + usercustomize.py." + ) + return report + + +def _do_install(all_kicad_versions: list[dict]) -> dict: + """Internal entry. Iterates over every detected KiCad version and + installs into each. Returns a top-level summary dict.""" + started_at = datetime.utcnow().isoformat() + "Z" + + if not all_kicad_versions: + return { + "triggered": True, + "startedAt": started_at, + "completedAt": datetime.utcnow().isoformat() + "Z", + "summary": "no-kicad", + "errorCode": "no_kicad_installed", + "perVersion": [], + "_hint": ( + "Auto-install skipped because kicad_detect found zero KiCad installs. " + "Report this to the user; recommend installing KiCad from " + "https://www.kicad.org/download/ (or via `adom-desktop desktop_install_kicad` " + "which uses winget). After install, the next kicad_* command will retry " + "the auto-install for you." + ), + } + + running = _is_kicad_running() + per_version = [] + overall_action = "already-current" + overall_error = None + for v in all_kicad_versions: + version = v.get("version") or "10.0" + try: + per_version.append(_install_one_version(version, v)) + except Exception as e: # pylint: disable=broad-except + per_version.append({ + "kicadVersion": version, + "action": "failed", + "errorCode": "unexpected_error", + "error": f"{type(e).__name__}: {e}", + "_traceback": traceback.format_exc(), + "_hint": ( + "Unexpected exception during plugin install for this version. " + "Full traceback in `_traceback`. Report to John on the adom-desktop " + "side; this should not happen and is a real bug, not an environment " + "issue." + ), + }) + + # Pick the most-severe action across all versions as the overall summary. + actions = [pv.get("action") for pv in per_version] + if any(a == "failed" for a in actions): + overall_action = "partial-failure" + overall_error = next(pv for pv in per_version if pv.get("action") == "failed").get("errorCode") + elif any(a == "installed" for a in actions): + overall_action = "installed" + elif any(a == "updated" for a in actions): + overall_action = "updated" + elif any(a == "skipped" for a in actions): + overall_action = "skipped" + + result: dict = { + "triggered": True, + "startedAt": started_at, + "completedAt": datetime.utcnow().isoformat() + "Z", + "summary": overall_action, + "payloadVersion": PAYLOAD_VERSION, + "perVersion": per_version, + "runningKiCad": running, + } + + if overall_error: + result["errorCode"] = overall_error + + # Top-level hint depends on what happened + whether KiCad was running + if overall_action == "installed": + if running: + running_summary = ", ".join(f"{r['exeName']} pid={r['pid']}" for r in running) + result["_hint"] = ( + f"Plugin successfully INSTALLED for the first time. HOWEVER, KiCad is " + f"currently running ({running_summary}). Those running processes will " + f"NOT pick up the new plugin (usercustomize.py only fires at Python " + f"interpreter init, which happens at KiCad startup). " + f"\n\nTo use the new reverse bridge, ask the user to: (1) Save any work " + f"in their open KiCad windows; (2) Close ALL KiCad windows; (3) Reopen " + f"KiCad. After that, run kicad_bridge_status to verify the plugin came " + f"online.\n\nIf the user doesn't want to restart KiCad right now, the " + f"existing non-bridge commands (open_board, install_symbol, etc.) keep " + f"working through the legacy UI-automation path. The bridge is purely " + f"additive." + ) + else: + result["_hint"] = ( + "Plugin successfully INSTALLED. No KiCad processes were running at " + "install time, so the next time the user (or your next kicad_* command) " + "launches KiCad, the reverse-bridge plugin will auto-load. Verify with " + "kicad_bridge_status once KiCad is up." + ) + elif overall_action == "updated": + if running: + result["_hint"] = ( + "Plugin successfully UPDATED to v" + PAYLOAD_VERSION + ". Running " + "KiCad processes still have the OLD plugin loaded in-memory. They'll " + "switch to the new version on next restart. Not urgent — current " + "session still works." + ) + else: + result["_hint"] = ( + "Plugin successfully UPDATED to v" + PAYLOAD_VERSION + ". Next KiCad " + "launch picks it up automatically." + ) + elif overall_action == "already-current": + result["_hint"] = ( + "Plugin already current at v" + PAYLOAD_VERSION + ". No-op. " + "Future kicad_* commands won't re-check; this status is cached for the " + "bridge process lifetime." + ) + elif overall_action == "partial-failure": + result["_hint"] = ( + "AT LEAST ONE KiCad version's plugin install failed. Inspect perVersion[] " + "for which exact version's errorCode applies. The bridge still works for " + "the versions that succeeded; the failed ones fall back to legacy UI " + "automation. Report the specific errorCode to the user — each one has its " + "own recovery path documented in the per-version `_hint` field." + ) + elif overall_action == "skipped": + result["_hint"] = ( + "Plugin install skipped — see per-version errorCodes. Most common reason: " + "running on macOS/Linux where Phase 2 hasn't been ported yet. Legacy " + "kicad_* commands keep working; you just don't have the reverse bridge." + ) + + return result + + +def ensure_plugin_installed_once(all_kicad_versions: list[dict]) -> dict: + """Install the plugin if needed; cache the result. + + Idempotent: every call after the first returns the cached dict, marked + with `triggered: False` and `cached: True` so consumers can tell whether + this call actually did anything. + """ + 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 + # Honor disable env var. Useful for development/diagnostic. + if os.environ.get("ADOM_KICAD_PLUGIN_AUTO_INSTALL_DISABLE") == "1": + _install_result = { + "triggered": False, + "summary": "disabled", + "errorCode": "disabled_via_env", + "_hint": ( + "ADOM_KICAD_PLUGIN_AUTO_INSTALL_DISABLE=1 in the kicad bridge's " + "environment. To install manually, call: " + "adom-desktop kicad_install_plugin (Phase 2.1)." + ), + } + return dict(_install_result) + _install_result = _do_install(all_kicad_versions) + return _install_result + + +def attach_to_response_if_first(result: dict) -> dict: + """Attach `pluginAutoInstall` to a response dict EXACTLY ONCE per + bridge boot. After the AI has been notified on first command, future + responses skip the field to keep payloads small. + + bridge_status / bridge_call ALWAYS get the field attached regardless, + since those verbs explicitly ask about plugin state. + """ + global _response_attached # pylint: disable=global-statement + if not isinstance(result, dict): + return result + if _response_attached and _install_result is not None: + return result + if _install_result is None: + return result + result["pluginAutoInstall"] = dict(_install_result) + _response_attached = True + return result + + +def force_attach(result: dict) -> dict: + """For bridge_status / bridge_call — always attach the current install + state, regardless of `_response_attached`. Useful when the AI is + explicitly inspecting plugin state.""" + if not isinstance(result, dict) or _install_result is None: + return result + result["pluginAutoInstall"] = dict(_install_result) + return result + + +def get_cached_result() -> Optional[dict]: + return dict(_install_result) if _install_result is not None else None + + +def force_install(all_kicad_versions: list[dict]) -> dict: + """Bypass the once-per-boot cache and re-run the install. The new + result replaces the cache so subsequent attach_to_response_if_first() + calls see the fresh state. + + Used by the explicit `kicad_install_plugin` verb.""" + global _install_result, _response_attached # pylint: disable=global-statement + with _install_lock: + _install_result = _do_install(all_kicad_versions) + # Reset the "attached on first response" flag so future calls + # surface the fresh install state once more. + _response_attached = False + return dict(_install_result) + + +def handle_install_plugin(kicad_info: dict, args: dict) -> dict: + """Explicit kicad_install_plugin verb. Bypasses the auto-install + cache and forces a fresh deploy + status check. + + Args: + force: default true. If false, behaves like the auto-install path + (returns cached result if it's already run this boot). + + Returns the full install report. Useful for: debugging when the + plugin isn't loading, updating the payload between releases without + restarting the bridge, recovering from a partial-failure install. + """ + # Need the full all_kicad_versions list from server.py — caller + # passed kicad_info (the default version). Re-detect to get all. + from kicad_detect import detect_all_kicad_versions + all_versions = detect_all_kicad_versions() + + if args.get("force") is False: + # Honor explicit force=false by behaving like the auto path + return {"success": True, "result": ensure_plugin_installed_once(all_versions)} + + result = force_install(all_versions) + return { + "success": result.get("summary") not in ("partial-failure",), + "result": result, + "_hint": ( + "force-reinstall complete. Future kicad_* commands will show " + "this fresh install state in their pluginAutoInstall fields. " + "If KiCad was running during install, ask the user to restart " + "those processes — the in-memory Python interpreter still has " + "the OLD plugin loaded until next launch." + ), + }
plugin_payload/adom_bridge.py+60−13@@ -2141,22 +2141,69 @@ def _ui_select_and_load(editor_kind: str, name: str, use_send_input: bool = Fals keys_sent = ["SendInput VK_RETURN", "SendInput Ctrl+Shift+E"] if si.get("ok") else [f"SendInput failed: {si}"] transport_detail = {"sendInput": si, "robustFocus": focus_detail} else:+ # v0.9.0 background-activate (Phase 2.ae, John's no-foreground+ # mandate): NO simulated keyboard, NO focus. After the filter,+ # KiCad's LIB_TREE pre-selects the best match — so we take+ # dvc.GetSelection() (NOT the v0.6.0 whole-model walk that+ # segfaulted on big libraries) and activate it two ways, both+ # window-targeted:+ # 1. wx.dataview.DataViewEvent(ITEM_ACTIVATED) posted to the+ # tree — the same event a double-click produces.+ # 2. Win32 PostMessageW(VK_RETURN) to the tree's OWN hwnd —+ # reaches the native handler even when wx-level binding+ # doesn't, and single keys post fine (chords don't).+ keystroke_mode = "background_activate" keys_sent = []- for keycode, label in ((wx.WXK_DOWN, "WXK_DOWN"), (wx.WXK_RETURN, "WXK_RETURN")):+ transport_detail = {}+ dvc = _find_dataview_ctrl_recursive(target_frame)+ sel_ok = False+ if dvc is not None: try:- key_down = wx.KeyEvent(wx.wxEVT_KEY_DOWN)- key_down.SetEventObject(sctrl)- key_down.SetKeyCode(keycode)- wx.PostEvent(sctrl, key_down)-- key_up = wx.KeyEvent(wx.wxEVT_KEY_UP)- key_up.SetEventObject(sctrl)- key_up.SetKeyCode(keycode)- wx.PostEvent(sctrl, key_up)- keys_sent.append(label)+ import wx.dataview # type: ignore[import-not-found]+ sel = dvc.GetSelection()+ sel_ok = bool(sel and sel.IsOk())+ transport_detail["selection"] = sel_ok+ if sel_ok:+ evt = wx.dataview.DataViewEvent(+ wx.dataview.wxEVT_DATAVIEW_ITEM_ACTIVATED)+ try:+ evt.SetEventObject(dvc)+ evt.SetItem(sel)+ except Exception: # pylint: disable=broad-except+ pass+ wx.PostEvent(dvc, evt)+ keys_sent.append("DATAVIEW_ITEM_ACTIVATED(selection)")+ except Exception as e: # pylint: disable=broad-except+ transport_detail["activateError"] = f"{type(e).__name__}: {e}"+ # 2. native Return posted to the tree's own window — never+ # the foreground queue, so it can't land in the user's app.+ try:+ import ctypes+ tree_win = dvc.GetMainWindow() if hasattr(dvc, "GetMainWindow") else dvc+ hwnd_tree = int(tree_win.GetHandle())+ if hwnd_tree:+ WM_KEYDOWN, WM_KEYUP, VK_RETURN = 0x0100, 0x0101, 0x0D+ ctypes.windll.user32.PostMessageW(hwnd_tree, WM_KEYDOWN, VK_RETURN, 0)+ ctypes.windll.user32.PostMessageW(hwnd_tree, WM_KEYUP, VK_RETURN, 0)+ keys_sent.append("PostMessage VK_RETURN -> tree hwnd") except Exception as e: # pylint: disable=broad-except- keys_sent.append(f"{label}:error={type(e).__name__}")- transport_detail = None+ transport_detail["postError"] = f"{type(e).__name__}: {e}"+ else:+ transport_detail["dataview"] = "not found"+ # last background resort: wx key events at the search ctrl+ for keycode, label in ((wx.WXK_DOWN, "WXK_DOWN"), (wx.WXK_RETURN, "WXK_RETURN")):+ try:+ key_down = wx.KeyEvent(wx.wxEVT_KEY_DOWN)+ key_down.SetEventObject(sctrl)+ key_down.SetKeyCode(keycode)+ wx.PostEvent(sctrl, key_down)+ key_up = wx.KeyEvent(wx.wxEVT_KEY_UP)+ key_up.SetEventObject(sctrl)+ key_up.SetKeyCode(keycode)+ wx.PostEvent(sctrl, key_up)+ keys_sent.append(label)+ except Exception as e: # pylint: disable=broad-except+ keys_sent.append(f"{label}:error={type(e).__name__}") return { "found": True,