app
Adom Desktop - 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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857
"""Auto-detect KiCad installation on Windows and macOS.
Windows: globs C:/Program Files/KiCad/<version>/ for parallel installs.
macOS: scans /Applications/ for KiCad.app bundles (KiCad.app, KiCad-nightly.app, etc.)
and reads CFBundleShortVersionString from Info.plist for the version.
Both platforms return the same dict shape so handlers can stay platform-agnostic.
Returns a sorted list (newest first); callers default to the latest unless they
explicitly request an older version via the `kicadVersion` arg on commands.
"""
import os
import re
import subprocess
from proc import run as _safe_run
import sys
from pathlib import Path
IS_WINDOWS = sys.platform == "win32"
IS_MACOS = sys.platform == "darwin"
# ── Windows paths ────────────────────────────────────────────────────────────
# v1.6.0+: scan ALL the locations a Windows KiCad install might land. v1.5.x
# only checked C:/Program Files/KiCad — that missed at least one user whose
# install was elsewhere (winget per-user install, custom dir, etc.) on a
# fresh-Windows-machine deploy. We now also check %LOCALAPPDATA%/Programs/
# (winget --scope user target) and the Windows registry's KiCad install key.
WIN_KICAD_BASES = [
Path("C:/Program Files/KiCad"),
Path("C:/Program Files (x86)/KiCad"),
]
_local = os.environ.get("LOCALAPPDATA")
if _local:
WIN_KICAD_BASES.append(Path(_local) / "Programs" / "KiCad")
# Also look directly in LOCALAPPDATA (rare, but seen in some manual installs)
if _local:
WIN_KICAD_BASES.append(Path(_local) / "KiCad")
# Legacy single-base name kept for back-compat with any callers that import it
WIN_KICAD_BASE = WIN_KICAD_BASES[0]
# ── macOS paths ──────────────────────────────────────────────────────────────
# KiCad on macOS installs individual .app bundles into /Applications/KiCad/.
# The "main" bundle is KiCad.app; Schematic Editor.app, PCB Editor.app, etc.
# are siblings and share the same install version.
MAC_APPS_DIR = Path("/Applications")
MAC_KICAD_DIR = MAC_APPS_DIR / "KiCad"
def _version_tuple(version: str):
"""Parse '10.0' / '9.0' / '8.0.4' into a sortable tuple. Falls back to (0,) on parse error."""
parts = []
for piece in re.split(r"[.\-_]", str(version)):
m = re.match(r"^(\d+)", piece)
if m:
parts.append(int(m.group(1)))
else:
parts.append(0)
return tuple(parts) if parts else (0,)
# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║ WINDOWS ║
# ╚═══════════════════════════════════════════════════════════════════════════╝
def _win_build_info(version: str, base_override: Path | None = None) -> dict | None:
"""Build the kicad_info dict for a Windows install version dir, or None
if it doesn't look like a real install. v1.6.0+: accepts an explicit
base override so we can describe installs in non-standard locations
(LOCALAPPDATA/Programs, custom dirs from registry) the same way the
Program Files install gets described.
v1.7.4+ (KiCad v2 Phase 1): enriched with pythonVersion + pluginDir +
adomPlugin status + IPC API probe + libraryTablePaths grouping. The
raw fields stay (back-compat); the new fields are additive.
"""
base = (base_override or WIN_KICAD_BASE) / version if base_override is None or not base_override.name == version else base_override
# If caller passed a base that already names the version dir, base==base_override.
# Otherwise we joined version onto a parent. Either way `base` is the version dir.
bin_dir = base / "bin"
kicad_exe = bin_dir / "kicad.exe"
if not kicad_exe.exists():
return None
config_dir = _win_find_config_dir(version)
user_dir = _win_find_user_dir(version)
# v1.7.4+: also resolve the per-version plugin dir + Adom plugin status.
plugin_dir = _win_find_plugin_dir(version)
adom_plugin_state = _adom_plugin_state(plugin_dir)
# v1.7.5+ (KiCad v2 Phase 2): the cross-process reverse bridge lives
# at the KiCad embedded-Python USER_SITE, not the old pcbnew action-
# plugin dir. usercustomize.py + adom_bridge.py land here and load
# in every KiCad GUI process at startup.
user_site_dir = _win_find_user_site_dir(version)
bridge_plugin_state = _adom_bridge_state(user_site_dir)
# Probe the bundled python for version + IPC API availability. Cheap
# subprocess call — only runs once per detection pass.
python_exe = bin_dir / "python.exe"
py_version = _probe_python_version(python_exe)
ipc_state = _probe_ipc_api(python_exe)
# Adom library inventory — read the user's sym-lib-table + fp-lib-table
# for entries whose name starts with "Adom" and count symbols/footprints.
adom_lib_state = _adom_library_stats(
config_dir / "sym-lib-table" if config_dir else None,
config_dir / "fp-lib-table" if config_dir else None,
version,
)
return {
# ── Legacy fields (kept verbatim for back-compat with v1.7.3 callers) ──
"installed": True,
"platform": "windows",
"version": version,
"base_dir": str(base),
"bin_dir": str(bin_dir),
"kicad_exe": str(kicad_exe),
"pcbnew_exe": str(bin_dir / "pcbnew.exe"),
"eeschema_exe": str(bin_dir / "eeschema.exe"),
"kicad_cli_exe": str(bin_dir / "kicad-cli.exe"),
"python_exe": str(python_exe),
"config_dir": str(config_dir) if config_dir else None,
"user_dir": str(user_dir) if user_dir else None,
"sym_lib_table": str(config_dir / "sym-lib-table") if config_dir else None,
"fp_lib_table": str(config_dir / "fp-lib-table") if config_dir else None,
"user_symbols_dir": str(user_dir / "symbols") if user_dir else None,
"user_footprints_dir": str(user_dir / "footprints") if user_dir else None,
"user_3dmodels_dir": str(user_dir / "3dmodels") if user_dir else None,
# ── v1.7.4 enriched fields (KiCad v2 Phase 1) ──
"installPath": str(base), # alias for base_dir, camelCase per PLAN
"binaries": { # grouped per PLAN.md
"kicad": str(kicad_exe),
"eeschema": str(bin_dir / "eeschema.exe"),
"pcbnew": str(bin_dir / "pcbnew.exe"),
"kicadCli": str(bin_dir / "kicad-cli.exe"),
"python": str(python_exe),
},
"pythonVersion": py_version, # e.g. "3.11.5", or None if probe failed
"pluginDir": str(plugin_dir) if plugin_dir else None,
"adomPluginInstalled": adom_plugin_state["installed"],
"adomPluginVersion": adom_plugin_state["version"],
# v1.7.5+ KiCad v2 Phase 2: cross-process reverse bridge state
"userSiteDir": str(user_site_dir) if user_site_dir else None,
"adomBridgeInstalled": bridge_plugin_state["installed"],
"adomBridgeVersion": bridge_plugin_state["version"],
"adomBridgeLoaderInstalled": bridge_plugin_state["loaderInstalled"],
"ipcApiAvailable": ipc_state["available"], # False on KiCad ≤10; True if `kipy` importable
"ipcApiScope": ipc_state["scope"], # ["pcb"] etc.; [] when not available
"libraryTablePaths": { # grouped per PLAN.md
"symLibTable": str(config_dir / "sym-lib-table") if config_dir else None,
"fpLibTable": str(config_dir / "fp-lib-table") if config_dir else None,
},
"adomLibrary": adom_lib_state, # symbol/footprint inventory
}
def _win_find_config_dir(version: str):
"""Find KiCad config dir on Windows: %APPDATA%/kicad/<version>/"""
appdata = os.environ.get("APPDATA", "")
if not appdata:
return None
config = Path(appdata) / "kicad" / version
return config if config.exists() else None
def _win_find_user_dir(version: str):
"""Find KiCad user dir on Windows: %USERPROFILE%/Documents/KiCad/<version>/"""
userprofile = os.environ.get("USERPROFILE", "")
if not userprofile:
return None
user = Path(userprofile) / "Documents" / "KiCad" / version
return user if user.exists() else None
# v1.7.4+ (KiCad v2 Phase 1): enriched-discovery helpers.
# All return None / empty / False on failure — never raise. Detection has to be
# best-effort: the caller iterates over multiple installs and one buggy install
# shouldn't break the whole list.
def _win_find_user_site_dir(version: str) -> Path | None:
"""Find KiCad's embedded-Python USER_SITE on Windows.
KiCad's bundled `sitecustomize.py` overrides `site.USER_SITE` to
`%USERPROFILE%/Documents/KiCad/<version>/3rdparty/Python311/site-packages/`.
This is where Phase 2's reverse-bridge plugin (adom_bridge.py +
usercustomize.py) is deployed — usercustomize.py loads automatically
for every KiCad GUI process at Python init.
Returns the Path regardless of whether the dir exists. plugin_install
handles mkdir at deploy time.
"""
userprofile = os.environ.get("USERPROFILE", "")
if not userprofile:
return None
return Path(userprofile) / "Documents" / "KiCad" / version / "3rdparty" / "Python311" / "site-packages"
def _adom_bridge_state(user_site_dir: Path | None) -> dict:
"""Check whether the Phase 2 reverse-bridge plugin is installed at
the given USER_SITE.
Returns {"installed": bool, "version": str|None, "loaderInstalled": bool}.
"installed" = adom_bridge.py exists.
"version" = __version__ parsed from the first ~50 lines (regex).
"loaderInstalled" = usercustomize.py exists (the loader that imports
adom_bridge at site init). Both files must be
present for the bridge to actually start.
"""
if user_site_dir is None:
return {"installed": False, "version": None, "loaderInstalled": False}
bridge_path = user_site_dir / "adom_bridge.py"
loader_path = user_site_dir / "usercustomize.py"
if not bridge_path.exists():
return {
"installed": False,
"version": None,
"loaderInstalled": loader_path.exists(),
}
try:
with open(bridge_path, "r", encoding="utf-8", errors="replace") as f:
head = "".join(f.readline() for _ in range(50))
m = re.search(r"""__version__\s*=\s*["']([^"']+)["']""", head)
return {
"installed": True,
"version": m.group(1) if m else None,
"loaderInstalled": loader_path.exists(),
}
except OSError:
return {"installed": True, "version": None, "loaderInstalled": loader_path.exists()}
def _win_find_plugin_dir(version: str) -> Path | None:
"""Find the per-version KiCad scripting plugin dir:
`%APPDATA%/KiCad/<version>/scripting/plugins/`.
Returns the Path regardless of whether the dir exists yet — caller may want
to install the Adom plugin INTO it, so a non-existent dir is still useful
information (vs None which means we couldn't even resolve APPDATA).
"""
appdata = os.environ.get("APPDATA", "")
if not appdata:
return None
return Path(appdata) / "KiCad" / version / "scripting" / "plugins"
def _adom_plugin_state(plugin_dir: Path | None) -> dict:
"""Check whether the Adom bridge plugin (adom_bridge.py) is installed
in the given KiCad scripting plugins dir, and what version it reports.
Returns: {"installed": bool, "version": str|None}.
Version is parsed from a `__version__ = "x.y.z"` line at the top of the
plugin file. Falls back to None if the file exists but has no __version__.
"""
if plugin_dir is None:
return {"installed": False, "version": None}
plugin_path = plugin_dir / "adom_bridge.py"
if not plugin_path.exists():
return {"installed": False, "version": None}
# Read the first ~50 lines looking for `__version__`. Don't load/eval the
# plugin code; just regex-extract the literal.
try:
with open(plugin_path, "r", encoding="utf-8", errors="replace") as f:
head = "".join(f.readline() for _ in range(50))
m = re.search(r"""__version__\s*=\s*["']([^"']+)["']""", head)
return {"installed": True, "version": m.group(1) if m else None}
except OSError:
return {"installed": True, "version": None}
def _probe_python_version(python_exe: Path) -> str | None:
"""Run `<kicad>/bin/python.exe --version` and parse the version string.
Returns "3.11.5" etc., or None if the probe fails (python.exe missing,
spawn error, timeout). Subprocess call costs ~30-80 ms.
"""
if not python_exe.exists():
return None
try:
result = _safe_run(
[str(python_exe), "--version"],
capture_output=True, text=True, timeout=5,
)
# Python writes its version to stdout (>=3.4) or stderr (<3.4).
out = (result.stdout or "") + (result.stderr or "")
m = re.search(r"Python\s+(\d+\.\d+(?:\.\d+)?)", out)
return m.group(1) if m else None
except (subprocess.TimeoutExpired, OSError):
return None
def _adom_library_stats(
sym_lib_table: Path | None,
fp_lib_table: Path | None,
version: str,
) -> dict:
"""Read the user's sym-lib-table + fp-lib-table for entries whose name
starts with "Adom" (case-insensitive — handles "Adom", "adom",
"Adom_Symbols", "AdomCustom", etc.) and count the symbols/footprints
inside each matching library.
Returns:
{
"symbolsInstalled": bool, # any Adom sym-lib entry exists
"footprintsInstalled": bool, # any Adom fp-lib entry exists
"symbolCount": int, # total count across all Adom symbol libs
"footprintCount": int, # total count across all Adom .pretty dirs
"symbolLibraries": [{name, uri, count}], # per-entry detail
"footprintLibraries": [{name, uri, count}],
}
Best-effort. Returns the empty-shape on any IO / parse error so callers
can rely on the keys always being present.
"""
out = {
"symbolsInstalled": False,
"footprintsInstalled": False,
"symbolCount": 0,
"footprintCount": 0,
"symbolLibraries": [],
"footprintLibraries": [],
}
# ── Symbols ──
if sym_lib_table is not None and sym_lib_table.exists():
for entry in _parse_lib_table_entries(sym_lib_table):
if not entry["name"].lower().startswith("adom"):
continue
resolved_uri = _resolve_kicad_var(entry["uri"], version)
count = _count_symbols_in_kicad_sym(resolved_uri)
out["symbolLibraries"].append({
"name": entry["name"],
"uri": str(resolved_uri),
"count": count,
})
out["symbolCount"] += count
out["symbolsInstalled"] = len(out["symbolLibraries"]) > 0
# ── Footprints ──
if fp_lib_table is not None and fp_lib_table.exists():
for entry in _parse_lib_table_entries(fp_lib_table):
if not entry["name"].lower().startswith("adom"):
continue
resolved_uri = _resolve_kicad_var(entry["uri"], version)
count = _count_footprints_in_pretty_dir(resolved_uri)
out["footprintLibraries"].append({
"name": entry["name"],
"uri": str(resolved_uri),
"count": count,
})
out["footprintCount"] += count
out["footprintsInstalled"] = len(out["footprintLibraries"]) > 0
return out
def _parse_lib_table_entries(table_path: Path) -> list[dict]:
"""Parse a KiCad sym-lib-table or fp-lib-table file.
Both formats are s-expressions like:
(sym_lib_table
(version 7)
(lib (name "Adom")(type "KiCad")(uri "...")(options "")(descr "..."))
)
Returns one dict per (lib ...) entry: {name, type, uri, options, descr}.
Best-effort regex parse — these files are small (~dozens of entries max)
and the format is rigid enough that a full s-expr parser is overkill.
"""
try:
with open(table_path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError:
return []
entries = []
# Each (lib ...) entry is on its own conceptual unit; match against the
# five quoted-value fields. The whitespace between fields varies but
# they always appear in this order.
pattern = re.compile(
r"""\(lib\s+
\(name\s+"([^"]*)"\)\s*
\(type\s+"([^"]*)"\)\s*
\(uri\s+"([^"]*)"\)\s*
\(options\s+"([^"]*)"\)\s*
\(descr\s+"([^"]*)"\)\s*
\)""",
re.VERBOSE | re.DOTALL,
)
for m in pattern.finditer(content):
entries.append({
"name": m.group(1),
"type": m.group(2),
"uri": m.group(3),
"options": m.group(4),
"descr": m.group(5),
})
return entries
def _resolve_kicad_var(uri: str, version: str) -> Path:
"""Resolve KiCad path variables in a library URI. KiCad uses
`${VAR_NAME}` syntax for things like `${KICAD_USER_TEMPLATE_DIR}`,
`${KIPRJMOD}`, `${KICAD9_FOOTPRINT_DIR}`, etc.
For the Adom-library case, the URIs in the user's tables we've seen
are absolute paths (no vars). But we handle the common ones for
robustness.
"""
userprofile = os.environ.get("USERPROFILE", "")
documents = Path(userprofile) / "Documents" if userprofile else None
# Common KiCad path vars + their typical resolved values
substitutions = {
"KICAD_USER_TEMPLATE_DIR": str(documents / "KiCad" / version) if documents else "",
"KICAD_3RD_PARTY": str(Path(os.environ.get("APPDATA", "")) / "kicad" / version / "3rdparty") if os.environ.get("APPDATA") else "",
f"KICAD{version.split('.')[0]}_SYMBOL_DIR": str(Path("C:/Program Files/KiCad") / version / "share" / "kicad" / "symbols"),
f"KICAD{version.split('.')[0]}_FOOTPRINT_DIR": str(Path("C:/Program Files/KiCad") / version / "share" / "kicad" / "footprints"),
f"KICAD{version.split('.')[0]}_3DMODEL_DIR": str(Path("C:/Program Files/KiCad") / version / "share" / "kicad" / "3dmodels"),
}
resolved = uri
for var, val in substitutions.items():
resolved = resolved.replace(f"${{{var}}}", val)
# KIPRJMOD = the project dir of the currently-open project; can't be
# resolved at detection time. Leave it as-is; the count probe will fail
# gracefully if it ever shows up in an Adom entry (unlikely — Adom libs
# aren't project-scoped).
return Path(resolved)
def _count_symbols_in_kicad_sym(sym_path: Path) -> int:
"""Count `(symbol "..."` entries in a .kicad_sym file. KiCad's symbol
file format is s-expression with one or more `(symbol "Name" ...)` at
the top level of `(kicad_symbol_lib ...)`.
Returns 0 on any error (file missing, parse fail, etc.).
"""
if not sym_path.exists():
return 0
try:
with open(sym_path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError:
return 0
# Top-level `(symbol "Name"`. The kicad_sym format also has nested
# `(symbol "Name_0_0" ...)` for unit/sub-symbol drawings — those start
# with the parent symbol's name + underscore, so we filter them by
# requiring the count to be at line-start indentation level (tab or 2
# spaces). The simplest reliable approach: count `^\s*(symbol "..."` at
# the beginning of a line. Top-level symbols typically have 1 leading
# tab/2 spaces in pretty-printed output; nested ones have more.
# Regex matches `(symbol "X"` where X is not just digits/underscore-only:
matches = re.findall(r'^\s+\(symbol\s+"([^"]+)"', content, re.MULTILINE)
# Filter out nested unit symbols (named like "ParentName_0_0" or
# "ParentName_1_1"). These never appear as top-level entries in a
# well-formed .kicad_sym, but Symbol Editor exports them with these
# exact suffixes. Match: anything that ends with "_<digit>_<digit>".
top_level = [n for n in matches if not re.search(r"_\d+_\d+$", n)]
return len(top_level)
def _count_footprints_in_pretty_dir(pretty_dir: Path) -> int:
"""Count .kicad_mod files in a KiCad footprint library directory.
KiCad footprint libraries are directories (suffixed `.pretty/`) with
one `.kicad_mod` file per footprint.
"""
if not pretty_dir.exists() or not pretty_dir.is_dir():
return 0
try:
return sum(1 for entry in pretty_dir.iterdir() if entry.suffix == ".kicad_mod")
except OSError:
return 0
def _probe_ipc_api(python_exe: Path) -> dict:
"""Probe whether KiCad's IPC API (`kipy` module) is importable from the
bundled python. KiCad 10 returns False everywhere; KiCad 11 (when it ships)
is expected to bundle it. The probe is a one-shot subprocess that imports
+ prints a small structured result so we don't pollute stderr.
Returns: {"available": bool, "scope": list[str]}.
"""
if not python_exe.exists():
return {"available": False, "scope": []}
probe = (
"import json,sys\n"
"try:\n"
" import kipy\n"
" scope = []\n"
" for name in ('pcb', 'sch', 'sym', 'fp'):\n"
" if hasattr(kipy, name): scope.append(name)\n"
" json.dump({'available': True, 'scope': scope}, sys.stdout)\n"
"except ImportError:\n"
" json.dump({'available': False, 'scope': []}, sys.stdout)\n"
)
try:
result = _safe_run(
[str(python_exe), "-c", probe],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0 or not result.stdout:
return {"available": False, "scope": []}
import json
data = json.loads(result.stdout)
return {
"available": bool(data.get("available")),
"scope": list(data.get("scope") or []),
}
except (subprocess.TimeoutExpired, OSError, ValueError):
return {"available": False, "scope": []}
def _win_registry_kicad_paths() -> list[Path]:
"""Read KiCad install paths from the Windows registry. Looks under
HKLM\\SOFTWARE\\KiCad\\<version>\\InstallationPath (and the WOW6432Node
mirror for 32-bit installs on 64-bit Windows). Returns version-dir
Paths so the standard _win_build_info pipeline can take over."""
if not IS_WINDOWS:
return []
try:
import winreg # stdlib on Windows; ImportError on others
except ImportError:
return []
out: list[Path] = []
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
for subkey in (r"SOFTWARE\KiCad", r"SOFTWARE\WOW6432Node\KiCad"):
try:
with winreg.OpenKey(hive, subkey) as parent:
i = 0
while True:
try:
version = winreg.EnumKey(parent, i)
except OSError:
break
i += 1
try:
with winreg.OpenKey(parent, version) as ver_key:
# KiCad's installer writes "InstallationPath" or
# the unnamed default value. Try both.
for value_name in ("InstallationPath", "Install_Dir", ""):
try:
path_str, _ = winreg.QueryValueEx(ver_key, value_name)
if path_str:
out.append(Path(str(path_str).strip().strip('"')))
break
except OSError:
continue
except OSError:
continue
except OSError:
continue
return out
def _win_detect_all() -> list[dict]:
found: list[dict] = []
seen: set[str] = set()
def consider(version_dir: Path):
"""version_dir is something like C:/Program Files/KiCad/9.0/."""
try:
resolved = str(version_dir.resolve())
except OSError:
resolved = str(version_dir)
if resolved in seen:
return
seen.add(resolved)
# Pull the version name from the dir if it looks like X.Y.Z, otherwise
# try to read it from kicad-cli; bail if neither works.
name = version_dir.name
if re.match(r"^\d+(\.\d+)*$", name):
info = _win_build_info(name, base_override=version_dir)
else:
# Non-standard layout (registry-pointed install with no version
# in the path). Fall back: probe bin/kicad-cli.exe --version.
cli = version_dir / "bin" / "kicad-cli.exe"
if not cli.exists():
return
try:
result = _safe_run([str(cli), "--version"], capture_output=True, text=True, timeout=5)
m = re.search(r"(\d+\.\d+(?:\.\d+)?)", result.stdout + result.stderr)
if not m:
return
info = _win_build_info(m.group(1), base_override=version_dir.parent / m.group(1) if (version_dir.parent / m.group(1)).exists() else version_dir)
except (subprocess.TimeoutExpired, OSError):
return
if info:
found.append(info)
# 1. Scan all known parent dirs for version subdirs.
for parent in WIN_KICAD_BASES:
if not parent.exists():
continue
try:
for entry in parent.iterdir():
if not entry.is_dir():
continue
if not re.match(r"^\d+(\.\d+)*$", entry.name):
continue
consider(entry)
except (OSError, PermissionError):
continue
# 2. Scan registry for installs in non-standard locations.
for reg_path in _win_registry_kicad_paths():
if not reg_path.exists():
continue
# Registry value can point at the version dir directly (newer installs)
# or at the parent (older installs). Handle both.
if (reg_path / "bin" / "kicad.exe").exists():
consider(reg_path)
else:
try:
for entry in reg_path.iterdir():
if entry.is_dir() and re.match(r"^\d+(\.\d+)*$", entry.name):
consider(entry)
except (OSError, PermissionError):
continue
found.sort(key=lambda info: _version_tuple(info["version"]), reverse=True)
return found
# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║ macOS ║
# ╚═══════════════════════════════════════════════════════════════════════════╝
def _mac_app_version(app_path: Path) -> str | None:
"""Read CFBundleShortVersionString from an app's Info.plist.
Returns the version string ('9.0.0', '8.0.4') or None on failure."""
plist = app_path / "Contents" / "Info.plist"
if not plist.exists():
return None
try:
result = _safe_run(
["/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", str(plist)],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
return result.stdout.strip() or None
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return None
def _mac_short_version(full_version: str) -> str:
"""'9.0.0' -> '9.0' (KiCad's config/user dirs use major.minor only)."""
parts = full_version.split(".")
if len(parts) >= 2:
return f"{parts[0]}.{parts[1]}"
return full_version
def _mac_find_config_dir(short_version: str):
"""KiCad config dir on macOS: ~/Library/Preferences/kicad/<X.Y>/"""
home = Path.home()
config = home / "Library" / "Preferences" / "kicad" / short_version
return config if config.exists() else None
def _mac_find_user_dir(short_version: str):
"""KiCad user dir on macOS: ~/Documents/KiCad/<X.Y>/"""
home = Path.home()
user = home / "Documents" / "KiCad" / short_version
# We can't always stat ~/Documents under the macOS Privacy layer (TCC),
# but the path is conventional. Return it unconditionally and let callers
# handle missing dirs gracefully.
return user
def _mac_build_info(app_path: Path) -> dict | None:
"""Build the kicad_info dict for a macOS KiCad.app bundle, or None
if it doesn't look like a real install."""
bin_dir = app_path / "Contents" / "MacOS"
kicad_exe = bin_dir / "kicad"
if not kicad_exe.exists():
return None
full_version = _mac_app_version(app_path)
if not full_version:
return None
short_version = _mac_short_version(full_version)
# Sibling editor apps next to the main bundle (PCB Editor.app, Schematic Editor.app, etc.).
# KiCad ships these alongside KiCad.app under /Applications/KiCad/. The actual
# binaries inside each bundle are Contents/MacOS/<name> — directly Popen-able
# by handlers that were written for Windows .exe paths.
parent = app_path.parent
pcbnew_bin = parent / "PCB Editor.app" / "Contents" / "MacOS" / "pcbnew"
eeschema_bin = parent / "Schematic Editor.app" / "Contents" / "MacOS" / "eeschema"
config_dir = _mac_find_config_dir(short_version)
user_dir = _mac_find_user_dir(short_version)
# kicad-cli ships in the main bundle's MacOS dir on macOS
kicad_cli = bin_dir / "kicad-cli"
return {
"installed": True,
"platform": "macos",
# Use major.minor for the version key so it matches the Windows convention
# ('9.0' not '9.0.0') and lines up with config/user dir names.
"version": short_version,
"full_version": full_version,
"base_dir": str(app_path),
"bin_dir": str(bin_dir),
# Direct path to the kicad project-manager binary
"kicad_exe": str(kicad_exe),
# Path to the editor binaries inside their bundles. Handlers can Popen
# these directly (same shape as Windows .exe paths). Falls back to the
# main kicad binary if the editor bundle is missing.
"pcbnew_exe": str(pcbnew_bin) if pcbnew_bin.exists() else str(kicad_exe),
"eeschema_exe": str(eeschema_bin) if eeschema_bin.exists() else str(kicad_exe),
"kicad_cli_exe": str(kicad_cli) if kicad_cli.exists() else None,
# macOS bundles ship their own embedded Python under Frameworks/Python.framework;
# plugin code that needs to invoke it should call `kicad-cli` instead.
"python_exe": None,
"config_dir": str(config_dir) if config_dir else None,
"user_dir": str(user_dir) if user_dir else None,
"sym_lib_table": str(config_dir / "sym-lib-table") if config_dir else None,
"fp_lib_table": str(config_dir / "fp-lib-table") if config_dir else None,
"user_symbols_dir": str(user_dir / "symbols") if user_dir else None,
"user_footprints_dir": str(user_dir / "footprints") if user_dir else None,
"user_3dmodels_dir": str(user_dir / "3dmodels") if user_dir else None,
}
def _mac_detect_all() -> list[dict]:
"""Discover every KiCad install on macOS.
Looks first inside /Applications/KiCad/ (where the official installer puts
the bundle), then falls back to scanning /Applications/ directly for
KiCad*.app — covers users who drag the bundle straight into Applications.
"""
found: list[dict] = []
seen: set[str] = set()
candidates: list[Path] = []
if MAC_KICAD_DIR.exists():
for entry in MAC_KICAD_DIR.iterdir():
if entry.suffix == ".app" and entry.name.lower().startswith("kicad"):
candidates.append(entry)
if MAC_APPS_DIR.exists():
try:
for entry in MAC_APPS_DIR.iterdir():
if entry.suffix == ".app" and entry.name.lower().startswith("kicad"):
candidates.append(entry)
except PermissionError:
pass
for app in candidates:
key = str(app.resolve())
if key in seen:
continue
seen.add(key)
info = _mac_build_info(app)
if info:
found.append(info)
found.sort(key=lambda info: _version_tuple(info["version"]), reverse=True)
return found
# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║ PUBLIC API (cross-platform) ║
# ╚═══════════════════════════════════════════════════════════════════════════╝
def detect_all_kicad_versions() -> list[dict]:
"""Discover every installed KiCad version on the current platform.
Returns a list sorted newest first. Empty list if KiCad isn't installed."""
if IS_WINDOWS:
return _win_detect_all()
if IS_MACOS:
return _mac_detect_all()
return []
def detect_kicad(version: str | None = None) -> dict:
"""Detect a specific KiCad install (by version) or the latest installed.
- `version=None`: return the newest installed version
- `version="9.0"`: return that exact version, or {installed:false} with a hint
listing all available versions if not found.
Backward-compatible with the original (no-arg) behavior — existing call sites
that do `detect_kicad()` still get the latest install.
"""
all_versions = detect_all_kicad_versions()
if not all_versions:
if IS_WINDOWS:
scanned = ", ".join(str(p) for p in WIN_KICAD_BASES)
err = (
f"KiCad not found in any of: {scanned}, or in the Windows registry "
f"(HKLM/HKCU\\SOFTWARE\\KiCad). Install from https://www.kicad.org/download/, "
f"or run `adom-desktop desktop_install_kicad` for an unattended winget install (v1.6.0+)."
)
hint = (
"Trigger an unattended install with `adom-desktop desktop_install_kicad '{}'`. "
"It runs `winget install --id KiCad.KiCad --silent --accept-package-agreements "
"--accept-source-agreements`. Requires winget (Windows 10 1809+ / Windows 11). "
"Takes ~2-5 min depending on network. Re-run kicad_list_versions after to verify."
)
elif IS_MACOS:
err = "KiCad not found in /Applications/. Install from https://www.kicad.org/download/macos/."
hint = "On macOS the install is manual — download the KiCad installer DMG from kicad.org."
else:
err = f"KiCad detection is not implemented for platform {sys.platform!r}."
hint = None
result = {
"installed": False,
"platform": _current_platform(),
"version": None,
"available_versions": [],
"error": err,
"errorCode": "kicad_not_installed",
}
if hint:
result["_hint"] = hint
return result
if version is None:
return all_versions[0]
# Try exact match, then prefix match (e.g. "9" matches "9.0")
for info in all_versions:
if info["version"] == version:
return info
for info in all_versions:
if info["version"].startswith(f"{version}."):
return info
available = [v["version"] for v in all_versions]
return {
"installed": False,
"platform": _current_platform(),
"version": version,
"available_versions": available,
"error": f"KiCad {version} is not installed. Available versions: {', '.join(available)}.",
"_hint": f"Pass kicadVersion as one of: {', '.join(available)}, or omit it to use the newest ({available[0]}).",
}
def _current_platform() -> str:
if IS_WINDOWS:
return "windows"
if IS_MACOS:
return "macos"
return sys.platform
if __name__ == "__main__":
import json
all_v = detect_all_kicad_versions()
print(json.dumps({
"platform": _current_platform(),
"all_versions": all_v,
"latest": all_v[0] if all_v else None,
}, indent=2))