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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
"""KiCad version check + upgrade via the OFFICIAL downloads.kicad.org installer.
Why not winget: winget's KiCad package lags the real KiCad release (often by a
minor version or more), and it can't pin a specific build. The official
`downloads.kicad.org` NSIS installer is the canonical, signed artifact — so the
bridge fetches THAT and installs it silently (`/S`).
- `check_for_updates` — read-only: current installed version vs latest STABLE
(resolved from KiCad's GitLab tags, baked fallback on a network blip), plus the
"your KiCad predates the IPC/kipy API — upgrade" nudge.
- `upgrade` — downloads the official installer and runs it silently. Needs
elevation for Program Files, so the user may see (and approve) a UAC prompt.
"""
import json
import os
import re
import ssl
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
import kicad_detect
import proc
# A CA bundle shipped WITH the bridge (certifi's cacert.pem). This is the durable
# trust source: Bridge's provisioned portable Python (Runtime contract v1.9.63+) has
# no usable system CA store, so create_default_context() verifies against nothing
# and the Windows cert enumeration proved unreliable on it — every https fetch
# died with CERTIFICATE_VERIFY_FAILED. Bundling the roots guarantees working TLS
# regardless of the interpreter. Verification stays ON (no MITM window).
_BUNDLED_CACERT = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "certs", "cacert.pem"
)
def _ssl_context() -> ssl.SSLContext:
"""A verifying SSL context that works even on Bridge's certless portable Python."""
ctx = ssl.create_default_context()
# Primary: the bundled certifi roots (works on any interpreter).
try:
if os.path.exists(_BUNDLED_CACERT):
ctx.load_verify_locations(cafile=_BUNDLED_CACERT)
except Exception:
pass
# Secondary (belt-and-suspenders): the Windows system ROOT/CA store, if usable.
if sys.platform == "win32":
try:
pem = "".join(
ssl.DER_cert_to_PEM_cert(cert_der)
for store in ("ROOT", "CA")
for cert_der, enc, _trust in ssl.enum_certificates(store)
if enc == "x509_asn"
)
if pem:
ctx.load_verify_locations(cadata=pem)
except Exception:
pass
return ctx
# Baked fallback if the GitLab tag query fails (network blip). Bump on new stable.
_FALLBACK_LATEST = "10.0.4"
_TAGS_URL = (
"https://gitlab.com/api/v4/projects/kicad%2Fcode%2Fkicad/repository/tags?per_page=30"
)
_DL_URL = "https://downloads.kicad.org/kicad/windows/explore/stable/download/kicad-{v}-x86_64.exe"
_UA = "kicad-bridge"
def _is_stable(tag: str) -> bool:
"""True for a real stable tag like 10.0.4 / 9.0.9.1. Drops `-rcN` prereleases
and KiCad's `X.99.*` nightly/development tags (minor component == 99)."""
if not re.fullmatch(r"\d+(?:\.\d+)+", tag):
return False
parts = tag.split(".")
return not (len(parts) >= 2 and parts[1] == "99")
def latest_stable_version() -> tuple[str, str]:
"""Return (version, source) where source is 'gitlab' or 'fallback'."""
try:
req = urllib.request.Request(_TAGS_URL, headers={"User-Agent": _UA})
with urllib.request.urlopen(req, timeout=10, context=_ssl_context()) as r:
tags = [t["name"] for t in json.loads(r.read().decode())]
stables = sorted(
(t for t in tags if _is_stable(t)),
key=kicad_detect._version_tuple,
reverse=True,
)
if stables:
return stables[0], "gitlab"
except Exception:
pass
return _FALLBACK_LATEST, "fallback"
def download_url(version: str) -> str:
return _DL_URL.format(v=version)
def handle_check_for_updates(kicad_info: dict, args: dict) -> dict:
"""Read-only: is a newer KiCad available, and does the current one have the IPC API?"""
current = kicad_info.get("version") if kicad_info else None
latest, src = latest_stable_version()
cur_t = kicad_detect._version_tuple(current) if current else (0,)
lat_t = kicad_detect._version_tuple(latest)
upgrade_available = current is None or lat_t > cur_t
ipc = bool(kicad_info.get("ipcApiAvailable")) if kicad_info else False
if current is None:
rec = (
f"KiCad isn't installed. Run kicad_upgrade to install {latest} "
f"from the official downloads.kicad.org installer."
)
elif upgrade_available and not ipc:
rec = (
f"Your KiCad {current} predates the modern IPC / kipy automation API — "
f"upgrade to {latest} to unlock it (plus the latest fixes). Run kicad_upgrade."
)
elif upgrade_available:
rec = f"KiCad {latest} is available (you're on {current}). Run kicad_upgrade to update."
else:
rec = f"KiCad {current} is up to date (latest stable is {latest})."
return {
"success": True,
"currentVersion": current,
"latestVersion": latest,
"latestSource": src,
"upgradeAvailable": upgrade_available,
"ipcApiAvailable": ipc,
"downloadUrl": download_url(latest),
"recommendation": rec,
"_hint": (
"Run kicad_upgrade '{}' to install the latest (downloads the OFFICIAL "
"downloads.kicad.org installer + silent /S install — NOT winget, which lags). "
'Pass {"version":"X.Y.Z"} to pin a specific build.'
),
}
def _elevation_facts() -> dict:
"""Hard, verifiable elevation/UAC facts for THIS process — no guessing.
Settles "is the bridge actually elevated, and why didn't a UAC prompt appear":
- tokenElevationType: 1=Default (no split token — UAC disabled OR built-in
Administrator), 2=Full (elevated via UAC), 3=Limited (normal user token).
- tokenElevated: the token's TokenElevation flag (the ground truth).
- uacEnableLUA: HKLM ...\\Policies\\System EnableLUA — 0 means UAC is OFF
machine-wide, so admin accounts get a full token with NO prompt ever.
"""
facts: dict = {"platform": sys.platform}
if sys.platform != "win32":
return facts
import ctypes
import ctypes.wintypes as wt
import getpass
facts["user"] = getpass.getuser()
try:
facts["isUserAnAdmin"] = bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception as e:
facts["isUserAnAdmin"] = f"error: {e}"
try:
advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
TOKEN_QUERY = 0x0008
token = wt.HANDLE()
if not advapi32.OpenProcessToken(
kernel32.GetCurrentProcess(), TOKEN_QUERY, ctypes.byref(token)
):
raise OSError(f"OpenProcessToken failed: {ctypes.get_last_error()}")
for name, cls in (("tokenElevationType", 18), ("tokenElevated", 20)):
val = wt.DWORD()
ret_len = wt.DWORD()
ok = advapi32.GetTokenInformation(
token, cls, ctypes.byref(val), ctypes.sizeof(val), ctypes.byref(ret_len)
)
facts[name] = int(val.value) if ok else f"error: {ctypes.get_last_error()}"
kernel32.CloseHandle(token)
facts["tokenElevationTypeMeaning"] = {
1: "Default — no split token (UAC disabled machine-wide, or built-in Administrator): full admin, NO UAC prompt ever",
2: "Full — elevated via an approved UAC prompt (or elevated parent)",
3: "Limited — standard filtered token, NOT admin",
}.get(facts.get("tokenElevationType"), "unknown")
except Exception as e:
facts["tokenError"] = str(e)
try:
import winreg
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System",
) as k:
facts["uacEnableLUA"] = winreg.QueryValueEx(k, "EnableLUA")[0]
# 0 = elevate silently WITHOUT a prompt (common on VMs) — explains
# "I never clicked UAC" even when a process ends up elevated.
try:
facts["uacConsentPromptBehaviorAdmin"] = winreg.QueryValueEx(
k, "ConsentPromptBehaviorAdmin"
)[0]
except OSError:
facts["uacConsentPromptBehaviorAdmin"] = "not set (default 5: prompt)"
except Exception as e:
facts["uacEnableLUA"] = f"error: {e}"
return facts
def _remote_installer_size(url: str) -> int | None:
"""The server's Content-Length for the installer (follows redirects). Lets us
verify a downloaded/cached file isn't silently TRUNCATED — a dropped
connection reads as EOF, logs 'download complete', passes the MZ+50MB sanity
checks, and then the NSIS integrity check fails instantly at install time."""
try:
req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": _UA})
with urllib.request.urlopen(req, timeout=30, context=_ssl_context()) as r:
cl = r.headers.get("Content-Length")
return int(cl) if cl else None
except Exception:
return None
def handle_upgrade(kicad_info: dict, args: dict) -> dict:
"""Download the official KiCad installer and run it silently (/S)."""
if sys.platform != "win32":
return {
"success": False,
"error": "kicad_upgrade is Windows-only (it downloads the .exe installer).",
"errorCode": "unsupported_platform",
}
# diagnoseOnly: return the elevation/UAC ground truth + cached-installer state
# WITHOUT downloading or installing anything. Used to settle "is this process
# actually elevated / why did no UAC prompt appear / why rc=666660".
if args.get("diagnoseOnly"):
facts = _elevation_facts()
t = args.get("version") or latest_stable_version()[0]
cached = Path(tempfile.gettempdir()) / f"kicad-{t}-x86_64.exe"
facts["cachedInstaller"] = str(cached) if cached.exists() else None
facts["cachedInstallerBytes"] = cached.stat().st_size if cached.exists() else 0
expected = _remote_installer_size(download_url(t))
facts["serverInstallerBytes"] = expected
facts["cachedInstallerTruncated"] = (
bool(cached.exists() and expected and cached.stat().st_size != expected)
)
return {
"success": True,
"diagnoseOnly": True,
**facts,
"_hint": (
"Read tokenElevationTypeMeaning + uacEnableLUA together: EnableLUA=0 means UAC is "
"disabled machine-wide, so an admin account always runs with a full token and NO UAC "
"prompt ever appears — IsUserAnAdmin=True without anyone clicking anything. "
"tokenElevated=1 with an rc=666660 install failure means elevation is NOT the cause "
"(look at SmartScreen/AV or the installer's silent flag instead)."
),
}
target = args.get("version") or latest_stable_version()[0]
current = kicad_info.get("version") if kicad_info else None
if (
current
and kicad_detect._version_tuple(current) >= kicad_detect._version_tuple(target)
and not args.get("force")
):
return {
"success": True,
"alreadyCurrent": True,
"currentVersion": current,
"targetVersion": target,
"_hint": f'KiCad {current} >= {target}; nothing to do. Pass {{"force":true}} to reinstall.',
}
url = download_url(target)
dest = Path(tempfile.gettempdir()) / f"kicad-{target}-x86_64.exe"
# Reuse an already-downloaded installer. The KiCad installer is ~1 GB, and a
# prior attempt that succeeded at DOWNLOAD but failed at INSTALL (e.g. UAC not
# approved / Bridge not elevated) leaves a valid .exe in Temp. Skipping the
# re-download makes a post-elevation retry near-instant.
def _valid_installer(p: Path) -> bool:
try:
if not p.exists() or p.stat().st_size < 50 * 1024 * 1024:
return False
with open(p, "rb") as fh:
return fh.read(2) == b"MZ"
except OSError:
return False
# The server's true size — the anti-truncation ground truth. A dropped
# connection reads as EOF ("download complete"), passes MZ+50MB checks, then
# NSIS's integrity check fails instantly at install time (the rc=666660
# failure mode). Compare every cached/downloaded file against this.
expected_bytes = _remote_installer_size(url)
print(f"[KiCad Bridge] kicad_upgrade: server reports installer size "
f"{expected_bytes if expected_bytes else 'unknown'} bytes", flush=True)
total = dest.stat().st_size if dest.exists() else 0
cache_ok = (
_valid_installer(dest)
and not args.get("reDownload")
and (expected_bytes is None or total == expected_bytes)
)
if _valid_installer(dest) and expected_bytes and total != expected_bytes:
print(f"[KiCad Bridge] kicad_upgrade: cached installer is TRUNCATED "
f"({total} of {expected_bytes} bytes) — discarding and re-downloading", flush=True)
if cache_ok:
print(f"[KiCad Bridge] kicad_upgrade: reusing cached installer {dest} "
f"({total // (1024*1024)} MB, size verified) — pass reDownload:true to force a fresh fetch", flush=True)
else:
# Download — urllib follows the downloads.kicad.org -> s3.cern.ch redirect.
# Progress is printed so `bridge_log_read` reveals where a slow/stuck
# install is. timeout is the per-read blocking timeout (300s for slow VMs).
print(f"[KiCad Bridge] kicad_upgrade: downloading {url}", flush=True)
try:
req = urllib.request.Request(url, headers={"User-Agent": _UA})
total = 0
next_log = 25 * 1024 * 1024
with urllib.request.urlopen(req, timeout=300, context=_ssl_context()) as r, open(dest, "wb") as f:
if expected_bytes is None:
cl = r.headers.get("Content-Length")
expected_bytes = int(cl) if cl else None
while True:
chunk = r.read(1 << 20)
if not chunk:
break
f.write(chunk)
total += len(chunk)
if total >= next_log:
print(f"[KiCad Bridge] kicad_upgrade: downloaded {total // (1024*1024)} MB...", flush=True)
next_log += 25 * 1024 * 1024
print(f"[KiCad Bridge] kicad_upgrade: download finished ({total} of "
f"{expected_bytes if expected_bytes else 'unknown'} bytes) -> {dest}", flush=True)
except (urllib.error.URLError, OSError) as e:
print(f"[KiCad Bridge] kicad_upgrade: DOWNLOAD FAILED: {e}", flush=True)
return {
"success": False,
"error": f"download failed: {e}",
"errorCode": "download_failed",
"url": url,
}
if expected_bytes and total != expected_bytes:
print(f"[KiCad Bridge] kicad_upgrade: TRUNCATED download ({total} of {expected_bytes}) — deleting", flush=True)
try:
dest.unlink()
except OSError:
pass
return {
"success": False,
"error": f"download truncated: got {total} of {expected_bytes} bytes "
f"(connection dropped mid-transfer). The file was deleted.",
"errorCode": "download_truncated",
"url": url,
"_hint": "Re-run kicad_upgrade — it will re-download. If this repeats, the mirror "
"or the VM's link is flaky; try again later.",
}
# Sanity: a real KiCad installer is a >50 MB Windows PE (MZ header).
if total < 50 * 1024 * 1024:
return {
"success": False,
"error": f"download too small ({total} bytes) — not a full installer",
"errorCode": "download_incomplete",
"url": url,
"path": str(dest),
}
with open(dest, "rb") as f:
if f.read(2) != b"MZ":
return {
"success": False,
"error": "downloaded file is not a Windows executable",
"errorCode": "download_corrupt",
"path": str(dest),
}
# Silent install (NSIS /S). Installing into Program Files needs elevation, so
# the user may get a UAC prompt to approve. Block until the installer exits.
# If Bridge isn't elevated, this is where it STALLS (a UAC prompt no one approves)
# until the timeout — the log line below is the last thing you'll see.
# Is THIS process (hence Bridge, its parent) elevated? Definitive answer to
# "did the elevated relaunch actually take" — the KiCad installer needs admin,
# so if we're not elevated it exits fast with a failure code (no UAC prompt to
# click) and nothing installs.
def _is_admin():
if sys.platform != "win32":
return None
try:
import ctypes
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return None
elevated = _is_admin()
# KiCad 10's installer is built on NsisMultiUser: in SILENT mode it REQUIRES an
# explicit install scope — bare "/S" is "invalid command-line parameters" and
# exits instantly with rc=666660 (NsisMultiUser's documented error level; its
# sibling 666661 = elevation restricted). THE root cause of the fast-fail.
# /allusers /S → per-machine (Program Files; needs elevation)
# /currentuser /S → per-user (%LOCALAPPDATA%\Programs\KiCad; NO admin, NO UAC)
# scope arg: "machine" | "user" | default auto (machine when elevated, else the
# zero-UAC per-user install — kicad_detect scans both locations).
scope = args.get("scope") or ("machine" if elevated else "user")
scope_flag = "/allusers" if scope == "machine" else "/currentuser"
print(f"[KiCad Bridge] kicad_upgrade: elevated={elevated}; launching installer "
f"{scope_flag} /S (scope={scope})...", flush=True)
try:
result = proc.run([str(dest), scope_flag, "/S"], timeout=900)
rc = result.returncode
print(f"[KiCad Bridge] kicad_upgrade: installer exited rc={rc}", flush=True)
except Exception as e:
print(f"[KiCad Bridge] kicad_upgrade: INSTALLER LAUNCH/RUN FAILED: {e}", flush=True)
return {
"success": False,
"error": f"installer launch failed: {e}",
"errorCode": "install_failed",
"elevated": elevated,
"path": str(dest),
"_hint": "If a UAC prompt appeared, approve it; or run the downloaded installer manually.",
}
# Confirm by re-scanning installed versions.
after = kicad_detect.detect_all_kicad_versions()
installed = [v["version"] for v in after]
now_has = any(
kicad_detect._version_tuple(v) >= kicad_detect._version_tuple(target)
for v in installed
)
return {
"success": bool(now_has),
"upgraded": bool(now_has),
"targetVersion": target,
"previousVersion": current,
"installerExit": rc,
"elevated": elevated,
"installScope": scope,
"installedVersions": installed,
"downloadUrl": url,
"_hint": (
f"KiCad {target} installed ({scope} scope)."
if now_has
else (
f"Installer ran (exit {rc}) but {target} wasn't detected. elevated={elevated}, scope={scope}. "
+ ("rc=666660 is NsisMultiUser 'invalid command-line parameters' and 666661 is 'elevation "
"restricted' — retry with the other scope: "
'{"scope":"user"} needs NO admin/UAC (installs to %LOCALAPPDATA%\\Programs\\KiCad); '
'{"scope":"machine"} needs elevation. '
if rc in (666660, 666661) else
"Check SmartScreen/AV blocking the .exe or a pending reboot. ")
+ f"Installed: {', '.join(installed) or 'none'}."
)
),
}