app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
Colby Knox
0.9.260: reveal falls back to a win32 filter set (library name only, no Enter) since eeschema's embedded Python binds m…
ed4a185
2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
"""Handler for install_library_bundle: install an Adom wiki Cloud Library
bundle (one zip: <Lib>.kicad_sym + <Lib>.pretty/ + 3dmodels/) in one verb.
This is the no-AI lane: a wiki page stages the zip over the web-control
transport (write_file / write_file_chunk) and calls this verb; the whole
symbol + footprint + 3D-model install happens here, reusing the proven
install_library paths (including the 0.9.251 model-ref rewrite)."""
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from handlers.install_library import handle_install_library, _install_footprint
MAX_ZIP_BYTES = 256 * 1024 * 1024
MAX_UNPACKED_BYTES = 512 * 1024 * 1024
# Library names become file/dir names and lib-table entries; keep them tame.
_NAME_OK = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")
def _safe_extract(zf: zipfile.ZipFile, dest: Path) -> int:
"""Extract with a zip-slip guard and an unpacked-size cap. Returns the
number of files extracted."""
total = 0
count = 0
dest = dest.resolve()
for info in zf.infolist():
name = info.filename
if not name or name.endswith("/"):
continue
# Reject absolute paths, drive letters, parent escapes, backslash tricks.
norm = name.replace("\\", "/")
if norm.startswith("/") or re.match(r"^[A-Za-z]:", norm) or ".." in norm.split("/"):
raise ValueError(f"unsafe zip entry: {name}")
total += info.file_size
if total > MAX_UNPACKED_BYTES:
raise ValueError("bundle unpacks past the size cap")
target = (dest / norm).resolve()
if not str(target).startswith(str(dest)):
raise ValueError(f"unsafe zip entry: {name}")
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, open(target, "wb") as out:
shutil.copyfileobj(src, out)
count += 1
return count
def _find_bundle_parts(root: Path):
"""Locate the bundle pieces regardless of a wrapping top-level folder:
the first *.kicad_sym (shallowest wins), its sibling *.pretty dir, and a
sibling 3dmodels dir."""
syms = sorted(root.rglob("*.kicad_sym"), key=lambda p: (len(p.parts), str(p)))
if not syms:
return None, None, None
sym = syms[0]
base = sym.parent
pretty = None
for d in sorted(base.iterdir()):
if d.is_dir() and d.suffix == ".pretty":
pretty = d
break
models = base / "3dmodels"
return sym, pretty, (models if models.is_dir() else None)
def _top_symbol_names(sym_path: Path) -> list:
"""Top-level symbol names only (sub-units are named NAME_<n>_<n>)."""
try:
text = sym_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return []
names = re.findall(r'\(symbol\s+"([^"]+)"', text)
return [n for n in names if not re.search(r"_\d+_\d+$", n)]
def _capture_proof(kicad_info: dict, args: dict, lib_name: str, first_symbol: str, result: dict):
"""Proof frame for the browser caller: open the Symbol Editor on the
freshly installed library IN THE BACKGROUND (the show path's contract)
and grab the shot. The editor is LEFT OPEN, loaded on the new library
with the tree filtered to its name, so the user who surfaces KiCad sees
their list immediately (Colby, 2026-08-27: closing it made the install
feel unverified). It stays BEHIND the user's windows, never foregrounded.
Pass closeEditor:true for the old close-after behavior. Best effort: any
failure lands in proofSkipped, never in the install's success."""
try:
from handlers.show import handle_show_symbol
show = handle_show_symbol(kicad_info, {
"libraryName": lib_name,
**({"symbolName": first_symbol} if first_symbol else {}),
"capture": True,
"captureMaxWidth": args.get("captureMaxWidth", 480),
})
if show.get("shot"):
result["shot"] = show["shot"]
else:
result["proofSkipped"] = show.get("error") or show.get("shotUnavailable") or "no frame captured"
if args.get("closeEditor"):
if not show.get("reused"):
try:
from handlers.close_windows import handle_close_symbol_editor
handle_close_symbol_editor(kicad_info, {})
except Exception:
pass
else:
result["editorLeftOpen"] = True
# Show the FULL list: clear the tree filter and select/expand the
# library node by name (plugin 0.9.2 reveal_library; a text filter
# made generic names like 'UI' lose a fuzzy match to other libs).
_revealed = False
try:
import time as _rt
from handlers.bridge_client import _resolve_plugin, call as _plugin_call
# Plugin path first when it happens to be alive (node select,
# generic-name-proof) -- but eeschema's embedded Python
# initializes LAZILY (minutes after launch, issue #21), so on a
# cold start this almost never binds in time. Short wait only.
_deadline = _rt.monotonic() + 4
info = err = None
while _rt.monotonic() < _deadline:
info, err = _resolve_plugin("eeschema")
if info and err is None:
break
_rt.sleep(1.0)
if info and err is None:
rev = _plugin_call(info["port"], "reveal_library",
{"libraryName": lib_name}, timeout=20.0)
_revealed = bool((rev or {}).get("found"))
if _revealed:
result["libraryRevealed"] = "tree"
except Exception:
pass
if not _revealed:
# Plugin-free fallback (the normal cold-start path): WM_SETTEXT
# the LIB_TREE filter to JUST the library name -- no Enter, so
# the loaded symbol stays put and the tree shows the library's
# full list. (A generic name like 'UI' can still fuzzy-match
# other libraries above ours; only the plugin path fixes that.)
try:
import ctypes
from handlers.kicad_ui import _find_all_kicad_windows
from handlers.open_symbol_editor import _find_child_by_class_and_parent
user32 = ctypes.windll.user32
hwnd = next((w.get("hwnd") for w in (_find_all_kicad_windows() or [])
if "symbol editor" in (w.get("title") or "").lower()), None)
edit = _find_child_by_class_and_parent(hwnd, "Edit", "searchCtrl") if hwnd else None
if edit:
user32.SendMessageW(edit, 0x000C, 0, ctypes.create_unicode_buffer(lib_name))
result["libraryRevealed"] = "filter"
else:
result["libraryRevealed"] = False
result["revealDetail"] = "no symbol editor filter control found"
except Exception as e:
result["libraryRevealed"] = False
result["revealDetail"] = f"{type(e).__name__}: {e}"
except Exception as e:
result["proofSkipped"] = str(e)
def handle_install_library_bundle(kicad_info: dict, args: dict) -> dict:
if not kicad_info.get("installed"):
return {
"success": False,
"error": "KiCad not installed",
"_hint": "Install KiCad first (kicad_upgrade can do it), then retry.",
}
zip_path = args.get("zipPath", "")
if not zip_path:
return {"success": False, "error": "No zipPath specified",
"_hint": "Stage the bundle zip with write_file / write_file_chunk first, then pass its path."}
source = Path(zip_path).expanduser()
if not source.is_file():
return {"success": False, "error": f"Bundle zip not found: {zip_path}",
"_hint": "The path must exist on THIS machine. Stage it with write_file / write_file_chunk first."}
if source.stat().st_size > MAX_ZIP_BYTES:
return {"success": False, "error": "Bundle zip is larger than the 256MB cap"}
lib_name = args.get("libraryName", "") or source.stem
lib_name = lib_name.strip()
if not _NAME_OK.match(lib_name):
return {"success": False, "error": f"Library name not usable as a KiCad library id: {lib_name!r}",
"_hint": "Letters, digits, space, dot, dash, underscore; up to 64 chars."}
extract_dir = Path(tempfile.mkdtemp(prefix="adom-lib-bundle-"))
try:
try:
with zipfile.ZipFile(str(source)) as zf:
extracted = _safe_extract(zf, extract_dir)
except (zipfile.BadZipFile, ValueError, OSError) as e:
return {"success": False, "error": f"Could not unpack the bundle: {e}"}
if not extracted:
return {"success": False, "error": "The bundle zip is empty"}
sym, pretty, models = _find_bundle_parts(extract_dir)
if not sym:
return {"success": False, "error": "No .kicad_sym found in the bundle",
"_hint": "Expected an Adom wiki Cloud Library bundle: <Lib>.kicad_sym + <Lib>.pretty/ + 3dmodels/."}
top_names = _top_symbol_names(sym)
report = {"libraryName": lib_name, "symbols": len(top_names),
"footprints": 0, "modelsInstalled": 0, "modelRefsRewritten": 0}
sym_res = handle_install_library(kicad_info, {
"libraryPath": str(sym), "libraryType": "symbol", "libraryName": lib_name,
"description": "Adom wiki Cloud Library",
})
if not sym_res.get("success"):
return {"success": False, "error": f"Symbol install failed: {sym_res.get('error')}",
"step": "symbols", "detail": sym_res}
# KiCad registers the file under its OWN name; a bundle whose inner file
# is <Lib>.kicad_sym matches lib_name because the exporter names them
# together. When they disagree, the installed copy keeps the file name
# but the table entry uses lib_name (handle_install_library's contract).
if pretty:
fp_res = _install_footprint(kicad_info, {
"libraryPath": str(pretty), "libraryName": lib_name,
"modelsPath": str(models) if models else "",
"description": "Adom wiki Cloud Library",
})
if not fp_res.get("success"):
return {"success": False, "error": f"Footprint install failed: {fp_res.get('error')}",
"step": "footprints", "detail": fp_res, "symbolsInstalled": True}
report["footprints"] = len(list(pretty.glob("*.kicad_mod")))
fp_data = fp_res.get("data") or {}
report["modelsInstalled"] = fp_data.get("modelsInstalled", 0)
report["modelRefsRewritten"] = fp_data.get("modelRefsRewritten", 0)
try:
from handlers import win_focus
kicad_running = bool(win_focus._any_kicad_process_running())
except Exception:
kicad_running = False
report["kicadRunning"] = kicad_running
report["restartNeeded"] = kicad_running
parts = [f"Library '{lib_name}' installed: {report['symbols']} symbols"]
if report["footprints"]:
parts.append(f"{report['footprints']} footprints")
if report["modelsInstalled"]:
parts.append(f"{report['modelsInstalled']} 3D models")
summary = ", ".join(parts)
if kicad_running:
summary += ". KiCad is running and reads library tables at startup, so restart KiCad to see it."
result = {
"success": True,
"output": summary,
"data": report,
"_hint": (
"The bundle is installed and registered in both lib tables. "
+ ("KiCad was RUNNING during the install: it reads sym-/fp-lib-table at startup only, "
"so the user must restart KiCad before the library appears. Never close their KiCad for them."
if kicad_running else
"KiCad was not running; the library appears on next launch. With capture:true the "
"Symbol Editor is left open IN THE BACKGROUND on the new library (tree filtered to "
"its name), so the user sees their parts the moment they surface KiCad.")
),
}
# Proof frame (capture:true): only when KiCad was NOT already running.
# A running instance predates the lib-table registration, so an editor
# there cannot see the new library; the restart note is the message.
if args.get("capture"):
if kicad_running:
result["proofSkipped"] = "kicad_running: the open KiCad predates the install; restart to see the library"
else:
_capture_proof(kicad_info, args, lib_name, top_names[0] if top_names else "", result)
return result
finally:
shutil.rmtree(str(extract_dir), ignore_errors=True)