← Commit history

v0.9.58: (1) 3D-viewer menu id now RESOLVED live from get_menu_ids (drifts across KiCad builds: was 20563, is 20568 on KiCad 10 winvm) instead of hardcoded — fixes open_3d_viewer on fresh installs. (2) export_pdf: KiCad 10 now requires --layers; default them for PDF too + accept layers as a list, not only a comma-string.

John Lauer ·9614410f29 ·3mo ago ·parent 9e75fbc
6 files changed +520−349
adom-bridge-kicad-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "kicad",-  "version": "0.9.57",-  "url": "https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/adom-bridge-kicad-v0.9.57.zip",-  "sha256": "14407b453f34283338acecbe68500c05da4361cf990404e6a5cd69a69fb5c49f",-  "size": 641319,+  "version": "0.9.58",+  "url": "https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/adom-bridge-kicad-v0.9.58.zip",+  "sha256": "4f1429232a577407fa2b818951d9212d97ea5ca8d4075bab522bed50a0b7ebe9",+  "size": 642059,   "verbPrefixes": [     "kicad_"   ],
adom-bridge-kicad-v0.9.58.zipadded
⋯ 1 unchanged line ⋯
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "kicad",   "displayName": "KiCad EDA",-  "version": "0.9.57",+  "version": "0.9.58",   "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.",   "homepage": "https://wiki.adom.inc/adom/adom-desktop",   "author": "Adom Inc.",
handlers/bridge_client.py+38−4
@@ -352,19 +352,26 @@ def handle_open_editors(kicad_info: dict, args: dict) -> dict:     probed_plugins = 0     failed_plugins = 0 +    # Use the BUDGETED port-prober (3s wall-clock cap + per-port dedup), not raw+    # per-file 1s probes: many dead python processes register the SAME plugin port,+    # and probing each of N stale discovery files at 1s each hung open_editors for+    # the full 30s client timeout when no KiCad was live. (AdomLapper 2026-07.)+    probe_port = _make_port_prober()+    seen_ports: set = set()     for path in _discovery_files():         meta = _read_discovery(path)         if not meta:             continue         port = meta.get("port")-        if not port:+        if not port or port in seen_ports:             continue-        # Probe alive first-        status = _probe(port, timeout=1.0)+        seen_ports.add(port)+        # Probe alive first (budgeted — returns None instantly once over budget)+        status = probe_port(port)         if not status:             continue         probed_plugins += 1-        rpc = call(port, "get_open_editors", {}, timeout=5.0)+        rpc = call(port, "get_open_editors", {}, timeout=2.0)         if "error" in rpc:             failed_plugins += 1             continue@@ -443,3 +450,30 @@ def try_wm_command_via_bridge(exe_name: str, menu_id: int, frame_index: int = 0,         },         "menuId": menu_id,     }+++def resolve_menu_id_via_bridge(exe_name: str, contains: str, frame_index: int = 0,+                               pid: int | None = None, timeout: float = 10.0) -> int | None:+    """Look up a live menu-command id by matching text — menu ids DRIFT across KiCad+    builds/versions (e.g. the PCB '3D Viewer' id was 20563 on one build, 20568 on+    another), so hardcoding one silently breaks on the next release. Ask the plugin+    for the real menu tree (`get_menu_ids`) and return the id of the first item whose+    label / helpString / accelerator contains `contains` (case-insensitive).+    Returns None if the plugin isn't reachable or nothing matches."""+    meta, err = _resolve_plugin(exe_name, pid)+    if err:+        return None+    rpc = call(meta["port"], "get_menu_ids", {"frame_index": frame_index}, timeout=timeout)+    if "error" in rpc:+        return None+    result = rpc.get("result") or {}+    needle = contains.lower()+    for frame in result.get("frames", []):+        for it in frame.get("menuItems", []):+            hay = " ".join(str(it.get(k, "")) for k in ("label", "helpString", "accelerator")).lower()+            if needle in hay:+                try:+                    return int(it.get("id"))+                except (TypeError, ValueError):+                    continue+    return None
handlers/export.py+284−275
@@ -1,275 +1,284 @@-"""Handlers for KiCad CLI export commands (Gerber, PDF, SVG, STEP, BOM CSV)."""--import os-import subprocess-import tempfile-from pathlib import Path---def handle_export_gerber(kicad_info: dict, args: dict) -> dict:-    """Export Gerber files from a PCB using kicad-cli."""-    return _run_pcb_export(kicad_info, args, "gerbers", "Gerber")---def handle_export_pdf(kicad_info: dict, args: dict) -> dict:-    """Export PDF from a PCB or schematic using kicad-cli."""-    file_path = args.get("filePath", "")-    if not file_path:-        return {"success": False, "error": "No filePath specified"}--    if file_path.endswith(".kicad_sch"):-        return _run_sch_export(kicad_info, args, "pdf", "PDF")-    else:-        return _run_pcb_export(kicad_info, args, "pdf", "PDF")---def handle_export_svg(kicad_info: dict, args: dict) -> dict:-    """Export SVG from a PCB using kicad-cli."""-    return _run_pcb_export(kicad_info, args, "svg", "SVG")---def handle_export_step(kicad_info: dict, args: dict) -> dict:-    """Export 3D STEP file from a PCB using kicad-cli."""-    return _run_pcb_export(kicad_info, args, "step", "STEP")---def handle_export_bom_csv(kicad_info: dict, args: dict) -> dict:-    """Export BOM CSV from a schematic using kicad-cli."""-    return _run_sch_export(kicad_info, args, "bom", "BOM CSV")---def handle_run_erc(kicad_info: dict, args: dict) -> dict:-    """Run ERC (Electrical Rules Check) on a schematic using kicad-cli."""-    if not kicad_info.get("installed"):-        return {-            "success": False,-            "error": "KiCad not installed",-            "_hint": "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.",-        }--    file_path = args.get("filePath", "")-    if not file_path:-        return {"success": False, "error": "No filePath specified"}--    sch_path = Path(file_path)-    if not sch_path.exists():-        return {-            "success": False,-            "error": f"Schematic file not found: {file_path}",-            "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.",-        }--    cli_exe = kicad_info.get("kicad_cli_exe")-    if not cli_exe or not Path(cli_exe).exists():-        return {-            "success": False,-            "error": "kicad-cli not found",-            "_hint": "Report to the user and ask them to reinstall KiCad — kicad-cli is missing from the installation.",-        }--    tmp_fd, output_path = tempfile.mkstemp(suffix=".json")-    os.close(tmp_fd)--    try:-        cmd = [-            cli_exe,-            "sch", "erc",-            "--format", "json",-            "--severity-all",-            "--output", output_path,-            str(sch_path),-        ]--        result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)--        output_file = Path(output_path)-        if not output_file.exists() or output_file.stat().st_size == 0:-            return {"success": False, "error": f"ERC produced no output. stderr: {result.stderr}"}--        import json-        erc_data = json.loads(output_file.read_text(encoding="utf-8"))--        violations = _parse_erc_violations(erc_data)--        total = len(violations)-        errors = sum(1 for v in violations if v["severity"] == "error")-        warnings = sum(1 for v in violations if v["severity"] == "warning")--        return {-            "success": True,-            "output": f"ERC complete: {errors} error(s), {warnings} warning(s), {total} total",-            "data": {-                "violations": violations,-                "summary": {"total": total, "errors": errors, "warnings": warnings},-                "source_file": str(sch_path),-            },-        }--    except subprocess.TimeoutExpired:-        return {"success": False, "error": "ERC timed out after 120 seconds"}-    except Exception as e:-        return {"success": False, "error": f"ERC failed: {e}"}-    finally:-        try:-            Path(output_path).unlink(missing_ok=True)-        except OSError:-            pass---def _parse_erc_violations(erc_data: dict) -> list:-    """Extract structured violation list from ERC JSON output."""-    violations = []-    for v in erc_data.get("violations", []):-        violation = {-            "type": v.get("type", "unknown"),-            "severity": v.get("severity", "warning"),-            "description": v.get("description", ""),-            "items": [],-        }-        for item in v.get("items", []):-            violation["items"].append({-                "description": item.get("description", ""),-                "pos": item.get("pos", {}),-            })-        violations.append(violation)-    return violations---def _run_pcb_export(kicad_info: dict, args: dict, export_type: str, label: str) -> dict:-    """Generic PCB export via kicad-cli pcb export <type>."""-    if not kicad_info.get("installed"):-        return {-            "success": False,-            "error": "KiCad not installed",-            "_hint": "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.",-        }--    file_path = args.get("filePath", "")-    if not file_path:-        return {"success": False, "error": "No filePath specified"}--    pcb_path = Path(file_path)-    if not pcb_path.exists():-        return {-            "success": False,-            "error": f"PCB file not found: {file_path}",-            "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.",-        }--    cli_exe = kicad_info.get("kicad_cli_exe")-    if not cli_exe or not Path(cli_exe).exists():-        return {-            "success": False,-            "error": "kicad-cli not found",-            "_hint": "Report to the user and ask them to reinstall KiCad — kicad-cli is missing from the installation.",-        }--    # Determine output path-    output_dir = args.get("outputDir", "")-    if not output_dir:-        output_dir = str(pcb_path.parent / f"{export_type}_output")--    # For gerbers, output is a directory; for others, it's a file-    if export_type == "gerbers":-        os.makedirs(output_dir, exist_ok=True)-        output_arg = output_dir-    elif export_type == "step":-        os.makedirs(output_dir, exist_ok=True)-        output_arg = os.path.join(output_dir, pcb_path.stem + ".step")-    else:-        os.makedirs(output_dir, exist_ok=True)-        ext = ".pdf" if export_type == "pdf" else ".svg"-        output_arg = os.path.join(output_dir, pcb_path.stem + ext)--    try:-        cmd = [cli_exe, "pcb", "export", export_type, "--output", output_arg, str(pcb_path)]--        # Add layers for SVG/PDF if specified-        layers = args.get("layers", "")-        if layers and export_type in ("svg", "pdf"):-            cmd.extend(["--layers", layers])--        result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)--        if result.returncode != 0:-            return {"success": False, "error": f"{label} export failed: {result.stderr}"}--        # List output files-        if export_type == "gerbers":-            files = [f.name for f in Path(output_dir).iterdir() if f.is_file()]-        else:-            files = [Path(output_arg).name] if Path(output_arg).exists() else []--        return {-            "success": True,-            "output": f"{label} exported to {output_arg} ({len(files)} file(s))",-            "data": {"output_path": output_arg, "files": files},-        }--    except subprocess.TimeoutExpired:-        return {"success": False, "error": f"{label} export timed out after 120 seconds"}-    except Exception as e:-        return {"success": False, "error": f"{label} export failed: {e}"}---def _run_sch_export(kicad_info: dict, args: dict, export_type: str, label: str) -> dict:-    """Generic schematic export via kicad-cli sch export <type>."""-    if not kicad_info.get("installed"):-        return {-            "success": False,-            "error": "KiCad not installed",-            "_hint": "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.",-        }--    file_path = args.get("filePath", "")-    if not file_path:-        return {"success": False, "error": "No filePath specified"}--    sch_path = Path(file_path)-    if not sch_path.exists():-        return {-            "success": False,-            "error": f"Schematic file not found: {file_path}",-            "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.",-        }--    cli_exe = kicad_info.get("kicad_cli_exe")-    if not cli_exe or not Path(cli_exe).exists():-        return {-            "success": False,-            "error": "kicad-cli not found",-            "_hint": "Report to the user and ask them to reinstall KiCad — kicad-cli is missing from the installation.",-        }--    output_dir = args.get("outputDir", "")-    if not output_dir:-        output_dir = str(sch_path.parent / f"{export_type}_output")--    os.makedirs(output_dir, exist_ok=True)--    ext_map = {"pdf": ".pdf", "bom": ".csv"}-    ext = ext_map.get(export_type, f".{export_type}")-    output_arg = os.path.join(output_dir, sch_path.stem + ext)--    try:-        cmd = [cli_exe, "sch", "export", export_type, "--output", output_arg, str(sch_path)]--        result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)--        if result.returncode != 0:-            return {"success": False, "error": f"{label} export failed: {result.stderr}"}--        exists = Path(output_arg).exists()-        return {-            "success": True,-            "output": f"{label} exported to {output_arg}",-            "data": {-                "output_path": output_arg,-                "files": [Path(output_arg).name] if exists else [],-            },-        }--    except subprocess.TimeoutExpired:-        return {"success": False, "error": f"{label} export timed out after 120 seconds"}-    except Exception as e:-        return {"success": False, "error": f"{label} export failed: {e}"}+"""Handlers for KiCad CLI export commands (Gerber, PDF, SVG, STEP, BOM CSV)."""
+
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+
+def handle_export_gerber(kicad_info: dict, args: dict) -> dict:
+    """Export Gerber files from a PCB using kicad-cli."""
+    return _run_pcb_export(kicad_info, args, "gerbers", "Gerber")
+
+
+def handle_export_pdf(kicad_info: dict, args: dict) -> dict:
+    """Export PDF from a PCB or schematic using kicad-cli."""
+    file_path = args.get("filePath", "")
+    if not file_path:
+        return {"success": False, "error": "No filePath specified"}
+
+    if file_path.endswith(".kicad_sch"):
+        return _run_sch_export(kicad_info, args, "pdf", "PDF")
+    else:
+        return _run_pcb_export(kicad_info, args, "pdf", "PDF")
+
+
+def handle_export_svg(kicad_info: dict, args: dict) -> dict:
+    """Export SVG from a PCB using kicad-cli."""
+    return _run_pcb_export(kicad_info, args, "svg", "SVG")
+
+
+def handle_export_step(kicad_info: dict, args: dict) -> dict:
+    """Export 3D STEP file from a PCB using kicad-cli."""
+    return _run_pcb_export(kicad_info, args, "step", "STEP")
+
+
+def handle_export_bom_csv(kicad_info: dict, args: dict) -> dict:
+    """Export BOM CSV from a schematic using kicad-cli."""
+    return _run_sch_export(kicad_info, args, "bom", "BOM CSV")
+
+
+def handle_run_erc(kicad_info: dict, args: dict) -> dict:
+    """Run ERC (Electrical Rules Check) on a schematic using kicad-cli."""
+    if not kicad_info.get("installed"):
+        return {
+            "success": False,
+            "error": "KiCad not installed",
+            "_hint": "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.",
+        }
+
+    file_path = args.get("filePath", "")
+    if not file_path:
+        return {"success": False, "error": "No filePath specified"}
+
+    sch_path = Path(file_path)
+    if not sch_path.exists():
+        return {
+            "success": False,
+            "error": f"Schematic file not found: {file_path}",
+            "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.",
+        }
+
+    cli_exe = kicad_info.get("kicad_cli_exe")
+    if not cli_exe or not Path(cli_exe).exists():
+        return {
+            "success": False,
+            "error": "kicad-cli not found",
+            "_hint": "Report to the user and ask them to reinstall KiCad — kicad-cli is missing from the installation.",
+        }
+
+    tmp_fd, output_path = tempfile.mkstemp(suffix=".json")
+    os.close(tmp_fd)
+
+    try:
+        cmd = [
+            cli_exe,
+            "sch", "erc",
+            "--format", "json",
+            "--severity-all",
+            "--output", output_path,
+            str(sch_path),
+        ]
+
+        result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
+
+        output_file = Path(output_path)
+        if not output_file.exists() or output_file.stat().st_size == 0:
+            return {"success": False, "error": f"ERC produced no output. stderr: {result.stderr}"}
+
+        import json
+        erc_data = json.loads(output_file.read_text(encoding="utf-8"))
+
+        violations = _parse_erc_violations(erc_data)
+
+        total = len(violations)
+        errors = sum(1 for v in violations if v["severity"] == "error")
+        warnings = sum(1 for v in violations if v["severity"] == "warning")
+
+        return {
+            "success": True,
+            "output": f"ERC complete: {errors} error(s), {warnings} warning(s), {total} total",
+            "data": {
+                "violations": violations,
+                "summary": {"total": total, "errors": errors, "warnings": warnings},
+                "source_file": str(sch_path),
+            },
+        }
+
+    except subprocess.TimeoutExpired:
+        return {"success": False, "error": "ERC timed out after 120 seconds"}
+    except Exception as e:
+        return {"success": False, "error": f"ERC failed: {e}"}
+    finally:
+        try:
+            Path(output_path).unlink(missing_ok=True)
+        except OSError:
+            pass
+
+
+def _parse_erc_violations(erc_data: dict) -> list:
+    """Extract structured violation list from ERC JSON output."""
+    violations = []
+    for v in erc_data.get("violations", []):
+        violation = {
+            "type": v.get("type", "unknown"),
+            "severity": v.get("severity", "warning"),
+            "description": v.get("description", ""),
+            "items": [],
+        }
+        for item in v.get("items", []):
+            violation["items"].append({
+                "description": item.get("description", ""),
+                "pos": item.get("pos", {}),
+            })
+        violations.append(violation)
+    return violations
+
+
+def _run_pcb_export(kicad_info: dict, args: dict, export_type: str, label: str) -> dict:
+    """Generic PCB export via kicad-cli pcb export <type>."""
+    if not kicad_info.get("installed"):
+        return {
+            "success": False,
+            "error": "KiCad not installed",
+            "_hint": "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.",
+        }
+
+    file_path = args.get("filePath", "")
+    if not file_path:
+        return {"success": False, "error": "No filePath specified"}
+
+    pcb_path = Path(file_path)
+    if not pcb_path.exists():
+        return {
+            "success": False,
+            "error": f"PCB file not found: {file_path}",
+            "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.",
+        }
+
+    cli_exe = kicad_info.get("kicad_cli_exe")
+    if not cli_exe or not Path(cli_exe).exists():
+        return {
+            "success": False,
+            "error": "kicad-cli not found",
+            "_hint": "Report to the user and ask them to reinstall KiCad — kicad-cli is missing from the installation.",
+        }
+
+    # Determine output path
+    output_dir = args.get("outputDir", "")
+    if not output_dir:
+        output_dir = str(pcb_path.parent / f"{export_type}_output")
+
+    # For gerbers, output is a directory; for others, it's a file
+    if export_type == "gerbers":
+        os.makedirs(output_dir, exist_ok=True)
+        output_arg = output_dir
+    elif export_type == "step":
+        os.makedirs(output_dir, exist_ok=True)
+        output_arg = os.path.join(output_dir, pcb_path.stem + ".step")
+    else:
+        os.makedirs(output_dir, exist_ok=True)
+        ext = ".pdf" if export_type == "pdf" else ".svg"
+        output_arg = os.path.join(output_dir, pcb_path.stem + ext)
+
+    try:
+        cmd = [cli_exe, "pcb", "export", export_type, "--output", output_arg, str(pcb_path)]
+
+        # Add layers for SVG/PDF. kicad-cli `pcb export svg` AND (on KiCad 10)
+        # `pcb export pdf` both REQUIRE --layers (they error "At least one layer must
+        # be specified" with none), so when the caller didn't specify any we default
+        # to a useful full-board set for BOTH. Accept `layers` as either a list
+        # (["F.Cu","B.Cu"]) or a comma string ("F.Cu,B.Cu") — a list used to blow up
+        # with "expected str … not list" when handed straight to cmd.extend. (winvm 2026-07.)
+        layers = args.get("layers", "")
+        if isinstance(layers, (list, tuple)):
+            layers = ",".join(str(x) for x in layers)
+        if not layers and export_type in ("svg", "pdf"):
+            layers = "F.Cu,B.Cu,F.Silkscreen,B.Silkscreen,F.Mask,B.Mask,Edge.Cuts"
+        if layers and export_type in ("svg", "pdf"):
+            cmd.extend(["--layers", layers])
+
+        result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
+
+        if result.returncode != 0:
+            return {"success": False, "error": f"{label} export failed: {result.stderr}"}
+
+        # List output files
+        if export_type == "gerbers":
+            files = [f.name for f in Path(output_dir).iterdir() if f.is_file()]
+        else:
+            files = [Path(output_arg).name] if Path(output_arg).exists() else []
+
+        return {
+            "success": True,
+            "output": f"{label} exported to {output_arg} ({len(files)} file(s))",
+            "data": {"output_path": output_arg, "files": files},
+        }
+
+    except subprocess.TimeoutExpired:
+        return {"success": False, "error": f"{label} export timed out after 120 seconds"}
+    except Exception as e:
+        return {"success": False, "error": f"{label} export failed: {e}"}
+
+
+def _run_sch_export(kicad_info: dict, args: dict, export_type: str, label: str) -> dict:
+    """Generic schematic export via kicad-cli sch export <type>."""
+    if not kicad_info.get("installed"):
+        return {
+            "success": False,
+            "error": "KiCad not installed",
+            "_hint": "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.",
+        }
+
+    file_path = args.get("filePath", "")
+    if not file_path:
+        return {"success": False, "error": "No filePath specified"}
+
+    sch_path = Path(file_path)
+    if not sch_path.exists():
+        return {
+            "success": False,
+            "error": f"Schematic file not found: {file_path}",
+            "_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.",
+        }
+
+    cli_exe = kicad_info.get("kicad_cli_exe")
+    if not cli_exe or not Path(cli_exe).exists():
+        return {
+            "success": False,
+            "error": "kicad-cli not found",
+            "_hint": "Report to the user and ask them to reinstall KiCad — kicad-cli is missing from the installation.",
+        }
+
+    output_dir = args.get("outputDir", "")
+    if not output_dir:
+        output_dir = str(sch_path.parent / f"{export_type}_output")
+
+    os.makedirs(output_dir, exist_ok=True)
+
+    ext_map = {"pdf": ".pdf", "bom": ".csv"}
+    ext = ext_map.get(export_type, f".{export_type}")
+    output_arg = os.path.join(output_dir, sch_path.stem + ext)
+
+    try:
+        cmd = [cli_exe, "sch", "export", export_type, "--output", output_arg, str(sch_path)]
+
+        result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
+
+        if result.returncode != 0:
+            return {"success": False, "error": f"{label} export failed: {result.stderr}"}
+
+        exists = Path(output_arg).exists()
+        return {
+            "success": True,
+            "output": f"{label} exported to {output_arg}",
+            "data": {
+                "output_path": output_arg,
+                "files": [Path(output_arg).name] if exists else [],
+            },
+        }
+
+    except subprocess.TimeoutExpired:
+        return {"success": False, "error": f"{label} export timed out after 120 seconds"}
+    except Exception as e:
+        return {"success": False, "error": f"{label} export failed: {e}"}
handlers/open_footprint_editor.py+193−65
@@ -179,21 +179,20 @@ def _find_child_by_class_and_parent(parent_hwnd: int, child_class: str, parent_t     return result[0]  -def _force_foreground(hwnd: int):-    """Bring a window to the foreground using the Alt-key trick.+SW_SHOWNOACTIVATE = 4 -    The Alt key press/release grants foreground permission. We wrap it in-    try/finally to ensure the key-up event ALWAYS fires — a stuck Alt key-    breaks Alt+Tab and general window focus on the user's desktop.-    """++def _force_foreground(hwnd: int):+    """DELIBERATELY does NOT foreground. The bridge must never steal the user's+    focus or flash a window in front of their work (John, 2026-07 — 'do your stuff+    in the background'). We only make the window VISIBLE without activating it+    (SW_SHOWNOACTIVATE); the plugin WM_COMMAND path drives it fine without focus,+    and the dispatcher restores the user's window afterward anyway. Name kept so+    the ~10 call sites don't churn."""     try:-        user32.keybd_event(VK_MENU, 0, 0, 0)-    finally:-        user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0)-    time.sleep(0.05)-    user32.ShowWindow(hwnd, SW_RESTORE)-    user32.SetForegroundWindow(hwnd)-    user32.BringWindowToTop(hwnd)+        user32.ShowWindow(hwnd, SW_SHOWNOACTIVATE)+    except Exception:+        pass   def _click_at(x: int, y: int):@@ -234,7 +233,7 @@ def _open_footprint_editor_via_click(kicad_hwnd: int) -> bool:     _click_at(click_x, click_y)      # Wait for the Footprint Editor to appear-    for _ in range(20):+    for _ in range(12):         time.sleep(0.5)         if _find_footprint_editor():             return True@@ -382,6 +381,40 @@ def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict:     fp_hwnd = _find_footprint_editor()     pathways_tried: list[str] = [] +    # Tier 0: open Footprint Editor from a RUNNING pcbnew (menu id 20572 —+    # "Footprint Editor", discovered via get_menu_ids on KiCad 10). The Footprint+    # Editor is hosted inside pcbnew, so when a board is open this is the reliable+    # path and it does NOT need the kicad.exe project manager (which may not be+    # running, and whose first-run "KiCad Setup" wizard otherwise blocks the icon+    # click). Mirrors how open_symbol_editor drives a live eeschema. (ADOMBASELINE 2026-07.)+    PCBNEW_FOOTPRINT_EDITOR_MENU_ID = 20572  # noqa: N806+    if not fp_hwnd:+        try:+            from handlers.bridge_client import try_wm_command_via_bridge+            tier0 = try_wm_command_via_bridge(+                exe_name="pcbnew", menu_id=PCBNEW_FOOTPRINT_EDITOR_MENU_ID, frame_index=0,+            )+            pathways_tried.append("pcbnew" if tier0.get("success") else f"pcbnew-failed:{tier0.get('errorCode','')}")+            if tier0.get("success"):+                for _ in range(12):+                    time.sleep(0.5)+                    fp_hwnd = _find_footprint_editor()+                    if fp_hwnd:+                        break+                if fp_hwnd:+                    _force_foreground(fp_hwnd)+                    if not footprint_name and not library_name:+                        return {+                            "success": True,+                            "output": "Footprint Editor opened successfully via a running pcbnew.",+                            "pathway": "pcbnew",+                            "pathwaysTried": pathways_tried,+                            "bridgePid": tier0.get("pid"),+                        }+                    # else fall through to navigate_footprint for footprint_name/library_name+        except Exception as e:  # pylint: disable=broad-except+            pathways_tried.append(f"pcbnew-import-error:{type(e).__name__}")+     # Phase 3 Tier 1: try the reverse bridge first if the kicad.exe     # project manager has the plugin loaded. WM_COMMAND 20013 from the     # Project Manager's Tools menu opens Footprint Editor. This sidesteps@@ -396,7 +429,7 @@ def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict:             )             pathways_tried.append("plugin" if tier1.get("success") else f"plugin-failed:{tier1.get('errorCode','')}")             if tier1.get("success"):-                for _ in range(20):+                for _ in range(12):                     time.sleep(0.5)                     fp_hwnd = _find_footprint_editor()                     if fp_hwnd:@@ -428,8 +461,15 @@ def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict:                 }              try:+                _si = None+                try:+                    from handlers import win_focus+                    _si = win_focus.background_startupinfo()  # open the manager behind the user+                except Exception:+                    _si = None                 subprocess.Popen(                     [kicad_exe],+                    startupinfo=_si,                     stdout=subprocess.DEVNULL,                     stderr=subprocess.DEVNULL,                 )@@ -440,7 +480,7 @@ def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict:                     "_hint": "Call close_kicad with {\"force\": true} to clear stuck processes, then retry.",                 } -            for _ in range(20):+            for _ in range(12):                 time.sleep(0.5)                 kicad_hwnd = _find_kicad_manager()                 if kicad_hwnd:@@ -454,25 +494,63 @@ def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict:                 }             time.sleep(2.0) -        # Tier 2: legacy launcher-icon click (used when bridge isn't available).-        # Try clicking the launcher icon up to 3 times (icon click is flaky-        # if another window overlaps KiCad or DPI/layout changed).+        # Tier 1.5 — UIA background Invoke (the RELIABLE background fallback). KiCad+        # exposes an invokable UIA tree, so we can trigger the "Footprint Editor"+        # control PROGRAMMATICALLY: no foreground, no cursor move. Try a running+        # pcbnew first (its toolbar/Tools has the control), then the project manager.+        try:+            from handlers import uia+            uia_targets = [h for (h, _t, _o) in _find_windows_by_title("PCB Editor")]+            if kicad_hwnd:+                uia_targets.append(kicad_hwnd)+            for tgt in uia_targets:+                r = uia.uia_invoke(tgt, contains="Footprint Editor")+                if r.get("ok"):+                    pathways_tried.append("uia")+                    for _ in range(12):+                        time.sleep(0.5)+                        fp_hwnd = _find_footprint_editor()+                        if fp_hwnd:+                            _force_foreground(fp_hwnd)  # SW_SHOWNOACTIVATE — visible, not focused+                            if not footprint_name and not library_name:+                                return {+                                    "success": True,+                                    "output": "Footprint Editor opened via background UIA Invoke.",+                                    "pathway": "uia",+                                    "pathwaysTried": pathways_tried,+                                }+                            break+                    if fp_hwnd:+                        break+                else:+                    pathways_tried.append(f"uia-{r.get('reason','miss')}")+        except Exception as e:  # pylint: disable=broad-except+            pathways_tried.append(f"uia-error:{type(e).__name__}")++        # Tier 2: legacy launcher-icon click — DISABLED BY DEFAULT. It MOVES the+        # user's mouse cursor (SetCursorPos) and needs the manager foregrounded, so+        # it hijacks the desktop mid-work. The bridge must never do that (John,+        # 2026-07). We only run it if the caller EXPLICITLY opts in with+        # {"allowCursorClick": true}; otherwise we fail gracefully and point at the+        # background plugin path (which needs the reverse-bridge plugin loaded).         opened = False-        for attempt in range(3):-            if _open_footprint_editor_via_click(kicad_hwnd):-                opened = True-                break-            # Re-focus and retry-            _force_foreground(kicad_hwnd)-            time.sleep(1.0)-        pathways_tried.append("icon_click" if opened else "icon_click_failed")+        if args.get("allowCursorClick"):+            for attempt in range(2):+                if _open_footprint_editor_via_click(kicad_hwnd):+                    opened = True+                    break+                _force_foreground(kicad_hwnd)+                time.sleep(1.0)+            pathways_tried.append("icon_click" if opened else "icon_click_failed")+        else:+            pathways_tried.append("icon_click_skipped_background_mode")          if not opened:             return {                 "success": False,-                "error": "Could not open Footprint Editor. Launcher icon click failed after 3 attempts.",+                "error": "Could not open the Footprint Editor via the background (plugin) path.",                 "pathwaysTried": pathways_tried,-                "_hint": "Call close_kicad with {\"force\": true}, then retry. Or call screenshot_all to check for a blocking dialog. If the bridge is available (kicad_bridge_status alive>=1 for kicad), the tier-1 path should have worked — re-check the bridge install state.",+                "_hint": "The background WM_COMMAND path needs the reverse-bridge plugin loaded in a running pcbnew/kicad — open a board first (kicad_open_board) so pcbnew loads the plugin, then retry; the pcbnew tier (menu 20572) opens the Footprint Editor with NO focus theft. The mouse-moving launcher-click fallback is disabled by default; pass {\"allowCursorClick\":true} only if you accept it will move the cursor + foreground KiCad.",             }          fp_hwnd = _find_footprint_editor()@@ -607,18 +685,41 @@ def handle_open_3d_viewer(kicad_info: dict, args: dict) -> dict:     # Try Footprint Editor (only when chosen / sole open editor)     if fp_hwnd:         WM_COMMAND = 0x0111-        FP_3D_VIEWER_CMD = 20500-        user32.PostMessageW(fp_hwnd, WM_COMMAND, FP_3D_VIEWER_CMD, 0)--        time.sleep(3.0)-+        # KiCad's 3D-viewer WM_COMMAND id differs by version (20500 was KiCad 9;+        # KiCad 10 renumbered it), and the Footprint Editor isn't a plugin host so+        # we can't query its live menu ids. The ROBUST path is the Alt+3 keyboard+        # accelerator — the universal "open 3D viewer" shortcut that works in BOTH+        # pcbnew and the Footprint Editor regardless of KiCad version. Try that+        # first (foreground the FP window so the keystroke lands), then fall back to+        # the legacy WM_COMMAND ids. (Fix: FP→3D silently failed on KiCad 10.)+        VK_3 = 0x33+        _force_foreground(fp_hwnd)+        time.sleep(0.2)+        try:+            user32.keybd_event(VK_MENU, 0, 0, 0)           # Alt down+            user32.keybd_event(VK_3, 0, 0, 0)              # 3 down+            user32.keybd_event(VK_3, 0, KEYEVENTF_KEYUP, 0)+        finally:+            user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0)  # Alt up ALWAYS+        time.sleep(2.5)         windows = _find_windows_by_title("3D Viewer")         if windows:             return {                 "success": True,                 "output": f"3D Viewer opened from Footprint Editor: {windows[0][1]}",-                "pathway": "fp_wm_command",+                "pathway": "fp_alt3",             }+        # Legacy WM_COMMAND fallbacks (KiCad 9 id 20500; try the pcbnew id 20563 too).+        for cmd_id in (20500, 20563):+            user32.PostMessageW(fp_hwnd, WM_COMMAND, cmd_id, 0)+            time.sleep(2.0)+            windows = _find_windows_by_title("3D Viewer")+            if windows:+                return {+                    "success": True,+                    "output": f"3D Viewer opened from Footprint Editor: {windows[0][1]}",+                    "pathway": f"fp_wm_command_{cmd_id}",+                }          # FP path failed silently (WM_COMMAND posted but no 3D Viewer appeared).         # If caller asked for 'auto' AND a PCB editor is also open, fall through@@ -641,14 +742,20 @@ def handle_open_3d_viewer(kicad_info: dict, args: dict) -> dict:     # require foregrounding the window or stealing keyboard focus.     pcb_hwnd = _find_pcb_editor()     if pcb_hwnd:-        PCB_3D_VIEWER_CMD = 20563  # noqa: N806  PCB Editor → View → 3D Viewer+        # Menu ids DRIFT across KiCad builds (seen 20563 on one, 20568 on another),+        # so RESOLVE the live "3D Viewer" id from the running pcbnew instead of trusting+        # a constant. Fall back to the legacy id only if the lookup can't reach the plugin.+        PCB_3D_VIEWER_CMD = 20563  # noqa: N806  legacy fallback (PCB Editor → View → 3D Viewer)         pathways_tried: list[str] = []          # Tier 1: bridge wm_command via pcbnew plugin         try:-            from handlers.bridge_client import try_wm_command_via_bridge+            from handlers.bridge_client import try_wm_command_via_bridge, resolve_menu_id_via_bridge+            _live_id = resolve_menu_id_via_bridge(exe_name="pcbnew", contains="3D Viewer", frame_index=0)+            _cmd_id = _live_id if _live_id else PCB_3D_VIEWER_CMD+            pathways_tried.append(f"menuid={_cmd_id}{'(live)' if _live_id else '(fallback)'}")             tier1 = try_wm_command_via_bridge(-                exe_name="pcbnew", menu_id=PCB_3D_VIEWER_CMD, frame_index=0,+                exe_name="pcbnew", menu_id=_cmd_id, frame_index=0,             )             pathways_tried.append("plugin" if tier1.get("success") else f"plugin-failed:{tier1.get('errorCode','')}")             if tier1.get("success"):@@ -665,36 +772,57 @@ def handle_open_3d_viewer(kicad_info: dict, args: dict) -> dict:         except Exception as e:  # pylint: disable=broad-except             pathways_tried.append(f"plugin-import-error:{type(e).__name__}") -        # Tier 2 / legacy: Alt+3 keystroke (steals foreground).-        # Bring pcbnew to foreground so keystroke is received-        user32.SetForegroundWindow(pcb_hwnd)-        time.sleep(0.3)--        # Send Alt+3 (View > 3D Viewer in pcbnew)-        VK_MENU = 0x12   # Alt key-        VK_3 = 0x33      # '3' key-        KEYEVENTF_KEYUP = 0x0002--        user32.keybd_event(VK_MENU, 0, 0, 0)           # Alt down-        user32.keybd_event(VK_3, 0, 0, 0)               # 3 down-        user32.keybd_event(VK_3, 0, KEYEVENTF_KEYUP, 0) # 3 up-        user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0) # Alt up-        pathways_tried.append("alt_keystroke")--        time.sleep(3.0)--        windows = _find_windows_by_title("3D Viewer")-        if windows:-            return {-                "success": True,-                "output": f"3D Viewer opened from PCB Editor: {windows[0][1]}",-                "pathway": "alt_keystroke",-                "pathwaysTried": pathways_tried,-            }+        # Tier 1.5 — UIA background Invoke of the "3D Viewer" control on pcbnew.+        # Programmatic (no foreground, no cursor), replaces the Alt+3 keystroke.+        try:+            from handlers import uia+            r = uia.uia_invoke(pcb_hwnd, contains="3D Viewer")+            if r.get("ok"):+                pathways_tried.append("uia")+                time.sleep(3.0)+                windows = _find_windows_by_title("3D Viewer")+                if windows:+                    return {+                        "success": True,+                        "output": f"3D Viewer opened via background UIA Invoke: {windows[0][1]}",+                        "pathway": "uia",+                        "pathwaysTried": pathways_tried,+                    }+            else:+                pathways_tried.append(f"uia-{r.get('reason','miss')}")+        except Exception as e:  # pylint: disable=broad-except+            pathways_tried.append(f"uia-error:{type(e).__name__}")++        # Tier 2 / legacy: Alt+3 keystroke — needs pcbnew FOREGROUNDED so the global+        # keystroke lands there (a global Alt+3 to the user's own window could fire+        # their app's Alt+3). That steals focus, so it's OPT-IN only+        # ({"allowFocusSteal": true}); by default we fail to the background path.+        if args.get("allowFocusSteal"):+            user32.SetForegroundWindow(pcb_hwnd)+            time.sleep(0.3)+            VK_MENU = 0x12   # Alt key+            VK_3 = 0x33      # '3' key+            KEYEVENTF_KEYUP = 0x0002+            user32.keybd_event(VK_MENU, 0, 0, 0)           # Alt down+            user32.keybd_event(VK_3, 0, 0, 0)               # 3 down+            user32.keybd_event(VK_3, 0, KEYEVENTF_KEYUP, 0) # 3 up+            user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0) # Alt up+            pathways_tried.append("alt_keystroke")+            time.sleep(3.0)+            windows = _find_windows_by_title("3D Viewer")+            if windows:+                return {+                    "success": True,+                    "output": f"3D Viewer opened from PCB Editor: {windows[0][1]}",+                    "pathway": "alt_keystroke",+                    "pathwaysTried": pathways_tried,+                }+        else:+            pathways_tried.append("alt_keystroke_skipped_background_mode")          return {             "success": False,-            "error": "3D Viewer did not open from PCB Editor. A board must be loaded in pcbnew.",+            "error": "3D Viewer did not open via the background (plugin) path.",             "pathwaysTried": pathways_tried,             "_hint": "Call open_board {\"filePath\":\"...\"} to load a PCB in pcbnew first, then retry.",         }