← All Pull Requests

macOS 0.11.3: kicad_select_net selects again, kicad_send_key takes menu, and the kicad_pcm_* verbs (patch and tests) #64

Open opened by Drew Owens 2026-11-04

Versions: base: the macOS release 0.11.3 (adom-bridge-kicad-v0.11.3.zip, sha256 545469fc908113c68797263e5c795f106e93f13cd1f218ea3125a140cc8826c9); this page's head at posting ffc42b668d272fc98f0eb6afa76b8a2e313eb5bb · ab 1.9.232 (macOS, standalone; Hydrogen not involved) · KiCad 10.0.7 · the bridge's Python 3.12.13 with kicad-python 0.8.0, protobuf 5.29.6, pynng 0.9.0 · relay adom-bridge 2.1.185 · adom/core 4.15.3 · AI Flow 0.2.48 to 0.2.50

This adds macos/0.11.3-fixes/: a patch for the macOS line's source (that source is not in this repo; the release zip is), the hashes of the patched files, a README and the tests. Nothing in the Windows and Linux line changes. Apply with cd <0.11.3 tree> && patch -p1 < kicad-bridge-0.11.3-macos.patch, then set the release number in BRIDGE_VERSION and bridge.json (the patch labels it 0.11.3+tours, the build that was tested).

1. kicad_select_net fails on every net with ipc_unavailable "id"

  • The Mac's reply, every net, every time: {"success": false, "errorCode": "ipc_unavailable", "error": "id"}. kicad_select_clear and the IPC reads (kicad_routing_state, kicad_zone_state) work on the same board. AI Flow's tour nets stops on it: ERROR: net GND: 0 selected (ipc_unavailable).
  • Cause (handlers/live_zones.py): board.add_to_selection([KIID(value=i) for i in ids]). kicad-python's Board.add_to_selection takes board items and reads .id from each one (the same in every kicad-python from 0.2.0 to 0.8.0). A KIID has only value, so the upb backend raises AttributeError('id') before anything reaches KiCad, and _error maps any exception without a .code to ipc_unavailable. The Windows line builds the request from id strings itself, so only the macOS port shows it.
  • Fix: pass the items themselves. The reply adds selectionConfirmed (how many of the requested items KiCad's answer holds), and a selection KiCad did not take is refused (selection_not_applied) instead of being reported as made.
  • Worth a look, not changed here: _error turns every exception that is not a RoutingError into ipc_unavailable with an IPC hint. An internal_error code with the exception type would have made this one obvious.

2. kicad_send_key has no menu on macOS

  • The Mac's reply to {"hwnd": <editor>, "menu": "Zoom to Selected Objects"}: {"success": false, "error": "Missing required arg: key"}. The select_net hint recommends exactly this call, and AI Flow's tour nets sends it (and "Zoom to Fit") for every net.
  • Fix: the Windows contract on macOS. An exact label wins (case-insensitive, a trailing ellipsis ignored), a substring must name exactly one item, and several matches are refused with the list (menu_item_ambiguous). The item is pressed through Accessibility on the target window's own menu bar, after making that window its app's AXMain, as kicad_3d_view already does for the 3D Viewer. No focus change and no Apple Events. The menu walk keeps its CFArrays alive until the press: a full menu-bar walk makes more than the 64 arrays the keep-alive list holds.
  • Hints: select_net's catalog and reply hints said "Zoom to Selection", which names KiCad 10's interactive "Zoom to Selection Area" tool (#121). They now name "Zoom to Selected Objects".

3. kicad_pcm_list, kicad_pcm_install, kicad_pcm_uninstall, kicad_pcm_add_repository, kicad_pcm_remove_repository

  • 0.11.3 answers "Unknown command: kicad_pcm_list", so AI Flow's library step (pcm check, and the kicad_pcm_add_repository and kicad_pcm_install it asks for) cannot run on a Mac.
  • New handlers/pcm.py, ported from the Windows line's kicad-core pcm.rs: the same verbs, arguments, reply fields, registry rows and extraction. server.py routes them (describe catalog, argument docs, _NO_GUI, one-line outputs; it also matches the prefixed kicad_pcm_* name, which macOS Adom Bridge forwards). bridge.json lists the five verbs, with timeouts pcm_install 900 s and pcm_list 120 s.
  • The macOS layout comes from KiCad 10.0's own source: the 3rdparty folder is KICAD<n>_3RD_PARTY from Configure Paths (pcm.cpp ReadEnvVar, env_vars.cpp: the exact major first, then any), else ~/Documents/KiCad/<major.minor>/3rdparty (paths.cpp, environment.mm). installed_packages.json and kicad.json live in ~/Library/Preferences/kicad/<major.minor>. All eight package folders of pcm.h are extracted (pcm.rs lists six).
  • ~/Documents is TCC-protected. The verbs touch it only with Full Disk Access (kicad_detect._mac_can_touch_protected, like every other macOS verb here). Without it, install and uninstall refuse with full_disk_access_required before any download or write, and list reports onDisk: null. A KICAD10_3RD_PARTY outside the protected folders needs no grant.
  • Private wiki pages: a wiki URL that answers 401, 403 or 404 anonymously is read again through Adom Bridge's desktop_wiki_fetch, the Windows contract (#132). macOS Adom Bridge 1.9.232 does not list that verb, so the reply says so and points to kicad_pcm_install {zipPath}.
  • Two differences from pcm.rs: every zip entry is checked before an older install's files are removed, so a refused update keeps the installed version; and a zipPath install is recorded the way KiCad's own Install from File records it (repository_id "", repository_name "Local file": pcm_task_manager.cpp InstallFromFile, pcm.cpp MarkInstalled).

Tests (macos/0.11.3-fixes/tests, with Python 3.12, kicad-python 0.8.0, protobuf 5.29.6 upb and pynng 0.9.0, as on the Mac)

  • test_bridge_fix.py, 8 pass. The unchanged 0.11.3 tree reproduces the Mac's select_net reply byte for byte (after a ClearSelection, with no AddToSelection sent). The patched tree selects every copper item of the net (tracks, arcs, vias, pads, copper zones; never a rule area or another net), keeps the selection with add, refuses a selection KiCad did not take, and refuses unknown nets as before. The menu label rule, and send_key menu firing on the target window in the background.
  • test_pcm_macos.py, 10 pass: KiCad 10's paths on a Mac-shaped home and the Configure Paths override; install from a sha256-pinned repository (registry row, layout, PCM_ library names, KiCad's 4-space registry); update and replace; refusals that leave nothing behind (download sha mismatch, unsafe entry, packages.json hash, missing package, not installed); a refused update keeps the old version; PCM's version order (epoch, kicad_version, kicad_version_max); a private page read through the session, and the zipPath pointer without it; no Full Disk Access means no write into ~/Documents; kicad.json repositories kept like the Manage dialog; the five verbs through dispatch_command and the response normalizer, with AI Flow's pcm check rule (the identifier in the reply).
  • End to end without a Mac: AI Flow's pcm check (0.2.48) against this code behind a stand-in adom-bridge that relays the way macOS Adom Bridge 1.9.232 does. It fails on 0.11.3 ("Unknown command"), refuses without Full Disk Access and writes nothing, passes after a zipPath install of a real 27-file package, fails again after uninstall, and passes through the session route.
  • The patch applies to the 0.11.3 zip tree and reproduces SHA256SUMS.

On a Mac: the two faults above are 0.11.3's live replies on a MacBook Air with KiCad 10.0.7 (2026-11-02 and 2026-11-04). The patched build is staged on that Mac; its install waits for the owner's approval, so the background menu press has not yet been checked against the view. If the press is delivered but the view does not move (as on Windows, #121), the next step is kicad_zoom_to on macOS: kicad-python 0.8.0 has KiCad.run_action.

Diff Skip to comments (1)

--- a/macos/0.11.3-fixes/README.md+++ b/macos/0.11.3-fixes/README.md@@ -0,0 +1,33 @@+# macOS KiCad bridge 0.11.3: fixes for kicad_select_net, kicad_send_key menu and the kicad_pcm_* verbs++The macOS line's source is not in this repository (the release zip is), so this folder carries a patch for it.++- `kicad-bridge-0.11.3-macos.patch`: applies to `adom-bridge-kicad-v0.11.3.zip` unpacked+  (sha256 545469fc908113c68797263e5c795f106e93f13cd1f218ea3125a140cc8826c9):+  `cd <0.11.3 tree> && patch -p1 < kicad-bridge-0.11.3-macos.patch`.+  It changes BRIDGE_VERSION, bridge.json, server.py, handlers/kicad_ui.py, handlers/live_zones.py and+  handlers/mac_ui.py, and adds handlers/pcm.py.+- `SHA256SUMS`: the seven files after the patch.+- BRIDGE_VERSION reads `0.11.3+tours`, the label of the build tested on a Mac. Set the release number in+  BRIDGE_VERSION and bridge.json when publishing.++What it fixes:++1. `kicad_select_net` answered `ipc_unavailable` "id" for every net: KIID messages were passed to kicad-python's+   `Board.add_to_selection`, which reads `.id` from board items. It now passes the items, reports+   `selectionConfirmed`, and refuses a selection KiCad did not take (`selection_not_applied`).+2. `kicad_send_key {hwnd, menu}` answered "Missing required arg: key" on macOS. It now fires a menu item by its+   label with the Windows contract, pressed through Accessibility on the target window's menu bar, in the background.+3. `kicad_pcm_list`, `kicad_pcm_install`, `kicad_pcm_uninstall`, `kicad_pcm_add_repository`,+   `kicad_pcm_remove_repository` did not exist on macOS ("Unknown command"). handlers/pcm.py ports the Windows+   line's kicad-core pcm.rs with KiCad 10's macOS layout (3rdparty from KICAD<n>_3RD_PARTY, else+   ~/Documents/KiCad/<ver>/3rdparty; installed_packages.json and kicad.json in ~/Library/Preferences/kicad/<ver>).+   ~/Documents is touched only with Full Disk Access.++Tests (Python 3.12 with kicad-python 0.8.0, protobuf 5.29.6 and pynng 0.9.0, the Mac bridge's own stack):++```+KICAD_BRIDGE_BASE=<0.11.3 tree> KICAD_BRIDGE_TREE=<patched tree> python tests/test_bridge_fix.py+KICAD_BRIDGE_TREE=<patched tree> python tests/test_pcm_macos.py+```+--- a/macos/0.11.3-fixes/kicad-bridge-0.11.3-macos.patch+++ b/macos/0.11.3-fixes/kicad-bridge-0.11.3-macos.patch@@ -0,0 +1,1263 @@+KiCad bridge, macOS line: fixes on 0.11.3 (kicad_select_net, kicad_send_key menu, the kicad_pcm_* verbs).+Base: adom-bridge-kicad-v0.11.3.zip (sha256 545469fc908113c68797263e5c795f106e93f13cd1f218ea3125a140cc8826c9), unpacked.+Apply: cd <0.11.3 tree> && patch -p1 < kicad-bridge-0.11.3-macos.patch+Result: SHA256SUMS beside this file. BRIDGE_VERSION reads 0.11.3+tours (the label of the build tested on a Mac);+set the release number in BRIDGE_VERSION and bridge.json when publishing.++diff -uN a/BRIDGE_VERSION b/BRIDGE_VERSION+--- a/BRIDGE_VERSION++++ b/BRIDGE_VERSION+@@ -1 +1 @@+-0.11.3++0.11.3+tours+diff -uN a/bridge.json b/bridge.json+--- a/bridge.json++++ b/bridge.json+@@ -82,7 +82,12 @@+     "kicad_apply_zone_plan",+     "kicad_select_net",+     "kicad_select_clear",+-    "kicad_3d_view"++    "kicad_3d_view",++    "kicad_pcm_list",++    "kicad_pcm_install",++    "kicad_pcm_uninstall",++    "kicad_pcm_add_repository",++    "kicad_pcm_remove_repository"+   ],+   "statusVerb": "kicad_status",+   "timeouts": {+@@ -104,7 +109,9 @@+       "apply_zone_plan": 240,+       "routing_validate": 150,+       "placement_validate": 150,+-      "routing_deps": 630++      "routing_deps": 630,++      "pcm_install": 900,++      "pcm_list": 120+     }+   },+   "dependencies": {+diff -uN a/handlers/kicad_ui.py b/handlers/kicad_ui.py+--- a/handlers/kicad_ui.py++++ b/handlers/kicad_ui.py+@@ -470,9 +470,48 @@+     }+ + ++def _mac_send_menu(kicad_info: dict, args: dict, menu: str) -> dict:++    """kicad_send_key {hwnd, menu}: the Windows line's menu path (fire a menu item by its label, in the++    background), which the macOS line lacked: a `menu` call answered "Missing required arg: key", and++    AI Flow's net walkthrough ("Zoom to Selected Objects", "Zoom to Fit") never framed a net on a Mac.++    Pressed through Accessibility on the target window's own menu bar; no focus, no Apple Events."""++    from handlers import mac_ui++++    windows = mac_ui.list_kicad_windows(_mac_kicad_pids(kicad_info))++    target = None++    if args.get("hwnd"):++        target = next((w for w in windows if w["hwnd"] == int(args["hwnd"])), None)++        if target is None:++            return {"success": False, "errorCode": "no_window",++                    "error": f"No KiCad window with id {args['hwnd']} (stale id?). Call kicad_window_info for fresh window ids."}++    elif windows:++        target = windows[0]++    if target is None:++        return {"success": False, "errorCode": "no_window", "error": "No KiCad window found to send the menu command to."}++    if not mac_ui.ax_available():++        return mac_ui._permission_error("accessibility", "fire a KiCad menu item")++    title = target.get("title") or target.get("ownerName") or ""++    r = mac_ui.ax_menu_invoke_by_text(target["pid"], menu, window_title_contains=target.get("title") or "")++    if not r.get("ok"):++        out = {"success": False, "errorCode": r.get("errorCode") or "menu_invoke_failed",++               "error": f"{r.get('error')} (on '{title}')",++               "_hint": "The label is matched like the Windows line: an exact label wins, a substring must name one item. KiCad's PCB editor names them e.g. 'Zoom to Selected Objects', 'Zoom to Fit'."}++        if r.get("matches"):++            out["matches"] = r["matches"]++        return out++    return {"success": True, "mechanism": "menu",++            "output": f"Menu item '{r.get('menuItem')}' fired on '{title}' (background)",++            "data": {"method": "ax-menu-press", "menuItem": r.get("menuItem"), "menuPath": r.get("menuPath"),++                     "hwnd": target["hwnd"], "pid": target["pid"], "title": title, "madeMain": r.get("madeMain"), "foreground": False},++            "_hint": "Pressed on the window's own menu bar through Accessibility (the window made its app's main window first); no focus was touched. A delivered command is not proof the view moved: screenshot the window to see it."}+++++ def _mac_send_key(kicad_info: dict, args: dict) -> dict:+     from handlers import mac_ui+ ++    menu = args.get("menu")++    if isinstance(menu, str) and menu.strip():++        return _mac_send_menu(kicad_info, args, menu.strip())+     key = (args.get("key") or "").strip()+     if not key:+         return {"success": False, "error": "Missing required arg: key"}+diff -uN a/handlers/live_zones.py b/handlers/live_zones.py+--- a/handlers/live_zones.py++++ b/handlers/live_zones.py+@@ -473,7 +473,7 @@+     net = net.strip() if isinstance(net, str) else ""+     if not net:+         raise lr.RoutingError("missing_arg", "Pass net: the net name as KiCad writes it (e.g. \"+VBAT\", \"/DRV_SHA\")")+-    ids, kinds, seen = [], {}, set()++    ids, kinds, seen, picked = [], {}, set(), []+     for kind, items in (("tracks", board.get_tracks()), ("vias", board.get_vias()), ("pads", board.get_pads())):+         for it in items:+             n = it.net.name if it.net is not None else ""+@@ -481,6 +481,7 @@+                 seen.add(n)+             if n == net and it.id.value:+                 ids.append(it.id.value)++                picked.append(it)+                 kinds[kind] = kinds.get(kind, 0) + 1+     for z in board.get_zones():+         r = _zone_read(z)+@@ -488,16 +489,25 @@+             seen.add(r["net"])+         if r["net"] == net and r["type"] != "keepout":+             ids.append(r["id"])++            picked.append(z)+             kinds["zones"] = kinds.get("zones", 0) + 1+     if not ids:+         raise lr.RoutingError("unknown_net", f"No copper item on the live board carries net '{net}'", netsOnBoard=sorted(seen)[:200])+     if args.get("add") is not True:+         board.clear_selection()+-    from kipy.proto.common.types.base_types_pb2 import KIID+-    board.add_to_selection([KIID(value=i) for i in ids])++    # kipy's Board.add_to_selection takes the board items and reads each one's .id. 0.11.3 passed++    # KIID messages, which have no .id: every call raised AttributeError('id') before anything was++    # sent, answered as errorCode ipc_unavailable, error "id" (Mac, KiCad 10.0.7, 2026-11-02).++    current = board.add_to_selection(picked)++    wanted = set(ids)++    confirmed = sum(1 for c in current if getattr(getattr(c, "id", None), "value", None) in wanted)++    if confirmed == 0:++        raise lr.RoutingError("selection_not_applied", f"KiCad's selection holds none of the {len(ids)} items of net '{net}' after AddToSelection",++                              requested=len(ids), selectionCount=len(current))+     return {"success": True, "mutated": False, "source": "live-editor", "net": net, "selected": len(ids), "byKind": kinds, "itemIds": ids,++            "selectionConfirmed": confirmed,+             "_hint": "The net's pours, tracks, vias and pads are now the editor's selection (highlighted on every layer). "+-                     "Frame it in the PCB editor with its View menu (Zoom to Selection); clear it with kicad_select_clear. "++                     "Frame it with kicad_send_key {hwnd, menu: \"Zoom to Selected Objects\"} (the exact label: \"Zoom to Selection Area\" is an interactive drag tool); clear it with kicad_select_clear. "+                      "Selection is not an edit: nothing to undo, the revision is unchanged."}+ + +diff -uN a/handlers/mac_ui.py b/handlers/mac_ui.py+--- a/handlers/mac_ui.py++++ b/handlers/mac_ui.py+@@ -705,6 +705,93 @@+     return None+ + ++def _menu_norm(title: str) -> str:++    return (title or "").replace("&", "").rstrip(".…").strip().lower()++++++def resolve_menu_label(labels: list, needle: str):++    """Which of `labels` the caller's `menu` names, by the Windows line's rule (kicad-bridge 1.0.56,++    issue #121): an exact label wins (case-insensitive, a trailing ellipsis ignored); else a substring++    that names exactly one item. A substring naming several items is refused with every match++    ("Zoom to Selection" also names "Zoom to Selection Area", an interactive drag tool).++    Returns (index, None) or (None, ("ambiguous" | "not_found", [matching labels]))."""++    want = _menu_norm(needle)++    if not want:++        return None, ("not_found", [])++    exact = [i for i, t in enumerate(labels) if _menu_norm(t) == want]++    if exact:++        return exact[0], None++    subs = [i for i, t in enumerate(labels) if want in _menu_norm(t)]++    if len(subs) == 1:++        return subs[0], None++    if subs:++        return None, ("ambiguous", [labels[i] for i in subs])++    return None, ("not_found", [])++++++def ax_menu_invoke_by_text(pid: int, needle: str, window_title_contains: str = "") -> dict:++    """kicad_send_key {menu} on macOS: press the one menu-bar item `needle` names (resolve_menu_label)++    on the app that owns the target window, AX-natively and without activating it (the window is made++    the app's AXMain first, so the menu bar is that frame's). The menu tree's arrays are held until the++    press is done: a full walk makes more arrays than _ax_elems keeps alive.++++    Returns {ok, method, menuItem, menuPath, madeMain} or {ok: False, errorCode, error, matches?}."""++    if not ax_available() or not _AX_HAS_ELEMENT_API:++        return {"ok": False, "errorCode": "accessibility_not_granted", "error": "accessibility_not_granted"}++    _ax_ensure_attrs("AXMenuBar", "AXMain")++    app = _ax.AXUIElementCreateApplication(int(pid))++    if not app:++        return {"ok": False, "errorCode": "no_ax_application", "error": f"no AX application for pid {pid}"}++    _AX_KEEPALIVE.append(app)++    made_main = None++    if window_title_contains:++        for w, title, _sub in _ax_app_windows(pid):++            if window_title_contains.lower() in (title or "").lower():++                made_main = _ax_set_true(w, "AXMain")++                break++    bar = _ax_attr(app, "AXMenuBar")++    if not bar:++        return {"ok": False, "errorCode": "no_menu_bar", "error": "app has no AXMenuBar (hidden app or no window yet)", "madeMain": made_main}++    held = [bar]++    try:++        items = []  # (title, path of menu titles above it, element)++++        def walk(el, path, depth):++            if depth > 8:++                return++            arr = _ax_attr(el, "AXChildren")++            if not arr:++                return++            held.append(arr)++            for i in range(_cf.CFArrayGetCount(arr)):++                c = _cf.CFArrayGetValueAtIndex(arr, i)++                role = _ax_str(c, "AXRole")++                if role in ("AXMenuBarItem", "AXMenuItem"):++                    t = _ax_str(c, "AXTitle").strip()++                    if role == "AXMenuItem" and t:++                        items.append((t, path, c))++                    walk(c, path + (t,), depth + 1)++                elif role in ("AXMenu", "AXMenuBar"):++                    walk(c, path, depth + 1)++++        walk(bar, (), 0)++        idx, why = resolve_menu_label([t for t, _p, _c in items], needle)++        if idx is None:++            kind, matches = why++            if kind == "ambiguous":++                return {"ok": False, "errorCode": "menu_item_ambiguous", "matches": matches, "madeMain": made_main,++                        "error": f"'{needle}' names {len(matches)} menu items: {', '.join(matches)}; pass the exact label"}++            return {"ok": False, "errorCode": "menu_item_not_found", "madeMain": made_main, "itemsSeen": len(items),++                    "error": f"no menu item is labelled '{needle}' (exact, or a substring naming one item)"}++        title, path, el = items[idx]++        pressed = _ax_press(el)++        return {"ok": pressed, "method": "ax-menu-press", "menuItem": title, "menuPath": list(path) + [title], "madeMain": made_main,++                **({} if pressed else {"errorCode": "menu_invoke_failed", "error": f"AXPress on '{title}' was refused (a disabled item?)"})}++    finally:++        for ref in held:++            _cf.CFRelease(ref)+++++ def ax_menu_press(pid: int, item_title: str, window_title_contains: str = "", top_menu: str = "") -> dict:+     """Press a menu-bar item of one app by title, AX-natively, without activating it.+ +diff -uN a/handlers/pcm.py b/handlers/pcm.py+--- a/handlers/pcm.py++++ b/handlers/pcm.py+@@ -0,0 +1,884 @@++"""kicad_pcm_list, kicad_pcm_install, kicad_pcm_uninstall, kicad_pcm_add_repository,++kicad_pcm_remove_repository: KiCad's Plugin and Content Manager without the dialog (macOS line).++++Port (0.11.3+tours, 2026-11-04) of the Windows line's kicad-core pcm.rs (KiCad Bridge 1.0.x), same++verbs, arguments and reply fields. PCM is only files, checked against KiCad 10.0's own source:++++  * the 3rdparty folder: the versioned path variable KICAD<n>_3RD_PARTY (kicad_common.json++    environment.vars; pcm.cpp ReadEnvVar, env_vars.cpp GetVersionedEnvVarValue: the exact major++    first, then any KICAD<n>_3RD_PARTY), else PATHS::GetDefault3rdPartyPath() =++    <Documents>/KiCad/<major.minor>/3rdparty (paths.cpp; KICAD_PATH_STR is "KiCad" on macOS);++  * installed_packages.json in PATHS::GetUserSettingsPath() = ~/Library/Preferences/kicad/<major.minor>++    on macOS (KICAD_CONFIG_DIR "kicad"), written with a 4-space indent (pcm.cpp SaveInstalledPackages);++    each row {package, current_version, repository_id, repository_name, install_timestamp, pinned}++    (pcm_data.cpp);++  * kicad.json pcm.repositories [{name, url}], KiCad's official repository when the key is absent;++    pcm.lib_auto_add (default true) and pcm.lib_prefix (default "PCM_") (kicad_settings.cpp);++  * repository_id = sha256(url)[:16] (pcm.cpp SetRepositoryList);++  * extract <zip>/<folder>/<contents> -> <3rdparty>/<folder>/<identifier, dots to _>/<contents> for the++    package folders plugins, footprints, 3dmodels, symbols, resources, colors, templates, scripts++    (pcm.h PCM_PACKAGE_DIRECTORIES; pcm_task_manager.cpp extract); nothing else: KiCad adds the++    PCM_ library rows itself when the global tables next load (a running KiCad needs a restart).++++macOS: the default 3rdparty folder is inside TCC-protected ~/Documents. Like every other macOS verb++here, the bridge touches it only with Full Disk Access (kicad_detect._mac_can_touch_protected), never++by a per-folder consent prompt from this faceless process: without it install and uninstall refuse++with errorCode full_disk_access_required, and list reports onDisk null.++++Private Adom wiki pages: an https://wiki.adom.inc/ URL that answers 401, 403 or 404 anonymously is++read again through Adom Bridge's desktop_wiki_fetch over its loopback direct API (the Windows contract,++kicad-bridge #132). An Adom Bridge without that verb gets a reply that says so; then++kicad_pcm_install {zipPath} (the package zip sent to the Mac first) installs the same package with the++same registry row.++"""++from __future__ import annotations++++import base64++import hashlib++import io++import json++import os++import re++import shutil++import sys++import time++import urllib.error++import urllib.parse++import urllib.request++import zipfile++from pathlib import Path++++FOLDERS = ("plugins", "footprints", "3dmodels", "symbols", "resources", "colors", "templates", "scripts")++MAX_ZIP = 512 * 1024 * 1024++UA = "adom-kicad-bridge pcm"++DEFAULT_REPOSITORY_NAME = "KiCad official repository"++DEFAULT_REPOSITORY_URL = "https://repository.kicad.org/repository.json"++REPO_RESTART_NOTE = ("KiCad reads pcm.repositories at start: a running KiCad keeps its old list until it is closed and "++                     "launched again, and a KiCad that is running NOW will overwrite this edit when it saves its settings "++                     "on exit, so close KiCad first when kicadRunning is true or null.")++SELF_THREAD = "kicad bridge (self)"++IS_MACOS = sys.platform == "darwin"++++++class FetchError(Exception):++    def __init__(self, message, status=None):++        super().__init__(message)++        self.status = status++++++# --- fetching -----------------------------------------------------------------------------------++++def file_url_path(url: str):++    """file:///Users/x/repository.json (or file://localhost/...) as a local path, else None."""++    if not url.startswith("file://"):++        return None++    rest = url[len("file://"):]++    if rest.startswith("localhost"):++        rest = rest[len("localhost"):]++    return Path(urllib.parse.unquote(rest))++++++def _ssl_context():++    try:++        from handlers.upgrade import _ssl_context as ctx++        return ctx()++    except Exception:  # pylint: disable=broad-except++        import ssl++        return ssl.create_default_context()++++++def anonymous_fetch(url: str, timeout: float) -> bytes:++    req = urllib.request.Request(url, headers={"User-Agent": UA})++    try:++        with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as r:++            data = r.read(MAX_ZIP + 1)++    except urllib.error.HTTPError as e:++        raise FetchError(f"HTTPError: http status: {e.code}", status=e.code) from None++    except urllib.error.URLError as e:++        raise FetchError(f"URLError: {e.reason}") from None++    except TimeoutError as e:++        raise FetchError(f"TimeoutError: {e}") from None++    except OSError as e:++        raise FetchError(f"OSError: {e}") from None++    if len(data) > MAX_ZIP:++        raise FetchError(f"the download is over the {MAX_ZIP} byte cap")++    return data++++++def _direct_api_base():++    """Adom Bridge's loopback direct API: ADOM_DIRECT_API_URL, else the host:port in ~/.adom/direct-api-port."""++    env = (os.environ.get("ADOM_DIRECT_API_URL") or "").strip().rstrip("/")++    if env:++        return env++    try:++        hp = (Path.home() / ".adom" / "direct-api-port").read_text(encoding="utf-8").strip()++    except OSError:++        return None++    return f"http://{hp}" if hp else None++++++def ab_call(verb: str, args: dict, timeout: float) -> dict:++    """One Adom Bridge desktop verb over its direct API (POST /command {app, command, args}). The reply's++    `output` is a JSON string on macOS: it is unpacked. Raises FetchError when ab cannot be reached."""++    base = _direct_api_base()++    if not base:++        raise FetchError("Adom Bridge's direct API is not reachable (no ~/.adom/direct-api-port)")++    body = json.dumps({"app": "desktop", "command": verb, "args": args}).encode()++    headers = {"Content-Type": "application/json", "User-Agent": UA}++    tok = os.environ.get("ADOM_BRIDGE_TOKEN")++    if tok:++        headers["X-Adom-Bridge-Token"] = tok++    req = urllib.request.Request(base + "/command", data=body, headers=headers, method="POST")++    try:++        with urllib.request.urlopen(req, timeout=timeout) as r:++            reply = json.loads(r.read().decode("utf-8", "replace") or "{}")++    except (OSError, ValueError, urllib.error.URLError) as e:++        raise FetchError(f"Adom Bridge direct API: {e}") from None++    out = reply.get("output")++    if isinstance(out, str) and out.strip().startswith("{"):++        try:++            inner = json.loads(out)++            if isinstance(inner, dict):++                merged = dict(inner.get("data") or {}) if isinstance(inner.get("data"), dict) else {}++                merged.update({k: v for k, v in inner.items() if k != "data"})++                merged.update({k: v for k, v in reply.items() if k != "output" and k not in merged})++                return merged++        except ValueError:++            pass++    return reply++++++def wiki_session_fetch(url: str, timeout: float, caller=None) -> bytes:++    """A private wiki page's file, read through Adom Bridge's desktop_wiki_fetch (the user's signed-in++    wiki session). The body comes back base64, as text, or as a saved file (the Windows contract)."""++    args = {"url": url, "reason": "install a KiCad PCM package from a private wiki page",++            "caller": caller if isinstance(caller, dict) and caller.get("aiThread") else {"aiThread": SELF_THREAD}}++    reply = ab_call("desktop_wiki_fetch", args, timeout)++    code = str(reply.get("errorCode") or "")++    if reply.get("success") is False or code:++        unknown = code in ("unknown_verb", "unknown_command") or "unknown" in str(reply.get("error", "")).lower()++        raise FetchError(("this Adom Bridge has no desktop_wiki_fetch, so a private page cannot be read with the user's session: "++                          "send the package zip to this Mac and use kicad_pcm_install {zipPath}") if unknown else++                         f"desktop_wiki_fetch: {reply.get('error') or code}")++    status = reply.get("httpStatus")++    if isinstance(status, int) and status >= 400:++        raise FetchError(f"HTTPError: http status: {status} (signed in through Adom Bridge)", status=status)++    for key in ("base64", "dataBase64", "bodyBase64", "contentBase64", "b64"):++        if isinstance(reply.get(key), str):++            return base64.b64decode(reply[key])++    for key in ("text", "body", "content"):++        if isinstance(reply.get(key), str):++            return reply[key].encode("utf-8")++    for key in ("savedTo", "path", "fullPath", "localPath"):++        if isinstance(reply.get(key), str) and os.path.isfile(reply[key]):++            return Path(reply[key]).read_bytes()++    raise FetchError("desktop_wiki_fetch answered without a body field the bridge knows (keys: " + ", ".join(sorted(reply)) + ")")++++++def make_fetch(caller=None, notes=None):++    """The production fetcher: file://, then an anonymous https read, then (a wiki URL answering 401, 403++    or 404) the user's wiki session through Adom Bridge. `notes` collects what happened, for the reply."""++    def fetch(url: str, timeout: float) -> bytes:++        p = file_url_path(url)++        if p is not None:++            try:++                return p.read_bytes()++            except OSError as e:++                raise FetchError(f"OSError: {p}: {e}") from None++        try:++            return anonymous_fetch(url, timeout)++        except FetchError as e:++            if url.startswith("https://wiki.adom.inc/") and e.status in (401, 403, 404):++                try:++                    data = wiki_session_fetch(url, timeout, caller)++                except FetchError as w:++                    raise FetchError(f"{e}; then {w}") from None++                if notes is not None:++                    notes.append(f"read {url} with the user's wiki session through Adom Bridge (anonymous read: {e})")++                return data++            raise++    return fetch++++++def sha256_hex(data: bytes) -> str:++    return hashlib.sha256(data).hexdigest()++++++# --- versions (PCM's ordering) -------------------------------------------------------------------++++def compare_versions(a: str, b: str) -> int:++    """-1, 0, 1. Dotted numeric components; missing ones are 0; a non-numeric one counts as 0 and the raw++    strings break the tie."""++    def parse(s):++        lossy, parts = False, []++        for p in (s or "").strip().split("."):++            try:++                parts.append(int(p.strip()))++            except ValueError:++                parts.append(0); lossy = True++        return parts, lossy++    (pa, la), (pb, lb) = parse(a), parse(b)++    for i in range(max(len(pa), len(pb))):++        x, y = (pa[i] if i < len(pa) else 0), (pb[i] if i < len(pb) else 0)++        if x != y:++            return -1 if x < y else 1++    if la or lb:++        sa, sb = (a or "").strip(), (b or "").strip()++        return (sa > sb) - (sa < sb)++    return 0++++++def version_cmp(a: dict, b: dict) -> int:++    ea, eb = int(a.get("version_epoch") or 0), int(b.get("version_epoch") or 0)++    if ea != eb:++        return -1 if ea < eb else 1++    return compare_versions(str(a.get("version") or ""), str(b.get("version") or ""))++++++def version_compatible(v: dict, kicad_version: str) -> bool:++    mm = lambda s: ".".join(str(s).split(".")[:2])++    if not (kicad_version or "").strip():++        return True++    here = mm(kicad_version)++    lo, hi = v.get("kicad_version"), v.get("kicad_version_max")++    if isinstance(lo, str) and lo and compare_versions(mm(lo), here) > 0:++        return False++    if isinstance(hi, str) and hi and compare_versions(mm(hi), here) < 0:++        return False++    return True++++++def latest_version(versions: list, kicad_version: str):++    best = None++    for v in versions:++        if not isinstance(v, dict) or not v.get("download_url") or not version_compatible(v, kicad_version):++            continue++        if best is None or version_cmp(v, best) > 0:++            best = v++    return best++++++# --- KiCad's files on this Mac ---------------------------------------------------------------------++++def _versions(kicad_info: dict):++    full = str(kicad_info.get("full_version") or kicad_info.get("version") or "")++    mm = ".".join(full.split(".")[:2])++    return full, mm, (mm.split(".")[0] if mm else "")++++++def _env_overrides(kicad_info: dict) -> dict:++    cfg = kicad_info.get("config_dir")++    try:++        d = json.loads((Path(cfg) / "kicad_common.json").read_text(encoding="utf-8")) if cfg else {}++    except (OSError, ValueError):++        d = {}++    v = ((d.get("environment") or {}).get("vars") or {}) if isinstance(d, dict) else {}++    return {k: str(x) for k, x in v.items() if isinstance(x, str) and x.strip()}++++++def third_party_dir(kicad_info: dict):++    """(path, how) the way KiCad 10 resolves it: KICAD<major>_3RD_PARTY, then any KICAD<n>_3RD_PARTY, from++    Configure Paths (kicad_common.json), else <Documents>/KiCad/<major.minor>/3rdparty."""++    _full, mm, major = _versions(kicad_info)++    env = _env_overrides(kicad_info)++    if major and env.get(f"KICAD{major}_3RD_PARTY"):++        return Path(env[f"KICAD{major}_3RD_PARTY"]), "kicad_common.json override"++    for k in sorted(env):++        if re.fullmatch(r"KICAD\d+_3RD_PARTY", k):++            return Path(env[k]), "kicad_common.json override"++    if not mm:++        return None, None++    return Path.home() / "Documents" / "KiCad" / mm / "3rdparty", "KiCad default (Documents/KiCad/<ver>/3rdparty)"++++++def paths(kicad_info: dict):++    cfg = kicad_info.get("config_dir")++    third, how = third_party_dir(kicad_info)++    if not cfg or third is None:++        return None++    full, _mm, _major = _versions(kicad_info)++    return {"third": third, "installed": Path(cfg) / "installed_packages.json", "kicadJson": Path(cfg) / "kicad.json",++            "version": full, "resolvedBy": how}++++++_PROTECTED = ("Documents", "Desktop", "Downloads")++++++def is_protected(path: Path) -> bool:++    """A path macOS guards with a per-folder consent (TCC): under ~/Documents, ~/Desktop or ~/Downloads."""++    if not IS_MACOS:++        return False++    home = Path.home()++    try:++        rp = Path(os.path.abspath(path))++    except Exception:  # pylint: disable=broad-except++        return True++    return any(str(rp) == str(home / d) or str(rp).startswith(str(home / d) + os.sep) for d in _PROTECTED)++++++def can_touch(path: Path) -> bool:++    if not is_protected(path):++        return True++    try:++        import kicad_detect++        return bool(kicad_detect._mac_can_touch_protected())++    except Exception:  # pylint: disable=broad-except++        return False++++++def _fda_refusal(path: Path, what: str) -> dict:++    return {"success": False, "errorCode": "full_disk_access_required", "thirdPartyDir": str(path),++            "error": f"{what} writes {path}, inside macOS-protected ~/Documents, and this bridge holds no Full Disk Access",++            "_hint": ("KiCad 10 keeps PCM packages in Documents/KiCad/<ver>/3rdparty. The macOS bridge touches ~/Documents only with "++                      "Full Disk Access granted to Adom Bridge (System Settings > Privacy & Security > Full Disk Access, or "++                      "Hydrogen's permissions gate), never by a consent prompt from a background process. After the grant, call "++                      "this verb again (detection re-runs on every verb). A KICAD10_3RD_PARTY path outside the protected "++                      "folders (Preferences > Configure Paths) needs no grant.")}++++++def read_json_file(p: Path, default):++    try:++        v = json.loads(p.read_text(encoding="utf-8"))++        return v++    except (OSError, ValueError):++        return default++++++def read_installed(p: Path) -> dict:++    v = read_json_file(p, None)++    if isinstance(v, dict) and isinstance(v.get("packages"), list):++        return v++    return {"packages": []}++++++def _atomic_write(p: Path, text: str):++    p.parent.mkdir(parents=True, exist_ok=True)++    tmp = p.with_name(p.name + ".adom-tmp")++    tmp.write_text(text, encoding="utf-8")++    os.replace(tmp, p)++++++def write_installed(p: Path, d: dict):++    """KiCad writes it with std::setw(4): four-space indent."""++    _atomic_write(p, json.dumps(d, indent=4) + "\n")++++++def pkgdir(identifier: str) -> str:++    return identifier.replace(".", "_")++++++def extract(zip_bytes: bytes, third: Path, identifier: str, replace: bool = False) -> dict:++    """PCM's extract(): <folder>/<contents> -> <3rdparty>/<folder>/<pkgid>/<contents>; entries outside the++    package folders are ignored, unsafe paths refuse the whole package. Every entry is checked before++    anything is written; replace=True (an install over an older one, as PCM's Update does) removes the++    previous extraction only after that check, so a refused zip leaves the installed version intact."""++    zf = zipfile.ZipFile(io.BytesIO(zip_bytes))++    total = sum(i.file_size for i in zf.infolist())++    if total > MAX_ZIP:++        raise ValueError(f"package unpacks to {total} bytes, over the {MAX_ZIP} cap")++    pid = pkgdir(identifier)++    out = {"files": 0, "symbols": [], "footprints": [], "models": [], "packageDir": pid}++    plan = []++    for info in zf.infolist():++        name = info.filename.replace("\\", "/")++        if not name or name.endswith("/"):++            continue++        parts = [p for p in name.split("/") if p not in ("", ".")]++        if ".." in parts or name.startswith("/") or re.match(r"^[A-Za-z]:", name):++            raise ValueError(f"unsafe zip entry: {info.filename}")++        if len(parts) < 2 or parts[0] not in FOLDERS:++            continue++        base = third / parts[0] / pid++        target = base.joinpath(*parts[1:])++        if not os.path.abspath(target).startswith(os.path.abspath(base) + os.sep):++            raise ValueError(f"unsafe zip entry: {info.filename}")++        plan.append((info, parts, target))++    if replace:++        for folder in FOLDERS:++            old = third / folder / pid++            if old.exists():++                shutil.rmtree(old, ignore_errors=True)++    for info, parts, target in plan:++        target.parent.mkdir(parents=True, exist_ok=True)++        with zf.open(info) as src, open(target, "wb") as dst:++            shutil.copyfileobj(src, dst)++        out["files"] += 1++        last = parts[-1]++        if parts[0] == "symbols" and last.endswith(".kicad_sym"):++            out["symbols"].append(last[:-len(".kicad_sym")])++        elif parts[0] == "footprints" and len(parts) >= 3 and parts[1].endswith(".pretty"):++            lib = parts[1][:-len(".pretty")]++            if lib not in out["footprints"]:++                out["footprints"].append(lib)++        elif parts[0] == "3dmodels" and len(parts) >= 3 and parts[1] not in out["models"]:++            out["models"].append(parts[1])++    return out++++++def pcm_prefs(kicad_info: dict):++    p = paths(kicad_info)++    d = read_json_file(p["kicadJson"], {}) if p else {}++    pcm = d.get("pcm") if isinstance(d, dict) and isinstance(d.get("pcm"), dict) else {}++    auto = pcm.get("lib_auto_add") if isinstance(pcm.get("lib_auto_add"), bool) else True++    prefix = pcm.get("lib_prefix") if isinstance(pcm.get("lib_prefix"), str) and pcm.get("lib_prefix") else "PCM_"++    return auto, prefix++++++def running_block(kicad_info: dict):++    """(kicadRunning, restartNeeded): a real process scan; an unknown answer is (None, True)."""++    try:++        from handlers.close_windows import _mac_get_kicad_pids++        running = bool(_mac_get_kicad_pids(kicad_info))++        return running, running++    except Exception:  # pylint: disable=broad-except++        return None, True++++++def _ident_of(entry):++    p = entry.get("package") if isinstance(entry, dict) else None++    return p.get("identifier") if isinstance(p, dict) else None++++++# --- repositories (kicad.json pcm.repositories) ----------------------------------------------------++++def repository_id(url: str) -> str:++    return sha256_hex(url.encode("utf-8"))[:16]++++++def _read_kicad_json(p: Path):++    if not p.exists():++        return {"meta": {"filename": "kicad", "version": 0}}, None++    try:++        v = json.loads(p.read_text(encoding="utf-8"))++    except (OSError, ValueError) as e:++        return None, f"{p}: {e}"++    if not isinstance(v, dict):++        return None, f"{p}: top level is not a JSON object"++    return v, None++++++def repositories_of(doc) -> list:++    rows = ((doc or {}).get("pcm") or {}).get("repositories") if isinstance(doc, dict) else None++    if not isinstance(rows, list):++        return [(DEFAULT_REPOSITORY_NAME, DEFAULT_REPOSITORY_URL)]++    return [(str(r.get("name") or ""), r["url"]) for r in rows if isinstance(r, dict) and isinstance(r.get("url"), str)]++++++def registered_repositories(kicad_info: dict) -> list:++    p = paths(kicad_info)++    doc, _ = _read_kicad_json(p["kicadJson"]) if p else (None, None)++    return repositories_of(doc)++++++def _repositories_report(rows) -> list:++    return [{"name": n, "url": u, "repositoryId": repository_id(u)} for n, u in rows]++++++def _write_kicad_json(p: Path, doc: dict):++    """A .adom-bak beside it (when it exists), then tmp + rename; two-space indent like KiCad's writer."""++    backup = None++    if p.is_file():++        backup = p.with_name(p.name + ".adom-bak")++        shutil.copy2(p, backup)++    _atomic_write(p, json.dumps(doc, indent=2) + "\n")++    return backup++++++def _valid_repo_url(u: str) -> bool:++    return (u.startswith(("https://", "http://", "file://"))) and bool(u.split("://", 1)[1])++++++def fetch_repository(fetch, repo_url: str, timeout: float):++    """repository.json -> packages.json, sha256-checked the way PCM checks it. (repo, packages)."""++    try:++        repo = json.loads(fetch(repo_url, timeout).decode("utf-8"))++    except ValueError as e:++        raise FetchError(f"JSONDecodeError: {e}") from None++    pk = (repo.get("packages") or {}) if isinstance(repo, dict) else {}++    if not isinstance(pk, dict) or not pk.get("url"):++        raise FetchError("KeyError: 'packages'")++    raw = fetch(pk["url"], timeout)++    if sha256_hex(raw) != str(pk.get("sha256") or ""):++        raise FetchError("packages.json hash does not match repository.json", status="hash")++    try:++        lst = json.loads(raw.decode("utf-8")).get("packages")++    except (ValueError, AttributeError) as e:++        raise FetchError(f"JSONDecodeError: {e}") from None++    if not isinstance(lst, list):++        raise FetchError("KeyError: 'packages'")++    return repo, lst++++++def _repository_url_for(installed: dict, ident: str, repos: list):++    entry = next((e for e in installed.get("packages", []) if _ident_of(e) == ident), None)++    if not entry:++        return None++    rid, name = str(entry.get("repository_id") or ""), str(entry.get("repository_name") or "")++    for n, u in repos:++        if rid and repository_id(u) == rid:++            return u++    for n, u in repos:++        if name and n == name:++            return u++    return None++++++# --- verbs ------------------------------------------------------------------------------------------++++def add_repository(kicad_info: dict, args: dict, fetch=None) -> dict:++    url = str(args.get("url") or "").strip()++    if not url:++        return {"success": False, "errorCode": "invalid_arg", "error": "url is required",++                "_hint": "url is the repository.json URL, e.g. https://wiki.adom.inc/api/pages/<owner>/<page>/files/pcm/repository.json"}++    if not _valid_repo_url(url):++        return {"success": False, "errorCode": "invalid_arg", "error": f"not a repository URL: {url}",++                "_hint": "PCM accepts http(s):// and file:// URLs that point at a repository.json."}++    p = paths(kicad_info)++    if not p:++        return {"success": False, "errorCode": "settings_not_found", "error": "KiCad config directory unknown",++                "_hint": "kicad_readiness first; the bridge needs a detected KiCad to know where kicad.json lives."}++    notes = []++    fetch = fetch or make_fetch(args.get("caller"), notes)++    fetched, fetched_name, fetch_error = False, None, None++    try:++        v = json.loads(fetch(url, 30).decode("utf-8"))++        if isinstance(v, dict) and isinstance(v.get("packages"), dict) and v["packages"].get("url"):++            fetched, fetched_name = True, v.get("name")++        else:++            fetch_error = "the URL serves JSON, but not a PCM repository (no packages.url)"++    except FetchError as e:++        fetch_error = str(e)++    except ValueError as e:++        fetch_error = f"the URL does not serve JSON: {e}"++    name = str(args.get("name") or "").strip() or fetched_name++    if not name:++        return {"success": False, "errorCode": "repository_unreachable", "url": url,++                "error": f"could not read the repository and no name was given: {fetch_error or ''}",++                "_hint": "Check the URL serves a repository.json (a private wiki page needs the user's session: see the error), or pass name to register it unverified."}++    doc, err = _read_kicad_json(p["kicadJson"])++    if doc is None:++        return {"success": False, "errorCode": "settings_unreadable", "error": err}++    rows = repositories_of(doc)++    running, _restart = running_block(kicad_info)++    out = {"success": True, "url": url, "repositoryId": repository_id(url), "file": str(p["kicadJson"]), "verified": fetched,++           "kicadRunning": running, "restartNeeded": True}++    if fetch_error:++        out["warning"] = f"registered unverified: {fetch_error}"++    if notes:++        out["notes"] = notes++    existing = next(((n, u) for n, u in rows if u == url), None)++    if existing:++        out.update({"name": existing[0], "added": False, "alreadyRegistered": True, "repositories": _repositories_report(rows),++                    "_hint": f"Already in pcm.repositories as '{existing[0]}'; nothing written. {REPO_RESTART_NOTE}"})++        return out++    rows.append((name, url))++    if not isinstance(doc.get("pcm"), dict):++        doc["pcm"] = {}++    doc["pcm"]["repositories"] = [{"name": n, "url": u} for n, u in rows]++    try:++        backup = _write_kicad_json(p["kicadJson"], doc)++    except OSError as e:++        return {"success": False, "errorCode": "settings_write_failed", "error": str(e),++                "_hint": "The original kicad.json is intact (atomic replace). Check the directory is writable."}++    out.update({"name": name, "added": True, "alreadyRegistered": False, "backup": str(backup) if backup else None,++                "repositories": _repositories_report(rows),++                "_hint": (f"Written to kicad.json pcm.repositories (KiCad's own default list was kept in front of it). {REPO_RESTART_NOTE} "++                          f"Then kicad_pcm_install {{\"repositoryUrl\": \"{url}\", \"identifier\": ...}} installs from it without the dialog.")})++    return out++++++def remove_repository(kicad_info: dict, args: dict) -> dict:++    url = str(args.get("url") or "").strip()++    if not url:++        return {"success": False, "errorCode": "invalid_arg", "error": "url is required"}++    p = paths(kicad_info)++    if not p:++        return {"success": False, "errorCode": "settings_not_found", "error": "KiCad config directory unknown"}++    doc, err = _read_kicad_json(p["kicadJson"])++    if doc is None:++        return {"success": False, "errorCode": "settings_unreadable", "error": err}++    rows = repositories_of(doc)++    keep = [(n, u) for n, u in rows if u != url]++    running, _ = running_block(kicad_info)++    if len(keep) == len(rows):++        return {"success": True, "url": url, "removed": False, "file": str(p["kicadJson"]), "repositories": _repositories_report(rows),++                "kicadRunning": running, "restartNeeded": False, "_hint": "That URL is not in pcm.repositories; nothing written."}++    if not isinstance(doc.get("pcm"), dict):++        doc["pcm"] = {}++    doc["pcm"]["repositories"] = [{"name": n, "url": u} for n, u in keep]++    try:++        backup = _write_kicad_json(p["kicadJson"], doc)++    except OSError as e:++        return {"success": False, "errorCode": "settings_write_failed", "error": str(e)}++    return {"success": True, "url": url, "removed": True, "file": str(p["kicadJson"]), "backup": str(backup) if backup else None,++            "repositories": _repositories_report(keep), "kicadRunning": running, "restartNeeded": True,++            "_hint": f"Removed from kicad.json pcm.repositories. Packages installed from it stay installed. {REPO_RESTART_NOTE}"}++++++def _install_from_zip(kicad_info: dict, args: dict, zip_path: str) -> dict:++    """kicad_pcm_install {zipPath}: one local package zip, through the same install code as a repository++    (its metadata.json becomes a one-package repository served from memory)."""++    zp = file_url_path(zip_path) or Path(zip_path)++    try:++        blob = zp.read_bytes()++    except OSError as e:++        return {"success": False, "errorCode": "zip_unreadable", "error": f"cannot read {zp}: {e}"}++    try:++        with zipfile.ZipFile(io.BytesIO(blob)) as z:++            meta = json.loads(z.read("metadata.json").decode("utf-8"))++    except KeyError:++        return {"success": False, "errorCode": "not_a_pcm_package", "error": "the zip has no metadata.json at its root (not a KiCad PCM package)",++                "_hint": "A PCM package zip carries metadata.json beside its symbols/, footprints/ and 3dmodels/ folders."}++    except (zipfile.BadZipFile, ValueError) as e:++        return {"success": False, "errorCode": "not_a_pcm_package", "error": f"metadata.json: {e}"}++    ident = meta.get("identifier") if isinstance(meta, dict) else None++    if not isinstance(ident, str) or not ident:++        return {"success": False, "errorCode": "not_a_pcm_package", "error": "metadata.json has no identifier"}++    versions = meta.get("versions") if isinstance(meta.get("versions"), list) and meta.get("versions") else [{"version": "0.0.0", "status": "stable", "kicad_version": "6.0"}]++    v = dict(versions[0])++    v.update({"download_url": "file://" + str(zp.resolve()), "download_sha256": sha256_hex(blob), "download_size": len(blob)})++    pkg = dict(meta, versions=[v])++    packages = json.dumps({"packages": [pkg]}).encode()++    repo_url, pkgs_url = "local-zip://repository.json", "local-zip://packages.json"++    repo = json.dumps({"name": "Local package zip", "packages": {"url": pkgs_url, "sha256": sha256_hex(packages)}}).encode()++    real = make_fetch(args.get("caller"))++++    def serve(url, timeout):++        if url == repo_url:++            return repo++        if url == pkgs_url:++            return packages++        return real(url, timeout)++    a = {k: x for k, x in args.items() if k != "zipPath"}++    a.update({"identifier": ident, "repositoryUrl": repo_url})++    # the registry row as KiCad's own Install from File writes it: repository_id "", repository_name "Local file"++    # (pcm_task_manager.cpp InstallFromFile -> pcm.cpp MarkInstalled with an empty repository id)++    out = install(kicad_info, a, serve, _row_repository=("", "Local file"))++    out["zipPath"] = str(zp)++    if out.get("success") and out.get("filesInstalled") is not None:   # a real install, not a refusal or alreadyInstalled++        out["_note"] = ("Installed from a local zip, recorded the way KiCad's own Install from File records it (repository 'Local file'): "++                        "KiCad's PCM shows it as installed but cannot check it for updates. Install from the repository instead when it is reachable.")++    return out++++++def install(kicad_info: dict, args: dict, fetch=None, _row_repository=None) -> dict:++    zp = str(args.get("zipPath") or "").strip()++    if zp:++        return _install_from_zip(kicad_info, args, zp)++    s = lambda k: (str(args.get(k)).strip() or None) if isinstance(args.get(k), str) else None++    ident = s("identifier")++    if not ident:++        return {"success": False, "error": "identifier is required",++                "_hint": "identifier like inc.adom.wiki.<owner>.<page>; repositoryUrl is the repository.json URL (optional for an installed package: its own repository is used). Or zipPath: one local package zip."}++    p = paths(kicad_info)++    if not p:++        return {"success": False, "error": "KiCad user/config directories unknown",++                "_hint": "kicad_readiness first; the bridge needs a detected KiCad to know the 3rdparty and settings paths."}++    if not can_touch(p["third"]):++        return _fda_refusal(p["third"], "kicad_pcm_install")++    auto_add, default_prefix = pcm_prefs(kicad_info)++    prefix = s("libPrefix") or default_prefix++    update = args.get("update") is True++    force = args.get("force") is True++    want = s("version")++    notes = []++    fetch = fetch or make_fetch(args.get("caller"), notes)++    installed = read_installed(p["installed"])++    previous = next((e.get("current_version") for e in installed["packages"] if _ident_of(e) == ident), None)++    repo_url = s("repositoryUrl")++    if not repo_url:++        if previous is None:++            return {"success": False, "errorCode": "not_installed",++                    "error": f"repositoryUrl is required: {ident} is not installed, so there is no repository to take it from",++                    "_hint": "repositoryUrl is required for a first install; update:true on an installed package finds its repository itself."}++        repo_url = _repository_url_for(installed, ident, registered_repositories(kicad_info))++        if not repo_url:++            return {"success": False, "errorCode": "repository_not_registered",++                    "error": f"{ident} is installed but its repository is not in kicad.json pcm.repositories",++                    "_hint": "Pass repositoryUrl, or kicad_pcm_add_repository {url} first so KiCad's PCM can see it too."}++    try:++        repo, packages = fetch_repository(fetch, repo_url, 120)++    except FetchError as e:++        if e.status == "hash":++            return {"success": False, "error": "packages.json hash does not match repository.json", "_hint": "The repository is mid-update or corrupted; retry in a minute."}++        return {"success": False, "error": f"could not read repository: {e}",++                "_hint": "A private wiki page needs the user's session; when this Mac's Adom Bridge cannot give it, send the package zip here and use kicad_pcm_install {zipPath}."}++    pkg = next((x for x in packages if isinstance(x, dict) and x.get("identifier") == ident), None)++    if pkg is None:++        return {"success": False, "error": f"package not in repository: {ident}", "available": [x.get("identifier") for x in packages[:20] if isinstance(x, dict)]}++    versions = pkg.get("versions") if isinstance(pkg.get("versions"), list) else []++    if want:++        vsel = next((v for v in versions if isinstance(v, dict) and v.get("version") == want), None)++        if vsel is None:++            return {"success": False, "error": f"version {want} not offered", "available": [v.get("version") for v in versions if isinstance(v, dict)]}++    else:++        vsel = latest_version(versions, p["version"])++        if vsel is None:++            return {"success": False, "error": "package has no downloadable version", "_hint": f"No version with a download_url is compatible with KiCad {p['version']}."}++    download_url = vsel.get("download_url")++    if not download_url:++        return {"success": False, "error": "package has no downloadable version"}++    version = str(vsel.get("version") or "")++    if update and not want and previous is not None and compare_versions(version, previous) <= 0 and not force:++        return {"success": True, "upToDate": True, "identifier": ident, "version": previous, "latestVersion": version, "repositoryUrl": repo_url,++                "_hint": "Installed version is the newest the repository offers; nothing changed. Pass version to install a specific one, or force:true to reinstall."}++    if previous == version and not force:++        return {"success": True, "alreadyInstalled": True, "identifier": ident, "version": version,++                "_hint": "Already installed at this version; pass force:true to reinstall."}++    try:++        blob = fetch(download_url, 600)++    except FetchError as e:++        return {"success": False, "error": f"install failed: {e}"}++    got = sha256_hex(blob)++    want_sha = vsel.get("download_sha256")++    if isinstance(want_sha, str) and want_sha and got != want_sha:++        return {"success": False, "error": "download sha256 mismatch", "expected": want_sha, "got": got,++                "_hint": "Refusing to install a package whose bytes do not match the repository's digest."}++    try:++        bad = zipfile.ZipFile(io.BytesIO(blob)).testzip()++    except zipfile.BadZipFile as e:++        return {"success": False, "error": f"install failed: the download is not a zip ({e})"}++    if bad:++        return {"success": False, "error": f"install failed: the package zip is damaged ({bad} fails its CRC)"}++    try:  # replace-on-update: the previous extraction goes first, as PCM does (after the zip is checked)++        ext = extract(blob, p["third"], ident, replace=True)++    except (ValueError, OSError, zipfile.BadZipFile) as e:++        return {"success": False, "error": f"install failed: {e}"}++    package = {k: x for k, x in pkg.items() if k != "versions"}++    package["versions"] = [vsel]++    entry = {"package": package, "current_version": version, "repository_id": repository_id(repo_url),++             "repository_name": str(repo.get("name") or "") if isinstance(repo, dict) else "",++             "install_timestamp": int(time.time()), "pinned": False}++    if _row_repository is not None:++        entry["repository_id"], entry["repository_name"] = _row_repository++    rows = [e for e in installed["packages"] if _ident_of(e) != ident] + [entry]++    installed["packages"] = rows++    try:++        write_installed(p["installed"], installed)++    except OSError as e:++        return {"success": False, "error": f"install failed: could not write installed_packages.json: {e}"}++    running, restart = running_block(kicad_info)++    replaced = previous is not None and previous != version++    hint = (f"Replaced {previous} with {version} exactly as PCM's Update does (old files removed, registry row rewritten; keep_on_update patterns are not honoured by the bridge). "++            if replaced else "Installed exactly as PCM would; KiCad's PCM dialog will list it as Installed. ")++    hint += ("Library nicknames above are created by KiCad's own PCM scan when the global library table next LOADS, so a KiCad that is "++             "already running must be restarted (never done for you) before the libraries appear.")++    if not auto_add:++        hint += " kicad.json pcm.lib_auto_add is OFF on this box, so KiCad will NOT add the rows itself: register the libraries with kicad_install_library or turn lib_auto_add back on."++    out = {"success": True, "identifier": ident, "version": version, "previousVersion": previous, "updated": replaced,++           "repositoryUrl": repo_url, "filesInstalled": ext["files"], "installedTo": str(p["third"] / "*" / ext["packageDir"]),++           "thirdPartyDir": str(p["third"]), "thirdPartyResolvedBy": p["resolvedBy"],++           "symbolLibraries": [prefix + n for n in ext["symbols"]], "footprintLibraries": [prefix + n for n in ext["footprints"]],++           "modelSets": ext["models"], "libAutoAdd": auto_add, "libPrefix": prefix,++           "kicadRunning": running, "restartNeeded": restart, "_hint": hint}++    if notes:++        out["notes"] = notes++    return out++++++def list_packages(kicad_info: dict, args: dict, fetch=None) -> dict:++    p = paths(kicad_info)++    if not p:++        return {"success": False, "error": "KiCad config directory unknown"}++    check = args.get("checkUpdates") is not False++    notes = []++    fetch = fetch or make_fetch(args.get("caller"), notes)++    repos = registered_repositories(kicad_info)++    d = read_installed(p["installed"])++    can_see = can_touch(p["third"])++    cache, updates, rows = {}, 0, []++    for e in d["packages"]:++        pk = e.get("package") if isinstance(e.get("package"), dict) else {}++        ident = str(pk.get("identifier") or "")++        current = str(e.get("current_version") or "")++        on_disk = any((p["third"] / f / pkgdir(ident)).exists() for f in FOLDERS) if can_see else None++        row = {"identifier": pk.get("identifier"), "name": pk.get("name"), "type": pk.get("type"), "version": e.get("current_version"),++               "repository": e.get("repository_name"), "repositoryId": e.get("repository_id"), "pinned": e.get("pinned"),++               "onDisk": on_disk, "updateAvailable": None, "latestVersion": None, "repositoryUrl": None,++               "updateCheck": "repository_not_registered" if check else "skipped"}++        if check:++            url = _repository_url_for(d, ident, repos)++            if url:++                row["repositoryUrl"] = url++                if url not in cache:++                    try:++                        cache[url] = fetch_repository(fetch, url, 20)[1]++                    except FetchError as err:++                        cache[url] = err++                got = cache[url]++                if isinstance(got, FetchError):++                    row["updateCheck"] = f"fetch_failed: {got}"++                else:++                    q = next((x for x in got if isinstance(x, dict) and x.get("identifier") == ident), None)++                    if q is None:++                        row["updateCheck"] = "package_not_in_repository"++                    else:++                        v = latest_version(q.get("versions") or [], p["version"])++                        if v is None:++                            row["updateCheck"] = "no_compatible_version"++                        else:++                            latest = str(v.get("version") or "")++                            pinned = e.get("pinned") is True++                            newer = compare_versions(latest, current) > 0++                            updates += 1 if newer and not pinned else 0++                            row.update({"latestVersion": latest, "updateAvailable": newer and not pinned, "updateCheck": "pinned" if newer and pinned else "ok"})++        rows.append(row)++    hint = ("checkUpdates:false: no repository was fetched." if not check else++            (f"{updates} package(s) have a newer version in their repository: kicad_pcm_install {{\"identifier\": ..., \"update\": true}} applies it." if updates else++             "Every installed package is at the newest version its repository offers (updateCheck says why when a check could not run)."))++    out = {"success": True, "count": len(rows), "packages": rows, "updatesAvailable": updates, "repositories": _repositories_report(repos),++           "thirdPartyDir": str(p["third"]), "thirdPartyResolvedBy": p["resolvedBy"], "kicadVersion": p["version"],++           "installedPackagesFile": str(p["installed"]), "_hint": hint}++    if not can_see:++        out["onDiskNote"] = "onDisk is null: the 3rdparty folder is inside macOS-protected ~/Documents and this bridge holds no Full Disk Access, so it was not looked at (the installed record above is KiCad's own)."++    if notes:++        out["notes"] = notes++    return out++++++def uninstall(kicad_info: dict, args: dict) -> dict:++    ident = str(args.get("identifier") or "").strip()++    if not ident:++        return {"success": False, "error": "identifier is required"}++    p = paths(kicad_info)++    if not p:++        return {"success": False, "error": "KiCad user/config directories unknown"}++    if not can_touch(p["third"]):++        return _fda_refusal(p["third"], "kicad_pcm_uninstall")++    removed = []++    for folder in FOLDERS:++        d = p["third"] / folder / pkgdir(ident)++        if d.exists():++            shutil.rmtree(d, ignore_errors=True)++            removed.append(str(d))++    installed = read_installed(p["installed"])++    before = len(installed["packages"])++    installed["packages"] = [e for e in installed["packages"] if _ident_of(e) != ident]++    try:++        write_installed(p["installed"], installed)++    except OSError as e:++        return {"success": False, "error": f"could not write installed_packages.json: {e}"}++    running, restart = running_block(kicad_info)++    return {"success": True, "identifier": ident, "removedDirs": removed, "recordRemoved": before != len(installed["packages"]),++            "kicadRunning": running, "restartNeeded": restart,++            "_hint": "KiCad drops the PCM_ library rows itself on next load when pcm.lib_auto_remove is on."}++++++def handle_pcm_list(kicad_info: dict, args: dict) -> dict:++    return list_packages(kicad_info, args or {})++++++def handle_pcm_install(kicad_info: dict, args: dict) -> dict:++    return install(kicad_info, args or {})++++++def handle_pcm_uninstall(kicad_info: dict, args: dict) -> dict:++    return uninstall(kicad_info, args or {})++++++def handle_pcm_add_repository(kicad_info: dict, args: dict) -> dict:++    return add_repository(kicad_info, args or {})++++++def handle_pcm_remove_repository(kicad_info: dict, args: dict) -> dict:++    return remove_repository(kicad_info, args or {})+diff -uN a/server.py b/server.py+--- a/server.py++++ b/server.py+@@ -68,6 +68,8 @@+ from handlers.placement import handle_placement_state, handle_move_footprint, handle_placement_validate+ from handlers.live_zones import handle_select_net, handle_select_clear, handle_zone_state, handle_apply_zone_plan+ from handlers.view_3d import handle_3d_view++from handlers.pcm import (handle_pcm_list, handle_pcm_install, handle_pcm_uninstall,++                          handle_pcm_add_repository, handle_pcm_remove_repository)+ from handlers.export import (+     handle_export_gerber, handle_export_pdf, handle_export_svg,+     handle_export_step, handle_export_bom_csv,+@@ -339,7 +341,7 @@+         "related": ["kicad_zone_state", "kicad_routing_validate"],+         "pitfalls": ["Desired holes and arcs are refused by this polygon request schema; existing unowned holes/arcs remain untouched.", "Inspect postCommitError before retrying a committed edit.", "a keepout zone is {type:\"keepout\", keepout:{copperPour,tracks,vias,pads,footprints}} with no net"]},+     "select_net": {"summary": "Select every copper item of one net on the live board (pours, tracks, vias, pads) so the editor highlights the net as a whole; no cursor, no hotkey.", "long": False,+-        "hint": "The way to walk a board net by net on camera: select the net, then frame it from the PCB Editor's View menu (Zoom to Selection); kicad_select_clear afterwards. Selection is not an edit (no undo step, revision unchanged). add:true keeps the previous selection.",++        "hint": "The way to walk a board net by net on camera: select the net, then frame it with kicad_send_key {hwnd, menu: \"Zoom to Selected Objects\"} (the exact label: \"Zoom to Selection Area\" is an interactive drag tool); kicad_select_clear afterwards. Selection is not an edit (no undo step, revision unchanged). add:true keeps the previous selection.",+         "related": ["kicad_select_clear", "kicad_zone_state", "kicad_routing_state"],+         "pitfalls": ["net names are exact (KiCad's spelling, with the leading slash for local labels)", "a net with no copper yet has nothing to select"]},+     "select_clear": {"summary": "Clear the live editor's selection.", "long": False,+@@ -349,6 +351,24 @@+         "hint": "kicad_3d_view {\"action\":\"rotate_z_cw\",\"count\":8,\"pauseMs\":700}. action:\"actions\" lists them; action:\"tour\" runs the default walkthrough (fit, top, tilt, orbit, zoom in, swing, zoom out, front, bottom, flip, top, fit) or your own steps list of [action, count]. Open the viewer first with kicad_open_3d_viewer. No focus change, no cursor, no Apple Events: the item is pressed on the viewer process's menu bar via AX after making the viewer the app's main window.",+         "related": ["kicad_open_3d_viewer", "kicad_screenshot_all", "kicad_model_check"],+         "pitfalls": ["the viewer must be open (kicad_open_3d_viewer) and Accessibility granted to the host app", "a huge board takes seconds per redraw; keep pauseMs at 500 or more", "window titles (to find the viewer) need the Screen Recording grant"]},++    "pcm_list": {"summary": "Packages KiCad's PCM considers installed on this machine, each with updateAvailable from its own repository (read-only).", "long": False,++        "hint": "kicad_pcm_list {} reads installed_packages.json (~/Library/Preferences/kicad/<ver>), checks the 3rdparty directories exist (only with Full Disk Access: they sit in ~/Documents; onDisk is null otherwise), then fetches each package's repository (matched by repository_id against kicad.json pcm.repositories) and compares the newest compatible version with the installed one. checkUpdates:false skips the network.",++        "related": ["kicad_pcm_install", "kicad_pcm_add_repository"],++        "pitfalls": ["updateCheck repository_not_registered means the package's repository is not in kicad.json: kicad_pcm_add_repository fixes it", "a pinned package reports updateAvailable false with updateCheck pinned, as PCM does"]},++    "pcm_install": {"summary": "Install or update a package from a KiCad PCM repository, exactly as the Plugin and Content Manager would (no dialog).", "long": True,++        "hint": "repositoryUrl (a repository.json, https or file://) + identifier installs the newest compatible version (or version). A private Adom wiki page that answers 404 anonymously is fetched again with the user's session through Adom Bridge's desktop_wiki_fetch, when this Adom Bridge offers it. zipPath installs one local PCM package zip (its metadata.json gives the identifier) with the same registry row; KiCad cannot check such a package for updates. On macOS the 3rdparty folder is in ~/Documents: the bridge writes it only with Full Disk Access (errorCode full_disk_access_required otherwise).",++        "related": ["kicad_pcm_list", "kicad_pcm_uninstall", "kicad_pcm_add_repository"],++        "pitfalls": ["a RUNNING KiCad does not see the new libraries until restarted", "download is sha256-verified against the repository; a mismatch refuses", "an update removes the old package directories first", "macOS: no Full Disk Access, no install into ~/Documents (never a per-folder prompt)"]},++    "pcm_uninstall": {"summary": "Remove a PCM-installed package: its 3rdparty directories and its record.", "long": False,++        "hint": "kicad_pcm_uninstall {identifier}. KiCad drops the PCM_ library rows on next load when pcm.lib_auto_remove is on.",++        "related": ["kicad_pcm_install", "kicad_pcm_list"], "pitfalls": ["restart a running KiCad afterwards", "macOS: needs Full Disk Access when 3rdparty is in ~/Documents"]},++    "pcm_add_repository": {"summary": "Register a PCM repository in kicad.json (pcm.repositories), the way Plugin and Content Manager > Manage > add URL does.", "long": False,++        "hint": "Fetches the repository.json to confirm it is a PCM repository and to take its name (name overrides, and registers it unverified when the fetch fails). Idempotent on the URL. Writes kicad.json with a .adom-bak beside it and an atomic replace; KiCad's official repository row is kept in front when the key was absent. KiCad reads the list at start.",++        "related": ["kicad_pcm_remove_repository", "kicad_pcm_install", "kicad_pcm_list"],++        "pitfalls": ["a KiCad that is running NOW overwrites kicad.json when it saves settings on exit: close it first when in doubt"]},++    "pcm_remove_repository": {"summary": "Drop a PCM repository from kicad.json pcm.repositories. Installed packages stay installed.", "long": False,++        "hint": "Same backup and atomic write as add; removed:false when the URL was not registered.",++        "related": ["kicad_pcm_add_repository", "kicad_pcm_list"], "pitfalls": ["KiCad reads the list at start; a running KiCad keeps the old list until relaunched"]},+     "open_board": {"summary": "Open a .kicad_pcb in the PCB editor (pcbnew).", "long": True,+         "hint": "Pass an ABSOLUTE filePath; opens the board in pcbnew and returns once the window is up. Optional kicadVersion pins a specific install.",+         "related": ["kicad_open_3d_viewer", "kicad_run_drc", "kicad_screenshot_all", "kicad_close"],+@@ -548,6 +568,16 @@+                         "ownedItemIds": _A("array", "zone ids this plan owns (empty for a first plan)", req=True),+                         "zones": _A("array", "[{name, net, layers, polygon:[[x,y],...], priority?, clearance?, minThickness?, connection?, thermalGap?, thermalBridge?} | {type:'keepout', keepout:{...}}]", req=True),+                         "dryRun": _A("boolean", "DRC the candidate, change nothing"), "save": _A("boolean", "save after the commit"), "socketPath": _SOCKET},++    "pcm_list": {"checkUpdates": _A("boolean", "fetch each package's repository for updates (default true)")},++    "pcm_install": {"repositoryUrl": _A("string", "repository.json URL (https:// or file://); optional for an installed package"),++                    "identifier": _A("string", "package identifier, e.g. inc.adom.wiki.<owner>.<page>"),++                    "version": _A("string", "a specific version (default: newest compatible)"),++                    "update": _A("boolean", "PCM's Update button"), "force": _A("boolean", "reinstall the same version"),++                    "libPrefix": _A("string", "library nickname prefix (default kicad.json pcm.lib_prefix, PCM_)"),++                    "zipPath": _A("string", "a local PCM package zip instead of a repository")},++    "pcm_uninstall": {"identifier": _A("string", "package identifier", req=True)},++    "pcm_add_repository": {"url": _A("string", "repository.json URL", req=True), "name": _A("string", "display name (else the repository's own)")},++    "pcm_remove_repository": {"url": _A("string", "repository.json URL", req=True)},+     "select_net": {"filePath": _FILEPATH_PCB, "net": _A("string", "exact net name", req=True), "add": _A("boolean", "keep the previous selection"), "socketPath": _SOCKET},+     "select_clear": {"filePath": _FILEPATH_PCB, "socketPath": _SOCKET},+     "3d_view": {"action": _A("string", "zoom_in|zoom_out|fit|rotate_[xyz]_(cw|ccw)|view_(top|bottom|front|back|left|right)|flip|pan_*|tour|actions"),+@@ -611,7 +641,8 @@+     "screenshot_all": {},+     "send_key": {+         "hwnd": _A("number", "EXACT window handle from window_info", req=True),+-        "key": _A("string", "key or chord, e.g. 'enter', 'cmd+s' (chords: macOS only)", req=True),++        "key": _A("string", "key or chord, e.g. 'enter', 'cmd+s' (chords: macOS only); required unless menu is given"),++        "menu": _A("string", "a menu-item label fired in the background instead of a key: an exact label wins, a substring must name one item (e.g. 'Zoom to Selected Objects')"),+         "allowForeground": _A("boolean", "Windows only: permit a foreground fallback")},+     "click": {+         "hwnd": _A("number", "EXACT window handle from window_info", req=True),+@@ -713,7 +744,7 @@+     "routing_state": "ipc_read", "routing_validate": "ipc_read", "placement_state": "ipc_read",+     "placement_validate": "ipc_read", "zone_state": "ipc_read", "select_net": "ipc_read",+     "add_track": "file", "add_via": "file", "route": "file",+-    "routing_deps": "deps",++    "routing_deps": "deps", "pcm_install": "install",+ }+ + +@@ -875,6 +906,12 @@+     "select_net": handle_select_net,+     "select_clear": handle_select_clear,+     "3d_view": handle_3d_view,++    # KiCad PCM without the dialog (0.11.3+tours, port of the Windows line's pcm.rs)++    "pcm_list": handle_pcm_list,++    "pcm_install": handle_pcm_install,++    "pcm_uninstall": handle_pcm_uninstall,++    "pcm_add_repository": handle_pcm_add_repository,++    "pcm_remove_repository": handle_pcm_remove_repository,+     "enable_software_opengl": handle_enable_software_opengl,+     "install_library": handle_install_library,+     "install_symbol": handle_install_symbol,+@@ -1071,6 +1108,30 @@+             return f"{len(r.get('pads') or [])} pad(s) with nets from the file."+         if command in ("add_track", "add_via", "route"):+             return f"Copper written to the board FILE ({command}); the editor is not reloaded."++        # PCM verbs (0.11.3+tours): macOS Adom Bridge forwards the prefixed name (kicad_pcm_list), so match both++        pc = command[len("kicad_"):] if command.startswith("kicad_pcm_") else command++        if pc == "pcm_list":++            pk = [x for x in (r.get("packages") or []) if isinstance(x, dict)]++            ids = ", ".join(f"{x.get('identifier')} {x.get('version')}" for x in pk[:10]) + (f" (+{len(pk) - 10} more)" if len(pk) > 10 else "")++            return (f"{len(pk)} PCM package(s) installed{': ' + ids if pk else ''}; "++                    f"{r.get('updatesAvailable', 0)} update(s) available.")++        if pc == "pcm_install":++            if r.get("alreadyInstalled"):++                return f"{r.get('identifier')} {r.get('version')} is already installed; nothing changed."++            if r.get("upToDate"):++                return f"{r.get('identifier')} is up to date at {r.get('version')}; nothing changed."++            return (f"Installed {r.get('identifier')} {r.get('version')} ({r.get('filesInstalled')} file(s)) into "++                    f"{r.get('thirdPartyDir')}; KiCad adds its PCM libraries the next time it starts (restart it if it is running).")++        if pc == "pcm_uninstall":++            return (f"Uninstalled {r.get('identifier')}: {len(r.get('removedDirs') or [])} folder(s) removed, "++                    f"registry record removed={r.get('recordRemoved')}.")++        if pc == "pcm_add_repository":++            if r.get("alreadyRegistered"):++                return f"{r.get('url')} is already in KiCad's PCM repositories; nothing written."++            return f"Added {r.get('url')} to KiCad's PCM repositories (verified={r.get('verified')}); KiCad reads the list at start."++        if pc == "pcm_remove_repository":++            return (f"Removed {r.get('url')} from KiCad's PCM repositories." if r.get("removed")++                    else f"{r.get('url')} was not in KiCad's PCM repositories; nothing written.")+         if command == "adom_library_status":+             if not r.get("exists"):+                 return "The Adom library is not installed yet."+@@ -1261,7 +1322,8 @@+                "run_drc", "run_erc", "lint_board", "lint_schematic", "lint_library",+                "format_upgrade", "export_gerber", "export_gerbers", "export_pdf",+                "export_svg", "export_step", "export_bom_csv", "adom_library_status",+-               "list_versions", "install_library"}++               "list_versions", "install_library",++               "pcm_list", "pcm_install", "pcm_uninstall", "pcm_add_repository", "pcm_remove_repository"}+     # Only verbs that actually SPAWN a window get the ~3s settle-poll (they can+     # trigger an async dialog). Read-only reporters (open_editors, screenshot_all,+     # window_info) don't spawn windows → one cheap scan, no needless 3s poll.+--- a/macos/0.11.3-fixes/SHA256SUMS+++ b/macos/0.11.3-fixes/SHA256SUMS@@ -0,0 +1,8 @@+79094e4f73650dd9176f45ef94e8c1de3c452d30298082985874dbdc75dfa60d  BRIDGE_VERSION+c816e3dbc5ec3f8e2d8bf103534ff5df490a13f8622b450d189e52c6c8e73c19  bridge.json+10c58b5009e0e8470d104a4c384a13f0845d3ffb1a57d0779917f03742afa9f6  handlers/kicad_ui.py+8439e27e5ef1f771c6cb7f689cc95e0bbeb9a320e1d34668ace7c14bd1b59c79  handlers/live_zones.py+31fad1c589eb06e8a733ffb6c26daf23a6b0040e654eb7ce4414c4af3b5489a6  handlers/mac_ui.py+7fe51ef9d2c13a3841afcbc65aa34850d4ec096140b250363470bfa580172403  handlers/pcm.py+53512a212b6032eb12e0c718e41d9e7b728204baac51e40f1848eb702f9a54c5  server.py+--- a/macos/0.11.3-fixes/tests/test_bridge_fix.py+++ b/macos/0.11.3-fixes/tests/test_bridge_fix.py@@ -0,0 +1,124 @@+"""Tests for the KiCad bridge (macOS line 0.11.3) tour fix: kicad_select_net and kicad_send_key{menu}.++    KICAD_BRIDGE_BASE=<0.11.3 zip tree> KICAD_BRIDGE_TREE=<patched tree> python tests/test_bridge_fix.py+    (Python 3.12 with kicad-python 0.8.0, protobuf 5.29.6 and pynng 0.9.0, the Mac bridge's own stack)++Each tree runs in its own interpreter (both are a top-level `handlers` package).+"""+from __future__ import annotations++import json+import subprocess+import sys+from pathlib import Path++import os++HERE = Path(__file__).resolve().parent+TOURS = HERE.parent+PRISTINE = Path(os.environ["KICAD_BRIDGE_BASE"])    # adom-bridge-kicad-v0.11.3.zip, unpacked+PATCHED = Path(os.environ["KICAD_BRIDGE_TREE"])     # the same tree with kicad-bridge-0.11.3-macos.patch applied+++def probe(tree: Path) -> dict:+    out = subprocess.run([sys.executable, str(HERE / "bridge_select_net_probe.py"), str(tree)],+                         capture_output=True, text=True, timeout=120, cwd=str(TOURS))+    assert out.returncode == 0, out.stderr[-3000:]+    return json.loads(out.stdout.strip().splitlines()[-1])+++_CACHE: dict = {}+++def res(tree: Path) -> dict:+    if tree not in _CACHE:+        _CACHE[tree] = probe(tree)+    return _CACHE[tree]+++# --- the fault, as the Mac showed it ---------------------------------------------------------++def test_pristine_0_11_3_reproduces_the_mac_reply():+    g = res(PRISTINE)["gnd"]+    assert g["reply"]["success"] is False+    assert g["reply"]["errorCode"] == "ipc_unavailable"+    assert g["reply"]["error"] == "id"+    # the selection was cleared, then the add raised before anything was sent+    assert g["commands"][-1] == "ClearSelection"+    assert "AddToSelection" not in g["commands"]+    assert g["selection"] == []+++def test_pristine_0_11_3_cannot_frame_on_macos():+    s = res(PRISTINE)["sendKey"]+    assert s["success"] is False and s["error"] == "Missing required arg: key" and s["keysSent"] == []+++# --- the fix ------------------------------------------------------------------------------------++def test_select_net_selects_every_copper_item_of_the_net():+    g = res(PATCHED)["gnd"]+    r = g["reply"]+    assert r["success"] is True, r+    # 2 tracks + 1 arc, 1 via, 2 pads, 1 copper zone; never the rule area, never another net+    assert r["byKind"] == {"tracks": 3, "vias": 1, "pads": 2, "zones": 1}+    assert r["selected"] == 7 and r["selectionConfirmed"] == 7+    assert g["commands"][-2:] == ["ClearSelection", "AddToSelection"]+    assert sorted(g["selection"]) == sorted(["t1", "t2", "a1", "v1", "p1", "p2", "z1"])+++def test_add_keeps_the_previous_selection():+    g = res(PATCHED)["gndAdd"]+    assert g["reply"]["success"] is True+    assert "ClearSelection" not in g["commands"]+++def test_unknown_net_is_refused_as_before():+    u = res(PATCHED)["unknown"]["reply"]+    assert u["success"] is False and u["errorCode"] == "unknown_net"+    assert res(PRISTINE)["unknown"]["reply"]["errorCode"] == "unknown_net"+++def test_a_selection_kicad_did_not_take_is_not_reported_as_made():+    d = res(PATCHED)["dropped"]["reply"]+    assert d["success"] is False and d["errorCode"] == "selection_not_applied"+    assert d["requested"] == 7 and d["selectionCount"] == 0+++def test_menu_labels_follow_the_windows_rule():+    m = res(PATCHED)["menu"]+    assert m["Zoom to Selected Objects"] == "Zoom to Selected Objects"+    assert m["zoom to fit"] == "Zoom to Fit"+    assert m["Zoom to Fit…"] == "Zoom to Fit"            # a trailing ellipsis is ignored+    assert m["Footprint Browser"] == "Footprint Browser…"+    assert m["3D Viewer"] == "3D Viewer"+    assert m["to Objects"] == "Zoom to Objects"           # a substring naming one item+    assert m["Objects"] == ["ambiguous", ["Zoom to Objects", "Zoom to Selected Objects"]]+    assert m["Zoom"][0] == "ambiguous" and len(m["Zoom"][1]) == 6+    assert m["Nonexistent"] == ["not_found", []]+    assert m[""] == ["not_found", []]+    # a unique substring is taken, as on Windows: why the hints now name the exact label+    assert m["Zoom to Selection"] == "Zoom to Selection Area"+++def test_send_key_menu_fires_in_the_background_on_the_target_window():+    s = res(PATCHED)["sendKey"]+    assert s["ok"] == {"success": True, "mechanism": "menu", "menuItem": "Zoom to Selected Objects", "foreground": False}+    assert s["invokedWith"][0] == [52411, "Zoom to Selected Objects", "scratch_strip — PCB Editor"]+    assert s["ambiguous"] == {"success": False, "errorCode": "menu_item_ambiguous", "matches": ["Zoom In", "Zoom Out"]}+    assert s["stale"] == {"success": False, "errorCode": "no_window"}+    # a key press without key or menu is refused exactly as before+    assert s["noKey"] == {"success": False, "error": "Missing required arg: key"}+++if __name__ == "__main__":+    fails = 0+    for name, fn in sorted(globals().items()):+        if name.startswith("test_") and callable(fn):+            try:+                fn(); print(f"PASS {name}")+            except AssertionError as e:  # noqa: PERF203+                fails += 1; print(f"FAIL {name}: {e}")+    print(f"{fails} failed")+    raise SystemExit(1 if fails else 0)+--- a/macos/0.11.3-fixes/tests/bridge_select_net_probe.py+++ b/macos/0.11.3-fixes/tests/bridge_select_net_probe.py@@ -0,0 +1,173 @@+"""Drive one KiCad bridge tree's kicad_select_net / kicad_send_key{menu} against a fake KiCad IPC+endpoint, with the real kicad-python 0.8.0 Board in between. Prints one JSON line of results.++    venv/bin/python tests/bridge_select_net_probe.py <bridge tree>++The fake endpoint answers the three commands kipy sends for a net selection (GetItems by type,+ClearSelection, AddToSelection) the way KiCad's API handler does: AddToSelection adds the items+whose KIIDs exist on the board and answers the whole selection. Run by test_bridge_fix.py against+the pristine 0.11.3 tree and the patched tree.+"""+from __future__ import annotations++import contextlib+import json+import sys++TREE = sys.argv[1]+sys.path.insert(0, TREE)++from kipy.board import Board  # noqa: E402+from kipy.proto.board import board_types_pb2  # noqa: E402+from kipy.proto.common.commands import editor_commands_pb2  # noqa: E402+from kipy.proto.common.types import DocumentSpecifier, KiCadObjectType  # noqa: E402+from kipy.proto.common.types import base_types_pb2  # noqa: E402,F401+++def _track(i, net):+    t = board_types_pb2.Track(); t.id.value = i; t.net.name = net; return t+++def _arc(i, net):+    a = board_types_pb2.Arc(); a.id.value = i; a.net.name = net; return a+++def _via(i, net):+    v = board_types_pb2.Via(); v.id.value = i; v.net.name = net; return v+++def _pad(i, net):+    p = board_types_pb2.Pad(); p.id.value = i; p.net.name = net; return p+++def _zone(i, net, rule_area=False):+    z = board_types_pb2.Zone(); z.id.value = i+    z.type = board_types_pb2.ZT_RULE_AREA if rule_area else board_types_pb2.ZT_COPPER+    if not rule_area:+        z.copper_settings.net.name = net+    z.layers.append(board_types_pb2.BL_F_Cu)+    poly = z.outline.polygons.add()+    for x, y in ((0, 0), (10_000_000, 0), (10_000_000, 5_000_000), (0, 5_000_000)):+        n = poly.outline.nodes.add(); n.point.x_nm = x; n.point.y_nm = y+    poly.outline.closed = True+    return z+++BOARD = {+    KiCadObjectType.KOT_PCB_TRACE: [_track("t1", "GND"), _track("t2", "GND"), _track("t3", "5V_IN")],+    KiCadObjectType.KOT_PCB_ARC: [_arc("a1", "GND")],+    KiCadObjectType.KOT_PCB_VIA: [_via("v1", "GND"), _via("v2", "3V3A_S")],+    KiCadObjectType.KOT_PCB_PAD: [_pad("p1", "GND"), _pad("p2", "GND"), _pad("p3", "5V_IN")],+    KiCadObjectType.KOT_PCB_ZONE: [_zone("z1", "GND"), _zone("k1", "", rule_area=True)],+}+BY_ID = {m.id.value: m for ms in BOARD.values() for m in ms}+++class FakeKiCad:+    def __init__(self, drop_selection=False):+        self.log, self.selection, self.drop = [], [], drop_selection++    def send(self, cmd, response_type):+        name = type(cmd).__name__+        self.log.append(name)+        resp = response_type()+        if name == "GetItems":+            for t in cmd.types:+                for m in BOARD.get(t, []):+                    resp.items.add().Pack(m)+        elif name == "ClearSelection":+            self.selection = []+        elif name == "AddToSelection":+            if not self.drop:+                for k in cmd.items:+                    if k.value in BY_ID and k.value not in self.selection:+                        self.selection.append(k.value)+            for i in self.selection:+                resp.items.add().Pack(BY_ID[i])+        else:+            raise AssertionError(f"unexpected command {name}")+        return resp+++def run(net, add=None, drop=False):+    from handlers import live_routing as lr+    from handlers import live_zones as lz+    fake = FakeKiCad(drop_selection=drop)++    @contextlib.contextmanager+    def connect(_args):+        yield Board(fake, DocumentSpecifier())++    lr._connect = connect+    lr.routing_deps.available = lambda: True+    args = {"filePath": "/Users/someone/boards/board.kicad_pcb", "net": net}+    if add is not None:+        args["add"] = add+    reply = lz.handle_select_net({}, args)+    return {"reply": {k: reply.get(k) for k in ("success", "errorCode", "error", "selected", "selectionConfirmed", "byKind", "requested", "selectionCount")},+            "commands": fake.log, "selection": fake.selection}+++def menu_cases():+    from handlers import mac_ui+    if not hasattr(mac_ui, "resolve_menu_label"):+        return None+    labels = ["Zoom In", "Zoom Out", "Zoom to Fit", "Zoom to Objects", "Zoom to Selected Objects",+              "Zoom to Selection Area", "Redraw", "3D Viewer", "Footprint Browser…"]+    r = mac_ui.resolve_menu_label+    out = {}+    for needle in ("Zoom to Selected Objects", "zoom to fit", "Zoom to Fit…", "3D Viewer", "Footprint Browser",+                   "Zoom to Selection", "Zoom", "to Objects", "Objects", "Nonexistent", ""):+        idx, why = r(labels, needle)+        out[needle] = labels[idx] if idx is not None else list(why)+    return out+++def send_key_cases():+    from handlers import kicad_ui, mac_ui+    if not hasattr(kicad_ui, "_mac_send_menu"):+        return None+    win = {"hwnd": 2846, "pid": 52411, "ownerName": "PCB Editor", "title": "scratch_strip — PCB Editor",+           "rect": {"left": 0, "top": 33, "width": 1470, "height": 923}}+    calls = []+    kicad_ui._mac_kicad_pids = lambda info: [52411]+    mac_ui.list_kicad_windows = lambda pids=None: [win]+    mac_ui.ax_available = lambda: True++    def invoke(pid, needle, window_title_contains=""):+        calls.append((pid, needle, window_title_contains))+        if needle == "Zoom":+            return {"ok": False, "errorCode": "menu_item_ambiguous", "matches": ["Zoom In", "Zoom Out"], "error": "'Zoom' names 2 menu items"}+        return {"ok": True, "method": "ax-menu-press", "menuItem": needle, "menuPath": ["View", needle], "madeMain": True}++    mac_ui.ax_menu_invoke_by_text = invoke+    ok = kicad_ui._mac_send_key({}, {"hwnd": 2846, "menu": "Zoom to Selected Objects"})+    amb = kicad_ui._mac_send_key({}, {"hwnd": 2846, "menu": "Zoom"})+    stale = kicad_ui._mac_send_key({}, {"hwnd": 9999, "menu": "Zoom to Fit"})+    nokey = kicad_ui._mac_send_key({}, {"hwnd": 2846})+    return {"ok": {k: ok.get(k) for k in ("success", "mechanism")} | {"menuItem": ok["data"]["menuItem"], "foreground": ok["data"]["foreground"]},+            "ambiguous": {k: amb.get(k) for k in ("success", "errorCode", "matches")},+            "stale": {k: stale.get(k) for k in ("success", "errorCode")},+            "noKey": {k: nokey.get(k) for k in ("success", "error")},+            "invokedWith": calls}+++def unpatched_menu():+    """What the tree answers to AI Flow's framing call (kicad_send_key {hwnd, menu}) on a Mac."""+    from handlers import kicad_ui, mac_ui+    win = {"hwnd": 2846, "pid": 52411, "ownerName": "PCB Editor", "title": "b — PCB Editor", "rect": {}}+    kicad_ui._mac_kicad_pids = lambda info: [52411]+    mac_ui.list_kicad_windows = lambda pids=None: [win]+    sent = []+    mac_ui.send_key_to_pid = lambda pid, key: sent.append(key) or {"success": True}+    r = kicad_ui._mac_send_key({}, {"hwnd": 2846, "menu": "Zoom to Selected Objects"})+    return {"success": r.get("success"), "error": r.get("error"), "keysSent": sent}+++if __name__ == "__main__":+    res = {"tree": TREE,+           "gnd": run("GND"), "gndAdd": run("GND", add=True), "unknown": run("NOPE"), "dropped": run("GND", drop=True),+           "menu": menu_cases()}+    res["sendKey"] = send_key_cases() if res["menu"] is not None else unpatched_menu()+    print(json.dumps(res, default=str))+--- a/macos/0.11.3-fixes/tests/test_pcm_macos.py+++ b/macos/0.11.3-fixes/tests/test_pcm_macos.py@@ -0,0 +1,358 @@+"""Tests for the macOS KiCad bridge's PCM verbs (0.11.3+tours handlers/pcm.py), offline.++    KICAD_BRIDGE_TREE=<patched tree> python tests/test_pcm_macos.py++A temporary home laid out like the Mac (~/Library/Preferences/kicad/10.0, ~/Documents/KiCad/10.0), and+repositories written the way AI Flow's project-page writes them (repository.json -> packages.json -> the package+zip, each sha256-pinned).+`pcm check` (AI Flow main.rs) passes when kicad_pcm_list's reply text contains the identifier: tested as such.+"""+from __future__ import annotations++import base64+import hashlib+import io+import json+import os+import shutil+import sys+import tempfile+import unittest+import zipfile+from pathlib import Path++HERE = Path(__file__).resolve().parent+TREE = Path(os.environ["KICAD_BRIDGE_TREE"])+TMP = Path(os.environ.get("PCM_TEST_TMP") or tempfile.gettempdir())+sys.path.insert(0, str(TREE))++from handlers import pcm  # noqa: E402+import kicad_detect  # noqa: E402++H = lambda b: hashlib.sha256(b).hexdigest()+++def package_zip(lib="test-lib", ident="inc.adom.wiki.adom.test-board", version="1.0.0", extra=None):+    buf = io.BytesIO()+    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:+        z.writestr("metadata.json", json.dumps({"identifier": ident, "name": "Test", "type": "library",+                                                "versions": [{"version": version, "status": "stable", "kicad_version": "9.0"}]}))+        z.writestr(f"symbols/{lib}.kicad_sym", "(kicad_symbol_lib (version 20241209))\n")+        z.writestr(f"footprints/{lib}.pretty/R_0402.kicad_mod", '(footprint "R_0402")\n')+        z.writestr(f"3dmodels/{lib}.3dshapes/R_0402.step", "ISO-10303-21;\nEND-ISO-10303-21;\n")+        z.writestr("README.md", "a root file PCM ignores\n")+        for name, data in (extra or {}).items():+            z.writestr(name, data)+    return buf.getvalue()+++class Repo:+    """A PCM repository as project-page writes it: URLs under `base`, files served from a dict (or disk)."""++    def __init__(self, base, ident, versions):  # versions: [(version, zipbytes, extra fields)]+        self.files = {}+        pk_versions = []+        for v, blob, extra in versions:+            url = f"{base}/pcm/{ident}-{v}.zip"+            self.files[url] = blob+            pk_versions.append(dict({"version": v, "status": "stable", "kicad_version": "9.0", "download_url": url,+                                     "download_sha256": H(blob), "download_size": len(blob)}, **(extra or {})))+        pkgs = json.dumps({"packages": [{"identifier": ident, "name": "Test", "type": "library", "versions": pk_versions}]}).encode()+        self.files[f"{base}/pcm/packages.json"] = pkgs+        self.url = f"{base}/pcm/repository.json"+        self.files[self.url] = json.dumps({"name": "Test board (Adom wiki)", "packages": {"url": f"{base}/pcm/packages.json", "sha256": H(pkgs)}}).encode()+        self.calls = []++    def fetch(self, url, timeout):+        self.calls.append(url)+        if url not in self.files:+            raise pcm.FetchError("HTTPError: http status: 404", status=404)+        return self.files[url]++    def write_to(self, d: Path):+        """The same repository on disk with file:// URLs (kicad-bridge #132 route a)."""+        d.mkdir(parents=True, exist_ok=True)+        out = {}+        for url, data in self.files.items():+            out[url] = d / url.rsplit("/", 1)[-1]+        for url, path in out.items():+            data = self.files[url]+            if url.endswith(".json"):+                t = data.decode()+                for u2, p2 in out.items():+                    t = t.replace(u2, "file://" + str(p2))+                data = t.encode()+            path.write_bytes(data)+        # packages.json changed (file:// URLs): re-pin its hash in repository.json+        pk = out[[u for u in out if u.endswith("packages.json")][0]]+        rp = out[self.url]+        r = json.loads(rp.read_text()); r["packages"]["url"] = "file://" + str(pk); r["packages"]["sha256"] = H(pk.read_bytes())+        rp.write_text(json.dumps(r))+        return "file://" + str(rp)+++class _Home(unittest.TestCase):+    """A temporary home laid out like the Mac's, with Full Disk Access granted unless a test says otherwise."""++    def setUp(self):+        TMP.mkdir(exist_ok=True)+        self.tmp = tempfile.TemporaryDirectory(dir=TMP)+        self.home = Path(self.tmp.name)+        self.old_home = os.environ.get("HOME")+        os.environ["HOME"] = str(self.home)+        self.cfg = self.home / "Library" / "Preferences" / "kicad" / "10.0"+        self.cfg.mkdir(parents=True)+        self.info = {"version": "10.0", "full_version": "10.0.7", "config_dir": str(self.cfg), "base_dir": "/Applications/KiCad/KiCad.app"}+        self.third = self.home / "Documents" / "KiCad" / "10.0" / "3rdparty"+        self.saved = (pcm.IS_MACOS, kicad_detect._mac_can_touch_protected, pcm.running_block, pcm.ab_call, pcm.anonymous_fetch)+        pcm.IS_MACOS = True+        kicad_detect._mac_can_touch_protected = lambda: True   # Full Disk Access granted unless a test says otherwise+        pcm.running_block = lambda info: (False, False)++    def tearDown(self):+        pcm.IS_MACOS, kicad_detect._mac_can_touch_protected, pcm.running_block, pcm.ab_call, pcm.anonymous_fetch = self.saved+        if self.old_home is None:+            os.environ.pop("HOME", None)+        else:+            os.environ["HOME"] = self.old_home+        self.tmp.cleanup()++    def installed(self):+        return json.loads((self.cfg / "installed_packages.json").read_text())+++class PcmMac(_Home):++    # --- the layout KiCad 10 uses on macOS -----------------------------------------------------++    def test_paths_follow_kicad_10_on_macos(self):+        p = pcm.paths(self.info)+        self.assertEqual(p["third"], self.third)+        self.assertEqual(p["installed"], self.cfg / "installed_packages.json")+        self.assertEqual(p["kicadJson"], self.cfg / "kicad.json")+        self.assertEqual(p["version"], "10.0.7")+        self.assertTrue(pcm.is_protected(p["third"]), "Documents is TCC-protected on macOS")+        # Configure Paths wins: the exact major, then any versioned KICAD<n>_3RD_PARTY (env_vars.cpp)+        (self.cfg / "kicad_common.json").write_text(json.dumps({"environment": {"vars": {"KICAD9_3RD_PARTY": "/opt/k9-3rd"}}}))+        self.assertEqual(pcm.third_party_dir(self.info), (Path("/opt/k9-3rd"), "kicad_common.json override"))+        (self.cfg / "kicad_common.json").write_text(json.dumps({"environment": {"vars": {"KICAD9_3RD_PARTY": "/opt/k9-3rd", "KICAD10_3RD_PARTY": "/opt/pcm"}}}))+        self.assertEqual(pcm.third_party_dir(self.info)[0], Path("/opt/pcm"))+        self.assertFalse(pcm.is_protected(Path("/opt/pcm")))++    # --- install, list, update, uninstall ---------------------------------------------------------++    def test_install_from_a_repository_and_pcm_check_sees_it(self):+        repo = Repo("https://wiki.adom.inc/api/pages/adom/test-board/files", "inc.adom.wiki.adom.test-board", [("1.0.0", package_zip(), None)])+        r = pcm.install(self.info, {"repositoryUrl": repo.url, "identifier": "inc.adom.wiki.adom.test-board"}, repo.fetch)+        self.assertTrue(r["success"], r)+        self.assertEqual((r["version"], r["filesInstalled"], r["symbolLibraries"], r["footprintLibraries"], r["modelSets"]),+                         ("1.0.0", 3, ["PCM_test-lib"], ["PCM_test-lib"], ["test-lib.3dshapes"]))+        pid = "inc_adom_wiki_adom_test-board"+        self.assertTrue((self.third / "symbols" / pid / "test-lib.kicad_sym").is_file())+        self.assertTrue((self.third / "footprints" / pid / "test-lib.pretty" / "R_0402.kicad_mod").is_file())+        self.assertTrue((self.third / "3dmodels" / pid / "test-lib.3dshapes" / "R_0402.step").is_file())+        self.assertFalse((self.third / "README.md").exists(), "root files are not extracted")+        row = self.installed()["packages"][0]+        self.assertEqual(sorted(row), ["current_version", "install_timestamp", "package", "pinned", "repository_id", "repository_name"])+        self.assertEqual(row["repository_id"], H(repo.url.encode())[:16])+        self.assertEqual(row["repository_name"], "Test board (Adom wiki)")+        self.assertEqual(row["package"]["identifier"], "inc.adom.wiki.adom.test-board")+        self.assertEqual(len(row["package"]["versions"]), 1)+        self.assertIn('\n    "packages"', (self.cfg / "installed_packages.json").read_text(), "KiCad's own 4-space indent")+        lst = pcm.list_packages(self.info, {"checkUpdates": False})+        self.assertIn("inc.adom.wiki.adom.test-board", json.dumps(lst), "AI Flow's pcm check: the identifier is in the reply")+        self.assertEqual((lst["count"], lst["packages"][0]["onDisk"], lst["packages"][0]["updateCheck"]), (1, True, "skipped"))+        again = pcm.install(self.info, {"repositoryUrl": repo.url, "identifier": "inc.adom.wiki.adom.test-board"}, repo.fetch)+        self.assertTrue(again.get("alreadyInstalled"), again)+        out = pcm.uninstall(self.info, {"identifier": "inc.adom.wiki.adom.test-board"})+        self.assertTrue(out["success"] and out["recordRemoved"] and len(out["removedDirs"]) == 3, out)+        self.assertEqual(self.installed()["packages"], [])++    def test_update_replaces_the_old_version(self):+        ident = "inc.adom.wiki.adom.test-board"+        v1 = package_zip(extra={"footprints/test-lib.pretty/OLD.kicad_mod": "(footprint \"OLD\")\n"})+        repo = Repo("https://x.test/files", ident, [("1.0.0", v1, None)])+        self.assertTrue(pcm.install(self.info, {"repositoryUrl": repo.url, "identifier": ident}, repo.fetch)["success"])+        pcm.add_repository(self.info, {"url": repo.url}, repo.fetch)+        repo2 = Repo("https://x.test/files", ident, [("1.0.0", v1, None), ("1.1.0", package_zip(version="1.1.0"), None)])+        lst = pcm.list_packages(self.info, {}, repo2.fetch)+        self.assertEqual((lst["updatesAvailable"], lst["packages"][0]["latestVersion"], lst["packages"][0]["updateCheck"]), (1, "1.1.0", "ok"))+        r = pcm.install(self.info, {"identifier": ident, "update": True}, repo2.fetch)+        self.assertTrue(r["success"] and r["updated"] and r["previousVersion"] == "1.0.0" and r["version"] == "1.1.0", r)+        self.assertFalse((self.third / "footprints" / "inc_adom_wiki_adom_test-board" / "test-lib.pretty" / "OLD.kicad_mod").exists())+        self.assertTrue(pcm.install(self.info, {"identifier": ident, "update": True}, repo2.fetch).get("upToDate"))+        # a refused 1.2.0 (unsafe entry) leaves 1.1.0 installed and on disk: every entry is checked before the old files go+        repo3 = Repo("https://x.test/files", ident, [("1.2.0", package_zip(version="1.2.0", extra={"symbols/../../../x.txt": "x"}), None)])+        r = pcm.install(self.info, {"identifier": ident, "update": True}, repo3.fetch)+        self.assertIn("unsafe zip entry", r["error"])+        self.assertTrue((self.third / "symbols" / "inc_adom_wiki_adom_test-board" / "test-lib.kicad_sym").is_file())+        self.assertEqual(self.installed()["packages"][0]["current_version"], "1.1.0")++    def test_refusals_leave_nothing_behind(self):+        ident = "inc.adom.wiki.adom.test-board"+        repo = Repo("https://x.test/files", ident, [("1.0.0", package_zip(), {"download_sha256": "0" * 64})])+        r = pcm.install(self.info, {"repositoryUrl": repo.url, "identifier": ident}, repo.fetch)+        self.assertEqual(r["error"], "download sha256 mismatch")+        self.assertFalse((self.cfg / "installed_packages.json").exists()); self.assertFalse(self.third.exists())+        evil = package_zip(extra={"footprints/../../escape.txt": "x"})+        repo = Repo("https://x.test/files", ident, [("1.0.0", evil, None)])+        r = pcm.install(self.info, {"repositoryUrl": repo.url, "identifier": ident}, repo.fetch)+        self.assertIn("unsafe zip entry", r["error"])+        self.assertFalse((self.home / "Documents" / "KiCad" / "escape.txt").exists())+        r = pcm.install(self.info, {"repositoryUrl": repo.url, "identifier": "not.there"}, repo.fetch)+        self.assertIn("package not in repository", r["error"])+        repo.files[repo.url] = repo.files[repo.url].replace(b'"sha256": "', b'"sha256": "ff')+        self.assertIn("hash does not match", pcm.install(self.info, {"repositoryUrl": repo.url, "identifier": ident}, repo.fetch)["error"])+        self.assertEqual(pcm.install(self.info, {"identifier": ident}, repo.fetch)["errorCode"], "not_installed")++    def test_versions_follow_pcm(self):+        vs = [{"version": "1.2.0", "download_url": "u", "kicad_version": "11.0"},+              {"version": "1.1.0", "download_url": "u", "kicad_version": "9.0"},+              {"version": "1.0.9", "download_url": "u", "kicad_version": "9.0", "version_epoch": 1},+              {"version": "2.0.0", "kicad_version": "9.0"},+              {"version": "1.3.0", "download_url": "u", "kicad_version": "8.0", "kicad_version_max": "9.0"}]+        self.assertEqual(pcm.latest_version(vs, "10.0.7")["version"], "1.0.9", "epoch first, then the newest compatible with a download")+        self.assertEqual(pcm.compare_versions("1.0", "1.0.0"), 0)+        self.assertEqual(pcm.compare_versions("1.10", "1.9"), 1)++    # --- private pages, through Adom Bridge's session ---------------------------------------------++    def test_a_private_page_is_read_with_the_users_session(self):+        ident = "inc.adom.wiki.someone.private-board"+        repo = Repo("https://wiki.adom.inc/api/pages/someone/private-board/files", ident, [("1.0.0", package_zip(ident=ident), None)])+        pcm.anonymous_fetch = lambda url, t: (_ for _ in ()).throw(pcm.FetchError("HTTPError: http status: 404", status=404))+        asked = []++        def ab(verb, args, timeout):+            asked.append((verb, args["url"], args["caller"]))+            return {"success": True, "base64": base64.b64encode(repo.files[args["url"]]).decode()}+        pcm.ab_call = ab+        r = pcm.install(self.info, {"repositoryUrl": repo.url, "identifier": ident, "caller": {"aiThread": "board-run"}})+        self.assertTrue(r["success"], r)+        self.assertEqual([a[0] for a in asked], ["desktop_wiki_fetch"] * 3)+        self.assertEqual(asked[0][2], {"aiThread": "board-run"}, "the caller is forwarded")+        self.assertTrue(any("wiki session" in n for n in r["notes"]))+        # an Adom Bridge without the verb: the reply names the zipPath route+        pcm.ab_call = lambda verb, args, timeout: {"success": False, "errorCode": "unknown_verb", "error": "Unknown desktop command: desktop_wiki_fetch"}+        r = pcm.install(self.info, {"repositoryUrl": repo.url, "identifier": ident, "force": True})+        self.assertFalse(r["success"])+        self.assertIn("this Adom Bridge has no desktop_wiki_fetch", r["error"])+        self.assertIn("zipPath", r["error"])+        # a non-wiki 404 is never sent to the session+        pcm.ab_call = lambda *a: self.fail("ab must not be asked for a non-wiki URL")+        self.assertIn("404", pcm.install(self.info, {"repositoryUrl": "https://example.com/repository.json", "identifier": ident})["error"])++    # --- macOS: ~/Documents only with Full Disk Access ----------------------------------------------++    def test_no_full_disk_access_no_write_into_documents(self):+        ident = "inc.adom.wiki.adom.test-board"+        repo = Repo("https://x.test/files", ident, [("1.0.0", package_zip(), None)])+        kicad_detect._mac_can_touch_protected = lambda: False+        r = pcm.install(self.info, {"repositoryUrl": repo.url, "identifier": ident}, repo.fetch)+        self.assertEqual(r["errorCode"], "full_disk_access_required")+        self.assertEqual(repo.calls, [], "refused before any download")+        self.assertFalse((self.home / "Documents").exists(), "~/Documents never touched")+        self.assertEqual(pcm.uninstall(self.info, {"identifier": ident})["errorCode"], "full_disk_access_required")+        # the record still answers (pcm check needs only it), onDisk unknown+        (self.cfg / "installed_packages.json").write_text(json.dumps({"packages": [{"package": {"identifier": ident}, "current_version": "1.0.0",+                                                                                    "repository_id": "x", "repository_name": "y", "install_timestamp": 1}]}))+        lst = pcm.list_packages(self.info, {"checkUpdates": False})+        self.assertEqual((lst["count"], lst["packages"][0]["onDisk"]), (1, None))+        self.assertIn("Full Disk Access", lst["onDiskNote"])+        # a 3rdparty path outside the protected folders (Configure Paths) needs no grant+        (self.cfg / "kicad_common.json").write_text(json.dumps({"environment": {"vars": {"KICAD10_3RD_PARTY": str(self.home / "kicad-3rd")}}}))+        r = pcm.install(self.info, {"repositoryUrl": repo.url, "identifier": ident, "force": True}, repo.fetch)+        self.assertTrue(r["success"], r)+        self.assertTrue((self.home / "kicad-3rd" / "symbols" / "inc_adom_wiki_adom_test-board").is_dir())++    # --- repositories (kicad.json) -------------------------------------------------------------++    def test_repositories_are_kept_like_the_manage_dialog(self):+        repo = Repo("https://x.test/files", "a.b", [("1.0.0", package_zip(ident="a.b"), None)])+        r = pcm.add_repository(self.info, {"url": repo.url}, repo.fetch)+        self.assertTrue(r["success"] and r["added"] and r["verified"], r)+        self.assertIsNone(r["backup"], "no kicad.json existed: nothing to back up")+        doc = json.loads((self.cfg / "kicad.json").read_text())+        self.assertEqual(doc["pcm"]["repositories"], [{"name": "KiCad official repository", "url": "https://repository.kicad.org/repository.json"},+                                                      {"name": "Test board (Adom wiki)", "url": repo.url}])+        self.assertTrue(pcm.add_repository(self.info, {"url": repo.url}, repo.fetch)["alreadyRegistered"])+        r = pcm.remove_repository(self.info, {"url": repo.url})+        self.assertTrue(r["removed"] and Path(r["backup"]).is_file(), r)+        self.assertFalse(pcm.remove_repository(self.info, {"url": repo.url})["removed"])+        r = pcm.add_repository(self.info, {"url": "https://x.test/missing/repository.json"}, repo.fetch)+        self.assertEqual(r["errorCode"], "repository_unreachable")+        r = pcm.add_repository(self.info, {"url": "https://x.test/missing/repository.json", "name": "Private"}, repo.fetch)+        self.assertTrue(r["success"] and not r["verified"] and r["warning"].startswith("registered unverified"), r)+        self.assertEqual(pcm.add_repository(self.info, {"url": "ftp://x"}, repo.fetch)["errorCode"], "invalid_arg")+++class ServerWiring(_Home):+    """The verbs through server.dispatch_command and the response normalizer, as Adom Bridge reaches them.+    The dispatcher's per-boot side effects (deferred ~/Documents detection, the reverse-bridge plugin+    auto-install) are stubbed so the test writes nothing outside its temporary home."""++    def setUp(self):+        super().setUp()+        import server+        self.server = server+        self.saved_srv = (server._complete_deferred_detection, server.plugin_install.ensure_plugin_installed_once,+                          server.plugin_install.attach_to_response_if_first, server.kicad_info, list(server.all_kicad_versions))+        server._complete_deferred_detection = lambda: None+        server.plugin_install.ensure_plugin_installed_once = lambda *a, **k: {}+        server.plugin_install.attach_to_response_if_first = lambda *a, **k: None+        server.kicad_info = dict(self.info, installed=True)+        server.all_kicad_versions[:] = [server.kicad_info]++    def tearDown(self):+        s = self.server+        (s._complete_deferred_detection, s.plugin_install.ensure_plugin_installed_once,+         s.plugin_install.attach_to_response_if_first, s.kicad_info, versions) = self.saved_srv+        s.all_kicad_versions[:] = versions+        super().tearDown()++    def test_the_five_verbs_are_routed(self):+        server = self.server+        for v in ("pcm_list", "pcm_install", "pcm_uninstall", "pcm_add_repository", "pcm_remove_repository"):+            self.assertIn(v, server.COMMAND_HANDLERS)+            self.assertIn(v, server._VERB_ARGS)+        # the bare verb and kicad_<verb> both resolve (the dispatcher strips the prefix once)+        r = server.dispatch_command("kicad_pcm_uninstall", {})+        self.assertNotEqual(r.get("errorCode"), "unknown_verb")+        self.assertEqual(r.get("error"), "identifier is required")+        self.assertEqual(server.dispatch_command("pcm_list", {"checkUpdates": False})["count"], 0)+        cat = server._handle_describe(self.info, {})+        names = json.dumps(cat)+        for v in ("kicad_pcm_list", "kicad_pcm_install", "kicad_pcm_uninstall", "kicad_pcm_add_repository", "kicad_pcm_remove_repository"):+            self.assertIn(v, names)+        manifest = json.loads((TREE / "bridge.json").read_text())+        for v in ("kicad_pcm_list", "kicad_pcm_install", "kicad_pcm_uninstall", "kicad_pcm_add_repository", "kicad_pcm_remove_repository"):+            self.assertIn(v, manifest["verbs"])+        self.assertEqual(manifest["timeouts"]["verbs"]["pcm_install"], 900)+        self.assertEqual(manifest["version"], "0.11.3", "the registry version stays the release's")++    def test_ai_flow_pcm_check_sees_a_zip_install_through_the_dispatcher(self):+        """AI Flow's `pcm check`: raw_call("kicad_pcm_list", {}) passes when the reply's text contains the+        identifier. The reply is the normalized one (output + data), as Adom Bridge relays it."""+        server = self.server+        ident = "inc.adom.wiki.adom.test-board"+        zp = self.home / "AdomBoards" / "pcm" / "test-board-pcm-1.0.0.zip"+        zp.parent.mkdir(parents=True)+        zp.write_bytes(package_zip(ident=ident))+        before = server._normalize_response("kicad_pcm_list", server.dispatch_command("kicad_pcm_list", {}))+        self.assertNotIn(ident, json.dumps(before), "pcm check fails before the install")+        r = server._normalize_response("kicad_pcm_install", server.dispatch_command("kicad_pcm_install", {"zipPath": str(zp)}))+        self.assertTrue(r["success"], r)+        self.assertIn("Installed " + ident + " 1.0.0 (3 file(s))", r["output"])+        self.assertEqual(r["kicadVersionUsed"], "10.0")+        row = self.installed()["packages"][0]+        self.assertEqual((row["repository_id"], row["repository_name"]), ("", "Local file"), "as KiCad's own Install from File records it")+        after = server._normalize_response("kicad_pcm_list", server.dispatch_command("kicad_pcm_list", {}))+        self.assertIn(ident, json.dumps(after))+        self.assertIn(ident, after["output"], "the identifier is in the one-line output too")+        self.assertEqual(after["data"]["packages"][0]["updateCheck"], "repository_not_registered")+        no = server._normalize_response("kicad_pcm_install", server.dispatch_command("kicad_pcm_install", {}))+        self.assertEqual(no["output"], "identifier is required")++if __name__ == "__main__":+    unittest.main(verbosity=2)+

Comments

Colby Knox · 5d ago

Posted by Claude (the kicad-bridge thread) for Colby. We don't have a Mac, so I read the code and ran your tests here.

Result: the patch applies to the 0.11.3 zip and matches SHA256SUMS. All 18 tests pass with Python 3.12, kicad-python 0.8.0, protobuf 5.29.6 and pynng 0.9.0. I think this can merge after one change and one test on your Mac.

The change: refuse an identifier that is a path

handlers/pcm.py builds third / folder / pkgdir(identifier), then deletes it on install and uninstall. If the identifier is a full path, it replaces the whole path. A package zip sets its own identifier in metadata.json. In a scratch folder, kicad_pcm_install {zipPath} with a crafted zip wiped a folder outside 3rdparty and reported success, and kicad_pcm_uninstall did the same. The Full Disk Access check doesn't catch it, because it only looks at the 3rdparty folder.

The Windows line had the same fault, which pcm.py was ported from. It is fixed in 1.0.103 with the same rule and the same invalid_identifier error code.

Proposed change, on top of your patched tree. With it your tests and the new test below pass, and the new test fails without it:

--- a/handlers/pcm.py
+++ b/handlers/pcm.py
@@ -367,11 +367,25 @@
     return identifier.replace(".", "_")
 
 
+def identifier_is_safe(identifier) -> bool:
+    """The identifier becomes a folder name that install and uninstall delete, and a package zip chooses
+    it. A full path would replace the whole path in `third / folder / pid`."""
+    return isinstance(identifier, str) and bool(re.fullmatch(r"[A-Za-z0-9._-]+", identifier))
+
+
+def _invalid_identifier(identifier) -> dict:
+    return {"success": False, "errorCode": "invalid_identifier", "identifier": identifier,
+            "error": f"'{identifier}' is not a package identifier: only letters, digits, '.', '-' and '_' are allowed",
+            "_hint": "A PCM identifier looks like inc.adom.wiki.<owner>.<page>. Nothing was deleted or written."}
+
+
 def extract(zip_bytes: bytes, third: Path, identifier: str, replace: bool = False) -> dict:
     """PCM's extract(): <folder>/<contents> -> <3rdparty>/<folder>/<pkgid>/<contents>; entries outside the
     package folders are ignored, unsafe paths refuse the whole package. Every entry is checked before
     anything is written; replace=True (an install over an older one, as PCM's Update does) removes the
     previous extraction only after that check, so a refused zip leaves the installed version intact."""
+    if not identifier_is_safe(identifier):
+        raise ValueError(f"'{identifier}' is not a package identifier")
     zf = zipfile.ZipFile(io.BytesIO(zip_bytes))
     total = sum(i.file_size for i in zf.infolist())
     if total > MAX_ZIP:
@@ -670,6 +684,8 @@
     if not ident:
         return {"success": False, "error": "identifier is required",
                 "_hint": "identifier like inc.adom.wiki.<owner>.<page>; repositoryUrl is the repository.json URL (optional for an installed package: its own repository is used). Or zipPath: one local package zip."}
+    if not identifier_is_safe(ident):
+        return _invalid_identifier(ident)
     p = paths(kicad_info)
     if not p:
         return {"success": False, "error": "KiCad user/config directories unknown",
@@ -791,7 +807,7 @@
         pk = e.get("package") if isinstance(e.get("package"), dict) else {}
         ident = str(pk.get("identifier") or "")
         current = str(e.get("current_version") or "")
-        on_disk = any((p["third"] / f / pkgdir(ident)).exists() for f in FOLDERS) if can_see else None
+        on_disk = (identifier_is_safe(ident) and any((p["third"] / f / pkgdir(ident)).exists() for f in FOLDERS)) if can_see else None
         row = {"identifier": pk.get("identifier"), "name": pk.get("name"), "type": pk.get("type"), "version": e.get("current_version"),
                "repository": e.get("repository_name"), "repositoryId": e.get("repository_id"), "pinned": e.get("pinned"),
                "onDisk": on_disk, "updateAvailable": None, "latestVersion": None, "repositoryUrl": None,
@@ -840,6 +856,8 @@
     ident = str(args.get("identifier") or "").strip()
     if not ident:
         return {"success": False, "error": "identifier is required"}
+    if not identifier_is_safe(ident):
+        return _invalid_identifier(ident)
     p = paths(kicad_info)
     if not p:
         return {"success": False, "error": "KiCad user/config directories unknown"}

Test to add to the PcmMac class in test_pcm_macos.py:

    def test_a_path_as_identifier_deletes_nothing(self):
        victim = self.home / "victim"
        victim.mkdir()
        (victim / "keep.txt").write_text("keep")
        zp = self.home / "evil.zip"
        zp.write_bytes(package_zip(ident=str(victim)))
        self.assertEqual(pcm.install(self.info, {"zipPath": str(zp)})["errorCode"], "invalid_identifier")
        self.assertEqual(pcm.uninstall(self.info, {"identifier": str(victim)})["errorCode"], "invalid_identifier")
        self.assertEqual((victim / "keep.txt").read_text(), "keep")
        self.assertFalse((self.cfg / "installed_packages.json").exists())

Then regenerate the patch and SHA256SUMS. KiCad's schema allows letters, digits, - and .. I also kept _ so a package already installed under it can still be removed.

What to test on the Mac

The tests replace ax_menu_invoke_by_text with a stand-in, so the real menu press hasn't run yet. Using your staged build, with KiCad in the background and a routed board open:

  1. Select: call kicad_window_info for the PCB editor's hwnd, then kicad_select_net {"filePath": "<board>", "net": "<a small net>"}. Expect success: true, selectionConfirmed equal to selected, and the net highlighted.
  2. Zoom: screenshot, call kicad_send_key {"hwnd": <hwnd>, "menu": "Zoom to Selected Objects"}, then screenshot again. Expect mechanism: "menu" and data.madeMain: true, and the view should frame the net. This is the main check.
  3. Focus: the app in front before step 2 should still be in front, with no dialog and no tool left active. Then call kicad_select_net again, to confirm IPC isn't left busy (the #121 symptom on Windows).
  4. Right window: open the 3D Viewer or schematic as well, then repeat step 2 on the PCB editor's hwnd. The PCB view should move and the other window shouldn't. Then send "menu": "Zoom to Fit", which should bring the whole board back.
  5. Refusals: "menu": "Zoom" should answer menu_item_ambiguous with the list of matches, and a made-up hwnd should answer no_window.
  6. AI Flow: tour nets on that board should get through every net.
  7. PCM, with Full Disk Access: kicad_pcm_install {"zipPath": "<a real package zip>"}, then kicad_pcm_list, then restart KiCad and check the PCM_ library appears. kicad_pcm_uninstall should remove it.
  8. The new check: run mkdir /tmp/kicad-id-check, then kicad_pcm_uninstall {"identifier": "/tmp/kicad-id-check"}. Expect invalid_identifier, with the folder still there.

If step 2 is delivered but the view doesn't move, say so here. It wouldn't need to block the rest, because that call already fails on 0.11.3. The zoom could follow as kicad_zoom_to, as you suggested.

Please post what each step answered, with the before and after screenshots from step 2. Before publishing, BRIDGE_VERSION and bridge.json need a real release number, 0.11.4, in place of 0.11.3+tours.

🤖 Generated with Claude Code

Log in to comment.